feat(core): consolidate session restore, plugin extensions and agent safety - #88
Conversation
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.
A trusted plugin service can now declare a `launch` block (permission
launch:contribute, one service per plugin): up to 8 boolean, select or
text fields, optionally limited to some agents. The agent launcher shows
them under Advanced; the person turns a plugin on for one launch, and the
checked values (at most 4 KB per plugin) are saved in the session
record's plugin options slot and reused on restart and restore.
Before such a card is spawned, LaunchPipeline sends each chosen plugin's
service `canvastty.launch.prepare` (a host-only method surfaces cannot
send) and merges the answers in plugin-id order: env, secretEnv (names of
the plugin's own secrets, resolved in main, never shown to the service or
any UI, masked as <redacted:secret> in observe/result, the control CLI's
screen/result and failure details), args (appended before the resume
selection) and per-run files ({launchFiles}, removed on exit). A refusal,
a 5 s timeout, an error, an invalid answer, a missing secret, two plugins
setting one name, a reserved or core-set variable, or an approval or
conversation argument (coreOwnedLaunchArgument) refuses the launch with
the reason on the card; it is never started without the contribution.
Restore holds a card whose plugin is unavailable stopped with its reason
and keeps its record. Launches without options or policies stay
synchronous and unchanged.
What an account or policy plugin also needs:
- launch.policy: a contributor with `policy: true` is also asked before
every agent launch where the person did not choose it (`chosen: false`,
empty options). That answer may only refuse; a contribution, a timeout
or an error refuses too, so a policy never lets a launch through by
failing. Policy-only contributors are not shown in the launcher.
- A select may declare `optionsFrom: "service"`: the launcher asks
`canvastty.launch.options` (3 s) and lists up to 64 more choices (the
plugin's accounts, say) after the declared ones; such a value is any
short text the service re-checks when it prepares.
- spawn_agent takes `launchOptions` ({pluginId: {field: value}}), checked
exactly like the launcher's, so an orchestrator can start a subagent
with the account a plugin tool picked.
- Claude Code applies only its last inline --settings, so a plugin's
inline --settings is merged into CanvasTTY's own (hooks kept); one
that sets permissions, hooks, sandbox, defaultMode or apiKeyHelper is
refused.
Docs (en/ru/zh), schema, plugin-api.d.ts, examples
examples/plugins/launch-env (options, a service-filled Profile) and
examples/plugins/yolo-guard (a policy that refuses YOLO launches), and
tests/launch-contributors.test.mjs, tests/plugin-launch-choices.test.mjs,
tests/plugin-policy-budget-secrets.test.mjs.
A trusted plugin service can now list `environments` kinds (permission
environment:provide, up to 8 kinds per plugin with optional launcher
fields and appliesTo, "terminal" included). Kinds are unique within the
plugin and may be split over its services (one per module); each is
answered by the service that lists it. The launcher's Advanced section
shows "Where" (default "This computer"); while a kind applies to
terminals, Open terminal opens the same launcher (folder and Where)
instead of opening at once.
EnvironmentRegistry sends host-only requests: prepare (once, 15 s:
opaque ref <= 4 KB, badge label, optional cwd that becomes the card's
folder), wrap (before every start, 5 s), resume (10 s), release (10 s)
and describe (3 s, badge text). TerminalManager still spawns the PTY:
the launch is planned (planSpawn), wrapped, then spawned. Wrap output is
validated: an absolute executable or a bare name resolved on PATH, never
a shell string; args array without NUL; env/secretEnv under the launch
contributor rules (reserved names and names this launch already sets are
refused), secretEnv resolved from the plugin's own secrets and masked.
The environment sees the launch's own variables without CANVASTTY_*
names or secret values (secretEnvNames lists the latter).
The ref is saved in the v2 record. Restore resumes every saved
environment first, then plans parents before children; a missing,
disabled or untrusted plugin, a `stopped` answer, an error or a timeout
brings the card back stopped with the reason, keeps its record, and never
runs it locally. Closing a card asks once "Keep environment data?" and
releases with the answer; quitting releases nothing unless saving is off
(keepData: true, reason "quit"); a card closed while preparing releases
the new environment unkept.
Launch contributors and policies now receive the card's `environment`
({ pluginId, kind } or null) in canvastty.launch.prepare, so a policy can
allow risky launches only inside an isolated place; the yolo-guard
example now refuses YOLO only outside an environment.
How a plugin uses it: a worktree, container or remote-host plugin
declares its kinds, answers prepare/wrap/resume/release/describe, and
keeps whatever it needs in the opaque ref. Example
examples/plugins/env-worktree: git worktree add in the plugin's data
folder, wrap sets the folder, resume checks it, describe shows the
branch, release removes the worktree and the branch it created unless
kept. Docs (en/ru/zh), schema, plugin-api.d.ts, architecture,
changelogs, and tests/session-environments.test.mjs.
…edaction
Base protection (core, Settings -> Agents, on by default, switchable):
deny-only local rules (shellParse, commandFacts, the deny rules and their
"what to do instead" messages): elevation, pipe to a shell,
download-and-run, disk and format commands, fork bombs, writes or
deletes outside the working folder (/tmp and home included; deleting the
folder itself), with the agent's own plan and memory folders exempt. It
never allows.
Decision hooks (EP-5): permission-gate.mjs is installed as a PreToolUse
hook (Claude Code, Codex, Qwen Code; shells and file writes, YOLO
included) and opencode-decisions.mjs guards OpenCode's
tool.execute.before, both only for launches that need them (base
protection on or a decision plugin applies). They reach RuntimeGateway
over the session's runtime capability, also with status hooks off.
DecisionHooks runs base protection first, then every trusted service
that declares `decide` (permission decision:provide) answers
canvastty.decide in parallel: any deny wins; else any ask (a timeout,
error or unreadable answer is ask); else an allow counts only after the
person's separate "May allow agent actions" confirmation, revoked with
native code trust. Claude takes deny, ask and allow; Codex and Qwen deny
only; OpenCode deny (throw) and allow (permission reply "once"). Cut
input is never allowed.
A decision service may declare `decide.timeoutMs` (1-60 s, 3 s by
default) when it needs longer, for example to ask a local model: the
host waits that long, sends `budgetMs`, and sizes each card's hook,
helper (CANVASTTY_RUNTIME_DECISION_MS) and gateway deadlines at launch
for the longest budget that applies; the default keeps the old
15 s / 12 s / 10 s.
Redaction registry (EP-8): vault values the process reads or writes,
launch secretEnv values per card, values a service registers
(redaction.register) or reads with secrets.get, also wrapped over lines
or JSON-escaped, plus generic key shapes (incl. keys wrapped across
lines). Applied to observe/result and the control CLI's screen, result
and failure details.
How a plugin uses it: a guard or reviewer plugin declares
`decide: { events: ["pre-tool"], appliesTo?, timeoutMs? }` and answers
{ verdict, reason } or null; base protection still runs first and a
failure never allows. Example examples/plugins/deny-rm; docs
(en/ru/zh), schema, d.ts, architecture, changelogs; tests for the deny
table, redaction, precedence, timeout -> ask, allow gating, budgets, and
the real gate through the real gateway and supervisor.
Agent tools (EP-6): a trusted service with tools:agents declares tools
(name, JSON schema, roles). They are listed in canvastty_agents as
<pluginId>__<tool> (dots in the id written as "_", so the name matches
^[a-zA-Z0-9_-]{1,64}$ that Anthropic and OpenAI accept; long ids keep
their start plus a short hash, and a name two tools would share is
listed for neither). The helper asks the bridge (new list_tools message)
what the session sees: orchestrators get the core tools plus plugin
tools, and an agent or subagent card gets the bridge only when a plugin
tool lists its role, and then sees plugin tools only (core tools are
refused for non-orchestrators). Codex enabled_tools and Qwen
allowed-tools carry the session's exact list; Kimi and Hermes (shared
config) keep the core tools. Calls go to canvastty.tools.call with the
caller id and summary; the host checks top-level arguments, and the
answer is redacted, capped (32 K characters, 96 KB JSON) and bounded at
15 s; failures are error results.
Session events and owned cards (EP-4): sessions.subscribe/list
(sessions:events) with created/restored/status/exited/closed
notifications carrying id, provider, role, parent, folders and the
environment ref; masked plain screen text only with
sessions:read-screen. sessions.create (sessions:launch) starts an agent
card through the normal launch pipeline; sessions.send/stop
(sessions:control) only for cards the plugin started. The owning plugin
is saved on the card's session record (ownerPluginId, validated on
load), so ownership survives a restore.
Card badges and actions (EP-7): cards.setBadge (cards:decorate) sets a
plain-text badge (24 chars, tone, tooltip); cardActions with a
providers/environmentKinds/roles filter appear in the card options menu
and call canvastty.cards.invoke on the service that declares them; the
answer shows as a toast on the card. No HTML.
This carries the canvastty_agents helper fix (the capability's
connection id as CANVASTTY_ORCHESTRATION_CONNECTION_ID) unchanged, since
plugin tools reach agents through that helper; it is the same change as
the standalone fix and drops out when that one lands first.
How a plugin uses it: a results or review plugin offers a tool to
orchestrators or subagents, follows the cards it cares about, starts
and drives its own cards, and puts a badge and an action on matching
cards. Example examples/plugins/collect-demo (Show changes on worktree
cards, collect-demo__diffstat for orchestrators); docs en/ru/zh, schema,
d.ts, architecture, changelogs; tests for manifests, naming, listing per
role, routing, redaction and bounds, ownership across restore, events
without screen text, decorations, and the example through the real
supervisor and git.
…x trust
Auto profile: offered only where the CLI has a native auto mode, checked
with each CLI's --help under a fake HOME: Codex --approve-for-me (its own
reviewer in its workspace-write sandbox), Claude Code
--permission-mode auto with its sandbox ({ enabled,
autoAllowBashIfSandboxed: false }) merged into the one --settings, and
Grok --permission-mode auto. Normal stays the default and unchanged;
spawn_agent children stay Normal. The launcher has an Auto button with
a note, cards show an "Auto" badge, and the control CLI (--profile auto),
plugin sessions.create and saved cards accept it.
Third-party models: a launch contribution (or a policy answer) may set
`thirdPartyModel: true`; it only restricts. Auto then runs as
accept-edits in the same sandbox (Codex --sandbox workspace-write
--ask-for-approval on-request, Claude/Grok --permission-mode
acceptEdits), metadata.autoDowngraded is set and the badge reads
"auto · edits", so a model the CLI vendor does not review never gets
the vendor's auto approval.
Codex trust: CanvasTTY's own hooks get per-run `-c hooks.state` trust
(the hashes Codex records), so a card no longer stops at "Hooks need
review". A Codex subagent in or below its orchestrator's folder gets
per-run `-c projects` trust, and launch contributors receive that folder
as `trustedFolder`. Plugins cannot pass --approve-for-me,
approvals_reviewer or -c hooks...
Claude status: the ✳ title reads idle; once a card's hooks report, only
hooks end a turn, and a declined prompt settles idle 3 s after the
answer when no hook moved the card.
How a plugin uses it: an account plugin marks launches that route to a
non-vendor endpoint with thirdPartyModel so Auto downgrades itself, and
can seed the trusted folder into the account's own CLI home. Docs
en/ru/zh, d.ts, changelogs, UI contract; launch-env example (a local
model profile); tests/auto-profile.test.mjs.
There was a problem hiding this comment.
@BIackFIame — the direction of this work matches what we want for CanvasTTY. Please keep the features and finish the integration here. We are consolidating #81–88 into this PR so there is one implementation branch, one discussion and one set of merge criteria.
Review outcome: changes requested before merging into main. The findings below are about concrete code paths in the combined implementation, not a request to replace the design or remove the new functionality.
Reviewed head: 1f38707009962f5fb4132e4690dfe5e6609cade8. This was a static review of the implementation, its callers, existing tests and documentation. I did not run local tests, the build, or live CLI/UI checks. The reproduction and verification cases below are scenarios to cover, not claims that I executed them. The three GitHub CI checks were green at this head.
Update after the concurrent stack refresh: I inspected the delta to e2de0d87cd95751604f26ceb00cb1e8033f8908e. Item 8 is addressed at the code-review level: the restore plan now carries the selected conversation identity, fresh launches discard the previous ID, and regression cases were added for Reopen and held cards. I have not run those tests. Items 1–7 remain open; their implementation paths were not changed by this update. The refreshed #82–87 heads are all ancestors of the refreshed #88 head, so their changes are retained in this consolidated PR.
1. P1 — Preserve a pending environment choice across shutdown and restore
Code: TerminalManager.create, environment preparation, persisted session serialization.
create() stores environmentChoice only on the in-memory session. It immediately persists the card with exitCode: null, so the saved state is running. However, extras.environment is not assigned until environment.prepare resolves, and the serializer does not preserve the pending choice.
If the app exits or crashes while preparation is pending, the saved record looks like an ordinary local session. On the next start it can launch locally in the original directory. This violates the documented guarantee that a selected environment never silently falls back to this computer. A failed prepare also loses its original selection after saving and restoring the failed card, so a later Restart can take the local path.
Required behavior: persist enough information to distinguish a pending or failed environment launch from a local session. After restore, either safely recover the selected environment or hold the card stopped with an explanation. Do not infer “local” from a missing prepared ref. Keep the chosen options available for retry, and make the lifetime of any late prepare result explicit.
Regression coverage: defer prepare, create a card, save/shut down before it resolves, then restore from that record and assert that no local PTY is spawned. Cover a preparation failure followed by app restart and manual Restart as well. The existing environment shutdown test waits for all three launches to finish; its pending-prepare case closes the card rather than shutting down and restoring it.
2. P1 — Deliver the initial subagent prompt only after the asynchronous launch is ready
Code: AgentControlService.spawn/send, asynchronous creation, inputChecked.
spawn() calls TerminalManager.create() and immediately calls send() for initialPrompt. With launch options or an applicable launch policy, create() returns a card whose process is still null. send() accepts it because exitCode is still null; inputChecked() then returns false, and input() discards that result. The orchestration caller receives success while the child eventually opens without its task.
This is an integration regression caused by making launch asynchronous without updating the prompt-delivery contract. It also affects send_to_agent while preparation is pending. A policy can trigger it even when the caller did not explicitly select plugin options.
Required behavior: give callers a reliable launch/delivery contract. Await readiness or retain the pending initial prompt and deliver it exactly once when the process is ready. A refused, cancelled or superseded launch must not be reported as successful delivery, and queued input must not reach a later unrelated restart. Apply the fix at the shared boundary used by these callers.
Regression coverage: call the real orchestration/control path with both prompt and launchOptions, hold preparation pending, then resolve it and check the PTY receives the prompt exactly once. Cover policy-only preparation, refusal, and cancellation before readiness. The existing spawn_agent launch-options test uses a synchronous stub and does not supply a prompt.
3. P1 — Validate and merge every supported Claude settings argument form
Code: launch argument validation, mergeClaudeInlineSettings, coreOwnedLaunchArgument.
Validation considers each argv element separately. It parses protected Claude settings only when the element itself starts with {. The merger recognizes only the two-element form --settings, followed by inline JSON. File paths are deliberately left alone.
Consequently, contributions such as the following escape the intended protection:
["--settings={\"disableAllHooks\":true}"]
["--settings", "{launchFiles}/settings.json"]
The first form is not inspected as JSON. The second can point at a contributed file containing protected settings and is not merged with CanvasTTY's own settings. Under the last---settings behavior documented and measured in this implementation, a later settings argument can also displace the host's hooks and sandbox even when the plugin only intended to set an ordinary option.
Required behavior: validate complete option/value pairs and normalize supported forms before merging. Either reject plugin-provided settings files or load and validate the allowed content before merging it. Keep one effective Claude settings payload with core-owned permission, hook and sandbox settings intact. Reject protected overrides regardless of whether they use an equals form, separate arguments, or a file.
This is about enforcing the selected launch profile and preserving host integration. It is not a claim that already trusted native plugin code is OS-sandboxed.
Regression coverage: exercise equals-form inline JSON, settings files, and allowed plugin settings together with core hooks in Normal and Auto. Assert both rejection of protected overrides and preservation of hooks/sandbox for allowed contributions. Check the resulting behavior with the supported real Claude CLI version as well as argv assertions.
4. P2 — Honor decision budgets longer than the supervisor's 15-second default
Code: PluginServiceSupervisor.hostCall, supervisor construction, existing long-budget test.
The manifest accepts decide.timeoutMs up to 60,000, and the hook/helper/gateway deadlines are sized accordingly. However, hostCall() uses Math.min(timeoutMs, this.options.requestTimeoutMs), and production construction leaves the supervisor at its 15,000 ms default.
A service declaring a 45-second budget and returning deny after 20 seconds is therefore replaced by the timeout's ask result at 15 seconds. Increasing the declared budget does not deliver the advertised behavior.
Required behavior: honor the validated host-call budget while retaining the normal surface-request default and an explicit bounded maximum. Keep the helper, gateway and supervisor deadlines consistent.
Regression coverage: pass a decision through an actual supervisor with a declared budget above 15 seconds and an answer after the default limit; verify the answer is retained within budget and times out beyond budget. Controlled timers are fine if the production call chain is exercised. The current 11-second gateway test bypasses the supervisor and never crosses this limit.
5. P2 — Start plugin services after the host APIs they can call are initialized
Code: early service start, later PluginSessions construction, example startup subscription.
pluginServices.sync() starts services before PluginSessions and PluginCards exist. Several asynchronous initialization steps run in between. A fast service can receive canvastty.initialize and immediately call sessions.subscribe while the optional host callback still returns undefined; the supervisor turns that into Unknown host method.
The shipped collect-demo does exactly this subscription on initialize and only logs the failure. It can remain running without the session map it needs to handle its subagents. Waiting for the start promise later does not undo the failed initialization call.
Required behavior: make service initialization happen only after the advertised host APIs and their dependencies are ready. Ensure services can subscribe before restored-session events are emitted, or reliably receive the equivalent initial snapshot.
Regression coverage: delay host startup, use a service that subscribes immediately on initialize, and verify it gets a valid snapshot and subsequent restored/new session events without a manual disable/enable cycle. Include startup calls to any other advertised APIs that have the same ordering dependency.
6. P2 — Redact complete text before selecting the agent-visible tail
Code: AgentControlService.observe/result, PluginSessions.screen.
These paths currently perform redact(tail(buffer)) or redact(plainText(buffer).slice(-limit)). If the cutoff falls inside a registered secret, its prefix is removed before matching. The remaining suffix no longer matches the full known value and need not match a generic credential pattern, so it can be returned to another agent or a screen-reading plugin.
Required behavior: mask the full available text before cutting it to the requested output size. Preserve the existing output bounds. Inspect sibling output paths for the same ordering mistake.
Regression coverage: register a custom secret that does not match a generic token shape, place it across the observation/screen cutoff, and assert no surviving fragment is exposed. Cover observe_agent, get_agent_result, and plugin screen events. Keep the existing wrapped-secret cases too.
7. P2 — Do not classify mutating Git operations as unconditional reads
Code: GIT_READ, classifyGit.
The unconditional read set includes pull, stash, config, branch, tag and other commands with mutating forms. classifyGit() returns for them before checking whether -C selects an outside repository. For example, git -C /other/repo stash pop can change another repository's worktree, and stash clear can delete its saved stashes, without adding a write/delete-outside fact.
Required behavior: separate genuinely read-only forms from mutating forms and apply the existing outside-project policy to the latter. Preserve legitimate read-only Git usage. This does not require turning the heuristic into a general shell sandbox; it requires fixing the explicit misclassification of commands the implementation already recognizes.
Regression coverage: classify outside-repository mutating stash, pull, branch/tag deletion and config-writing forms, alongside read-only status/log and appropriate listing variants. These parser tests should inspect the verdict and never execute the destructive commands.
8. P2 — Resolve the inherited stale conversation ID finding from #82
Status: addressed in the refreshed head e2de0d8 on static inspection; final validation is still required. The original finding below is retained for traceability.
Code: restored threadId assignment. Previous feedback on #82.
The combined head still unconditionally copies descriptor.threadId into the restored session even when the restore plan starts a fresh conversation, such as Reopen. Until a new lifecycle signal replaces it, persistence and later resume can still refer to the previous conversation. Closing before the new ID arrives, or running without lifecycle reporting, leaves the wrong identity attached to the new card.
Required behavior: retain the saved ID for an actual resume or an intentionally held stopped card. A fresh launch must start with no previous conversation ID; its own valid lifecycle signal can then assign the new one.
Regression coverage: restore a saved ID in Reopen mode, save again before any new lifecycle signal, and assert the old ID is absent. Repeat without lifecycle reporting. Verify that exact Resume and held stopped cards still retain the correct ID.
How to finish this PR
- Address the seven remaining open items on this branch, with a reply mapping each number to the fixing commit and focused regression coverage. Include final validation for the item 8 fix already supplied. If a finding does not apply, explain the exact production path and provide a case that demonstrates the claimed behavior.
- Preserve plugin services, launch options, environments, decision hooks, agent tools, session events, card actions and Auto. Fix the shared lifecycle/validation boundaries rather than removing the features or adding unrelated architecture.
- Run the existing required suite, typecheck and build against the final head, plus the focused cases above. Report which real CLI versions/platforms were checked and distinguish live checks from mocks. The original reported live checks are useful, but do not exercise every failure path above.
- Keep this PR Draft until the corrections and their evidence are available. We can then review the complete corrected implementation for merging into
main.
The source history and authorship remain intact. Consolidating the discussion does not merge any of this code into main.
1f38707 to
e2de0d8
Compare
|
The consolidation is complete: #81–87 are closed as superseded, and #88 is the single open PR for finishing this series. The source branches were retained, and no code was merged into @BIackFIame, I also saw the concurrent stack refresh to I verified that every refreshed head from #82 through #87 is contained in the refreshed #88 head. Please continue fixes here on |
…restore
create() kept the launcher's environment choice only in memory and saved the
card as running before the plugin had prepared it. A quit or crash while
prepare was pending (or a failed prepare followed by a restart of the app)
therefore restored the card as an ordinary local session.
- The choice is now part of the saved extras (environmentChoice, validated on
load; an unreadable one drops the card instead of restoring it locally) until
prepare succeeds and the environment ref replaces it.
- The restore plan holds a running card with a pending choice stopped
("environment-pending", with the reason); nothing is prepared or spawned
until the person restarts it, which prepares again with the saved options.
Restart refuses when the chosen plugin is unavailable.
- A failed prepare keeps its choice across an app restart the same way.
- A prepare answer for a launch that no longer exists (closed, restarted, app
quitting) is never adopted or saved; it is released with keepData false.
Tests: tests/session-environments.test.mjs (pending prepare + quit + restore,
failed prepare + app restart + Restart, unreadable saved choice).
With launch options, an environment or an applicable launch policy, create() returns a card whose process is still null. spawn() wrote the initial prompt at once; inputChecked() returned false, input() dropped it, and spawn_agent still reported success, so the child opened without its task. send_to_agent and plugin sessions.send had the same gap while a launch was pending, and the control CLI answered "no longer accepts input" for a card that had not started. TerminalManager.deliverInput() is now the one delivery rule for these callers: a running card gets the text at once; a card whose launch is pending gets it exactly once, when that launch has started. A refused, failed, cancelled or superseded launch (closed, restarted), or one that does not start within 60 s, delivers nothing, says why, and drops the text, so it cannot reach a later restart (a per-card launch epoch is bumped on every restart). - AgentControlService.spawn/send resolve after delivery and reject with PromptNotDeliveredError; spawn_agent/send_to_agent map it to a non-retryable INVALID_REQUEST that names the session id (the card stays). - PluginSessions sessions.send waits the same way; sent is false when the launch did not start. - The control CLI answers NOT_READY for a card whose launch is still being prepared, instead of SESSION_EXITED, and queues nothing. Tests: tests/launch-readiness.test.mjs (spawn_agent with launchOptions and a prompt through ScopedOrchestrationHandler, policy-only launch plus send_to_agent, refusal, cancellation, plugin sessions.send, control CLI); existing spawn/send tests now await the delivery.
…load
Validation looked at each argv element alone and parsed protected Claude
settings only when the element itself was inline JSON; the merger knew only
`--settings <json>`. So `--settings={"disableAllHooks":true}` and
`--settings {launchFiles}/settings.json` passed unchecked, and since Claude
2.1 applies only its last --settings, such an argument displaced CanvasTTY's
hooks (and Auto's sandbox) even when the plugin only meant to set `env`.
- LaunchPipeline checks a Claude contribution's settings as option/value
pairs: `--settings <json>`, `--settings=<json>`, or `--settings
{launchFiles}/<file>` naming one of its own launch files (read from the
contribution, checked like inline JSON and passed inline). Any other file,
a non-object value, a missing value, or core-owned keys (permissions,
hooks, disableAllHooks, sandbox, defaultMode, apiKeyHelper) refuse.
- mergeClaudeInlineSettings also merges the equals form, so the launch keeps
one --settings with the core's hooks, permissions and sandbox.
- --bare, --safe-mode, --allowedTools, --permission-prompt-tool and
--permission-prompts (claude 2.1.281 --help) are core-owned for Claude.
Tests: tests/claude-launch-settings.test.mjs (every form refused for each
protected key; allowed equals form and launch file normalized; real
TerminalManager + LaunchPipeline in Normal and Auto keep one --settings with
the hooks and Auto's sandbox). Checked live with claude 2.1.281 on Ollama
qwen3.5:9b under a fake HOME: hooks fire with an allowed file/equals
contribution; the old equals-form argv silenced every hook; Auto's sandbox
blocked a write outside the project ("Operation not permitted").
…ervisor A manifest may declare decide.timeoutMs up to 60 000, and the hook, helper and gateway deadlines are sized from it, but hostCall() capped every host call at requestTimeoutMs, which production leaves at the 15 s surface default. A service with a 45 s budget that answered deny after 20 s was replaced by the timeout's ask at 15 s. hostCall() now keeps the caller's validated budget up to an explicit bound, MAX_HOST_CALL_TIMEOUT_MS = MAX_DECIDE_TIMEOUT_MS (60 s); surface requests keep requestTimeoutMs. The gate's gateway and helper deadlines (permissionGateTimings) stay longer than that bound. Test: tests/plugin-policy-budget-secrets.test.mjs "a decide budget above the supervisor's 15 s request default is honored through the real supervisor, and still ends at the budget" (DecisionHooks -> PluginServiceSupervisor -> a real service process: deny after 16 s within an 18 s budget is kept; an answer after a 16 s budget is ask). Fixture: tests/fixtures/slow-decide-service.mjs.
index.ts called pluginServices.sync() before PluginSessions, PluginCards and the plugin secrets existed, with several awaits in between. A service that called sessions.subscribe on canvastty.initialize (collect-demo does) got "Unknown host method" and kept running without its session map; secrets.get and cards.setBadge answered "not ready" the same way. PluginServiceSupervisor takes waitForHost: services wait in start() until hostReady(). index.ts sets it and calls hostReady() once sessions, cards, tools, secrets, the launch pipeline and environments exist, and before saved cards are restored, so a subscriber gets restored cards as events or in its subscribe snapshot. dispose() releases a start still waiting, spawning nothing. Without waitForHost the supervisor starts services at once, as before. Tests: tests/plugin-startup-order.test.mjs (index.ts order with a delayed host: a service subscribing on initialize gets a valid snapshot, secrets.get and cards.setBadge answers, and the restored and new card events; dispose before hostReady). Fixture: tests/fixtures/startup-subscriber-service.mjs.
…gins read observe_agent, get_agent_result and plugin screen events did redact(tail(buffer)) or redact(plainText(buffer).slice(-limit)). A cut inside a registered secret removed its head before matching, and the remaining tail matches neither the known value nor, for a custom secret, a generic pattern, so it reached another agent or a screen-reading plugin. Sibling paths had the same order: failure details (last lines/8 000 characters of the output, shown to agents and the control CLI) and the control CLI screen (the xterm viewport, whose top row can hold the tail of a secret that scrolled off). - AgentControlService observe/result and PluginSessions screen mask the whole buffer, then cut to the same bounds. - Failure details are built from the masked buffer. - The control CLI screen masks the scrollback plus viewport, then keeps the last `rows` lines. Tests: tests/secret-redaction.test.mjs (observe_agent, get_agent_result, plugin screen events and failure details with a custom secret across each cutoff); tests/agent-control.test.mjs (control screen with the secret split at the viewport's top edge). Existing wrapped-secret cases unchanged.
GIT_READ listed pull, stash, config, branch, tag, remote, reflog, fetch and push as unconditional reads, and classifyGit() returned for them before the outside-project check. `git -C /other/repo stash pop` changed another worktree and `stash clear` deleted its stashes with no write/delete-outside fact, so base protection had nothing to deny. - GIT_READ keeps only subcommands with no changing form. - gitEffect() parses the others: stash list/show, branch/tag listing and --contains style queries, config get/--get/--list/one key, remote (-v)/show/get-url, reflog/show and fetch/push --dry-run stay reads; stash drop/clear, branch/tag delete, remote remove and reflog expire/delete are deletes; every other form (stash push/pop/apply, pull, fetch, push, branch/tag create/move/force, config set/unset/add, remote add/set-url, ...) is a write. With -C (and now --work-tree/--git-dir) naming a folder outside the project, those become write-/delete-outside. Inside the project nothing changes. Test: tests/base-protection.test.mjs "git with -C another repository: mutating forms are writes or deletes outside; read-only forms stay allowed (parsed, never run)". The parser runs nothing.
|
Thanks for the detailed review. Items 1–7 are fixed as seven new commits on top of Final head: 1. P1: pending or failed environment choice across shutdown and restoreCommit: Root cause. The launcher's environment choice lived only on the in-memory session. The card was saved as Fix.
Tests (
Live (hidden window). I used the built app with a harness plugin whose
2. P1: initial prompt only after the asynchronous launch is readyCommit: Root cause. Fix.
Tests (
Live (hidden window). I used the real
3. P1: Claude settings forms and one merged payloadCommit: Root cause. Validation looked at each argv element on its own, and it parsed JSON only when the element started with Fix.
Tests (
Live, real CLI. Claude Code 2.1.281 ran under a fake HOME with a clean environment, on local Ollama
4. P2: decide budgets above 15 s through the real supervisorCommit: Root cause. Fix.
Test (
5. P2: services start after the host APIs are readyCommit: Root cause. Fix.
Tests (
On the unfixed head the first test failed with 6. P2: redact before selecting the tailCommit: Root cause. These paths redacted after cutting the text: Fix.
Tests. Each case places a custom secret with no generic token shape across the cutoff.
The existing wrapped-secret cases are unchanged. The source assertion in 7. P2: mutating Git formsCommit: Root cause. Fix.
Test ( 8. Final validation of the stale conversation-ID fixI re-ran the regression tests on
26 of 26 pass across these files and Final head resultsAll of these ran with a fake HOME:
Typecheck and the full suite also pass at every commit:
Platform and what was live
|
Purpose and scope
This is the consolidated PR for the session, plugin and agent-safety work originally submitted as #81–88. It provides one place to finish the implementation and review the complete integration against
main.The intended features remain in scope. The PR stays Draft while the correctness and integration findings in the maintainer review are addressed. Please push follow-up fixes to this PR's existing branch,
core/7-auto-profile, and link each fix and its regression coverage in your reply to the review.Included changes
The branch already contains the #82–87 commits; no cherry-picking or history rewriting is needed to consolidate their review. The #81 authentication implementation and regression tests are included through #87. The superseded PR branches and commit history are retained.
The branch also contains the exact Codex restore fix from #80 by @teo-nex. That PR remains a separately tracked dependency; the consolidation does not change its authorship or merge it independently.
Auto profile behavior
spawn_agentchildren.--approve-for-me), Claude Code (--permission-mode autowith sandbox settings), and Grok (--permission-mode auto).thirdPartyModel: truedowngrades Auto to the provider's accept-edits behavior. Codex and Claude retain the configured sandbox; Grok has no sandbox or decision-hook guarantee here.Validation status
At reviewed head
1f38707009962f5fb4132e4690dfe5e6609cade8, GitHub reports successfulverify,windows-pipe-hostandmacos-cli-resolutionchecks.The original author reported 993/993 suite checks, typecheck, electron-vite build, the secret audit, and 47/47
test:evenchecks, plus live checks with Claude 2.1.281, Codex 0.156.1 and a local model under a temporary HOME. These are the author's reported results, not additional runs by the maintainer reviewer.The maintainer review is static: code paths, existing tests and documentation were inspected; no local tests, build or live application checks were run. The review identifies missing regression scenarios despite the existing green CI.
After the review was posted, the author refreshed the stack to
e2de0d87cd95751604f26ceb00cb1e8033f8908e. The maintainer inspected that delta: review item 8 (stale conversation identity after Reopen) is addressed in code with added regression cases; items 1–7 remain open. The CI results above belong to the earlier reviewed head and are not a claim about this updated head.Completion before merge
Address each numbered item in the maintainer review, preserve the intended features, add focused regression coverage, and report validation against the final corrected head. Once that evidence is available, this single PR can be reviewed for merging into
main.