Skip to content

feat: add supervised pause and human takeover#59

Merged
ElbertePlinio merged 5 commits into
mainfrom
feat/21-takeover
Jul 23, 2026
Merged

feat: add supervised pause and human takeover#59
ElbertePlinio merged 5 commits into
mainfrom
feat/21-takeover

Conversation

@ElbertePlinio

Copy link
Copy Markdown
Member

Summary

Implements #21 — supervised pause and human takeover for desktop sessions:

  • atomic wx-created lease (human.lease.json) with 30s TTL / 5s heartbeat, single-winner acquisition proven by a real two-OS-process race test; agent permits with acquire→recheck→execute→release-in-finally, fail-closed on any lease appearance
  • writable VNC strictly lease-scoped: loopback-only, -viewonly flip only; reverted on every exit path (return/timeout/cancel), with three layered wall-clock guarantees — immediate end on failed renew, hard deadline timer at expiresAt, and a detached identity-scoped watchdog process that survives SIGKILL of the controller and reclaims via the TOCTOU-hardened recover (re-read + exact-raw-bytes compare-and-delete under the VNC lock)
  • renewHumanLease refuses stale leases (no resurrection after agents unblock); every agent input route permit-gated incl. desktop_launch (security adjudication: a launched client can steal focus on bare Xvfb during secret entry); desktop_screenshot is the only ungated desktop tool (read-only)
  • DevTools relay fails closed with a stable −32050 busy error through a single ordered output writer; evidence brackets every lifecycle transition (takeover_start/takeover_{return,timeout,cancelled,recovered})
  • surface: picklab watch --control (spec-literal) + read-only takeover status / MCP takeover_status; structured resume summary + fresh screenshot on return

Refs #21 — issue stays open for the one recorded end-to-end hardware takeover proof on Elberte-PC (RC phase; also reconciles parent #17 then). No feature flag: --control is per-invocation opt-in.

Validation

  • typecheck/build clean; full suite within the documented 43–45 Darwin band with zero regressions (test-by-test diff); 41+ new tests incl. real cross-process lease race, real SIGKILL watchdog survival, VNC argv sequences per exit path, fail-closed permit matrices — ubuntu CI runs these against real Xvfb/x11vnc
  • known inherent residual (documented): after a dead controller, reclaim is bounded by the watchdog poll interval

Local review: serious risk class — Grok 4.5 (correctness/races: 2 P0s found and fixed), Opus 4.8 (security/trust: desktop_launch P1 found and fixed; full failure-matrix fix-verification clean), Sonnet 5 (conformance: clause-by-clause table, clean); KISS keep ×3.

Add a leased, fail-closed human takeover for desktop-capable sessions.
`picklab watch --control` atomically acquires a TTL/heartbeat human
lease, drains in-flight agent permits before granting control, switches
the session's VNC to writable for the lease's duration, and hands
control back with a fresh screenshot and evidence record on every exit
path (return, cancellation, failed renewal, or crash recovery on the
next VNC touch).

Core (packages/core/src/takeover.ts): atomic O_EXCL lease file, agent
permit files, withAgentPermit's fail-closed acquire/recheck/execute/
release protocol, staleness (identity-or-TTL), and takeover evidence
recording.

Desktop-linux (packages/desktop-linux/src/takeover.ts): VNC mode
switching (read-only <-> writable) scoped to the lease, crash recovery
wired into ensureSessionVnc so a stale writable VNC self-heals instead
of blocking watch.

Browser: the DevTools MCP relay gained a generic NDJSON intercept hook
so a blocked tools/call is answered with a stable busy error directly,
never forwarded to the child process.

Desktop input (CLI + MCP): all seven input operations now go through
withAgentPermit, so no permit or an active lease means no input
delivery.

New picklab takeover status / takeover_status expose read-only lease
state; request_user_input's secret guidance now points at --control
instead of the uncoordinated --vnc-control.
…ritable VNC

Panel-adjudicated fixes to the human-takeover invariant (writable VNC
must never outlive its lease), addressing two P0 violations and a P1
TOCTOU:

- renewHumanLease refuses to extend a lease that has already gone
  stale by TTL, even for its own live owner, so a straggling renewal
  can never resurrect a lease a recovery is already reclaiming (P0-B).
- recoverStaleTakeoverLocked re-reads and re-checks staleness
  immediately before the destructive VNC stop, and releases via
  compare-and-delete on the exact raw bytes captured at that check
  (not just leaseId, which a renewal never changes) — closing the
  window where an unlocked renewal between the initial check and the
  stop could make recovery kill a now-live takeover's VNC (P1-C).
- watch --control now holds three independent mechanisms so writable
  VNC cannot outlive its lease in wall-clock terms, not only "the next
  time something happens to touch the session": it ends the takeover
  immediately on the first failed heartbeat renewal rather than
  waiting for the viewer to close; a hard deadline timer, rescheduled
  on every successful renewal, force-ends it if wall-clock time ever
  passes expiresAt; and a detached watchdog process (spawned via a new
  hidden `picklab internal takeover-watchdog` command, immune to a
  SIGKILL of its parent) independently polls the lease and reclaims a
  stale writable VNC on its own — proven against a genuinely separate
  OS process, not just an in-process mock (P0-A).

A detached process was chosen over OS-level parent-death coupling
(e.g. Linux PR_SET_PDEATHSIG) because the latter has no portable
Node.js API without a native addon or an external wrapper binary.

renewHumanTakeover's return type changes from boolean to the renewed
lease (or undefined), needed to read the fresh expiresAt for the
deadline-timer reschedule.
…ard pumps

The takeover busy-intercept path and the normal child-response pump
are two independent concurrent producers that can both write to the
same client-facing output stream (the intercept answers a blocked
tools/call directly on it; the forward pump also writes upstream
responses to it). Add createJsonRpcWriteQueue, a small write-ordering
queue, and thread it through pumpJsonRpcNdjson as writeSerializer /
interceptWriteSerializer so every write either pump issues to a shared
destination is fully ordered through one explicit gateway rather than
left as an incidental property of whichever Writable happens to be
passed in. Wired into runDevtoolsMcpRelay so a busy rejection racing
an in-flight forwarded response can never interleave on the wire.
…rmit

Security adjudication: launchApp spawns a new client onto the shared
X11 display a human may be actively controlling under a takeover
lease. On bare Xvfb a new client can grab input focus, which is
exactly what the lease exists to prevent when a human is mid-entry of
a secret. Wrap the launchApp call in withAgentPermit at both the CLI
(picklab desktop launch) and MCP (desktop_launch) call sites,
symmetric with the seven desktop input tools. desktop_screenshot
remains ungated (read-only, delivers no input).
…ch gating)

Update the README's takeover section and security model bullet for
the active (not lazy-only) crash-path VNC reclaim, the immediate-end
and hard-deadline-timer mechanisms, and desktop_launch joining the
gated tool set with desktop_screenshot as the only ungated one. Record
the same in docs/releases/UNRELEASED.md, plus the evidencing asymmetry
between a blocked MCP desktop tool call (evidenced) and a blocked
DevTools relay request (not — no equivalent per-call evidence
lifecycle to hook).
@ElbertePlinio
ElbertePlinio merged commit 09fcd78 into main Jul 23, 2026
1 check passed
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