Repository navigation
(triggers): deliver a trigger to a remote session through the CLI messaging socket (#437) - #439
Conversation
…saging socket A trigger naming a remote session id had no target. With the opt-in remoteTriggers setting, a single trigger to a session that is not attached locally is sent over the session's messaging socket on an enabled host; idle is read from the pulled descriptor, slash commands go through a fixed allow-list, and success reads assumed, never delivered. Closes #437
|
Review at 8e35be1: approved, no blocking finding; all checks green. 13 mutations on the security-relevant guards (gating, slash allow-list, constant sent, PTY precedence on every poll and before the write, enabled-host filter, no content in argv, ambiguous id, age bound, second-pull rule, absent status, bucket, chain refusal) all caught. Before merge: the remote prefix refusal keys on trim().startsWith('/'), which lets |
The remote refusal keyed on trim().startsWith('/'), which let a zero-width character hide a slash command and did not cover ! (bash mode) or # (memory). Also read the setting and enabled hosts once per poll, prune idle rate buckets, and document that remoteTriggers has no UI yet.
Refs #437
|
Delta review at 9d59345: approved. Leading whitespace and zero-width characters are stripped before refusing |
An unattached remote session listed in the pulled descriptors previously had no trigger target. A single trigger can now send through the existing messaging socket adapter when the global remoteTriggers setting is enabled; the default is false and no Settings control is added.
Local and attached terminals retain precedence. Enabled hosts are checked dynamically; collisions, chains, stale pulls, unknown fresh status, attachment during a wait, and cwd mismatches refuse the send. Remote idle waits are passive and require two distinct completed post-start pulls from the same host. Only exact /compact and /clear are allowed slash commands after trimming. Success is assumed only and captures the descriptor before writing; an ambiguous write returns send unconfirmed with written unknown.
The shared adapter adds structured failure codes and a per-host-session bucket of 30 prompts, refilling one every two seconds. It refunds definite failures and preserves the success object exactly as { ok: true }. Timeout dedupe remains reserved; other non-zero exits preserve the existing dedupe release behavior. Documentation and the Unreleased changelog entry describe the setting, passive waits, result fields and retry limits.
Validation
Mutations
36/36 mutations were detected with exit 1 in a disposable worktree-local copy. Each edit was restored in finally. No tracked source was mutated. U4 and U20 are guard-only tests. The first U6 mutation survived an assertion that included the final lookup; the freshness assertion was tightened, rerun green, and the same mutation then failed. No production change was needed for that survivor.
Reproduce all: node .work-files/mutate.cjs. This copies the current working tree sources and tests, links the existing dependencies, applies one edit at a time, and executes node --test --test-name-pattern= test/.test.js in the copy, with a 12-second bound per mutation. .work-files/mutations.json contains the exact edits and measured exit statuses. The copy uses current sources because the implementation is uncommitted; archiving HEAD alone would omit it.
return { ok: true, submitted: SUBMITTED_ASSUMED, sessionId, command: text→return { ok: true, submitted: SUBMITTED_CONFIRMED, sessionId, command: textif (aliases.length > 1) return { aliases };→if (false) return { aliases };const result = await ctx.remote.send(snapshot.alias, snapshot.descriptor, text);→const result = await ctx.remote.send('evil', snapshot.descriptor, text);if (chain !== undefined) return refuse('a chain cannot be sent→if (false) return refuse('a chain cannot be sentsnapshot.at != null && snapshot.at >= started→snapshot.at != nullif (snapshot.alias !== initial.alias) return resolve(refuse('the session moved to another host while waiting; nothing was written'));→if (false) return resolve(refuse('the session moved to another host while waiting; nothing was written'));: REASON_REMOTE_NO_FRESH_PULL;→: REASON_CLI_BUSY;if (!['busy', 'waiting', 'shell'].includes(lastStatus)) return resolve(refuse(REASON_REMOTE_UNKNOWN_STATUS));→if (!['busy', 'waiting', 'shell'].includes(lastStatus)) return resolve(null);['busy', 'waiting', 'shell'].includes(lastStatus)→['busy', 'waiting'].includes(lastStatus)duringWait ? 'session exited during wait' : 'session not found'→duringWait ? 'not sent' : 'session not found'if (ctx.getPtyForSession(sessionId)) return resolve(refuse(REASON_REMOTE_ATTACHED));→if (false) return resolve(refuse(REASON_REMOTE_ATTACHED));if (ctx.getPtyForSession(sessionId)) return refuse(REASON_REMOTE_ATTACHED);→if (false) return refuse(REASON_REMOTE_ATTACHED);path.posix.normalize(value);→path.posix.normalize(value).toLowerCase();error: result.maybeWritten ? 'send unconfirmed'→error: result.maybeWritten ? 'not sent'status: snapshot.descriptor.status ?? null,→status: snapshot.descriptor.status ?? 'idle',sent_at: new Date(sentAtMs).toISOString()→sent_at: sentAtMsif (command.length > MAX_COMMAND_LEN)→if (false)if (FORBIDDEN_COMMAND_RE.test(command))→if (false)code: 'timeout', maybeWritten: true,→code: 'timeout',return { ok: true }; } return { send };→return { ok: true, extra: true }; } return { send };const RATE_CAPACITY = 30;→const RATE_CAPACITY = 31;const RATE_REFILL_PER_MS = 0.5 / 1000;→const RATE_REFILL_PER_MS = 1 / 1000;bucket.tokens = Math.min(RATE_CAPACITY, bucket.tokens + 1);→bucket.tokens = bucket.tokens;return aliases;→return aliases.slice(0, 1);if ('remote' in deps) Object.defineProperty→if (true) Object.definePropertyconst fs = require('fs');→const cp = require('child_process'); const fs = require('fs');if (!text) return refuse(REASON_REMOTE_SLASH);→if (!text) text = trimmed;text = REMOTE_SLASH_COMMANDS.find(value => value === trimmed);→text = REMOTE_SLASH_COMMANDS.includes(trimmed) ? command : undefined;if ((settings.remoteTriggers ?? SETTING_DEFAULTS.remoteTriggers) === true)→if (true)isEnabled(alias) && list.some→list.someif (wait === 'none' && (snapshot.at == null || Date.now() - snapshot.at > snapshot.maxAgeMs))→if (false)if (!session || session.exited) return null;→if (!session) return null;if (firstPull === null) firstPull = snapshot.at;→if (firstPull === null) { firstPull = snapshot.at; if (snapshot.descriptor.status === 'idle') return resolve(null); }if (acquireSessionLock) {→if (false) {const sessionEntry = ctx.getPtyForSession(sessionId);→const sessionEntry = null;if (remote) {→if (true) {Live-only checks: not performed
These need a real host and an isolated running instance. This environment has no network. Node 20/22 with c8 and external CI were not run; only Node 24 is available. No independent reviewer loop or publication was attempted.
Boundaries and deviations
Files changed
Round 2
The five review points are addressed against committed round 1,
8e35be1.Changes remain in the supplied worktree for the parent to commit.
!and#are refused before waiting; unsupported/retains its existing refusal. Exact/compactand/clearstill send the constants. Plain prompts, including middle!/#, retain the caller's original text. All three exact reason strings are documented indocs/automation.mdand referenced from the trigger context document.remoteTriggersis explicitly documented as having no UI and being maintainer-only for now. Its concrete storage is theremoteTriggersJSON property in the SQLitesettingstable,key = 'global', with the global object invalue; other properties must be preserved. No configuration command is invented.getSetting('global')call across three aliases per lookup, and verifies changed enabled hosts and opt-in on subsequent accesses.pruneRecentalso deletes fully refilled idle rate buckets, including when a duplicate will be refused. Depleted buckets and buckets with a pending send remain. A pending counter is released infinally, preserving the existing refund and send-result behavior. Tests observe the shipped adapter's Maps in a VM and also verify the public rate cap after a bucket is recreated; no production inspection API is added.SWITCHBOARD_TRIGGERS_DIRvalue, matchingrun(), and asserts restoration. Its before/after regression is also run with a pre-existing value.Round 2 validation
test/trigger-*.test.jsandtest/remote-*.test.jsfiles: 816 tests, 810 passed, 0 failed, 6 skipped, exit 0 (.work-files/round2-before.log)..work-files/round2-red.log). Failures showed commands proceeding instead of refusing, five settings reads instead of one, and retained full buckets. BOM trimming, plain prompts and existing allow-list cases already passed.SWITCHBOARD_TRIGGERS_DIR=round2-previous-directory: 1 failed before restoration, 1 passed after (.work-files/round2-u27-red.log,.work-files/round2-u27-green.log)..work-files/round2-green.log).pruneRecent, then both pruning tests passed (.work-files/round2-prune-dedupe-red.log,.work-files/round2-prune-dedupe-green.log)..work-files/round2-final.log). Reproduce in PowerShell:$files = Get-ChildItem test/trigger-*.test.js,test/remote-*.test.js | Select-Object -ExpandProperty FullName; node .work-files/run-bounded.cjs round2-final --test --test-concurrency=4 $files. Each test command has a 180-second bound..work-files/round2-wiring.log).npx --no-install eslint main.js remote-send.js trigger-context.js trigger-watcher.js test/remote-send.test.js test/trigger-remote.test.js: exit 0, 0 errors, 6 existing main-process warnings (.work-files/round2-eslint.log), 30-second bound.git diff --check: exit 0.Round 2 mutations
Final result: 8/8 mutations detected through assertion failures, each exit 1 with no timeout. Sources are copied to a disposable worktree-local directory and restored in
finally; tracked runtime files are never mutated. Reproduce:node .work-files/mutate-round2.cjs. Exact replacements and results:.work-files/round2-mutations.json; detailed outputs:.work-files/round2-mutation-0.logthroughround2-mutation-7.log. Each mutation has a 12-second bound. The first U27 mutation accidentally targeted therun()helper and survived; its anchor was corrected to U27 itself, which then failed as required.command.trim()U21: invisible prefix!guardU21: mode prefix#guardU21: mode prefixU17: pruningU17: pruning retainslookupU22: eachgetSettinginside the host predicateU22: eachU27:with a pre-existing environment valueRound 2 limits and files
npm testsuite was not repeated in this focused round; its earlier result above is historical evidence, not a fresh round 2 result. No round 2 test failure was attributed to sandbox restrictions.scripts/run-tests.js, invoked by the existingtestworkflow's coverage job. Local red/green evidence does not claim CI chronology.Access is deniedfor the continuity lock; reading that same session reported it was not registered, so no checkpoint could be created. No other session identity was reused. All verification processes have completed.trigger-watcher.js,trigger-context.js,main.js,remote-send.js,test/trigger-remote.test.js,test/remote-send.test.js,docs/automation.md,.ai/contexts/trigger-watcher.md. This PR body and the new validation artifacts are worktree-local scratch files. The existing round 1 changelog entry for (triggers): deliver a trigger to a remote session through the CLI messaging socket #437 remains the entry for this PR.