Skip to content

feat(plugins): add plugin services behind a separate native-code trust - #83

Closed
BIackFIame wants to merge 3 commits into
howdeploy:mainfrom
BIackFIame:core/2-plugin-services
Closed

BIackFIame wants to merge 3 commits into
howdeploy:mainfrom
BIackFIame:core/2-plugin-services

Conversation

@BIackFIame

@BIackFIame BIackFIame commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Goal

Give plugins a small, supervised way to run code outside the renderer. Every later extension point (#84 to #88) adds host requests on top of this.

What core gains

  • Manifest apiVersion: 2 adds services (at most 8 per plugin): bundled single-file .js, .mjs or .cjs entries, integrity-declared like hooks. v1 manifests stay valid.
  • PluginServiceSupervisor runs one process per service (Electron's node, cwd = the plugin folder, an allow-listed environment). It speaks JSON-RPC 2.0 lines with 1 MB frames and 15 s timeouts, keeps at most 64 pending requests, restarts with 1 to 16 s backoff, and marks a service failed after more than 5 crashes in 10 minutes. Stop is polite, then SIGTERM, then SIGKILL. The log keeps 300 entries.
  • Surfaces call their own plugin's services with host.service.request and host.service.onEvent.
  • A service can call the host back with log, its own storage.*, event, and secrets.get for its own plugin's secret (needs secrets). Names starting with canvastty. are reserved.

Security posture

  • Services start only after a separate per-plugin Extension native code confirmation (Settings → Agents).
  • Install never grants that confirmation. It pins each entry's SHA-256, rechecked before every start. Disable, update, a module change or a changed file revokes it.
  • The service environment has no keys, NODE_OPTIONS, CANVASTTY_* or CTTY_*.
  • Every host call is bound to the service's own plugin, so a service cannot name another plugin or read another plugin's secrets.
  • A secret goes only to the plugin's own trusted process, never to a surface.

Docs and examples

docs/plugins.md (en/ru/zh), plugin-api.d.ts, the schema, and examples/plugins/service-echo: a canvas app calls its service, and the service reads a token the page saved, answering only whether it is set.

Tests and checks

  • tests/plugin-services.test.mjs and tests/plugin-policy-budget-secrets.test.mjs. The second file only has the secrets.get tests here; later PRs in the stack add to it. Suite 908/908 and typecheck with a fake HOME.
  • Live, with hidden windows:
    • a call before trust failed;
    • trust by clicks started the service, the echo button and events worked, and storage was kept;
    • the service environment had no OPENAI_API_KEY or CANVASTTY_*;
    • revoke and disable stopped the process;
    • nothing was left after quit.

Dependency

Stacked on #80 and #82. Review only the top commit.

Used by all five plugins: environments, assistant (reads its model API key with secrets.get), accounts, context, acp.

Builds on howdeploy#80 (teo-nex, "restore each Codex card to its own
conversation"): its capture of the conversation id from authenticated
lifecycle hooks, the validated id saved per card, `codex resume <id>`,
the resume picker when no id is known and a plain restart forgetting
the id are kept as they are. This extends the same exact resume to
Claude Code (`claude --resume <id>`) and OpenCode (`opencode --session
<id>`); the field is renamed from codexThreadId to threadId for that,
with one per-provider check (canonical UUID for Codex and Claude, `ses_`
id for OpenCode) shared by the hook client, the gateway, the store and
the launch, and v1 records' codexThreadId still read.

Settings → General now offers Don't save / Reopen windows / Continue
conversations (settings v21; the old opt-in boolean migrates
true→continue, false→off). Session records move to v2 (v1 stays
readable): last state at quit or exit, the thread id, a per-card restore
flag, and two validated opaque plugin slots (launch options and an
environment ref, 4 KB each). No scrollback, prompts or secrets are saved.

Restore puts parents before children, resumes a recorded conversation
by id, and without one uses a "latest in this folder" flag only when
that CLI has one card in the folder (otherwise it starts fresh with a
note on the card; Codex opens its picker). Finished cards come back
stopped with Restart / Continue (Continue resumes the card's own
conversation), and a card whose environment is unavailable is held
stopped with its reason instead of running locally. Cards get an
options menu with "Don't restore this card".

For plugins: the v2 record's two opaque slots are where later extension
points keep per-card state across restarts. A launch contributor's chosen
options are saved in `options[pluginId]` and an environment's ref in
`environment`, both validated and capped at 4 KB, so a restored card can
be prepared or placed again (or held stopped with a reason) without the
core knowing what the values mean.
Manifest apiVersion 2 adds `services`: bundled single-file JavaScript
entries (integrity-declared like hook entries in modular plugins). A new
PluginServiceSupervisor runs each service of an enabled plugin as its own
process (process.execPath + ELECTRON_RUN_AS_NODE, cwd = plugin folder,
allow-listed environment without keys, NODE_OPTIONS or CANVASTTY_*),
speaks newline-delimited JSON-RPC 2.0 over stdio (1 MB messages, 15 s
request timeouts, 64 pending), restarts with backoff (at most 5 in 10
minutes), stops politely then with SIGTERM/SIGKILL on disable, uninstall,
update, module change, revoke and quit, and keeps a bounded per-plugin log.

Services run only after a separate per-plugin "Extension native code"
confirmation in Settings -> Agents. Install never grants it; it pins each
entry's SHA-256 (checked before every start) and is revoked by update,
module change, disable, or a changed entry file.

How a plugin uses it: its sandboxed surfaces call their own plugin's
services with host.service.request(serviceId, method, params) and receive
host.service.onEvent; the plugin id is bound by the frame host or the
identity-checked plugin window. A service may call back `log`,
own-plugin `storage.*` (storage permission), `event`, and `secrets.get`
(secrets permission) for its own plugin's secret, for example an API key
of a model it calls; anything else is -32601. This is the base the
following extension points (launch, environments, decisions, tools,
sessions, cards) add host requests to.

Docs (en/ru/zh), schema, plugin-api.d.ts, example
examples/plugins/service-echo (a canvas app that calls its service, and a
token the page saves and the service reads), and
tests/plugin-services.test.mjs, tests/plugin-policy-budget-secrets.test.mjs.
@howdeploy

Copy link
Copy Markdown
Owner

Consolidated into #88 at the maintainer's request. Its branch already includes this implementation (the #81 authentication fix is incorporated through #87). Please continue all follow-up fixes and discussion in #88. Detailed changes-requested review: #88 (review) . Closing this superseded PR preserves its branch, commits and authorship; no code is being merged into main.

@howdeploy howdeploy closed this Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants