Skip to content

fix: enforce device claims for sessionless device mutations (#1799) - #1809

Merged
thymikee merged 1 commit into
mainfrom
fix/1799-transient-device-claims
Aug 18, 2026
Merged

fix: enforce device claims for sessionless device mutations (#1799)#1809
thymikee merged 1 commit into
mainfrom
fix/1799-transient-device-claims

Conversation

@thymikee

@thymikee thymikee commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

Closes #1799.

Before: shutdown only refused a device that had an active session in its own daemon's session store, then called shutdownTarget(). It never consulted the host-global device claim store, so a daemon in another state directory terminated an emulator another worktree held a verified-live claim on and reported success. boot had the same gap.

After: a foreign live claim refuses the command with the existing DEVICE_IN_USE / DEVICE_CLAIM_LIVE_OWNER error (retriable: false, daemonless device status recovery hint) before any device operation exists.

#1799's third observation — daemon stop reporting claimsReleased: [] even when it released a claim — is fixed separately in #1818, stacked on this branch, so this PR stays scoped to the enforcement seam.

Design

The fix is not a claim check in the two handlers — it makes the class unrepresentable, per #1320's "Command descriptor policy" table:

  1. CommandDescriptor gains a REQUIRED deviceClaimPolicy with no default, using feat: add cross-worktree device ownership and safe recovery #1320's vocabulary (none | observe | require-owner | transient-exclusive | acquire-session | release-session). TypeScript forces every descriptor to declare one; a completeness/honesty test (mirroring the timeout-policy gate) pins the bounded, diffable set of everything that deviates from require-owner.
  2. Enforcement is derived from the trait at one seam. createRequestRuntimeBindings — the only way any handler obtains device operations — already caches one binding per device key, so admitting the claim as part of creating that binding is both the choke point and the natural per-device deduplication. bindExactDevice deliberately bypasses the cache, so it admits its own. There is no per-handler call to forget.
  3. transient-exclusive acquires a command-scoped claim after the gateway bind resolves (binding composes the operation catalog and mutates nothing) and before the narrowed projection reaches the handler; the request scope releases it on dispose, after the bindings it guards.
  4. Every other policy performs zero claim-store I/O, keeping feat: add cross-worktree device ownership and safe recovery #1320's non-goal ("no per-command host-global filesystem reads or writes to session-bound hot paths") intact. A claim already held by this daemon process covers the command rather than colliding with it — decided from the claim record's stateDir + owner identity, so a claim taken earlier in the same request (open's) can never lock the daemon out of its own device.
  5. acquireTransientDeviceClaim reuses the existing claim implementation (same lock, inspection, write, orphan reconciliation); it records session: "transient:<command>" so device status shows an honest owner for a claim that lives milliseconds. No second claim implementation.
  6. The same-daemon shutdown self-guard (active session → close --shutdown) is unchanged.

Classification (every command, honestly)

Policy Commands
acquire-session open
release-session close
transient-exclusive boot, shutdown, install, reinstall, install_source, push, prepare
observe devices, capabilities, device, doctor, apps, appstate
none lease/artifact/session bookkeeping, pure delegators (batch, install-from-source, react-devtools), and local-CLI commands
require-owner everything session-bound (including hover)

Why the sessionless-capable mutations stop where they do. I first classified keyboard, clipboard and trigger-app-event as transient-exclusive too. Live verification caught that as dishonest: those commands still declare ADR 0019 legacy platform execution and reach their device through dispatch, not through the request scope's device binding, so the gate never runs for them — a foreign keyboard status still succeeded against a claimed emulator. They are require-owner until their platform execution migrates to device-runtime, and the honesty test now requires device-runtime execution for transient-exclusive, so the same dishonest pairing cannot be declared again.

Two more deliberate require-owner classifications with reasons, as follow-ups:

  • record — sessionless record start leaves a recording running past the request, and a command-scoped claim released in finally would free a device whose attributable resource is still owned (feat: add cross-worktree device ownership and safe recovery #1320: "Prevent device claims from becoming free while attributable resources remain owned").
  • runtime (internal port-reverse) — same shape: it establishes durable per-session runtime state that outlives the request.

Validation

Regression tests, proven red first

Reverted the production change (git checkout HEAD -- on the touched production files, removed device-claim-admission.ts), kept the tests:

 FAIL  src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts >
       shutdown refuses a device held by a foreign live claim and never reaches the device
AssertionError: expected true to be false
  112|   expect(response?.ok).toBe(false);
 FAIL  ... > boot refuses a device held by a foreign live claim and never reaches the device
AssertionError: expected true to be false
  137|   expect(response?.ok).toBe(false);
 Test Files  1 failed (1) · Tests  2 failed | 2 passed (4)

That ok: true on a foreign-claimed device is #1799 exactly. Restored, green:

 ✓ src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts (4 tests)
   ✓ shutdown refuses a device held by a foreign live claim and never reaches the device

Coverage:

  • session-boot-shutdown-device-claims.test.ts — two-daemon-shaped (foreign state dir + claim): boot/shutdown refused with the typed reason, shutdownTarget/bootTarget never called, the owner's claim untouched, and the transient claim released after both a successful and a failing shutdown.
  • device-claim-admission.test.ts — one table over all six policies proving claim-store I/O happens only for transient-exclusive, plus foreign-claim refusal, same-daemon coverage, provider-owned skip, and the construction guard: the real createRequestExecutionScope claims for shutdown and not for snapshot on the same binding call.
  • device-claim-policy.test.ts — registry-driven completeness, the reviewed non-require-owner sets, and the transient-exclusive ⇒ device-runtime honesty invariant.

Live device verification (Android, Pixel_9_Pro_XLemulator-5554)

Pre-fix repro on the reported 0.20.9 build. Daemon A (state dir A) held a live claim; daemon B (different state dir, different cwd) ran:

$ agent-device shutdown --platform android --serial emulator-5554 --state-dir <B> --json
{ "success": true, "data": { ..., "shutdown": { "success": true, "stdout": "OK: killing emulator, bye bye" } } }
$ adb devices        # emulator-5554 gone; A's claim file still present and `live`

With this branch (rebooted the AVD, both daemons on the built branch):

$ agent-device shutdown --platform android --serial emulator-5554 --state-dir <B> --json
{
  "success": false,
  "error": {
    "code": "DEVICE_IN_USE",
    "message": "android device emulator-5554 is owned by session \"fixed-a\" in workspace \"/…/agent-a4d4b25de54894d5a\".",
    "hint": "Inspect the owner with: agent-device device status --platform android --serial emulator-5554",
    "retriable": false,
    "details": { "reason": "DEVICE_CLAIM_LIVE_OWNER", "classification": "live",
                 "deviceKey": "local:android:none:emulator-5554",
                 "owner": { "session": "fixed-a", "workspace": "…", "stateDir": "…/stateA" },
                 "recovery": { "command": "agent-device device status --platform android --serial emulator-5554" } }
  }
}
$ adb devices        # emulator-5554 still up

boot --platform android --device Pixel_9_Pro_XL from B returned the identical refusal. After close --session fixed-a in A, B's shutdown succeeded ("OK: killing emulator, bye bye") and left no claim file behind.

Sessionless install from B while A held the claim was refused the same way, while the owner's own in-session install reached the device (it failed on INSTALL_FAILED_TEST_ONLY, an unrelated property of the helper APK I used) — the same-daemon coverage path with no claim-store collision.

Cleanup: both daemons stopped, the AVD I booted shut down, no leftover claim file. iOS was not exercised; the seam is platform-neutral and isLocalDeviceClaimTarget gates it on the admitted runtime owner exactly as open does.

Gates

pnpm exec tsc --noEmit, pnpm format, pnpm check:affected --run (432 files / 3684 tests, layering + fallow green — fallow reports no issues in the changed files). Wire compat: pnpm check:daemon-wire-compat (151 declarations, 0 changed — this PR adds no daemon wire surface).

Tradeoffs and follow-ups

  • Scope: 16 files, +947/−83. It expanded past boot/shutdown by design — feat: add cross-worktree device ownership and safe recovery #1320 requires auditing every sessionless device-mutating command, and the descriptor trait is what makes the audit permanent. A large share of the diff is one declaration line per descriptor, matching the existing timeoutPolicy convention; the interaction descriptors that were byte-identical now share a TARGETED_TOUCH_INTERACTION_TRAITS bundle beside the existing GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, which also removes the clone group the added line would otherwise have created.
  • Follow-ups: keyboard, clipboard, trigger-app-event become enforceable once ADR 0019 migrates their platform execution to device-runtime; record and runtime need a claim whose lifetime follows the durable resource, not the request, before they can leave require-owner.
  • install/reinstall/install_source/push/prepare are newly transient-exclusive — a behavior change for sessionless use against a foreign-claimed device (now refused). install was live-verified both ways; push/prepare were not.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.27 MB 2.28 MB +3.8 kB
JS gzip 747.7 kB 748.3 kB +634 B
npm tarball 869.5 kB 870.1 kB +652 B
npm unpacked 3.03 MB 3.04 MB +3.8 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.0 ms 25.7 ms -0.3 ms
CLI --help 66.8 ms 64.7 ms -2.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js +3.0 kB +895 B
dist/src/session2.js -1.1 kB -420 B
dist/src/sdk-batch-runner.js +1.9 kB +169 B
dist/src/runtime2.js 0 B -17 B
dist/src/runtime.js +1 B +4 B

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head b807323. Clean code/architecture verdict: claim policy is descriptor-required, transient-exclusive admission sits at the request-runtime binding seam before operations are exposed, legacy classifications are gate-rejected, and the two-daemon regressions plus live Android A/B directly prove #1799 and cleanup. Readiness is currently blocked by exact-head CI: Android Smoke failed in the unrelated live scroll helper (Active application interaction viewport is unavailable) and needs a rerun or same-load main comparison before it can be classified; iOS Smoke is still in progress. No ready-for-human label while that failure remains confirmed.

@thymikee

Copy link
Copy Markdown
Member Author

Re the Android Smoke failure at b807323: the failing step is smoke:automation-system scroll (Active application interaction viewport is unavailable, an Android helper IllegalStateException during viewport lookup) — nothing on this branch touches the helper, snapshot presentation, or the scroll path; the change only gates bindDevice for transient-exclusive descriptors (boot/shutdown/install*/push/prepare), and the smoke session runs through open (acquire-session) + session-bound commands, which do zero claim-store I/O. Re-ran the failed job to classify it as load flake vs. real; will update here with the result.

@thymikee

Copy link
Copy Markdown
Member Author

Android Smoke rerun on the same head passed → the earlier Active application interaction viewport is unavailable was load flake in the live scroll helper, unrelated to this branch. All checks green now.

@thymikee thymikee added ready-for-human Valid work that needs human implementation, judgment, or maintainer merge and removed ready-for-human Valid work that needs human implementation, judgment, or maintainer merge labels Aug 18, 2026
@thymikee
thymikee force-pushed the fix/1799-transient-device-claims branch from b807323 to 01fcba5 Compare August 18, 2026 10:29
@thymikee

Copy link
Copy Markdown
Member Author

Tightening pass after review — rebased onto current main and made smaller without weakening the guarantee.

Before → after (this PR's diff vs main)

files +/−
Before 23 +1251 / −88
After (#1809 alone) 16 +951 / −83
Split out to #1818 8 +294 / −41

What changed:

  1. Split. The daemon stop claim reporting (shutdown can terminate a device held by a foreign live claim (0.20.9) #1799's third observation) moved to fix: report real claim results from daemon stop #1818, stacked on this branch and opened as a draft; it will be retargeted to main after this merges. This PR is now only the descriptor policy, the admission seam, and the boot/shutdown regression.
  2. Reused the binding cache instead of adding one. createRequestRuntimeBindings already memoizes one binding promise per device key, so the claim admission now runs inside that binding creation — once per device, deduped by machinery that already existed. device-claim-admission.ts lost its own Map/pending-promise bookkeeping and now only remembers the ownerships it acquired so it can release them (94 → 83 lines, and the remaining logic is a straight-line acquire/release). bindExactDevice bypasses the cache by design, so it admits its own.
  3. Tests folded. device-claim-admission.test.ts (176 → 150) is now one table over all six policies asserting claim-store I/O only for transient-exclusive, keeping the foreign-refusal, same-daemon-coverage, provider-skip and createRequestExecutionScope construction-guard cases. device-claim-policy.test.ts (123 → 97) dropped the assertions TypeScript already proves and collapsed the four bounded sets into one diffable record. Both boot/shutdown regression tests and the claim-store setup now come from shared fixtures (ANDROID_EMULATOR, and a new src/__tests__/test-utils/device-claim-store.ts used by three test files) instead of per-file copies.
  4. Registry. The 76 one-line deviceClaimPolicy: declarations stay — required-with-no-default is the point, and it matches the timeoutPolicy convention. The fallow clone-group warning is resolved at its cause: fill/press/longpress were byte-identical for 14 lines, so they now share a TARGETED_TOUCH_INTERACTION_TRAITS bundle next to the existing GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS (which also absorbed the policy for the four commands that spread it). No suppression, no regenerated baseline — fallow audit now reports no issues in the changed files, where it previously reported one clone group.

pnpm format && pnpm check:affected --run green on both branches (432 files / 3684 tests here; 436 / 3700 on #1818).

@thymikee

Copy link
Copy Markdown
Member Author

Clean re-review at 01fcba5: the scope split is coherent—daemon-stop claim reporting moved to #1818, while #1809 retains the descriptor-owned claim policy, runtime-binding admission, transient lifecycle, and the exact foreign-claim boot/shutdown regressions for #1799. No shutdown-reporting files remain in this diff. Exact-head CI and all platform smokes pass; merge state is CLEAN. Ready-for-human.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 18, 2026
`boot` and `shutdown` never consulted the host-global device claim store, so a
daemon in one state directory could terminate an emulator another daemon held a
verified-live claim on and report success (#1799).

Rather than adding a claim check to those two handlers, this makes the class
unrepresentable: `CommandDescriptor` gains a REQUIRED `deviceClaimPolicy` trait
(#1320's vocabulary), and the request-execution scope enforces it where the
request runtime bindings create a device binding — the one seam through which
any handler can obtain device operations, and already the place per-device
deduplication lives. A `transient-exclusive` command acquires a command-scoped
claim before operations reach the handler, refuses a foreign live claim with the
existing DEVICE_IN_USE/DEVICE_CLAIM_LIVE_OWNER error, and releases in the
scope's finally. Every other policy performs no claim-store I/O, so session-bound
commands keep #1320's non-goal intact.
@thymikee
thymikee force-pushed the fix/1799-transient-device-claims branch from 01fcba5 to 36083a9 Compare August 18, 2026 11:58
@thymikee
thymikee merged commit 2b6d04a into main Aug 18, 2026
31 checks passed
@thymikee
thymikee deleted the fix/1799-transient-device-claims branch August 18, 2026 12:12
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-18 12:12 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

shutdown can terminate a device held by a foreign live claim (0.20.9)

1 participant