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:
parent
883ffebdfb
commit
f32282d1fe
5 changed files with 77 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue