From f32282d1fe40189dfb7a9a3d1c87eed30e83ddeb Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 7 Jul 2026 18:32:44 +0000 Subject: [PATCH] sandbox: add orbit.command + orbit.shortcut; harden ui.claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/PLUGIN-SDK.md | 9 +++++++- public/plugin-sandbox.html | 17 +++++++++++++++ public/plugins/third/orbit-hello.js | 18 +++++++++++---- src/modules/sandbox/host.ts | 34 +++++++++++++++++++++++++++-- src/modules/sandbox/protocol.ts | 6 +++++ 5 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/PLUGIN-SDK.md b/docs/PLUGIN-SDK.md index 21b0d93..cd6ac56 100644 --- a/docs/PLUGIN-SDK.md +++ b/docs/PLUGIN-SDK.md @@ -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 diff --git a/public/plugin-sandbox.html b/public/plugin-sandbox.html index 86bf441..c4ea4c7 100644 --- a/public/plugin-sandbox.html +++ b/public/plugin-sandbox.html @@ -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; }, diff --git a/public/plugins/third/orbit-hello.js b/public/plugins/third/orbit-hello.js index 8992261..a84e694 100644 --- a/public/plugins/third/orbit-hello.js +++ b/public/plugins/third/orbit-hello.js @@ -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'); + }); }); diff --git a/src/modules/sandbox/host.ts b/src/modules/sandbox/host.ts index 60c18ac..c25a2fa 100644 --- a/src/modules/sandbox/host.ts +++ b/src/modules/sandbox/host.ts @@ -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 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 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], diff --git a/src/modules/sandbox/protocol.ts b/src/modules/sandbox/protocol.ts index 3d04544..ebcd989 100644 --- a/src/modules/sandbox/protocol.ts +++ b/src/modules/sandbox/protocol.ts @@ -38,6 +38,12 @@ export const RPC_CAPABILITY: Record = { // 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, };