Skip to content

feat(agents): messaging rides the per-project capability switch - #237

Merged
eneskirca merged 5 commits into
mainfrom
feat/messaging-per-project-switch
Aug 15, 2026
Merged

feat(agents): messaging rides the per-project capability switch#237
eneskirca merged 5 commits into
mainfrom
feat/messaging-per-project-switch

Conversation

@eneskirca

@eneskirca eneskirca commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Messaging PR 6 of the agent-messaging series — now a small PR by design: the generic per-project capability mechanism landed in #213, and #212 shipped the send/reply/notify verbs fail-closed behind messagingEnabled: () => false. This PR is the wiring between the two.

The verbs go live ONLY per project, and only with BOTH halves of the grant: the strict agentMessaging === true flag in the git-shared .nodeterm/project.json AND this machine's recorded 'kept' answer to the one-time clone notice (projectCapabilityGrantedFor). A flag that is on but unanswered, a recorded decline, a forged file-borne capabilityAck, and a hand-edited "true" all keep every delivery refused as notPermitted (switch-off).

Commits

fc1e1003 — feat(project): agentMessaging joins the capability registry
One line in PROJECT_CAPABILITIES plus a PROJECT_CAPABILITY_COPY entry (src/shared/project-capabilities.ts), and agentMessaging?: boolean on Project/ProjectFileV1. Everything else appears generated by #213's mechanism, with zero new code:

  • the strict === true validation, hostile-input refusals, and the no-bytes-when-off file round-trip (the registry-iterating suites now run for the new key);
  • the one-time clone notice with the machine-local kept/declined ack — a new test drives a cloned agentMessaging: true through the real dialog and pins messaging's own copy;
  • the Settings → Agents row (rows are generated from the registry; no hand-written row, per the feat(projects): the per-project capability mechanism #213 design).

042aa409 — feat(agents): messagingEnabled is the capability GRANT, read per call off the store
The real work:

  • WorkspaceStore.capabilityProjectFor(projectId) — the capability view of one project: strict flags from the shared file, answers from the machine-local index entry only (a repo hand-carrying capabilityAck is a forgery and is never read). Same id semantics as persistedCanvases, so delivery scope and the switch agree on project identity.
  • messagingEnabledVia(getProject) in src/main/agent-messaging.ts — the production wiring as one testable call: projectCapabilityGrantedFor(getProject(projectId), 'agentMessaging'). Never projectCapabilityFlagInFile — the raw file bit answers true in exactly the states that must refuse (pending notice, recorded decline), and the new suite goes red on that swap (the feat(projects): the per-project capability mechanism #213 review's I2 trap, mutation-checked).
  • src/main/index.ts replaces messagingEnabled: () => false with the wired check. Read per call: a decline or an off-toggle refuses the very next delivery.

Evidence

  • src/main/agent-messaging-switch.test.ts runs the REAL control path (deliverFromControl), half of it over a REAL WorkspaceStore on disk: (a) switch absent → switch-off (the feat(agents): the send, reply and notify verbs #212 test also stays green), (b) flag true + ack pending or declined → still refused, (c) flag true + 'kept' → delivered to the pane-write layer. Forged file-ack and hand-edited "true" cases run through a fresh store load.
  • Mutation checks: registry-drop 3/3 red; wiring 4/4 red (grant→flag swap 5 tests red, file-borne ack accepted, entry ack dropped for cwd and for inline entries).
  • npm run typecheck clean; full npx vitest run at HEAD: 465 files / 6474 tests passed, 0 failed (1 file / 12 tests skipped = the opt-in Docker SSH suite).

Surfaces: Desktop is the only surface with the verbs; the Server Edition still answers control-unsupported-on-this-edition (untouched); mobile is unaffected (never a sender).

🤖 Generated with Claude Code


Fix round 1 (review: spec PASS / quality CHANGES-REQUIRED)

I-1 (Important — confused deputy, closed): 6b867a6d. The switch was evaluated against the source's project while panes are keyed by the bare node id globally, so a granted project A whose file listed ungranted project B's node id could deliver into B's live pane once A's clone notice was kept (proved by execution in review). The grant now gates the project that OWNS the target pane: resolveDeliveryScope computes the set of projects claiming the target id — more than one owner is refused as the new, named notPermitted (ambiguous-target-node-id) (never "pick the sender's"), and a unique target resolves to its owning project, whose grant the switch then checks. The reviewer's exact scenario runs as a test over a REAL WorkspaceStore (granted A listing ungranted B's node id → refused, nothing written), plus scope-unit coverage for both directions, the named reason carried through decideDelivery, terminality, and the unique-target control. Mutation-checked: reverting to source-project resolution turns the attack test back into delivered (3 red); renaming the refusal to cross-project also goes red. The scope docblock's "cannot close" residual note is rewritten to state what now holds.

M-1 (report tally): corrected in the report — the registry-drop mutation's 3 red span two files (2 in project-capabilities.test.ts, 1 in the Settings-row suite).

M-2 (CapabilityNotice mount pin): carried forward as a tracked concern — a real reachability assertion needs a full Canvas.tsx mount (9.2k-line monolith, no harness), and a source-text grep test is this repo's documented anti-pattern. Fails safe if regressed (permanently switch-off). Listed for device verification.

Full npx vitest run at 6b867a6d: 465 files / 6477 tests passed, 0 failed (12 skipped = opt-in Docker suite).


Fix round 2 (re-review: new CRITICAL — the round-1 residual was FAIL-OPEN)

9ecd0c5f — prove pane ownership at runtime. The round-1 ambiguity check needs two store claimants; when the victim's real project is ABSENT from the persisted store, a granted hostile project A that lists the live pane's node id is the SOLE store claimant, so ambiguity never fires, scope resolves same-project to A, A's switch passes, and the envelope reaches the victim's one global tmux pane (driven end-to-end: delivered, reply.ok:true). project.json's node-set is attacker-writable, so it cannot be the ownership authority.

The distinguishing fact is runtime — which project's create() actually spawned the pane. A new in-memory ledger (src/core/agents/pane-ownership.ts) records nodeId → owning projectId on a GENUINE fresh spawn only (spawnNew, gated on the manager's existing fresh signal), so an attach/co-attach never claims a session another project spawned; the owner is the renderer's machine-local project id (PtyCreateOptions.ownerProjectId), never the file id. The delivery gate reads the LEDGER for the target's true owner and evaluates the grant against THAT, with the store as a cross-check. No entry (restart / never spawned here) or a disagreement → refused with the new terminal reason unproven-target-owner.

Cold state is fail-CLOSED by decision: after an app restart the tmux server survives and re-open ATTACHES (fresh=false, nothing recorded), so a re-attached pane is unproven and messaging to it is refused until a true respawn. No repopulation on attach — no cross-restart signal exists that a hostile agent's shell could not also write.

Tests (real store + real ledger, mutation-checked): the re-reviewer's exact attack → refused, sent:[] (red-first: pre-fix delivered); ledger-empty restart → refused; ledger owner disagrees with a granted sole claimant → refused; legitimate self-delivery → delivered. Mutations 2/2: bypass the ledger (fall back to store projectId) flips the attack to delivered; drop the owner !== projectId cross-check → attack+disagree red.

Residual stated plainly: re-attached-after-restart panes fail closed until respawn (safe, deliberate); a durable userData ledger would remove that cost at the workspace.json trust level (future work). The fresh-gate itself is covered by unit semantics + the documented spawnNew contract, not a node-pty spawn/attach integration test (environment-flaky) — flagged for device verification.

Full npx vitest run at 9ecd0c5f: 466 files / 6487 tests passed, 0 failed (12 skipped = opt-in Docker suite).


Fix round 3 (re-review: SECURITY-CLOSED — two Minors before merge)

f2a99056 — kanban modal ownership parity + an automated pin for the fresh-gate.

  • Minor 1 (parity): ModalTerminal.tsx was the one renderer transport.create site not threading ownerProjectId. Harmless when it co-attaches to an already-proven pane, but as the FIRST/sole fresh spawner of a node (a card for a node whose project is not the active canvas) that pane recorded as unproven and messaging to it failed closed. It now passes the CARD's project (the one the modal already resolved), not the active canvas id. Both create sites thread the owner identically.
  • Minor 2 (doc): corrected the residual note — phones spawn through the host renderer's TerminalNode, which threads ownerProjectId, so there is no phone/relay fresh-spawn path that fails closed. The only residual is the deliberate re-attach-after-restart refusal.
  • Optional (taken): extracted the load-bearing fresh-gate as the pure shouldRecordOwnership(fresh, persistKey, owner) with its own unit suite; a mutation dropping the fresh === true leg (record on attach) turns it red — the fresh-gate now has an automated guard, not just a co-located comment.

Full npx vitest run at f2a99056: 466 files / 6491 tests passed, 0 failed (12 skipped = opt-in Docker suite); typecheck clean.

eneskirca and others added 5 commits August 15, 2026 17:33
…tch, the notice and the Settings row all appear generated

The mechanism is PR #213's: one line in PROJECT_CAPABILITIES plus a copy
entry, and the strict === true file round-trip, the one-time clone notice
(machine-local kept/declined ack) and the generated Settings -> Agents row
all cover the new key with zero new mechanism. Project/ProjectFileV1 gain
the optional agentMessaging field; tests name the messaging capability
specifically so the generic coverage is pinned, not assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… off the store

Replaces the fail-closed () => false placeholder (PR #212) with the real
per-project check: projectCapabilityGrantedFor(project, 'agentMessaging') —
the strict === true flag in the git-shared project.json AND this machine's
recorded 'kept' answer to the clone notice. NEVER the raw file bit
(projectCapabilityFlagInFile): during the pending-notice window and after a
recorded decline the flag answers true while delivery must refuse, and the
new suite goes red on exactly that swap (PR #213 review, I2).

- WorkspaceStore.capabilityProjectFor: the capability view of one project
  (strict flags from the shared file, answers from the machine-local index
  entry — a file-borne capabilityAck is a forgery and is never read), same
  id semantics as persistedCanvases so scope and switch agree on identity.
- messagingEnabledVia: the production wiring as one testable call.
- main/index.ts wires the IPC deps through both; nothing else changes.
- agent-messaging-switch.test.ts drives the REAL control path, half of it
  over a REAL WorkspaceStore on disk: unanswered/declined/forged/hand-edited
  all refuse as notPermitted (switch-off); flag+kept delivers to the pane
  write. Mutation-checked 4/4 (flag-for-grant swap, file-borne ack, entry
  ack dropped for cwd and inline entries).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rant can never speak for another's pane

Closes PR #237 review I-1 (confused deputy, proved by execution): panes are
keyed by the BARE node id globally, so a hostile/cloned project A with
agentMessaging granted that merely LISTS ungranted project B's node id
resolved same-project to A, passed A's switch, and delivered into B's one
global pane the moment the user kept A's clone notice.

The grant now gates the project that OWNS the target pane, resolved at
scope time: resolveDeliveryScope computes the set of projects claiming the
target id; more than one owner is refused as the new, named
notPermitted (ambiguous-target-node-id) — never 'pick the sender's', which
was exactly the hole — and a unique target resolves to ITS owning project
(by construction the one the sender shares), whose id is what the switch
is then evaluated against. The #208 residual note in agent-message-scope.ts
documented this as 'cannot close'; it is now closed at the reachable layer
and the docblock rewritten to say what holds instead of what leaks.

Tests: the reviewer's exact scenario over a REAL WorkspaceStore (granted A
listing ungranted B's live node id -> refused, nothing written) plus scope
unit coverage: both directions of a duplicate refused, the refusal carries
its own reason through decideDelivery and is terminal, and a unique target
still resolves. Mutation-checked 2/2: reverting to source-project grant
resolution turns the confused-deputy test back into 'delivered' (3 red);
renaming the refusal to cross-project also goes red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ot reach a pane it did not spawn

CRITICAL (PR #237 re-review): the round-1 ambiguity check needs TWO store
claimants, so when the victim's real project is ABSENT from the persisted
store a granted hostile project A that lists the live pane's node id is the
sole store claimant — ambiguity never fires, scope resolves same-project to
A, A's switch passes, and the envelope reaches the victim's one global
tmux pane (driven end-to-end: outcome=delivered, reply.ok=true). The store's
node-set is attacker-writable (project.json lists ids A never spawned), so
it cannot be the ownership authority.

The distinguishing fact is RUNTIME: which project's create() actually
spawned the pane. A new in-memory ledger (src/core/agents/pane-ownership.ts)
records nodeId -> owning projectId on a GENUINE FRESH spawn only
(spawnNew, gated on the manager's existing 'fresh' signal), so an
attach/co-attach to a session another project spawned never claims it. The
owner is the renderer's machine-local project id (PtyCreateOptions.
ownerProjectId), never the git-shared file id. The delivery gate now reads
the LEDGER for the target's true owner and evaluates the grant against THAT;
the store's projectId is a cross-check. No entry (restart / never spawned
here) or a disagreement -> refused with the new, terminal reason
unproven-target-owner. forgetPaneOwner on session end.

Cold state is fail-CLOSED by decision: after an app restart the tmux server
survives and re-open ATTACHES (fresh=false, nothing recorded), so a
re-attached pane is unproven and messaging to it is refused until a true
respawn. No repopulation on attach — no cross-restart signal exists that a
hostile agent's shell could not also write (a tmux session-env var), and
guessing owner on attach is how the attacker would re-acquire it.

Tests (real store + real ledger, all mutation-checked): the re-reviewer's
exact attack (victim absent, pane live, granted attacker) -> refused,
sent:[] (red-first: pre-fix delivered); ledger-empty restart -> refused;
ledger owner disagrees with a granted sole claimant -> refused; legitimate
self-delivery (ledger owner == store project, granted+kept) -> delivered.
Ledger unit suite for record/overwrite/forget/unproven-default. Mutations
2/2: bypass the ledger (fall back to store projectId) flips the attack to
delivered; drop the owner!==projectId cross-check leg -> attack+disagree red.

Residual stated in pr6-report.md: re-attached-after-restart panes fail
closed until respawn (safe, deliberate); a durable userData ledger would
remove that cost at the workspace.json trust level (future work). The
fresh-gate itself is covered by unit semantics + the documented spawnNew
contract, not a node-pty spawn/attach integration test (env-flaky).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fresh-gate

PR #237 re-review (security-closed) left two Minors before merge, both
fail-closed, neither a hole.

Minor 1 (parity): ModalTerminal was the one renderer transport.create site
not threading ownerProjectId. It co-attaches to an already-proven
TerminalNode pane in the common case, but as the FIRST/sole fresh spawner
of a node (a card for a node whose project is not the active canvas) it
recorded the pane as unproven and messaging to it failed closed. It now
passes ownerProjectId using the CARD project it already resolved
(sshConnectionScope(ssh) ?? activeProjectId), deliberately not the active
canvas id. Both create sites now thread the owner identically.

Minor 2 (doc): corrected the round-2 residual note in pr6-report.md.
Phones spawn through the host renderer TerminalNode, which threads
ownerProjectId, so there is no direct phone/relay fresh-spawn path that
fails closed; the only residual is the deliberate re-attach-after-restart
refusal.

Optional refactor (taken, clean): extracted the load-bearing fresh-gate as
the pure shouldRecordOwnership(fresh, persistKey, owner), used by spawnNew,
with its own unit suite. Records only a genuine fresh spawn with a
persistKey and a known owner; attach / no-persistKey / no-owner do not.
Mutation: dropping the fresh===true leg (record on attach) turns the suite
red, so the confused-deputy-via-restart hole now has an automated pin.

Full vitest run: 466 files / 6491 tests passed, 0 failed. typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eneskirca
eneskirca enabled auto-merge August 15, 2026 18:56
@eneskirca
eneskirca merged commit f2698b7 into main Aug 15, 2026
4 checks passed
aedanilyak added a commit to Adscale-R-D/nodeterm that referenced this pull request Aug 17, 2026
…/notify)

Asked whether we couldn't just make it work, instead of refusing it more politely.
We can, and it was a wiring change: `src/main/agent-messaging.ts` imported nothing
but `core` and `shared`, and its entire surface is the injected AgentMessagingDeps —
every one of whose fields the server already had (PtyManager for the pane probes and
the framed write, WorkspaceStore for the delivery scope and the capability grant,
SettingsStore for custom agents, the board-log router, and paneOwnerProject, which
has been sitting in core since PR eneskirca#237 with a comment saying it was put there for
both shells). Only the boot wiring was main-side.

So: the module moves to core/agents/, and both shells call one factory,
`initAgentMessaging` (core/agents/agent-messaging-boot.ts), which builds the deps,
arms the deliver-on-idle queue and registers agent:message-deliver on whichever
platform is installed. A factory rather than two deps objects because those nine
fields are duplicated SECURITY wiring, and the two likeliest to drift are the two
that must not: messagingEnabled reading the GRANT (never the raw project.json flag)
and paneOwnerProject being consulted at all.

NOTHING WAS LOOSENED to make the new edition work. Same per-project capability grant
(off by default: strict `=== true` in the git-shared project.json AND this machine's
recorded 'kept' ack), same runtime pane-ownership gate, same flow budgets, same
verified-only route in hook-server. `wake`/`isHibernated` stays renderer state with
no shell-side signal on EITHER shell — a pre-existing residual, unchanged.

Two shell-specific answers, both explicit: `isRemoteNode` is REQUIRED rather than
defaulted (desktop asks ptyManager.sshRemoteForNode; the server answers a constant
false, which is complete there because SSH projects are desktop-only on that
edition), and the queue-flush leg rides wireAgentStatus's new `onMessagingEvent`.
That leg is the subtle one: miss it and deliveries to an idle target still work, so
it looks wired, while every delivery to a BUSY target is answered `queued` and waits
forever — the flush trigger is the target's own `done` event.
agent-messaging-both-shells.test.ts pins both legs at source level, because this repo
has shipped a one-shell hook change three times and a boundary test cannot tell you a
call is missing.

Also fixes the refusal wording that prompted the question. It read "Canvas control is
not available on the nodeterm Server Edition" for a single refused verb — true while
every verb was refused, a lie once most worked. An agent asked for `send`, was told
the whole feature was missing, and asked its user to check an unrelated Settings
switch. Refusals now name the VERB and say other verbs work; the regression is
pinned. EDITION_UNSUPPORTED_VERBS is down to `browser`, which is genuinely structural.

Relay tabs still refuse messaging, deliberately: a delivery there types into a pane on
the HOST while the gate chain would be evaluated against the guest's own stores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant