Skip to content

feat(hub,desktop): hub-relayed desktop UI context (D5) - #489

Merged
physercoe merged 3 commits into
j8-d4-element-pointingfrom
j8-d5-remote-desktop-ui
Jul 31, 2026
Merged

feat(hub,desktop): hub-relayed desktop UI context (D5)#489
physercoe merged 3 commits into
j8-d4-element-pointingfrom
j8-d5-remote-desktop-ui

Conversation

@physercoe

Copy link
Copy Markdown
Owner

D5 of docs/plans/desktop-ui-context-and-pointing.md (§3.5/§3.6). Stacked on #488 (D4)#487 (D3).

The second traffic class over the W3 reverse tunnel: not the embedded browser tabs, but the desktop's own UI. An agent anywhere in the team asks a desktop what its user is looking at (ui_get_focus), or — per-call approved, always — for a frame of it (ui_screenshot). desktop_ui_invoke packages the call as a Kind="desktop.invoke" envelope, exactly as browser_invoke does with "browser.invoke".

The structural machinery is now shared (one grant store, one approval helper, one router, two kind constants), so a third class costs ~zero — which is what §3.6 asked for. The reviewable part is the three places the classes deliberately differ.

1. Grants are keyed by kind; revocation is not

W3's store keyed team|host|agent with no kind dimension, so a naive "one helper, two kind constants" generalization would let a browser-driving session grant silence a desktop screenshot card — two different sentences, one consent. Granting is now kind-scoped.

