sandbox: add orbit.command + orbit.shortcut; harden ui.claim

Full sandbox re-audit: the isolation/capability model is sound (opaque origin,
fail-closed gate, irc-raw split, 'raw' withheld, namespaced storage, cloneable
event payloads). The gap was that sandboxed plugins couldn't register the slash
commands / shortcuts the registry already supports in-page.

- orbit.command(name, run, help): the host registers via the registry and relays
  (args, rest) to the guest; the plugin's action still goes through gated verbs.
- orbit.shortcut(combo, run): modifier REQUIRED — the shortcut loop fires while
  typing, so a bare-key combo would keylog and eat keystrokes; the host rejects it.
- Registration hooks are disposed on each handshake so the adoption reload can't
  duplicate them.
- ui.claim now validates the slot against the known set (bogus slot -> footer).

Verified live: /hello relays to the guest and posts to the channel. Template +
docs updated.
This commit is contained in:
Jean Chevronnet 2026-07-07 18:32:44 +00:00
parent 883ffebdfb
commit f32282d1fe
No known key found for this signature in database
5 changed files with 77 additions and 7 deletions

View file

@ -78,7 +78,14 @@ Slots: `nav_item`, `topbar_item`, `footer_item`, `sidebar_item`, `composer_butto
- `orbit.storage.get(key, fallback)` / `.set(key, value)` — needs `storage`; per-plugin, persisted.
- `orbit.irc.say(text)` / `.msg(to, text)` / `.send(line)` / `.join(chan)` / `.part(chan)` — needs `irc`.
- `orbit.notify(title, body)` — needs `notify`; rate-limited.
- `orbit.on(event, fn)` — subscribe to `message`, `connected`, `buffer.active`, … Returns an unsubscribe fn.
- `orbit.on(event, fn)` — subscribe to app events; returns an unsubscribe fn. Events:
- `message``{ from, target, text, self }` on each incoming PRIVMSG (no permission).
- `connected``{ nick }`, `buffer.active` → the active buffer name, `status` → connection status.
- `orbit.command(name, run, help?)` — register a slash command. Typing `/name a b`
calls `run(["a","b"], "a b")`. Returns an unregister fn. Built-in commands win.
- `orbit.shortcut(combo, run)` — register a keyboard shortcut, e.g. `"mod+j"` or
`"alt+shift+r"` (`mod` = ⌘/Ctrl). **A modifier is required** — bare keys are refused
so a plugin can't swallow your typing. Built-in shortcuts win.
- `log(...)` — console log (shown when plugin debug is on).
## Permissions

View file

@ -28,6 +28,7 @@
(function () {
var port, snap = { active: '', nick: '', account: '', buffers: [], network: '', isupport: {}, caps: [] }, store = {};
var listeners = {}; // event name -> [fn]
var hookN = 0; // unique id source for command/shortcut hooks
var pending = new Map(), rpcId = 0; // in-flight RPCs
// Is this background colour a dark one? Rough luminance on a #rgb/#rrggbb or
@ -210,6 +211,22 @@
list: function () { return rpc('irc.list'); },
},
notify: function (title, body) { return rpc('notify', title, body); },
// Register a slash command: typing "/name a b" calls run(["a","b"], "a b").
// Returns an unregister fn. Built-in commands win over a plugin's.
command: function (nameStr, run, help) {
var id = 'cmd:' + (++hookN);
(listeners[id] = listeners[id] || []).push(run);
rpc('command.register', String(nameStr), id, help != null ? String(help) : undefined);
return function () { delete listeners[id]; rpc('command.dispose', id); };
},
// Register a keyboard shortcut, e.g. "mod+j" or "alt+shift+r" — a modifier is
// REQUIRED (bare keys are refused). run() fires when it's pressed in the chat view.
shortcut: function (combo, run) {
var id = 'key:' + (++hookN);
(listeners[id] = listeners[id] || []).push(run);
rpc('shortcut.register', String(combo), id);
return function () { delete listeners[id]; rpc('shortcut.dispose', id); };
},
state: {
active: function () { return snap.active; },
nick: function () { return snap.nick; },

View file

@ -1,9 +1,9 @@
/*
* Template plugin copy this to start your own. A themed nav panel in ~15 lines,
* built entirely with the SDK (orbit.panel + orbit.el). The SDK handles theming,
* the frame sizing, upward growth and the open animation you just declare content.
* Template plugin copy this to start your own. Shows the SDK essentials: a themed
* nav panel, a slash command, and a keyboard shortcut, in a handful of lines. The
* SDK handles theming, frame sizing, upward growth and the open animation.
*
* To load it, add to config.json (drop "irc" from permissions if you don't call it):
* To load it, add to config.json (drop "irc" if you don't call it):
* { "url": "/app/plugins/third/orbit-hello.js", "sandbox": true, "permissions": ["irc"] }
*/
Orbit.plugin('orbit-hello', function (orbit) {
@ -22,4 +22,14 @@ Orbit.plugin('orbit-hello', function (orbit) {
body.appendChild(say);
},
});
// A slash command: "/hello world" -> says "hi world".
orbit.command('hello', function (args, rest) {
orbit.irc.say('hi ' + (rest || 'there') + '!');
}, 'say hi in the channel');
// A keyboard shortcut (a modifier is required).
orbit.shortcut('mod+shift+h', function () {
orbit.notify('Hello', 'shortcut pressed');
});
});

View file

@ -88,6 +88,10 @@ export function mountSandboxed(spec: SandboxSpec): void {
let claimed = false;
let lastNotify = 0;
let removeUi: () => void = () => {};
let currentPort: MessagePort | null = null; // the live guest port (rebuilt each handshake)
const hooks: Record<string, () => void> = {}; // command/shortcut disposers, keyed by guest hook id
const KNOWN_SLOTS = ['composer_button', 'settings_section', 'topbar_item', 'sidebar_item', 'footer_item', 'nav_item'];
const hasModifier = (combo: string) => /\b(mod|ctrl|control|meta|cmd|super|alt|option)\b/i.test(combo);
const impl: Record<string, (args: unknown[]) => unknown> = {
log: (a) => log(...a),
'irc.say': (a) => activeStore().getState().sendInput(String(a[0] ?? '')),
@ -105,7 +109,8 @@ export function mountSandboxed(spec: SandboxSpec): void {
'storage.set': (a) => persistStorage(name, String(a[0]), a[1]),
'ui.claim': (a) => {
if (claimed) return; claimed = true;
const slot = String(a[0] ?? 'footer_item') as UiSlot;
const raw = String(a[0] ?? 'footer_item');
const slot = (KNOWN_SLOTS.includes(raw) ? raw : 'footer_item') as UiSlot;
removeUi = usePluginRegistry.getState().addUi(slot, name, () => createElement(SandboxFrame, { iframe }));
},
'ui.resize': (a) => {
@ -115,6 +120,30 @@ export function mountSandboxed(spec: SandboxSpec): void {
iframe.style.height = `${h}px`;
if (h > 0) iframe.style.visibility = 'visible';
},
// A plugin /command: the registry runs this handler on the app thread; we relay it
// (args + rest string) to the guest, which invokes the plugin's callback. Whatever
// the plugin does next still goes through the gated verbs. Re-register is idempotent
// (teardown disposes hooks each handshake, so ids stay in sync with the guest).
'command.register': (a) => {
const cmd = String(a[0] ?? ''); const id = String(a[1] ?? '');
if (!cmd || !id) return;
hooks[id]?.();
hooks[id] = usePluginRegistry.getState().addCommand(name, cmd,
(args, rest) => currentPort?.postMessage({ type: 'event', name: id, args: [args, rest] }),
a[2] != null ? String(a[2]) : undefined);
},
'command.dispose': (a) => { const id = String(a[0] ?? ''); hooks[id]?.(); delete hooks[id]; },
// A plugin shortcut. Bare-key combos are refused: the shortcut loop fires even while
// typing, so a modifier-less combo would keylog and swallow keystrokes in the composer.
'shortcut.register': (a) => {
const combo = String(a[0] ?? ''); const id = String(a[1] ?? '');
if (!combo || !id) return;
if (!hasModifier(combo)) { log('DENIED shortcut without a modifier', combo); return; }
hooks[id]?.();
hooks[id] = usePluginRegistry.getState().addShortcut(name, combo,
(e) => { e.preventDefault(); currentPort?.postMessage({ type: 'event', name: id, args: [] }); });
},
'shortcut.dispose': (a) => { const id = String(a[0] ?? ''); hooks[id]?.(); delete hooks[id]; },
};
// (Re)establish the bridge on EVERY iframe load. Adopting the iframe into a UI
@ -127,6 +156,7 @@ export function mountSandboxed(spec: SandboxSpec): void {
iframe.style.visibility = 'hidden'; // re-hide across the adoption reload until resized
const chan = new MessageChannel();
const port = chan.port1;
currentPort = port;
port.onmessage = (e: MessageEvent) => {
const m = e.data as GuestToHost;
if (!m || m.type !== 'rpc' || typeof m.method !== 'string') return;
@ -148,7 +178,7 @@ export function mountSandboxed(spec: SandboxSpec): void {
const offTheme = useThemeStore.subscribe((s, prev) => {
if (s.theme !== prev.theme) port.postMessage({ type: 'theme', theme: themeVars() });
});
teardown = () => { offs.forEach((o) => o()); offTheme(); port.close(); };
teardown = () => { offs.forEach((o) => o()); offTheme(); for (const k in hooks) { hooks[k](); delete hooks[k]; } port.close(); };
iframe.contentWindow?.postMessage(
{ type: 'init', name, permissions, source, snapshot: snapshot(), storage: loadStorage(name), theme: themeVars() },
'*', [chan.port2],

View file

@ -38,6 +38,12 @@ export const RPC_CAPABILITY: Record<string, Permission | null> = {
// UI stays inside the plugin's own sandboxed iframe, so it needs no grant.
'ui.claim': null,
'ui.resize': null,
// Registering a /command or a shortcut is benign — it only wires a user-driven
// trigger; whatever the plugin then DOES still goes through the gated verbs above.
'command.register': null,
'command.dispose': null,
'shortcut.register': null,
'shortcut.dispose': null,
'log': null,
};