Revoking is not, and that asymmetry is deliberate: "Revoke" in Settings → Remote driving means this agent no longer touches this desktop, so it clears every kind. (The desktop's own revoked-set already applies the same rule to reads.)

2. ui_screenshot consults no grant at all

Enforced in the approval helper, not by trusting the client to hide the button: a class with no grant namespace ignores option_id: "session" entirely. The card also carries session_grant: false, so a renderer states the consent shape instead of inferring it from the kind.

3. The desktop re-checks the envelope kind

The hub gates by classbrowser_invoke never raises a desktop_action card — so a ui_screenshot routed as browser.invoke would be a capture nobody approved. dispatchHubInvoke refuses a tool that arrived under the wrong kind. The desktop is the authority for its own pixels: it checks rather than trusts.

The capability key desktop_ui tracks the sharing toggle and is re-posted when it flips, so hosts_list answers truthfully instead of making the agent discover the refusal on call. Bridge toggle + Remote-driving toggle still gate the relay itself — all three consents stand.

The driver work replaced three drop sites, not one

The plan named pane/stdio and image:false ACP; gemini's exec-per-turn argv was a third with the same defect. All three now materialize the image into <workdir>/.termipod/annotations/ (0600) and name the path in the text, which every one of them can read.

Two consequences worth flagging in review:

  • An image-only input used to be rejected by the gemini driver and is now a valid turn whose body is the path. The user pointed at something and said nothing — a legitimate message. (Two of that driver's tests encoded the old drop-and-warn contract and were rewritten to the new one.)
  • A failed write keeps the historical drop-and-warn, with the reason in the event, because "notes the drop rather than failing silently" is the plan's rule.

Verification

  • go build ./..., go vet ./..., go test ./...: all green
  • 11 new Go tests in mcp_desktop_ui_test.go (class differences + the grant-store asymmetry), 7 in annotation_materialize_test.go (incl. partial-write and nowhere-to-write)
  • 6 new electron tests (tunnelclass.test.ts) — 299 pass
  • All 12 repo lints clean (lint-docs, lint-glossary, lint-arb, lint-design-tokens, lint-desktop-tokens, lint-vocab, lint-templates, lint-governed-actions, lint-doc-anchors, lint-hardcoded-strings, …)
  • Not run: the live tunnel — it needs two machines
  • Docs: reference/attention-kinds.md gains the desktop_action section

Stack: D3 (#487) → D4 (#488) → D5 → D6.

🤖 Generated with Claude Code

The second traffic class over the W3 reverse tunnel: not the embedded
browser tabs but the desktop's own UI. An agent anywhere in the team
asks a desktop what its user is looking at (ui_get_focus), or — per-call
approved, always — for a frame of it (ui_screenshot). desktop_ui_invoke
packages the call as a Kind="desktop.invoke" envelope, exactly as
browser_invoke does with "browser.invoke".

The structural machinery is now shared (one grant store, one approval
helper, one router, two kind constants), so a third class costs ~zero.
The interesting parts are the three places the classes deliberately
DIFFER:

- Grants are keyed by KIND; revocation is not. W3's store keyed
  team|host|agent with no kind dimension, so generalizing without one
  would let a browser-driving grant silence a desktop screenshot card —
  two different sentences, one consent. But "Revoke" in Settings →
  Remote driving means this agent no longer touches this desktop, so
  revoke clears every kind: the same asymmetry the desktop's own
  revoked-set already applies to reads.
- ui_screenshot consults no grant at all, and the approval helper
  enforces that rather than trusting the client to hide the button — a
  class with no grant namespace ignores option_id:"session" entirely.
  The card carries session_grant:false so a renderer states the consent
  shape instead of inferring it from the kind.
- The desktop re-checks the envelope kind. The hub gates BY CLASS, so a
  ui_screenshot routed as browser.invoke would be a capture nobody
  approved. dispatchHubInvoke refuses a tool that arrived under the
  wrong kind: the desktop is the authority for its own pixels, and it
  checks rather than trusts.

The capability key `desktop_ui` tracks the sharing toggle and is
re-posted when it flips, so hosts_list answers truthfully instead of
making the agent discover the refusal on call. Bridge toggle +
Remote-driving toggle still gate the relay itself — all three consents
stand.

The §3.5 driver work replaced THREE drop sites, not one. The plan named
pane/stdio and image:false ACP; gemini's exec-per-turn argv was a third
with the same defect. All three now materialize the image into
<workdir>/.termipod/annotations/ (0600) and name the path in the text,
which every one of them can read. Two consequences worth stating: an
image-only input used to be rejected by the gemini driver and is now a
valid turn whose body is the path (the user pointed at something and
said nothing — a legitimate message); and a FAILED write keeps the
historical drop-and-warn with the reason in the event, because "notes
the drop rather than failing silently" is the rule.

Tests: 11 new Go (hub class differences + the grant-store asymmetry) +
7 (materialization, including partial-write and nowhere-to-write) + 6
electron (kind↔tool enforcement). go build/vet/test ./... green, 299
electron node --test pass, all 12 repo lints clean. The live tunnel is
unexercised here — it needs two machines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@physercoe
physercoe force-pushed the j8-d5-remote-desktop-ui branch from 92dabaa to b9802e3 Compare July 31, 2026 03:22
@agentfleets

Copy link
Copy Markdown
Collaborator

Review of D5 (verified against head 92dabaa1, re-checked on the rebased branch — the issue below is unchanged). The tunnel generalization is solid: kind-keyed grants with kind-blind revoke, ui_screenshot correctly gets GrantKind: "" so session is ignored server-side, one dispatcher for local + tunnel with tool_kind_mismatch refusal, and desktop-side policy refusal on the via:'hub' leg. Test coverage of the acceptance list is strong. One substantive issue:

Major — raw-pane agents never run in the workdir their annotations materialize into

hub/internal/hostrunner/runner.go:887 launches the raw pane command bare:

p, err = launchCmdWithEnv(ctx, a.Launcher, sp, cmd, secretEnv)

— no cd <workdir> && wrapper. Every other M4 path adds one (launch_m4_kimi.go:194, launch_m4_locallogtail.go:313, launch_m4_antigravity.go:223, and M1/M2 at launch_m1.go:165/launch_m2.go:289). But runner.go:926 derives PaneDriver.Workdir with the comment "The workdir the pane command runs in — same derivation the M1/M2 launchers use" and materializes crops into ~/hub-work/<team>/<pid8>/<handle>/.termipod/annotations/.

So a pane agent on the raw PaneDriver path (the kimi wire-tail fallback, or any M4 kind without a specialized arm — exactly the driver class this fallback exists for) runs in the tmux server's cwd while its annotation lands elsewhere. Delivery works in practice only because the note names an absolute path and yolo-mode TUIs read those; a cwd-confined agent would never find the file, and the plan's "materializes the crop into the agent's workdir" is false for this path.

Fix options: wrap the raw pane launch in the same cd prefix (broader behavior change), or derive Workdir only where the launch actually cds and drop-with-warning otherwise — at minimum, correct the comment.

Minor

  • Audit mislabelmcp_browser_bridge.go:319 hardcodes the summary "desktop action awaiting approval: " while the event name uses req.AttentionKind; every browser_action request now audits as "desktop action awaiting approval: browser_click …".
  • Remote-leg card can't show what §3.3 promises — the hub-raised payload is {host_id, host_name, tool, args, agent_id, session_grant} — no scope/surfaces/url (the hub can't know what's on screen). attention-kinds.md documents the richer payload, true only for the desktop-raised local card. A remote window-capture approval is sight-unseen; at minimum fix the doc.
  • Materialized images never expire — screen-crop PNGs accumulate in <workdir>/.termipod/annotations/ for the workdir's lifetime. A count/age cap would be reasonable follow-up.
  • Nits: gemini's exec-resume drop warning lost the fallback reason the other two drivers include; stale comments — desktopui.ts:22 still says "D1 is LOCAL-ONLY … (that is D5)" while the file now POSTs capabilities to the hub, and desktopui.test.ts:97 says "consent-gated until D5 builds its own stack"; server.go calls bridgeGrants "the desktop session-grant cache" though it serves both kinds; attention-kinds.md header credits D5 with desktop_action (D3 added the kind, D5 adds the hub-raised leg).

Test gaps

Approval timeout → auto-resolve → deny is untested (needs a clock seam); the via-hub provider re-check wiring (capture() in uicapture_host.ts) is electron-bound and untestable under node --test as written; no explicit hub-side test that browser_invoke with tool:"ui_screenshot" is rejected (the desktop-side smuggle test exists in tunnelclass.test.ts); refreshHostCapabilities and answerTunnelEnvelope's kind mapping untested.

Ubuntu and others added 2 commits July 31, 2026 04:13
… the right class

Review fixes for #489 (D5):

- Major: the raw PaneDriver fallback launched its command bare while
  deriving Workdir 'same as the M1/M2 launchers' — the pane ran in the
  tmux server's cwd and annotation crops materialized into a directory
  the agent was never in. The launch now MkdirAll+cd-wraps into the
  derived workdir exactly like every specialized launcher, and Workdir
  is set only when that cd actually happened (else the materialization
  drop-with-warns honestly). Pinned by a launchOne test.
- Audit mislabel: the pre-route audit summary hardcoded 'desktop action
  awaiting approval' for every class — now derived from the attention
  kind, so browser_action requests audit as browser actions again.
- attention-kinds.md: the payload is documented per leg — the hub-raised
  remote card is sight-unseen ({host_id, host_name, tool, args, …}), the
  richer surfaces/url shape is desktop-raised only; header credit
  corrected (D3 introduced the kind, D5 adds the hub leg).
- Materialized crops are capped per workdir (newest 32 kept) — they
  accumulated for the workdir's lifetime; prune is best-effort and
  test-pinned.
- Gemini's drop warning regains the fallback reason the pane/ACP
  drivers carry (WHY the workdir fallback failed, not just that it did).
- Stale comments swept: desktopui.ts D1-local-only header,
  desktopui.test.ts 'until D5', server.go bridgeGrants now names both
  tunnel classes.
- Tests: browser_invoke rejects desktop-UI tools hub-side (the mirror
  of the existing cross-class check), raw-pane workdir contract,
  annotation-dir prune.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@physercoe

Copy link
Copy Markdown
Owner Author

Second review pass (agent): the major confirmed against code and fixed on the branch — commit ea5926f8 (merges the D4 fix underneath).

Major (fixed, the cd-wrap variant): the raw PaneDriver fallback launched its command bare while every specialized launcher (launch_m4_kimi :194, locallogtail :313, antigravity :223, M1/M2) cds into the derived workdir — so PaneDriver.Workdir named a directory the pane never entered, and a cwd-confined agent could not find its materialized crop. The raw launch now MkdirAll + cd <wd> && -wraps exactly like the others, and Workdir is set only when the cd actually happened (failed derivation/mkdir → bare launch, empty Workdir, honest drop-with-warn). Pinned by TestLaunchOne_RawPaneRunsInTheDerivedWorkdir.

Minors, all fixed:

  • Audit summary derives from req.AttentionKindbrowser_action requests no longer audit as "desktop action awaiting approval".
  • attention-kinds.md documents the payload per leg (hub-raised remote card is sight-unseen: {host_id, host_name, tool, args, …}) + header credit corrected (D3 introduced the kind).
  • Materialized crops capped per workdir (newest 32; UTC-stamped names make lexical order chronological) — best-effort prune, test-pinned.
  • Gemini's drop warning regains the fallback reason field the pane/ACP drivers carry.
  • Stale comments swept: desktopui.ts header, desktopui.test.ts, server.go bridgeGrants (names both kinds).

Test gaps: added the hub-side browser_invoke × ui_screenshot/ui_get_focus rejection test (mirror of the desktop-side smuggle test). The approval-timeout auto-resolve and answerTunnelEnvelope kind mapping remain untested (need a clock seam / electron) — recorded, not fixed.

Verified: go build/vet/test ./... green, electron 313/313, lints clean.

🤖 Generated with Claude Code

@physercoe
physercoe merged commit e3b8098 into j8-d4-element-pointing Jul 31, 2026
8 checks passed
physercoe pushed a commit that referenced this pull request Jul 31, 2026
…490)

The stacked PRs squash-merged into their base branches (the GitHub
stack cascade), so main received only D3 (#487); this merge brings the
cascaded j8-d5-remote-desktop-ui head — element-resolved pointing (D4),
hub-relayed desktop UI context (D5), and agent pointing (D6), each
including its review-fix commit — onto main. Conflicts (main's D3-squash
regions vs the branch's D5/D6-extended versions of the same code)
resolved by taking the branch side, which is a strict superset; the
plan's status block updated to all-shipped.

Co-Authored-By: Claude Fable 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.

2 participants