From 58c91c71ff6fcfacf60de286bab0bc50726281bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 15:04:28 +0200 Subject: [PATCH 1/7] fix(daemon): scope a transient device claim to the session that holds it #1809 made install/reinstall and their transient-exclusive siblings covered by any claim this daemon's process held, so a second session in the same daemon could install over a device another session had open. Sessions of one daemon are separate device owners: admission now compares the request's routed session against the held claim, and a foreign session's claim refuses the command with DEVICE_IN_USE the way open already does. Session addresses are copyable, so the covered claim must additionally record the caller's own workspace, canonicalized on both sides through the same scope root session routing hashes; a request without a cwd carries no workspace claim. A release whose unlink fails now abandons the claim instead of leaving it live, so the owner is never locked out of its own device. The versioned help and user docs name the second DEVICE_IN_USE flavor's real owner; main's device-verification notes already attribute that claim to another session, so no guidance budget moves there. --- .../daemon-contracts/src/device-claims.ts | 54 ++- packages/daemon/src/core/caller-scope-root.ts | 39 ++ .../__tests__/device-claim-admission.test.ts | 333 +++++++++++++++++- .../device/device-claim-admission.ts | 53 ++- .../src/server/request-execution-scope.ts | 6 + .../daemon/src/sessions/session-routing.ts | 30 +- src/commands/schema/cli-help.ts | 2 +- website/docs/docs/commands.md | 4 +- website/docs/docs/sessions.md | 8 +- 9 files changed, 470 insertions(+), 59 deletions(-) create mode 100644 packages/daemon/src/core/caller-scope-root.ts diff --git a/packages/daemon-contracts/src/device-claims.ts b/packages/daemon-contracts/src/device-claims.ts index 2fbd71f0db..b6f5cc0af5 100644 --- a/packages/daemon-contracts/src/device-claims.ts +++ b/packages/daemon-contracts/src/device-claims.ts @@ -67,9 +67,11 @@ export type DeviceClaimAcquireResult = | { status: 'conflict'; conflict: InspectedDeviceClaim }; /** - * A `transient-exclusive` command may find the device already claimed by a - * session of the very daemon executing it. That session claim already carries - * the exclusion the command needs, so the command adds none of its own. + * A `transient-exclusive` command may find the device already claimed by this daemon. + * Only the claim of the session the command runs in carries the exclusion the command + * needs, so that one adds none of its own. Any other claim — another session's, or + * another command's `transient:` claim this daemon holds in flight — is a + * foreign live owner (#3345): sessions of one daemon are separate owners of a device. */ export type TransientDeviceClaimResult = | DeviceClaimAcquireResult @@ -106,16 +108,37 @@ export async function acquireDeviceClaim(params: { /** * #1320 `transient-exclusive`: exclusive ownership for the duration of one - * sessionless device mutation. Identical to a session claim except that a claim - * this daemon still holds covers the command instead of colliding with it — the - * claim file itself, not the in-memory session table, is the authority for that, - * so a claim acquired earlier in the same request (`open`'s, for instance) can - * never lock the daemon out of its own device. An abandoned claim holds nothing, - * so it is superseded into this command's own transient claim instead. + * sessionless device mutation. Identical to a session claim except that the claim + * of the session this command runs in covers the command instead of colliding with + * it — the claim file itself, not the in-memory session table, is the authority for + * that, so a claim acquired earlier in the same request (`open`'s, for instance) can + * never lock the session out of its own device. A claim this daemon holds for a + * *different* session collides, because #3345 established that one daemon serving + * several worktrees does not make those worktrees one owner. An abandoned claim holds + * nothing, so it is superseded into this command's own transient claim instead. + * + * Coverage proves ownership the way {@link acquireDeviceClaim} does: the covered claim + * must have been recorded by this request's workspace, not merely carry the same session + * string. Session addresses are copyable — `device status` prints them and refusal + * details carry them — so without that, a caller in another workspace could paste the + * owner's address into `--session` and pass as the owner. */ export async function acquireTransientDeviceClaim(params: { device: DeviceInfo; command: string; + /** + * The session this command runs in, i.e. the session whose own claim covers it. + * Unlike {@link acquireDeviceClaim}, this is not the session the record gets: a + * command-scoped claim stays attributable to the command that took it. + */ + coveredBySession: string; + /** + * Whether the live claim under {@link coveredBySession} was taken by this request's + * workspace rather than merely recorded under the same session string. Session + * addresses are copyable (#3345), so session equality alone lets a caller that + * pasted another workspace's address act as that workspace's owner. + */ + coveredClaimIsOwnedByRequest: (claim: DeviceClaim) => boolean; workspace: string; stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; @@ -129,7 +152,18 @@ export async function acquireTransientDeviceClaim(params: { !isAbandonedDeviceClaim(existing.claim) && isClaimOwnedByThisDaemon(existing.claim, params.stateDir, readCurrentOwnerIdentity()) ) { - return { status: 'covered-by-owned-claim' }; + if ( + existing.claim.session === params.coveredBySession && + params.coveredClaimIsOwnedByRequest(existing.claim) + ) { + return { status: 'covered-by-owned-claim' }; + } + // A command-scoped claim this daemon holds belongs to the one command in flight, + // not to the daemon: adopting it would hand this request the other command's + // release token and delete its claim on completion. It is a live owner. + if (existing.claim.session === transientDeviceClaimSession(params.command)) { + return { status: 'conflict', conflict: existing }; + } } return await claimHeldDevice({ device: params.device, diff --git a/packages/daemon/src/core/caller-scope-root.ts b/packages/daemon/src/core/caller-scope-root.ts new file mode 100644 index 0000000000..7b93e4c6fb --- /dev/null +++ b/packages/daemon/src/core/caller-scope-root.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * The canonical workspace root a caller's cwd belongs to: the git worktree root when the + * cwd sits inside one, otherwise the realpath of the directory. Two commands issued from + * different directories of one checkout resolve to the same root, so it is the comparison + * key for anything that asks "did this caller and that record come from the same + * workspace?" — session routing hashes it into the implicit session address, and + * device-claim coverage (#3345) compares covered claims by it rather than by the raw cwd, + * which would refuse an owner that ran `open` from the checkout root and `install` from a + * subdirectory of it. + */ +export function resolveCallerScopeRoot(rawCwd: string | undefined): string | undefined { + if (!rawCwd || rawCwd.trim().length === 0) return undefined; + const cwd = resolveExistingPath(rawCwd); + return findGitWorktreeRoot(cwd) ?? cwd; +} + +function resolveExistingPath(rawPath: string): string { + const resolved = path.resolve(rawPath); + try { + return fs.realpathSync.native(resolved); + } catch { + return resolved; + } +} + +function findGitWorktreeRoot(startDir: string): string | undefined { + let current = startDir; + while (true) { + if (fs.existsSync(path.join(current, '.git'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts index 57830bb24d..97da4ddcb2 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts @@ -1,5 +1,5 @@ import { ANDROID_EMULATOR, deviceClaimFilePath } from '@agent-device/test-kit'; -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import { daemonPlatformServicesFixture, daemonClaimRecoveryGatewayFixture, @@ -7,7 +7,7 @@ import { import fs from 'node:fs'; import path from 'node:path'; import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; -import { asAppError } from '@agent-device/kernel/errors'; +import { asAppError, type AppError } from '@agent-device/kernel/errors'; import { isolatedDeviceClaimStores, retainOrphanedDeviceClaims, @@ -20,6 +20,7 @@ import { canonicalLocalDeviceKey, } from '@agent-device/daemon-contracts/device-claims'; import { createRequestExecutionScope } from '../../../server/request-execution-scope.ts'; +import { resolveEffectiveSessionName } from '../../../sessions/session-routing.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; import { SessionStore } from '../../../core/session-store.ts'; import { unavailableDeviceRuntimeGateway } from '../../../__tests__/support/unavailable-device-runtime-gateway.ts'; @@ -28,11 +29,22 @@ import type { DeviceClaimPolicy } from '@agent-device/command-registry/types'; const setup = isolatedDeviceClaimStores('agent-device-claim-admission-'); const localAndroid = localRuntimeOwner('android'); -function makeAdmission(policy: DeviceClaimPolicy, stateDir: string, command = 'made-up-command') { +// `callerCwd: null` means the request carried no cwd at all (an SDK caller), which +// the default would otherwise hide: a default parameter fires on an explicit +// undefined too. +function makeAdmission( + policy: DeviceClaimPolicy, + stateDir: string, + command = 'made-up-command', + session = 'request-session', + callerCwd: string | null = '/worktrees/current', +) { return createDeviceClaimAdmission({ policy, command, - workspace: '/worktrees/current', + session, + workspace: callerCwd ?? '/worktrees/current', + callerCwd: callerCwd ?? undefined, stateDir, reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, }); @@ -99,7 +111,11 @@ test('a foreign live claim refuses the command before it can reach device operat expect(claimedSessions()).toEqual(['owner-session']); }); -test('a claim already held by this daemon covers the command instead of colliding with it', async () => { +// #3345: sessions of one daemon are separate owners of a device. A claim this daemon +// holds covers a `transient-exclusive` command only when the command runs in the +// session that claim belongs to; a daemon that serves more than one session or +// worktree must not let one session install over another's device. +test('a claim this daemon holds for the same session covers the command instead of colliding with it', async () => { const { stateDir } = setup(); await acquireDeviceClaim({ device: ANDROID_EMULATOR, @@ -109,7 +125,7 @@ test('a claim already held by this daemon covers the command instead of collidin reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, }); - const admission = makeAdmission('transient-exclusive', stateDir, 'install'); + const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'open-session'); await admission.admit(ANDROID_EMULATOR, localAndroid); await admission[Symbol.asyncDispose](); @@ -117,6 +133,203 @@ test('a claim already held by this daemon covers the command instead of collidin expect(claimedSessions()).toEqual(['open-session']); }); +test('a claim this daemon holds for another session refuses the command like any live owner', async () => { + const { stateDir } = setup(); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'owning-session', + workspace: '/worktrees/current', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'other-session'); + const error = asAppError( + await admission.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), + ); + + expect(error.code).toBe('DEVICE_IN_USE'); + expect(error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + expect(error.details?.retriable).toBe(false); + expect(error.details?.owner).toEqual({ + session: 'owning-session', + workspace: '/worktrees/current', + stateDir, + }); + + await admission[Symbol.asyncDispose](); + // The refusal wrote nothing and released nothing: the owning session still holds + // the device with its original claim. + expect(claimedSessions()).toEqual(['owning-session']); +}); + +// The claim a transient command holds is command-scoped (`transient:`), never +// request-scoped, so the session comparison alone cannot tell two requests of the same +// command apart. The one in flight must not be adopted — adopting it hands the second +// request the first's release token, and its dispose unlinks a claim still in use. +test("another request in flight's transient claim of the same command refuses, and holds nothing", async () => { + const { stateDir } = setup(); + const held = await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'transient:install', + workspace: '/worktrees/current', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + expect(held.status).toBe('acquired'); + + const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'other-session'); + const error = asAppError( + await admission.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), + ); + + expect(error.code).toBe('DEVICE_IN_USE'); + expect(error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + await admission[Symbol.asyncDispose](); + expect(claimedSessions()).toEqual(['transient:install']); +}); + +// The ownerToken exemption in device-claim-admission.ts. Mutation: delete that branch — +// the second admit below throws DEVICE_IN_USE. Reachability: binding dedupes by +// `deviceIdentityKey` (carries `target`) while the claim key is the narrower +// `canonicalLocalDeviceKey`, so one device through two DeviceInfo shapes double-admits. +test('a transient claim this request took covers re-admitting the same device', async () => { + const { stateDir } = setup(); + const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'request-session'); + await admission.admit(ANDROID_EMULATOR, localAndroid); + expect(claimedSessions()).toEqual(['transient:install']); + + // Android TV is a distinct form-factor leaf: same family/id, so the same claim + // key, while the identity key carries the target and binding treats them apart. + await admission.admit({ ...ANDROID_EMULATOR, target: 'tv' }, localAndroid); + await admission[Symbol.asyncDispose](); + expect(inspectDeviceClaims({})).toEqual([]); +}); + +// #3345's copyable-address door: session addresses are printed by `device status` and +// re-enter through `--session`, so coverage requires the covered claim's workspace to +// be the caller's own, canonicalized on both sides. Mutation for the pair: drop the +// `coveredClaimIsOwnedByRequest` call in the contract's covered check — the foreign +// workspace below then covers instead of refusing. +test("a claim at this workspace's implicit address still covers its own workspace's command", async () => { + const { root, stateDir } = setup(); + const workspace = path.join(root, 'worktree'); + const subdirectory = path.join(workspace, 'packages', 'app'); + fs.mkdirSync(subdirectory, { recursive: true }); + // The canonical root is the git worktree root, which is what makes the two + // directories one workspace; without a checkout the two are just two paths. + fs.mkdirSync(path.join(workspace, '.git'), { recursive: true }); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'cwd:aaa111:android', + workspace, + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + // The owner issues `install` from a subdirectory: raw cwd comparison would refuse it. + const admission = makeAdmission( + 'transient-exclusive', + stateDir, + 'install', + 'cwd:aaa111:android', + subdirectory, + ); + await admission.admit(ANDROID_EMULATOR, localAndroid); + await admission[Symbol.asyncDispose](); + expect(claimedSessions()).toEqual(['cwd:aaa111:android']); +}); + +test('naming another workspace\u2019s session address does not cover a transient command', async () => { + const { root, stateDir } = setup(); + const ownerWorkspace = path.join(root, 'worktree-a'); + const foreignWorkspace = path.join(root, 'worktree-b'); + fs.mkdirSync(ownerWorkspace, { recursive: true }); + fs.mkdirSync(foreignWorkspace, { recursive: true }); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'cwd:aaa111:android', + workspace: ownerWorkspace, + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + const admission = makeAdmission( + 'transient-exclusive', + stateDir, + 'install', + 'cwd:aaa111:android', + foreignWorkspace, + ); + const error = asAppError( + await admission.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), + ); + + expect(error.code).toBe('DEVICE_IN_USE'); + expect(error.details?.owner).toMatchObject({ session: 'cwd:aaa111:android' }); + await admission[Symbol.asyncDispose](); + expect(claimedSessions()).toEqual(['cwd:aaa111:android']); +}); + +// A request without a cwd (SDK callers) cannot prove a workspace either way, so the +// workspace check stays out rather than refusing against the daemon's own directory. +test('a request that carries no cwd keeps coverage of its session\u2019s claim', async () => { + const { stateDir } = setup(); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'sdk-session', + workspace: '/somewhere/else', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'sdk-session', null); + await admission.admit(ANDROID_EMULATOR, localAndroid); + await admission[Symbol.asyncDispose](); + expect(claimedSessions()).toEqual(['sdk-session']); +}); + +// The release path is where a transient claim outlives its request: an unlink that +// throws leaves the record live, and a live claim now refuses its own session's next +// command. This injects the failure at the real unlink inside the real clear +// transaction and pins the recovery: the survivor ends abandoned, so the owner's next +// acquire supersedes it — the shape acquireTransientDeviceClaim already handles. +test('a failed release abandons the claim so the next command is not locked out', async () => { + const { stateDir } = setup(); + const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'own-session'); + await admission.admit(ANDROID_EMULATOR, localAndroid); + expect(claimedSessions()).toEqual(['transient:install']); + + // The fault lands on the one unlink the release needs: the owned claim file. + // Everything else — including the process lock's reclaim-mutex unlinks taken on + // the way in and out — forwards to the real implementation. + const claimPath = deviceClaimFilePath(canonicalLocalDeviceKey(ANDROID_EMULATOR)); + const originalUnlink = fs.unlinkSync; + const unlink = vi.spyOn(fs, 'unlinkSync').mockImplementation((target) => { + if (String(target) === claimPath) { + throw Object.assign(new Error('read-only file system'), { code: 'EROFS' }); + } + return originalUnlink(target as Parameters[0]); + }); + try { + await admission[Symbol.asyncDispose](); + } finally { + unlink.mockRestore(); + } + + // The claim survived the failed unlink as abandoned, not live: a live survivor would + // refuse this session's next install. + const survivor = inspectDeviceClaims({}); + expect(survivor.map((entry) => entry.claim?.session)).toEqual(['transient:install']); + expect(survivor[0]?.claim?.abandonedAtMs).toBeTypeOf('number'); + + const retry = makeAdmission('transient-exclusive', stateDir, 'install', 'own-session'); + await retry.admit(ANDROID_EMULATOR, localAndroid); + expect(claimedSessions()).toEqual(['transient:install']); + await retry[Symbol.asyncDispose](); + expect(inspectDeviceClaims({})).toEqual([]); +}); + test("an abandoned claim becomes this command's own transient claim and is released", async () => { const { stateDir } = setup(); const aborted = await acquireDeviceClaim({ @@ -220,13 +433,25 @@ test('the none policy still reaches no device state at all', async () => { expect(fs.readdirSync(claimsDir)).toEqual(before); }); -/** Claims visible while one command holds a device binding from the real request scope. */ -async function claimsWhileBound(command: string, stateDir: string) { +/** What one command sees when it binds a device through the real request scope. */ +async function bindInRequestSession(params: { + command: string; + stateDir: string; + session?: string; + cwd?: string; +}): Promise<{ claims: (string | undefined)[]; error?: AppError }> { const scope = await createRequestExecutionScope({ platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), - req: { token: 't', session: 'default', command, positionals: [], flags: {} }, - sessionStore: new SessionStore(path.join(stateDir, 'sessions')), + req: { + token: 't', + session: params.session ?? '', + command: params.command, + positionals: [], + flags: {}, + meta: params.cwd ? { cwd: params.cwd } : undefined, + }, + sessionStore: new SessionStore(path.join(params.stateDir, 'sessions')), leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: unavailableDeviceRuntimeGateway, platformRequestScope: { @@ -240,7 +465,9 @@ async function claimsWhileBound(command: string, stateDir: string) { required: [], preferred: [], }); - return claimedSessions(); + return { claims: claimedSessions() }; + } catch (error) { + return { claims: claimedSessions(), error: asAppError(error) }; } finally { await scope[Symbol.asyncDispose](); } @@ -250,7 +477,87 @@ test('the request scope claims only for commands whose descriptor declares trans const { stateDir } = setup(); // Same binding call, same device: the difference is the descriptor policy. - expect(await claimsWhileBound('shutdown', stateDir)).toEqual(['transient:shutdown']); - expect(await claimsWhileBound('snapshot', stateDir)).toEqual([]); + expect( + (await bindInRequestSession({ command: 'shutdown', stateDir, session: 'default' })).claims, + ).toEqual(['transient:shutdown']); + expect( + (await bindInRequestSession({ command: 'snapshot', stateDir, session: 'default' })).claims, + ).toEqual([]); expect(inspectDeviceClaims({})).toEqual([]); }); + +// #3345 at the seam the router runs: one daemon serves several sessions and +// worktrees, so the owning session's `install` has to keep working while +// another session's `install` is refused before its handler can reach a device +// operation. +test('the request scope admits a transient command only in the session whose claim owns the device', async () => { + const { stateDir } = setup(); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'owning-session', + workspace: '/worktrees/current', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + const owner = await bindInRequestSession({ + command: 'install', + stateDir, + session: 'owning-session', + }); + expect(owner.error).toBeUndefined(); + expect(owner.claims).toEqual(['owning-session']); + + const intruder = await bindInRequestSession({ + command: 'install', + stateDir, + session: 'intruder-session', + }); + expect(intruder.error?.code).toBe('DEVICE_IN_USE'); + expect(intruder.error?.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + expect(intruder.error?.details?.owner).toMatchObject({ session: 'owning-session' }); + expect(intruder.claims).toEqual(['owning-session']); +}); + +// Coverage must compare the claim against the *routed* session address, not the requested +// name: an implicit CLI request carries no name, and its claim lives at the workspace's +// implicit address. Passing the bare request name here would refuse the owner's own +// install on every real CLI session. +test('the request scope covers the implicit workspace session that routed to the claim', async () => { + const { root, stateDir } = setup(); + const workspace = path.join(root, 'worktree'); + const elsewhere = path.join(root, 'other-worktree'); + fs.mkdirSync(workspace, { recursive: true }); + fs.mkdirSync(elsewhere, { recursive: true }); + const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); + const routedAddress = resolveEffectiveSessionName( + { + token: 't', + session: '', + command: 'install', + positionals: [], + flags: {}, + meta: { cwd: workspace }, + }, + sessionStore, + { attachesToSession: true }, + ); + // The point of the pin: the owning address is not the name the request carried. + expect(routedAddress).not.toBe('default'); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: routedAddress, + workspace, + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + const owner = await bindInRequestSession({ command: 'install', stateDir, cwd: workspace }); + expect(owner.error).toBeUndefined(); + expect(owner.claims).toEqual([routedAddress]); + + const intruder = await bindInRequestSession({ command: 'install', stateDir, cwd: elsewhere }); + expect(intruder.error?.code).toBe('DEVICE_IN_USE'); + expect(intruder.error?.details?.owner).toMatchObject({ session: routedAddress }); + expect(intruder.claims).toEqual([routedAddress]); +}); diff --git a/packages/daemon/src/resources/device/device-claim-admission.ts b/packages/daemon/src/resources/device/device-claim-admission.ts index 3dd98c5728..753dafeaf4 100644 --- a/packages/daemon/src/resources/device/device-claim-admission.ts +++ b/packages/daemon/src/resources/device/device-claim-admission.ts @@ -2,9 +2,11 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; import type { DeviceClaimPolicy } from '@agent-device/command-registry/types'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { resolveCallerScopeRoot } from '../../core/caller-scope-root.ts'; import { deviceClaimConflictError } from './device-claim-conflict.ts'; import { deviceClaimRuleForOwner } from './device-claim-rule.ts'; import { + abandonDeviceClaim, acquireTransientDeviceClaim, clearDeviceClaim, type DeviceClaimReconciler, @@ -35,13 +37,32 @@ export type DeviceClaimAdmission = AsyncDisposable & export function createDeviceClaimAdmission(params: { policy: DeviceClaimPolicy; command: string; + /** + * The session this request runs in, which is the only session whose own claim + * covers a `transient-exclusive` command. Sessions of one daemon are separate + * device owners (#3345), so the request's routed session address — not the + * daemon's process identity — is what admission compares a held claim against. + * Addresses are copyable, so session equality alone is not ownership: the + * covered claim must also record this request's workspace. + */ + session: string; workspace: string; + /** + * The caller's raw cwd when the request carried one, or nothing for a caller without a + * workspace of its own (SDK requests). The covered-claim check compares workspaces; + * {@link workspace} cannot serve it because its daemon-directory fallback would refuse + * exactly the SDK requests whose own session claims should cover them. + */ + callerCwd: string | undefined; stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; }): DeviceClaimAdmission { // The caller admits once per device binding, so this only has to remember what // it took in order to give it back. const acquired: DeviceClaimSessionOwnership[] = []; + // Comparing both sides through resolveCallerScopeRoot — the root routing hashes into + // the implicit address — keeps one checkout's commands together across directories. + const requestScopeRoot = resolveCallerScopeRoot(params.callerCwd); /** * Only `transient-exclusive` writes the claim store here: `observe`/`require-owner` never do, @@ -53,12 +74,24 @@ export function createDeviceClaimAdmission(params: { const result = await acquireTransientDeviceClaim({ device, command: params.command, + coveredBySession: params.session, + coveredClaimIsOwnedByRequest: (claim) => + !requestScopeRoot || resolveCallerScopeRoot(claim.workspace) === requestScopeRoot, workspace: params.workspace, stateDir: params.stateDir, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, }); - if (result.status === 'conflict') throw deviceClaimConflictError(device, result.conflict); - if (result.status === 'acquired') acquired.push(result.ownership); + if (result.status === 'acquired') { + acquired.push(result.ownership); + return; + } + if (result.status === 'conflict') { + // A command-scoped claim this request took for this device covers a re-bind of + // it; the same claim seen by any other request is a live owner. + const held = result.conflict.claim; + if (held && acquired.some((ownership) => ownership.ownerToken === held.ownerToken)) return; + throw deviceClaimConflictError(device, result.conflict); + } } return { @@ -84,6 +117,22 @@ export function createDeviceClaimAdmission(params: { error: error instanceof Error ? error.message : String(error), }, }); + // A live record whose unlink failed would refuse this session's own next + // command, so it must not stay live: abandoned means "holds it for nobody", + // which the next acquire supersedes. + try { + await abandonDeviceClaim(ownership); + } catch (abandonError) { + emitDiagnostic({ + level: 'error', + phase: 'transient_device_claim_abandon_failed', + data: { + command: params.command, + deviceKey: ownership.deviceKey, + error: abandonError instanceof Error ? abandonError.message : String(abandonError), + }, + }); + } } } }, diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index 21eef92c7a..478aca0d04 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -257,8 +257,10 @@ export async function createRequestExecutionScope(params: { const dispatchLedger = requestDispatchLedger(scopedReq); const { claimAdmission, runtimeBindings } = createRequestDeviceAccess({ command, + sessionName, dispatchLedger, workspace: scopedReq.meta?.cwd ?? process.cwd(), + callerCwd: scopedReq.meta?.cwd, stateDir: sessionStore.resolveDaemonStateDir(), deviceRuntimeGateway: params.deviceRuntimeGateway, platformRequestScope: params.platformRequestScope, @@ -391,8 +393,10 @@ export async function createRequestExecutionScope(params: { */ function createRequestDeviceAccess(params: { command: string; + sessionName: string; dispatchLedger: RequestDispatchLedger; workspace: string; + callerCwd: string | undefined; stateDir: string; deviceRuntimeGateway: DeviceRuntimeGateway | undefined; platformRequestScope: PlatformRequestScope | undefined; @@ -409,7 +413,9 @@ function createRequestDeviceAccess(params: { const claimAdmission = createDeviceClaimAdmission({ policy: resolveCommandDeviceClaimPolicy(params.command), command: params.command, + session: params.sessionName, workspace: params.workspace, + callerCwd: params.callerCwd, stateDir: params.stateDir, reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler({ scope: platformRequestScope, diff --git a/packages/daemon/src/sessions/session-routing.ts b/packages/daemon/src/sessions/session-routing.ts index 8f8aff815d..68fcc1a701 100644 --- a/packages/daemon/src/sessions/session-routing.ts +++ b/packages/daemon/src/sessions/session-routing.ts @@ -1,8 +1,7 @@ import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import { resolveCallerScopeRoot } from '../core/caller-scope-root.ts'; import { PLATFORM_SELECTORS, type PlatformSelector, @@ -304,30 +303,3 @@ function buildAmbiguousWorkspaceSessionError(candidates: SessionRef[]): AppError function hashScopeRoot(scopeRoot: string): string { return crypto.createHash('sha256').update(scopeRoot).digest('hex').slice(0, 16); } - -function resolveCallerScopeRoot(rawCwd: string | undefined): string | undefined { - if (!rawCwd || rawCwd.trim().length === 0) return undefined; - const cwd = resolveExistingPath(rawCwd); - return findGitWorktreeRoot(cwd) ?? cwd; -} - -function resolveExistingPath(rawPath: string): string { - const resolved = path.resolve(rawPath); - try { - return fs.realpathSync.native(resolved); - } catch { - return resolved; - } -} - -function findGitWorktreeRoot(startDir: string): string | undefined { - let current = startDir; - while (true) { - if (fs.existsSync(path.join(current, '.git'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) return undefined; - current = parent; - } -} diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 4413e79e60..0cde008788 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -334,7 +334,7 @@ Example: agent-device network dump --include headers Device busy and ownership: - DEVICE_IN_USE has two flavors. "already in use by session X" is this daemon: reuse it with --session X, or run close --session X first. "owned by session X in workspace Y" is another worktree's daemon holding the host-global device claim: it is never retriable — run the error's exact recovery command instead of retrying. + DEVICE_IN_USE has two flavors. "already in use by session X" is this daemon's session table: reuse it with --session X, or run close --session X first. "owned by session X in workspace Y" is the host-global device claim another session holds — from this daemon or from another worktree's daemon: it is never retriable, so run the error's exact recovery command instead of retrying. Inspect ownership without any daemon: agent-device device status (add --stale for proven-dead owners; settle and release those with agent-device device release --stale). devices marks rows that are claimed, so pick an unclaimed device instead of contending. A live foreign owner is released by closing its session from its own workspace or stopping its daemon: agent-device daemon stop --state-dir (the error names the state dir). The device itself can settle a claim: when it rebooted after that claim was taken, its app, runner, and accessibility session were destroyed, so open reconciles the owner's resources, takes the claim, and reports the release in warnings. Never recover by hunting PIDs with ps/kill. boot/install/shutdown take the same claims as open and refuse foreign-claimed devices identically; they do not ask the device about its boot. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 9a4010823d..e34af8dae5 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -82,7 +82,7 @@ Select a device: Device claims and the daemon: - `device status` lists host-local device claims without starting or contacting a daemon; `--stale` shows the stale claims it hides by default (owner exited, PID reused, state directory deleted, or daemon replaced), some of whose owners may still be running. Scope it with `--platform` plus `--udid` (Apple) or `--serial` (Android). A claim held by another live or uncertain owner blocks `open`, unless the device rebooted after the claim was taken: then `open` settles that owner's durable resources, takes the claim, and reports the release in `warnings`. -- `device release --stale` settles the durable resources of an owner that provably can't release its claim (its process exited or a newer daemon replaced it), then clears the claim. Live, uncertain, PID-reused, corrupt, and deleted-state-directory claims are refused with the reason. See [Sessions](/docs/sessions#release-a-device-another-workspace-holds). +- `device release --stale` settles the durable resources of an owner that provably can't release its claim (its process exited or a newer daemon replaced it), then clears the claim. Live, uncertain, PID-reused, corrupt, and deleted-state-directory claims are refused with the reason. See [Sessions](/docs/sessions#release-a-device-another-session-or-workspace-holds). - A command that needs the shared local daemon starts it for its state directory when none runs; `device status` and `device release --stale` never contact one. When the running daemon comes from another version or build, the command replaces it only if it reports no work: no request in flight, no session, no repair session and no live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. Otherwise the command fails with `daemon-busy` and leaves the daemon alone. A daemon from a release that predates this check is never replaced, and fails with `daemon-incompatible`. End a busy or older daemon deliberately with `daemon stop`. Node.js clients choose this per client; see [Daemon lifecycle](/docs/client-api#daemon-lifecycle). - `daemon stop --state-dir ` stops a local daemon after verifying its identity. `--clean` also removes the Apple runner processes and leases that daemon retained. @@ -90,7 +90,7 @@ Open apps and links: - `open [app|url] [url]` boots and activates the selected target when needed. `open ` opens deep links on Android and iOS, and `open ` opens a deep link in that app on iOS. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom-scheme URLs need an active app in the session. -- `open --wait ` waits up to that budget for a device another session holds, then fails with `DEVICE_IN_USE`. The wait extends the command's timeout, so you don't need a longer `--timeout`. A claim held by another workspace's daemon is not waited for; `open` returns its recovery command at once. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device). +- `open --wait ` waits up to that budget for a device another session holds, then fails with `DEVICE_IN_USE`. The wait extends the command's timeout, so you don't need a longer `--timeout`. A claim held by another workspace's daemon is not waited for; `open` returns its recovery command at once. Device-mutating commands like `install` and `reinstall` never wait either: a claim held by a session other than yours fails them at once. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device). - `open --launch-console ` captures launch-time stdout/stderr for iOS simulator app launches. It is not valid for URL opens or other targets. - `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface; `app` is the default when you name an app. See [Desktop targets](#desktop-targets). `--save-script` records the surface so `replay` reopens it; with no surface given, replay opens an app session. - Android React Native: `open --relaunch` needs an installed package name. Install or reinstall the APK first; `open --relaunch` is rejected. For Metro-backed JS changes, run `metro reload` before reaching for `open --relaunch`. diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index b419bac96d..a25694314c 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -149,11 +149,15 @@ budget, so several agents can queue on one device. `--wait` waits only for another session. A device claimed by another workspace's daemon is never waited for; `open` fails at once and names the command that releases it. +Device-mutating commands like `install` and `reinstall` don't wait either: while a +session other than yours holds the device, they fail at once with `DEVICE_IN_USE` and +name the owner. -## Release a device another workspace holds +## Release a device another session or workspace holds Each daemon claims the devices its sessions use. A claim whose owner is live, or can't be proven -gone, blocks `open` from another workspace. List the claims on this host without starting a daemon: +gone, blocks `open` from another workspace, and blocks device-mutating commands like `install` +from any session other than the owner's. List the claims on this host without starting a daemon: ```bash agent-device device status From 7b510d48d0430c571e77b1db8ad2e1e23b3c83ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 15:04:28 +0200 Subject: [PATCH 2/7] test(integration): run the raw file push from inside the claiming workspace #3345's coverage rule pairs the routed session with the workspace that took the claim. The raw push request carried a temp cwd, so it routed under another workspace, and the open session's claim no longer covered it. Running it from a directory of the checkout reproduces the real CLI owner shape the client calls above already form, and keeps the relative payload resolution the test is for. Probe evidence at the pre-fix commit: the push refused with DEVICE_CLAIM_LIVE_OWNER naming the checkout workspace. --- .../android-lifecycle.test.ts | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index d0432296a7..4ead6ec06a 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -439,36 +439,50 @@ async function runAndroidSetupAndInstallWorkflow( }), 'utf8', ); - const filePush = await daemon.callCommand( - 'push', - ['com.example.demo', './payload.json'], - selection, - { - meta: { cwd: tempRoot }, - }, - ); - assert.equal(filePush.json.result.data.package, 'com.example.demo'); - assert.equal(filePush.json.result.data.action, 'com.example.demo.FILE_PUSH'); - assert.equal(filePush.json.result.data.extrasCount, 1); - - const bracePayloadPath = path.join(tempRoot, '{payload}.json'); - fs.writeFileSync( - bracePayloadPath, - JSON.stringify({ - action: 'com.example.demo.BRACE_PUSH', - extras: { source: 'brace-file' }, - }), - 'utf8', - ); - const braceFilePush = await daemon.callCommand( - 'push', - ['com.example.demo', './{payload}.json'], - selection, - { meta: { cwd: tempRoot } }, - ); - assert.equal(braceFilePush.json.result.data.package, 'com.example.demo'); - assert.equal(braceFilePush.json.result.data.action, 'com.example.demo.BRACE_PUSH'); - assert.equal(braceFilePush.json.result.data.extrasCount, 1); + // #3345: a raw request routes to the implicit session of the workspace its `cwd` sits in, + // and a claim covers only the session-and-workspace pair that took it. The client calls + // above sent no cwd, so the open claimed this process's own workspace (the checkout) at + // the `default` address. This push joins that address the way `--session` does and runs + // from a directory inside the checkout: the canonical scope root is the worktree root from + // either directory, so coverage holds the way it holds for a real CLI owner, and the + // relative payload still resolves against `cwd`. A cwd in another workspace would refuse + // even under the same session name — that is the copyable-address rule. + fs.mkdirSync(path.join(process.cwd(), '.tmp'), { recursive: true }); + const callerWorkspace = fs.mkdtempSync(path.join(process.cwd(), '.tmp', 'push-')); + try { + fs.cpSync(pushPayloadPath, path.join(callerWorkspace, 'payload.json')); + const filePush = await daemon.callCommand( + 'push', + ['com.example.demo', './payload.json'], + selection, + { meta: { cwd: callerWorkspace, sessionExplicit: true } }, + ); + assert.equal(filePush.json.result.data.package, 'com.example.demo'); + assert.equal(filePush.json.result.data.action, 'com.example.demo.FILE_PUSH'); + assert.equal(filePush.json.result.data.extrasCount, 1); + + const bracePayloadPath = path.join(tempRoot, '{payload}.json'); + fs.writeFileSync( + bracePayloadPath, + JSON.stringify({ + action: 'com.example.demo.BRACE_PUSH', + extras: { source: 'brace-file' }, + }), + 'utf8', + ); + fs.cpSync(bracePayloadPath, path.join(callerWorkspace, '{payload}.json')); + const braceFilePush = await daemon.callCommand( + 'push', + ['com.example.demo', './{payload}.json'], + selection, + { meta: { cwd: callerWorkspace, sessionExplicit: true } }, + ); + assert.equal(braceFilePush.json.result.data.package, 'com.example.demo'); + assert.equal(braceFilePush.json.result.data.action, 'com.example.demo.BRACE_PUSH'); + assert.equal(braceFilePush.json.result.data.extrasCount, 1); + } finally { + fs.rmSync(callerWorkspace, { recursive: true, force: true }); + } const clipboard = await client.command.clipboard({ action: 'read', ...selection }); if (clipboard.action !== 'read') throw new Error('expected clipboard read result'); From 7133a8dc47621056e53edd90ff61af64370dfa05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:17:42 +0200 Subject: [PATCH 3/7] fix(daemon): record which kind of owner holds a device claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An in-flight command's transient claim and an open session's claim differed only by the shape of the claim's `session` label, so a request whose routed session string happened to equal `transient:` — reachable today with `--session transient:install`, which the store sanitizes only in directory segments — matched the coverage branch and ran while the first request still held the device (review r4295092657). The record now stores the kind: `transientCommand` names the scoped command (schema v3), and coverage admits only open-session claims; a command-scoped claim is a live owner to every request but its holder, proven by the release token at the admission layer. Legacy v1/v2 records translate the label that encoded the kind, once, in the record decode (tag history: v2 is released in v0.21.28, so live records use it). A session literally named this command's label is refused rather than renew-adopted, so its claim keeps only what its principal grants. Fold in the review's documentation findings: the DEVICE_IN_USE help names in-flight commands as owners a caller cannot close (r4295092708 sibling), commands.md and sessions.md state the workspace condition next to session ownership without implying every caller sends a workspace (r4295092708, r4295092739), and sessions.md names the owner only when the claim is readable, pointing at `device status` otherwise (r4295092723). Shrink the android-lifecycle raw-push fixture under the merge-base length: the file is over the 1,000-line ratchet tripwire and may not grow, so the push payloads are written directly into the claiming workspace by a loop (Coverage job test-file-size ratchet, run 38905771494). --- .../src/device-claim-reboot.test.ts | 2 +- .../src/device-claim-reconciliation.test.ts | 2 +- .../src/device-claim-record.test.ts | 38 ++++++++-- .../src/device-claim-record.ts | 74 ++++++++++++++++--- .../daemon-contracts/src/device-claims.ts | 40 +++++----- .../__tests__/device-claim-admission.test.ts | 66 ++++++++++++++++- .../__tests__/device-claim-conflict.test.ts | 2 +- .../__tests__/device-claim-settlement.test.ts | 2 +- .../device/__tests__/device-claims.test.ts | 2 +- .../device-claim-owner-recovery.test.ts | 2 +- src/commands/schema/cli-help.ts | 2 +- .../android-lifecycle.test.ts | 73 +++++++----------- website/docs/docs/commands.md | 2 +- website/docs/docs/sessions.md | 10 ++- 14 files changed, 223 insertions(+), 94 deletions(-) diff --git a/packages/daemon-contracts/src/device-claim-reboot.test.ts b/packages/daemon-contracts/src/device-claim-reboot.test.ts index a41ab90309..3a8cd96204 100644 --- a/packages/daemon-contracts/src/device-claim-reboot.test.ts +++ b/packages/daemon-contracts/src/device-claim-reboot.test.ts @@ -18,7 +18,7 @@ const device: DeviceInfo = { }; const CLAIM: DeviceClaim = { - schemaVersion: 2, + schemaVersion: 3, deviceKey: 'local:apple:ios:SIM-1', device: { family: 'apple', id: device.id, name: device.name, kind: device.kind }, session: 'cwd:/w:default', diff --git a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts index ebbe776150..9bae94c351 100644 --- a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts +++ b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts @@ -146,7 +146,7 @@ const unavailable = Object.freeze({ function makeClaim(stateDir: string): DeviceClaim { return { - schemaVersion: 2, + schemaVersion: 3, deviceKey: 'local:android:none:emulator-5554', device: { family: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' }, session: 'claimed-session', diff --git a/packages/daemon-contracts/src/device-claim-record.test.ts b/packages/daemon-contracts/src/device-claim-record.test.ts index d56ff0962e..0118c50db2 100644 --- a/packages/daemon-contracts/src/device-claim-record.test.ts +++ b/packages/daemon-contracts/src/device-claim-record.test.ts @@ -20,7 +20,7 @@ function legacyClaim(overrides: Record = {}): Record = {}): Record { +function v2Claim(overrides: Record = {}): Record { return { ...legacyClaim(), schemaVersion: 2, @@ -29,24 +29,48 @@ function currentClaim(overrides: Record = {}): Record { - for (const raw of [legacyClaim(), currentClaim()]) { +function currentClaim(overrides: Record = {}): Record { + return { ...v2Claim(), schemaVersion: 3, ...overrides }; +} + +test('v1, v2, and v3 records decode to the process-owned claim with its process principal intact', () => { + for (const raw of [legacyClaim(), v2Claim(), currentClaim()]) { const record = decodeStoredDeviceClaim(raw); expect(record).not.toBeNull(); if (!record) throw new Error('expected process-owned'); - expect(record.schemaVersion).toBe(2); + expect(record.schemaVersion).toBe(3); expect(record.session).toBe('work'); expect(record.ownerPid).toBe(4242); expect(record.ownerToken).toBe('token'); } +}); + +test('a legacy claim claim-kind comes from the label that encoded it, once at the boundary', () => { + // The pre-kind schemas recorded a command-scoped claim only as a `transient:` session. + for (const raw of [ + legacyClaim({ session: 'transient:install' }), + v2Claim({ session: 'transient:reinstall' }), + ]) { + expect(decodeStoredDeviceClaim(raw)?.transientCommand).toBe( + raw.session === 'transient:install' ? 'install' : 'reinstall', + ); + } + // From v3 the kind is a stored field, not a label pattern: a v3 open session may be named + // after a command, and a v3 record without the field is that session's claim. + expect( + decodeStoredDeviceClaim(currentClaim({ session: 'transient:install' }))?.transientCommand, + ).toBeUndefined(); expect( - decodeStoredDeviceClaim(legacyClaim({ session: 'transient:install' }))?.schemaVersion, - ).toBe(2); + decodeStoredDeviceClaim(currentClaim({ transientCommand: 'install' }))?.transientCommand, + ).toBe('install'); + // A malformed kind fails the record closed instead of guessing a kind. + expect(decodeStoredDeviceClaim(currentClaim({ transientCommand: '' }))).toBeNull(); + expect(decodeStoredDeviceClaim(currentClaim({ transientCommand: 7 }))).toBeNull(); }); test('a record of an unknown schema version is unreadable', () => { // A future-schema record carrying a whole process principal is still not migrated into a claim. - for (const schemaVersion of [0, 3, 4]) { + for (const schemaVersion of [0, 4, 5]) { expect(decodeStoredDeviceClaim(currentClaim({ schemaVersion }))).toBeNull(); } expect(decodeStoredDeviceClaim(null)).toBeNull(); diff --git a/packages/daemon-contracts/src/device-claim-record.ts b/packages/daemon-contracts/src/device-claim-record.ts index 974e1400fc..c41980d9e8 100644 --- a/packages/daemon-contracts/src/device-claim-record.ts +++ b/packages/daemon-contracts/src/device-claim-record.ts @@ -8,15 +8,15 @@ import { isRecord } from '@agent-device/kernel/record'; import { canonicalLocalDeviceKey } from './device-claim-paths.ts'; -/** Schema version of a process-owned claim record; v1 records migrate to it on read. */ -export const DEVICE_CLAIM_SCHEMA_VERSION = 2; +/** Schema version of a process-owned claim record; v1/v2 records migrate to it on read. */ +export const DEVICE_CLAIM_SCHEMA_VERSION = 3; /** * A claim owned by one process: the session or the sessionless command that took it. Its principal * is `ownerPid`/`ownerStartTime`/`ownerToken`, which is what every clearing surface matches on. */ export type DeviceClaim = { - schemaVersion: 2; + schemaVersion: 3; deviceKey: string; device: DeviceIdentity & { name: string }; session: string; @@ -27,6 +27,14 @@ export type DeviceClaim = { ownerToken: string; createdAtMs: number; updatedAtMs: number; + /** + * Set when a `transient-exclusive` command took the claim: the command it is scoped to. This is + * the claim-kind discriminant (#3345) — a command-scoped claim is never an open session's claim, + * even when its `session` label reads `transient:` and a caller copies that label into + * `--session`. Absent means an open session owns it. Legacy records carry no field and get their + * kind from the label that encoded it, once, in `decodeStoredDeviceClaim`. + */ + transientCommand?: string; /** Set by `abandonDeviceClaim`; absent while the claim still holds the device for its owner. */ abandonedAtMs?: number; }; @@ -55,18 +63,37 @@ export function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwners export function decodeStoredDeviceClaim(value: unknown): DeviceClaim | null { if (!isRecord(value)) return null; if (value.schemaVersion === DEVICE_CLAIM_SCHEMA_VERSION) return decodeCurrentClaim(value); - if (value.schemaVersion === 1) return migrateLegacyClaim(value); + if (value.schemaVersion === 2) return stampLegacyClaimKind(decodeClaimBody(value)); + if (value.schemaVersion === 1) return stampLegacyClaimKind(migrateLegacyClaim(value)); return null; } +/** + * The current schema states its claim kind explicitly: a missing `transientCommand` is an + * open-session claim, never a pattern re-derived from the session label. + */ function decodeCurrentClaim(raw: Record): DeviceClaim | null { + const claim = decodeClaimBody(raw); + if (!claim) return null; + if (raw.transientCommand === undefined) return claim; + return isNonEmptyString(raw.transientCommand) + ? { ...claim, transientCommand: raw.transientCommand } + : null; +} + +type DecodedClaimBody = Omit & { + schemaVersion: 3; + abandonedAtMs?: number; +}; + +function decodeClaimBody(raw: Record): DecodedClaimBody | null { if (!isRecord(raw.device) || !isNonEmptyString(raw.device.name)) return null; const identity = decodeDeviceIdentity(raw.device); - return identity ? buildDecodedClaim(raw, identity, raw.device.name) : null; + return identity ? decodeClaimFields(raw, identity, raw.device.name) : null; } -/** Released schema-v1 advisory claims are normalized into the canonical v2 model on read. */ -function migrateLegacyClaim(raw: Record): DeviceClaim | null { +/** Released schema-v1 advisory claims are normalized into the canonical v3 model on read. */ +function migrateLegacyClaim(raw: Record): DecodedClaimBody | null { if (!isRecord(raw.device)) return null; const legacy = raw.device; const name = legacy.name; @@ -87,14 +114,14 @@ function migrateLegacyClaim(raw: Record): DeviceClaim | null { ? {} : { iosPhysicalDeviceBackend: legacy.iosPhysicalDeviceBackend }), }); - return identity ? buildDecodedClaim(raw, identity, name) : null; + return identity ? decodeClaimFields(raw, identity, name) : null; } -function buildDecodedClaim( +function decodeClaimFields( raw: Record, identity: DeviceIdentity, name: string, -): DeviceClaim | null { +): DecodedClaimBody | null { const location = decodeClaimLocation(raw); const owner = decodeClaimOwner(raw); const timestamps = decodeClaimTimestamps(raw); @@ -109,6 +136,33 @@ function buildDecodedClaim( }; } +/** + * The claim `session` recorded for a command-scoped claim. Lives with the record so the label and + * its inverse stay one owner; admission decides on {@link DeviceClaim.transientCommand}, never on + * this shape. + */ +export function transientDeviceClaimSession(command: string): string { + return `${TRANSIENT_CLAIM_LABEL_PREFIX}${command}`; +} + +const TRANSIENT_CLAIM_LABEL_PREFIX = 'transient:'; + +/** + * The command a legacy claim's `session` label encodes a command-scoped claim for, or undefined + * for an open-session label. Records written before the kind field existed encoded it only in + * that label, so translating it here — once, at the legacy boundary — is what lets every current + * claim's kind be a stored field: reading the label at decision points is exactly the #3345 bug + * where a caller could copy `transient:` into `--session` and read as that claim's owner. + */ +function stampLegacyClaimKind(claim: DecodedClaimBody | null): DeviceClaim | null { + if (!claim) return null; + if (!claim.session.startsWith(TRANSIENT_CLAIM_LABEL_PREFIX)) return claim; + const transientCommand = claim.session.slice(TRANSIENT_CLAIM_LABEL_PREFIX.length); + // A bare `transient:` label was never a command claim: command names are non-empty, so such a + // claim could only have been an open session with that literal name. + return transientCommand === '' ? claim : { ...claim, transientCommand }; +} + function decodeClaimLocation( raw: Record, ): Pick | null { diff --git a/packages/daemon-contracts/src/device-claims.ts b/packages/daemon-contracts/src/device-claims.ts index b6f5cc0af5..1546d1c66a 100644 --- a/packages/daemon-contracts/src/device-claims.ts +++ b/packages/daemon-contracts/src/device-claims.ts @@ -29,6 +29,7 @@ import { import { DEVICE_CLAIM_SCHEMA_VERSION, ownershipFromClaim, + transientDeviceClaimSession, type DeviceClaim, type DeviceClaimSessionOwnership, } from './device-claim-record.ts'; @@ -68,24 +69,15 @@ export type DeviceClaimAcquireResult = /** * A `transient-exclusive` command may find the device already claimed by this daemon. - * Only the claim of the session the command runs in carries the exclusion the command - * needs, so that one adds none of its own. Any other claim — another session's, or - * another command's `transient:` claim this daemon holds in flight — is a - * foreign live owner (#3345): sessions of one daemon are separate owners of a device. + * Only an open session's own claim, taken from the request's workspace, carries the exclusion + * the command needs, so that one adds none of its own. Any other claim — another session's, or + * any command-scoped claim this daemon holds in flight — is a foreign live owner (#3345): + * sessions of one daemon are separate owners of a device. */ export type TransientDeviceClaimResult = | DeviceClaimAcquireResult | { status: 'covered-by-owned-claim' }; -/** - * The claim `session` recorded for a command-scoped claim. Claim records carry - * no separate kind discriminant, so the session field is what tells `device - * status` that the owner is a command in flight rather than an open session. - */ -function transientDeviceClaimSession(command: string): string { - return `transient:${command}`; -} - export async function acquireDeviceClaim(params: { device: DeviceInfo; session: string; @@ -136,7 +128,9 @@ export async function acquireTransientDeviceClaim(params: { * Whether the live claim under {@link coveredBySession} was taken by this request's * workspace rather than merely recorded under the same session string. Session * addresses are copyable (#3345), so session equality alone lets a caller that - * pasted another workspace's address act as that workspace's owner. + * pasted another workspace's address act as that workspace's owner. Only open + * sessions' claims are ever candidates: a claim's `transientCommand` kind, not its + * label, decides whether it can cover anything. */ coveredClaimIsOwnedByRequest: (claim: DeviceClaim) => boolean; workspace: string; @@ -153,14 +147,22 @@ export async function acquireTransientDeviceClaim(params: { isClaimOwnedByThisDaemon(existing.claim, params.stateDir, readCurrentOwnerIdentity()) ) { if ( + existing.claim.transientCommand === undefined && existing.claim.session === params.coveredBySession && params.coveredClaimIsOwnedByRequest(existing.claim) ) { return { status: 'covered-by-owned-claim' }; } - // A command-scoped claim this daemon holds belongs to the one command in flight, - // not to the daemon: adopting it would hand this request the other command's - // release token and delete its claim on completion. It is a live owner. + // A command-scoped claim belongs to the one request in flight, and the request that holds + // it is covered by its release token at the admission layer — never by an address match. + // The kind decides this, so a caller that copies the claim's `transient:` label + // into `--session` is refused instead of adopting it (#3345). + if (existing.claim.transientCommand !== undefined) { + return { status: 'conflict', conflict: existing }; + } + // A session literally named this command's label is not this command's claim either: + // below, `claimHeldDevice` matches holders by the session string it is handed, and + // renewing that claim would hand this request the open session's release token. if (existing.claim.session === transientDeviceClaimSession(params.command)) { return { status: 'conflict', conflict: existing }; } @@ -170,6 +172,7 @@ export async function acquireTransientDeviceClaim(params: { deviceKey, identity, session: transientDeviceClaimSession(params.command), + transientCommand: params.command, workspace: params.workspace, stateDir: params.stateDir, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, @@ -183,6 +186,8 @@ async function claimHeldDevice(params: { deviceKey: string; identity: DeviceIdentity; session: string; + /** The command a `transient-exclusive` acquisition scopes its claim to; absent for sessions. */ + transientCommand?: string; workspace: string; stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; @@ -212,6 +217,7 @@ async function claimHeldDevice(params: { ownerToken: crypto.randomUUID(), createdAtMs: now, updatedAtMs: now, + ...(params.transientCommand === undefined ? {} : { transientCommand: params.transientCommand }), }; writeDeviceClaim(claim); return { diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts index 97da4ddcb2..a07af3ce99 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts @@ -167,6 +167,47 @@ test('a claim this daemon holds for another session refuses the command like any // request-scoped, so the session comparison alone cannot tell two requests of the same // command apart. The one in flight must not be adopted — adopting it hands the second // request the first's release token, and its dispose unlinks a claim still in use. +// A public session name equal to a transient claim's label must not read as that claim's +// owner: an in-flight command's claim is exclusive even to a request that copies its +// address, because adopting it hands the intruder the command's release token. +// Mutation: read the claim kind off the session string instead of the record — the +// open session named below then stops covering, or the in-flight claim starts covering. +test('an in-flight transient claim refuses a request named after its label, while a real open session of that name covers', async () => { + const { stateDir } = setup(); + const holder = makeAdmission('transient-exclusive', stateDir, 'install', 'open-a'); + await holder.admit(ANDROID_EMULATOR, localAndroid); + expect(claimedSessions()).toEqual(['transient:install']); + + // The intruder routes to exactly "transient:install" (an explicit `--session` does this) + // in the holder's workspace, and asks for a DIFFERENT command so the label is not its own. + const intruder = makeAdmission('transient-exclusive', stateDir, 'reinstall', 'transient:install'); + const error = asAppError( + await intruder.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), + ); + expect(error.code).toBe('DEVICE_IN_USE'); + expect(error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + await intruder[Symbol.asyncDispose](); + // The held command still owns the device. + expect(claimedSessions()).toEqual(['transient:install']); + + await holder[Symbol.asyncDispose](); + expect(inspectDeviceClaims({})).toEqual([]); + + // A genuinely open session may carry that name; its claim is the session kind and covers + // its own workspace's command. + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'transient:install', + workspace: '/worktrees/current', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + const own = makeAdmission('transient-exclusive', stateDir, 'install', 'transient:install'); + await own.admit(ANDROID_EMULATOR, localAndroid); + await own[Symbol.asyncDispose](); + expect(claimedSessions()).toEqual(['transient:install']); +}); + test("another request in flight's transient claim of the same command refuses, and holds nothing", async () => { const { stateDir } = setup(); const held = await acquireDeviceClaim({ @@ -382,7 +423,7 @@ test('an ordinary owner is refused for an unknown-schema claim record, not waved const deviceKey = canonicalLocalDeviceKey(ANDROID_EMULATOR); const claimPath = deviceClaimFilePath(deviceKey); const record = JSON.stringify({ - schemaVersion: 3, + schemaVersion: 4, deviceKey, device: { family: 'android', @@ -490,6 +531,29 @@ test('the request scope claims only for commands whose descriptor declares trans // worktrees, so the owning session's `install` has to keep working while // another session's `install` is refused before its handler can reach a device // operation. +// The P1 attack in the shape the CLI sends it: `--session transient:install` is a legal +// explicit name (the store sanitizes only directory segments), so the router hands admission +// that exact address. It must not read as the owner of the in-flight command's claim — the +// claim's kind, not its label, decides — and the refusal must leave the first command holding +// the device. +test('the request scope refuses a session addressed by a transient label while a command holds the device', async () => { + const { stateDir } = setup(); + const holder = makeAdmission('transient-exclusive', stateDir, 'install', 'open-a'); + await holder.admit(ANDROID_EMULATOR, localAndroid); + + const intruder = await bindInRequestSession({ + command: 'reinstall', + stateDir, + session: 'transient:install', + }); + expect(intruder.error?.code).toBe('DEVICE_IN_USE'); + expect(intruder.error?.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + expect(intruder.claims).toEqual(['transient:install']); + + await holder[Symbol.asyncDispose](); + expect(inspectDeviceClaims({})).toEqual([]); +}); + test('the request scope admits a transient command only in the session whose claim owns the device', async () => { const { stateDir } = setup(); await acquireDeviceClaim({ diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts index 037847ae0c..6398799401 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts @@ -20,7 +20,7 @@ function conflict(classification: InspectedDeviceClaim['classification']): Inspe deviceKey: 'local:android:none:emulator-5554', classification, claim: { - schemaVersion: 2, + schemaVersion: 3, deviceKey: 'local:android:none:emulator-5554', device: { family: 'android', id: device.id, name: device.name, kind: device.kind }, session: 'checkout', diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts index e43e2bebc6..849822b2e1 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts @@ -75,7 +75,7 @@ test('preserves and reports a live foreign claim without overwriting it', async }); assert.equal(first.status, 'acquired'); const persisted = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; - assert.equal(persisted.schemaVersion, 2); + assert.equal(persisted.schemaVersion, 3); assert.deepEqual(persisted.device, { id: device.id, family: device.platform, diff --git a/packages/daemon/src/resources/device/__tests__/device-claims.test.ts b/packages/daemon/src/resources/device/__tests__/device-claims.test.ts index 74340b5561..cce909c75c 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claims.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claims.test.ts @@ -747,7 +747,7 @@ test('an unknown-schema record is never migrated into a claim, reconciled, or re const root = useClaimsRoot(); // A dead process principal: were this record read as a process claim, it would be releasable. const record = JSON.stringify({ - schemaVersion: 3, + schemaVersion: 4, deviceKey: canonicalLocalDeviceKey(device), device: { family: 'android', id: device.id, name: device.name, kind: device.kind }, session: 'future-owner', diff --git a/packages/daemon/src/server/device/__tests__/device-claim-owner-recovery.test.ts b/packages/daemon/src/server/device/__tests__/device-claim-owner-recovery.test.ts index 5e6a22eabf..6ed997f1ce 100644 --- a/packages/daemon/src/server/device/__tests__/device-claim-owner-recovery.test.ts +++ b/packages/daemon/src/server/device/__tests__/device-claim-owner-recovery.test.ts @@ -19,7 +19,7 @@ const scope = { function makeClaim(stateDir: string, session: string): DeviceClaim { return { - schemaVersion: 2, + schemaVersion: 3, deviceKey: `local:android:none:${session}`, device: { family: 'android', id: session, name: 'Pixel', kind: 'emulator' }, session, diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 0cde008788..4d6b8bbb12 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -334,7 +334,7 @@ Example: agent-device network dump --include headers Device busy and ownership: - DEVICE_IN_USE has two flavors. "already in use by session X" is this daemon's session table: reuse it with --session X, or run close --session X first. "owned by session X in workspace Y" is the host-global device claim another session holds — from this daemon or from another worktree's daemon: it is never retriable, so run the error's exact recovery command instead of retrying. + DEVICE_IN_USE has two flavors. "already in use by session X" is this daemon's session table: reuse it with --session X, or run close --session X first. "owned by session X in workspace Y" is the host-global device claim another session — from this daemon or another worktree's daemon — or an in-flight command holds; there may be no session to close, and it is never retriable, so run the error's exact recovery command instead of retrying. Inspect ownership without any daemon: agent-device device status (add --stale for proven-dead owners; settle and release those with agent-device device release --stale). devices marks rows that are claimed, so pick an unclaimed device instead of contending. A live foreign owner is released by closing its session from its own workspace or stopping its daemon: agent-device daemon stop --state-dir (the error names the state dir). The device itself can settle a claim: when it rebooted after that claim was taken, its app, runner, and accessibility session were destroyed, so open reconciles the owner's resources, takes the claim, and reports the release in warnings. Never recover by hunting PIDs with ps/kill. boot/install/shutdown take the same claims as open and refuse foreign-claimed devices identically; they do not ask the device about its boot. diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 4ead6ec06a..4507e21341 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -314,7 +314,7 @@ async function runAndroidSetupAndInstallWorkflow( world: AndroidSettingsWorld, client: AgentDeviceClient, ): Promise { - const { daemon, apkPath, aabPath, manifestApkPath, selection, tempRoot } = world; + const { daemon, apkPath, aabPath, manifestApkPath, selection } = world; const devices = await client.devices.list({ platform: 'android' }); assert.equal(devices.length, 1); @@ -430,56 +430,33 @@ async function runAndroidSetupAndInstallWorkflow( assert.equal(push.action, 'com.example.demo.PUSH'); assert.equal(push.extrasCount, 3); - const pushPayloadPath = path.join(tempRoot, 'payload.json'); - fs.writeFileSync( - pushPayloadPath, - JSON.stringify({ - action: 'com.example.demo.FILE_PUSH', - extras: { source: 'relative-file' }, - }), - 'utf8', - ); - // #3345: a raw request routes to the implicit session of the workspace its `cwd` sits in, - // and a claim covers only the session-and-workspace pair that took it. The client calls - // above sent no cwd, so the open claimed this process's own workspace (the checkout) at - // the `default` address. This push joins that address the way `--session` does and runs - // from a directory inside the checkout: the canonical scope root is the worktree root from - // either directory, so coverage holds the way it holds for a real CLI owner, and the - // relative payload still resolves against `cwd`. A cwd in another workspace would refuse - // even under the same session name — that is the copyable-address rule. + // #3345: a claim covers a raw request only from its own session AND its own workspace. The + // client calls above carried no cwd, so the open claimed this checkout at the `default` + // address; these pushes join that address the way `--session` does and run from a directory + // inside the checkout, so their relative payloads resolve against `cwd` as a real call's + // does. A foreign workspace would refuse even under the same name — the copyable-address rule. fs.mkdirSync(path.join(process.cwd(), '.tmp'), { recursive: true }); const callerWorkspace = fs.mkdtempSync(path.join(process.cwd(), '.tmp', 'push-')); try { - fs.cpSync(pushPayloadPath, path.join(callerWorkspace, 'payload.json')); - const filePush = await daemon.callCommand( - 'push', - ['com.example.demo', './payload.json'], - selection, - { meta: { cwd: callerWorkspace, sessionExplicit: true } }, - ); - assert.equal(filePush.json.result.data.package, 'com.example.demo'); - assert.equal(filePush.json.result.data.action, 'com.example.demo.FILE_PUSH'); - assert.equal(filePush.json.result.data.extrasCount, 1); - - const bracePayloadPath = path.join(tempRoot, '{payload}.json'); - fs.writeFileSync( - bracePayloadPath, - JSON.stringify({ - action: 'com.example.demo.BRACE_PUSH', - extras: { source: 'brace-file' }, - }), - 'utf8', - ); - fs.cpSync(bracePayloadPath, path.join(callerWorkspace, '{payload}.json')); - const braceFilePush = await daemon.callCommand( - 'push', - ['com.example.demo', './{payload}.json'], - selection, - { meta: { cwd: callerWorkspace, sessionExplicit: true } }, - ); - assert.equal(braceFilePush.json.result.data.package, 'com.example.demo'); - assert.equal(braceFilePush.json.result.data.action, 'com.example.demo.BRACE_PUSH'); - assert.equal(braceFilePush.json.result.data.extrasCount, 1); + for (const [payloadFile, action, source] of [ + ['payload.json', 'com.example.demo.FILE_PUSH', 'relative-file'], + ['{payload}.json', 'com.example.demo.BRACE_PUSH', 'brace-file'], + ] as const) { + fs.writeFileSync( + path.join(callerWorkspace, payloadFile), + JSON.stringify({ action, extras: { source } }), + 'utf8', + ); + const pushed = await daemon.callCommand( + 'push', + ['com.example.demo', `./${payloadFile}`], + selection, + { meta: { cwd: callerWorkspace, sessionExplicit: true } }, + ); + assert.equal(pushed.json.result.data.package, 'com.example.demo'); + assert.equal(pushed.json.result.data.action, action); + assert.equal(pushed.json.result.data.extrasCount, 1); + } } finally { fs.rmSync(callerWorkspace, { recursive: true, force: true }); } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index e34af8dae5..0c05d87de0 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -90,7 +90,7 @@ Open apps and links: - `open [app|url] [url]` boots and activates the selected target when needed. `open ` opens deep links on Android and iOS, and `open ` opens a deep link in that app on iOS. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom-scheme URLs need an active app in the session. -- `open --wait ` waits up to that budget for a device another session holds, then fails with `DEVICE_IN_USE`. The wait extends the command's timeout, so you don't need a longer `--timeout`. A claim held by another workspace's daemon is not waited for; `open` returns its recovery command at once. Device-mutating commands like `install` and `reinstall` never wait either: a claim held by a session other than yours fails them at once. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device). +- `open --wait ` waits up to that budget for a device another session holds, then fails with `DEVICE_IN_USE`. The wait extends the command's timeout, so you don't need a longer `--timeout`. A claim held by another workspace's daemon is not waited for; `open` returns its recovery command at once. Device-mutating commands like `install` and `reinstall` never wait either: a claim held by another session fails them at once, and when the caller sends a workspace, another workspace's claim under the same session name fails too. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device). - `open --launch-console ` captures launch-time stdout/stderr for iOS simulator app launches. It is not valid for URL opens or other targets. - `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface; `app` is the default when you name an app. See [Desktop targets](#desktop-targets). `--save-script` records the surface so `replay` reopens it; with no surface given, replay opens an app session. - Android React Native: `open --relaunch` needs an installed package name. Install or reinstall the APK first; `open --relaunch` is rejected. For Metro-backed JS changes, run `metro reload` before reaching for `open --relaunch`. diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index a25694314c..a23cd2237d 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -150,14 +150,18 @@ budget, so several agents can queue on one device. `--wait` waits only for another session. A device claimed by another workspace's daemon is never waited for; `open` fails at once and names the command that releases it. Device-mutating commands like `install` and `reinstall` don't wait either: while a -session other than yours holds the device, they fail at once with `DEVICE_IN_USE` and -name the owner. +session other than yours holds the device, they fail at once with `DEVICE_IN_USE`. +Copying the owner's `--session` address doesn't change that — the claim must also +record your workspace, so a command run from another checkout is refused even under +the owner's name. The error names the owner when the claim is readable; when it +isn't, `agent-device device status` shows what holds the device. ## Release a device another session or workspace holds Each daemon claims the devices its sessions use. A claim whose owner is live, or can't be proven gone, blocks `open` from another workspace, and blocks device-mutating commands like `install` -from any session other than the owner's. List the claims on this host without starting a daemon: +from any other owner — another session, or the same session name from another workspace. +List the claims on this host without starting a daemon: ```bash agent-device device status From 32083f4bfa490598f4a9a8141d9d7c1f49fb791f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:49:02 +0200 Subject: [PATCH 4/7] fix(daemon): decode legacy device claims kindless instead of guessing a kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r4295744998: released v1/v2 recorded a command-scoped claim and an open session literally named `transient:` as the same record, so the label is not a kind proof, and stamping a guessed kind made the schema version alone decide whether a colon-named session's claim covered its commands. Legacy records now decode kindless, which is safe by construction rather than by guess: every consumer of a claim's kind first requires `isClaimOwnedByThisDaemon` (same stateDir, pid, and process start time), a live process writes only v3 records whose kind is stored, and the forged-identity window that reaches a kind decision anyway is pinned by a closest-negative test: a kindless legacy record under this process's identity behaves exactly as a colon-named open session's claim — refused to foreign commands by the label guard, covering for the named session — never as a command claim any in-flight request can adopt or leak. The record test pins the kind assertions directly (ordinary legacy and current claims decode `transientCommand` undefined; the v3 kind comes only from its stored field; malformed kinds fail closed) and the android-lifecycle provenance comment states what the no-cwd client calls actually record: the daemon-side fallback workspace at the routed `default` address, joined explicitly by the pushes whose checkout-child cwd exists for relative payload resolution, not as a workspace-authority test (r4295745008, r4295745014). --- .../src/device-claim-record.test.ts | 28 +++++-- .../src/device-claim-record.ts | 36 ++++---- .../__tests__/device-claim-admission.test.ts | 82 +++++++++++++++++++ .../android-lifecycle.test.ts | 11 +-- 4 files changed, 123 insertions(+), 34 deletions(-) diff --git a/packages/daemon-contracts/src/device-claim-record.test.ts b/packages/daemon-contracts/src/device-claim-record.test.ts index 0118c50db2..75a5273164 100644 --- a/packages/daemon-contracts/src/device-claim-record.test.ts +++ b/packages/daemon-contracts/src/device-claim-record.test.ts @@ -42,28 +42,38 @@ test('v1, v2, and v3 records decode to the process-owned claim with its process expect(record.session).toBe('work'); expect(record.ownerPid).toBe(4242); expect(record.ownerToken).toBe('token'); + expect(record.transientCommand).toBeUndefined(); } }); -test('a legacy claim claim-kind comes from the label that encoded it, once at the boundary', () => { - // The pre-kind schemas recorded a command-scoped claim only as a `transient:` session. +// Released v1/v2 recorded a command-scoped claim and an open session *named* `transient:` +// as the same record — no stored field separates them, so the kind of a legacy claim is not a +// fact the decoder can read. Both decode kindless, which is safe by construction, not by guess: +// the kind is consulted only for claims `isClaimOwnedByThisDaemon` accepts (same stateDir, pid, +// and process start time), and a live process reads and writes only v3 records, whose kind is a +// stored field. No in-flight command's claim is ever a legacy record; the original #3345 bypass +// needs a current-process claim, which carries its kind. +test('a legacy claim with a command-label session decodes kindless, not as a guessed command claim', () => { for (const raw of [ legacyClaim({ session: 'transient:install' }), - v2Claim({ session: 'transient:reinstall' }), + v2Claim({ session: 'transient:install' }), ]) { - expect(decodeStoredDeviceClaim(raw)?.transientCommand).toBe( - raw.session === 'transient:install' ? 'install' : 'reinstall', - ); + const record = decodeStoredDeviceClaim(raw); + expect(record?.transientCommand).toBeUndefined(); + expect(record?.session).toBe('transient:install'); } - // From v3 the kind is a stored field, not a label pattern: a v3 open session may be named - // after a command, and a v3 record without the field is that session's claim. +}); + +// The kind is a stored field from v3 on. A v3 open session may be named after a command, and a +// v3 record without the field is exactly that session's claim; a malformed kind fails the record +// closed instead of guessing. +test('a v3 claim kind comes only from its stored field', () => { expect( decodeStoredDeviceClaim(currentClaim({ session: 'transient:install' }))?.transientCommand, ).toBeUndefined(); expect( decodeStoredDeviceClaim(currentClaim({ transientCommand: 'install' }))?.transientCommand, ).toBe('install'); - // A malformed kind fails the record closed instead of guessing a kind. expect(decodeStoredDeviceClaim(currentClaim({ transientCommand: '' }))).toBeNull(); expect(decodeStoredDeviceClaim(currentClaim({ transientCommand: 7 }))).toBeNull(); }); diff --git a/packages/daemon-contracts/src/device-claim-record.ts b/packages/daemon-contracts/src/device-claim-record.ts index c41980d9e8..d89051e9aa 100644 --- a/packages/daemon-contracts/src/device-claim-record.ts +++ b/packages/daemon-contracts/src/device-claim-record.ts @@ -31,8 +31,9 @@ export type DeviceClaim = { * Set when a `transient-exclusive` command took the claim: the command it is scoped to. This is * the claim-kind discriminant (#3345) — a command-scoped claim is never an open session's claim, * even when its `session` label reads `transient:` and a caller copies that label into - * `--session`. Absent means an open session owns it. Legacy records carry no field and get their - * kind from the label that encoded it, once, in `decodeStoredDeviceClaim`. + * `--session`. Absent means an open session owns it, including every legacy record: a v1/v2 + * record cannot prove which it was, and no kind decision is ever taken on one (see + * {@link decodeStoredDeviceClaim}). */ transientCommand?: string; /** Set by `abandonDeviceClaim`; absent while the claim still holds the device for its owner. */ @@ -60,11 +61,22 @@ export function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwners }; } +/** + * Legacy (v1/v2) records decode kindless — as open-session claims — because they cannot state a + * kind: released v1/v2 recorded a command-scoped claim and an open session *named* + * `transient:` identically, so the label is no proof of kind. Decoding kindless is safe + * by construction, not by guess: every consumer of a claim's kind first requires + * `isClaimOwnedByThisDaemon` (same stateDir, pid, and process start time), and a current process + * writes only v3 records whose kind is stored — a legacy record can only be one this process + * could not have written, so its owner identity is proven not to match. Recovery for a legacy + * record needs nothing special: the recording process still holds its token, and any other owner + * is settled or conflicted by the principal-based paths, none of which reads a kind. + */ export function decodeStoredDeviceClaim(value: unknown): DeviceClaim | null { if (!isRecord(value)) return null; if (value.schemaVersion === DEVICE_CLAIM_SCHEMA_VERSION) return decodeCurrentClaim(value); - if (value.schemaVersion === 2) return stampLegacyClaimKind(decodeClaimBody(value)); - if (value.schemaVersion === 1) return stampLegacyClaimKind(migrateLegacyClaim(value)); + if (value.schemaVersion === 2) return decodeClaimBody(value); + if (value.schemaVersion === 1) return migrateLegacyClaim(value); return null; } @@ -147,22 +159,6 @@ export function transientDeviceClaimSession(command: string): string { const TRANSIENT_CLAIM_LABEL_PREFIX = 'transient:'; -/** - * The command a legacy claim's `session` label encodes a command-scoped claim for, or undefined - * for an open-session label. Records written before the kind field existed encoded it only in - * that label, so translating it here — once, at the legacy boundary — is what lets every current - * claim's kind be a stored field: reading the label at decision points is exactly the #3345 bug - * where a caller could copy `transient:` into `--session` and read as that claim's owner. - */ -function stampLegacyClaimKind(claim: DecodedClaimBody | null): DeviceClaim | null { - if (!claim) return null; - if (!claim.session.startsWith(TRANSIENT_CLAIM_LABEL_PREFIX)) return claim; - const transientCommand = claim.session.slice(TRANSIENT_CLAIM_LABEL_PREFIX.length); - // A bare `transient:` label was never a command claim: command names are non-empty, so such a - // claim could only have been an open session with that literal name. - return transientCommand === '' ? claim : { ...claim, transientCommand }; -} - function decodeClaimLocation( raw: Record, ): Pick | null { diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts index a07af3ce99..d8738d8bb1 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { asAppError, type AppError } from '@agent-device/kernel/errors'; +import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; import { isolatedDeviceClaimStores, retainOrphanedDeviceClaims, @@ -208,6 +209,87 @@ test('an in-flight transient claim refuses a request named after its label, whil expect(claimedSessions()).toEqual(['transient:install']); }); +// The label-guard branch in acquireTransientDeviceClaim: the open session's claim below is +// named exactly this command's label, and a DIFFERENT session's `install` builds the same +// label as its own claim session. Without the guard, resolveExistingClaim's raw string match +// would hand that request the open session's release token (a renew-adopt), and disposing it +// would delete a claim it never took. Mutation: drop the guard — the foreign session acquires, +// and its dispose unlinks the open session's claim. +test("an open session named after a command's label is not renew-adopted by that command's requests", async () => { + const { stateDir } = setup(); + const open = await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'transient:install', + workspace: '/worktrees/current', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + expect(open.status).toBe('acquired'); + if (open.status !== 'acquired') return; + + const foreign = makeAdmission('transient-exclusive', stateDir, 'install', 'other-session'); + const error = asAppError( + await foreign.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), + ); + expect(error.code).toBe('DEVICE_IN_USE'); + await foreign[Symbol.asyncDispose](); + + // The open session's claim survived, with its original token: nothing was adopted. + const held = inspectDeviceClaims({})[0]?.claim; + expect(held?.ownerToken).toBe(open.ownership.ownerToken); + expect(await abandonDeviceClaim(open.ownership)).toBe('abandoned'); +}); + +// The safety argument for kindless legacy decoding, pinned rather than commented. A record that +// predates the kind field decodes as an open-session claim, and that is only reachable at a kind +// decision through `isClaimOwnedByThisDaemon` — same stateDir, pid, AND process start time — +// which a live process's command claims can never be, because a live process writes v3 kinds. +// This forges that window (the only way in) and pins what a kindless claim may and may not do: +// it behaves exactly as a colon-named open session's claim, never as a command claim an in-flight +// request can adopt or have adopted from it. +test('a kindless legacy record under this process identity reads as the named session, never as a command claim', async () => { + const { stateDir, claimsDir } = setup(); + fs.mkdirSync(claimsDir, { recursive: true }); + const identity = readCurrentOwnerIdentity(); + fs.writeFileSync( + deviceClaimFilePath(canonicalLocalDeviceKey(ANDROID_EMULATOR)), + JSON.stringify({ + schemaVersion: 2, + deviceKey: canonicalLocalDeviceKey(ANDROID_EMULATOR), + device: { + family: 'android', + id: ANDROID_EMULATOR.id, + name: ANDROID_EMULATOR.name, + kind: ANDROID_EMULATOR.kind, + }, + session: 'transient:install', + workspace: '/worktrees/current', + stateDir, + ownerPid: identity.pid, + ownerStartTime: identity.startTime, + ownerToken: 'legacy-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); + + // Another session's `install` must not adopt or renew it: the label guard refuses outright. + const foreign = makeAdmission('transient-exclusive', stateDir, 'install', 'other-session'); + const error = asAppError( + await foreign.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), + ); + expect(error.code).toBe('DEVICE_IN_USE'); + await foreign[Symbol.asyncDispose](); + expect(inspectDeviceClaims({})[0]?.claim?.ownerToken).toBe('legacy-token'); + + // Addressed as the name itself from the claim's workspace, it covers like the v3 colon-named + // session's claim does — the schema version alone decides nothing about the kind. + const named = makeAdmission('transient-exclusive', stateDir, 'install', 'transient:install'); + await named.admit(ANDROID_EMULATOR, localAndroid); + await named[Symbol.asyncDispose](); + expect(inspectDeviceClaims({})[0]?.claim?.ownerToken).toBe('legacy-token'); +}); + test("another request in flight's transient claim of the same command refuses, and holds nothing", async () => { const { stateDir } = setup(); const held = await acquireDeviceClaim({ diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 4507e21341..7de74923ed 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -430,11 +430,12 @@ async function runAndroidSetupAndInstallWorkflow( assert.equal(push.action, 'com.example.demo.PUSH'); assert.equal(push.extrasCount, 3); - // #3345: a claim covers a raw request only from its own session AND its own workspace. The - // client calls above carried no cwd, so the open claimed this checkout at the `default` - // address; these pushes join that address the way `--session` does and run from a directory - // inside the checkout, so their relative payloads resolve against `cwd` as a real call's - // does. A foreign workspace would refuse even under the same name — the copyable-address rule. + // #3345: coverage pairs the routed session with the workspace a claim records. The client + // calls above sent no cwd, so their open claim records the daemon-side fallback workspace — + // this checkout — at the routed `default` address. These pushes state their provenance + // explicitly: sessionExplicit joins that address, and their cwd — a checkout child directory, + // chosen so the relative payloads resolve against it — canonicalizes to that recorded + // workspace. A foreign cwd refuses even under the same session name: the copyable-address rule. fs.mkdirSync(path.join(process.cwd(), '.tmp'), { recursive: true }); const callerWorkspace = fs.mkdtempSync(path.join(process.cwd(), '.tmp', 'push-')); try { From 9bb0277642ef7f141ee256f85e1f2a9b18d484dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:16:53 +0200 Subject: [PATCH 5/7] test(daemon): tighten the device-claim admission tests Drop the duplicated transient-claim refusal case (its label-guard sibling already proves it) and rewrite comments that narrated mutations and review rounds into statements of the constraint each test pins. --- .../__tests__/device-claim-admission.test.ts | 96 ++++++------------- 1 file changed, 29 insertions(+), 67 deletions(-) diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts index d8738d8bb1..dd82e7485c 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts @@ -164,15 +164,10 @@ test('a claim this daemon holds for another session refuses the command like any expect(claimedSessions()).toEqual(['owning-session']); }); -// The claim a transient command holds is command-scoped (`transient:`), never -// request-scoped, so the session comparison alone cannot tell two requests of the same -// command apart. The one in flight must not be adopted — adopting it hands the second -// request the first's release token, and its dispose unlinks a claim still in use. -// A public session name equal to a transient claim's label must not read as that claim's -// owner: an in-flight command's claim is exclusive even to a request that copies its -// address, because adopting it hands the intruder the command's release token. -// Mutation: read the claim kind off the session string instead of the record — the -// open session named below then stops covering, or the in-flight claim starts covering. +// Adopting a claim whose `transientCommand` is set hands the adopter the release token of a +// command that is still running, and disposing it unlinks a claim in use. `--session` accepts +// any name the store can sanitize, so a copy of the label is a reachable request; only the +// stored kind can tell the holder's own session from that copy. test('an in-flight transient claim refuses a request named after its label, while a real open session of that name covers', async () => { const { stateDir } = setup(); const holder = makeAdmission('transient-exclusive', stateDir, 'install', 'open-a'); @@ -209,12 +204,10 @@ test('an in-flight transient claim refuses a request named after its label, whil expect(claimedSessions()).toEqual(['transient:install']); }); -// The label-guard branch in acquireTransientDeviceClaim: the open session's claim below is -// named exactly this command's label, and a DIFFERENT session's `install` builds the same -// label as its own claim session. Without the guard, resolveExistingClaim's raw string match -// would hand that request the open session's release token (a renew-adopt), and disposing it -// would delete a claim it never took. Mutation: drop the guard — the foreign session acquires, -// and its dispose unlinks the open session's claim. +// A claim's `session` is only a label to the code that renews a held claim, and renewing hands +// the caller that claim's release token. `install` builds the label `transient:install`, so a +// request in another session could otherwise renew an open session's claim of that name and +// delete it on release. The stored kind is what refuses it. test("an open session named after a command's label is not renew-adopted by that command's requests", async () => { const { stateDir } = setup(); const open = await acquireDeviceClaim({ @@ -232,6 +225,7 @@ test("an open session named after a command's label is not renew-adopted by that await foreign.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), ); expect(error.code).toBe('DEVICE_IN_USE'); + expect(error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); await foreign[Symbol.asyncDispose](); // The open session's claim survived, with its original token: nothing was adopted. @@ -240,13 +234,11 @@ test("an open session named after a command's label is not renew-adopted by that expect(await abandonDeviceClaim(open.ownership)).toBe('abandoned'); }); -// The safety argument for kindless legacy decoding, pinned rather than commented. A record that -// predates the kind field decodes as an open-session claim, and that is only reachable at a kind -// decision through `isClaimOwnedByThisDaemon` — same stateDir, pid, AND process start time — -// which a live process's command claims can never be, because a live process writes v3 kinds. -// This forges that window (the only way in) and pins what a kindless claim may and may not do: -// it behaves exactly as a colon-named open session's claim, never as a command claim an in-flight -// request can adopt or have adopted from it. +// A legacy record carries no kind, so decoding it kindless is only safe while no kind decision +// can reach one. That decision sits behind `isClaimOwnedByThisDaemon`, which matches stateDir, +// pid, and process start time; a record this process wrote states its kind. A record that names +// this live process identity without stating a kind is therefore forged or a PID-reuse survivor, +// and here it is written by hand to pin what such a record may never do: act as a command claim. test('a kindless legacy record under this process identity reads as the named session, never as a command claim', async () => { const { stateDir, claimsDir } = setup(); fs.mkdirSync(claimsDir, { recursive: true }); @@ -290,32 +282,10 @@ test('a kindless legacy record under this process identity reads as the named se expect(inspectDeviceClaims({})[0]?.claim?.ownerToken).toBe('legacy-token'); }); -test("another request in flight's transient claim of the same command refuses, and holds nothing", async () => { - const { stateDir } = setup(); - const held = await acquireDeviceClaim({ - device: ANDROID_EMULATOR, - session: 'transient:install', - workspace: '/worktrees/current', - stateDir, - reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, - }); - expect(held.status).toBe('acquired'); - - const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'other-session'); - const error = asAppError( - await admission.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), - ); - - expect(error.code).toBe('DEVICE_IN_USE'); - expect(error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); - await admission[Symbol.asyncDispose](); - expect(claimedSessions()).toEqual(['transient:install']); -}); - -// The ownerToken exemption in device-claim-admission.ts. Mutation: delete that branch — -// the second admit below throws DEVICE_IN_USE. Reachability: binding dedupes by -// `deviceIdentityKey` (carries `target`) while the claim key is the narrower -// `canonicalLocalDeviceKey`, so one device through two DeviceInfo shapes double-admits. +// Binding dedupes a request's devices by `deviceIdentityKey`, which carries `target`, while the +// claim key is the narrower `canonicalLocalDeviceKey`, so one device can reach admission twice. +// A claim this request took is the only claim that covers the second pass; anything else must +// still refuse rather than be adopted on the token this request wrote. test('a transient claim this request took covers re-admitting the same device', async () => { const { stateDir } = setup(); const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'request-session'); @@ -329,11 +299,10 @@ test('a transient claim this request took covers re-admitting the same device', expect(inspectDeviceClaims({})).toEqual([]); }); -// #3345's copyable-address door: session addresses are printed by `device status` and -// re-enter through `--session`, so coverage requires the covered claim's workspace to -// be the caller's own, canonicalized on both sides. Mutation for the pair: drop the -// `coveredClaimIsOwnedByRequest` call in the contract's covered check — the foreign -// workspace below then covers instead of refusing. +// Session addresses are printed by `device status` and re-enter through `--session`, so +// coverage has to compare what the claim records, not the string it carries. Both sides go +// through the same canonicalization the router hashes into an implicit address, so one +// checkout stays one workspace across its directories. test("a claim at this workspace's implicit address still covers its own workspace's command", async () => { const { root, stateDir } = setup(); const workspace = path.join(root, 'worktree'); @@ -412,11 +381,9 @@ test('a request that carries no cwd keeps coverage of its session\u2019s claim', expect(claimedSessions()).toEqual(['sdk-session']); }); -// The release path is where a transient claim outlives its request: an unlink that -// throws leaves the record live, and a live claim now refuses its own session's next -// command. This injects the failure at the real unlink inside the real clear -// transaction and pins the recovery: the survivor ends abandoned, so the owner's next -// acquire supersedes it — the shape acquireTransientDeviceClaim already handles. +// A transient claim outlives its request when the release unlink fails, and a live survivor now +// refuses its own session's next command. The recovery is to abandon the record the unlink left +// behind, which is the state acquireTransientDeviceClaim already supersedes. test('a failed release abandons the claim so the next command is not locked out', async () => { const { stateDir } = setup(); const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'own-session'); @@ -609,15 +576,10 @@ test('the request scope claims only for commands whose descriptor declares trans expect(inspectDeviceClaims({})).toEqual([]); }); -// #3345 at the seam the router runs: one daemon serves several sessions and -// worktrees, so the owning session's `install` has to keep working while -// another session's `install` is refused before its handler can reach a device -// operation. -// The P1 attack in the shape the CLI sends it: `--session transient:install` is a legal -// explicit name (the store sanitizes only directory segments), so the router hands admission -// that exact address. It must not read as the owner of the in-flight command's claim — the -// claim's kind, not its label, decides — and the refusal must leave the first command holding -// the device. +// `--session transient:install` is a legal explicit name — the store sanitizes only directory +// segments — so the router hands admission that exact address. It must not read as the owner of +// a command claim that is still in flight, and the refusal must leave that command holding the +// device. test('the request scope refuses a session addressed by a transient label while a command holds the device', async () => { const { stateDir } = setup(); const holder = makeAdmission('transient-exclusive', stateDir, 'install', 'open-a'); From e2fe4ba16f313f198fdad0d0802eb678b7f3b047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 18:28:20 +0200 Subject: [PATCH 6/7] fix(daemon): state a claim kind only from a record that states one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migrated v1/v2 record decoded with no kind at all, and coverage read an absent transientCommand as proof of an open session. The ownership check in front of that decision matches pid plus start time, and two processes agree on both whenever a start time is unreadable, so a leftover v2 command claim can outlive PID reuse and present as this daemon's own session. Decoded claims now carry a persisted claimOwnerKind. A current writer states open-session or transient-command; a record that predates the field states unstated and can hold and fence a device but can never cover a command. The kind is stored rather than derived from schemaVersion so renewal, abandon, and clear keep writing the current shape — a renewed record that kept its legacy version would be stamped with the migrated device shape and read back as inconsistent. --- .../src/device-claim-reboot.test.ts | 1 + .../src/device-claim-reconciliation.test.ts | 1 + .../src/device-claim-record.test.ts | 60 ++++++--- .../src/device-claim-record.ts | 93 ++++++++----- .../daemon-contracts/src/device-claims.ts | 18 +-- packages/daemon/src/core/caller-scope-root.ts | 39 ------ .../__tests__/device-claim-admission.test.ts | 127 +++++++++++------- .../__tests__/device-claim-conflict.test.ts | 1 + .../__tests__/device-claim-settlement.test.ts | 38 ++++++ .../device/device-claim-admission.ts | 14 +- .../device-claim-owner-recovery.test.ts | 1 + .../src/server/request-execution-scope.ts | 6 +- .../daemon/src/sessions/session-routing.ts | 37 ++++- 13 files changed, 277 insertions(+), 159 deletions(-) delete mode 100644 packages/daemon/src/core/caller-scope-root.ts diff --git a/packages/daemon-contracts/src/device-claim-reboot.test.ts b/packages/daemon-contracts/src/device-claim-reboot.test.ts index 3a8cd96204..aa83669d4d 100644 --- a/packages/daemon-contracts/src/device-claim-reboot.test.ts +++ b/packages/daemon-contracts/src/device-claim-reboot.test.ts @@ -19,6 +19,7 @@ const device: DeviceInfo = { const CLAIM: DeviceClaim = { schemaVersion: 3, + claimOwnerKind: 'open-session', deviceKey: 'local:apple:ios:SIM-1', device: { family: 'apple', id: device.id, name: device.name, kind: device.kind }, session: 'cwd:/w:default', diff --git a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts index 9bae94c351..8596db4d48 100644 --- a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts +++ b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts @@ -147,6 +147,7 @@ const unavailable = Object.freeze({ function makeClaim(stateDir: string): DeviceClaim { return { schemaVersion: 3, + claimOwnerKind: 'open-session', deviceKey: 'local:android:none:emulator-5554', device: { family: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' }, session: 'claimed-session', diff --git a/packages/daemon-contracts/src/device-claim-record.test.ts b/packages/daemon-contracts/src/device-claim-record.test.ts index 75a5273164..2c5ea6ddc9 100644 --- a/packages/daemon-contracts/src/device-claim-record.test.ts +++ b/packages/daemon-contracts/src/device-claim-record.test.ts @@ -30,7 +30,12 @@ function v2Claim(overrides: Record = {}): Record = {}): Record { - return { ...v2Claim(), schemaVersion: 3, ...overrides }; + return { + ...v2Claim(), + schemaVersion: 3, + claimOwnerKind: 'open-session', + ...overrides, + }; } test('v1, v2, and v3 records decode to the process-owned claim with its process principal intact', () => { @@ -47,35 +52,48 @@ test('v1, v2, and v3 records decode to the process-owned claim with its process }); // Released v1/v2 recorded a command-scoped claim and an open session *named* `transient:` -// as the same record — no stored field separates them, so the kind of a legacy claim is not a -// fact the decoder can read. Both decode kindless, which is safe by construction, not by guess: -// the kind is consulted only for claims `isClaimOwnedByThisDaemon` accepts (same stateDir, pid, -// and process start time), and a live process reads and writes only v3 records, whose kind is a -// stored field. No in-flight command's claim is ever a legacy record; the original #3345 bypass -// needs a current-process claim, which carries its kind. -test('a legacy claim with a command-label session decodes kindless, not as a guessed command claim', () => { +// as the same record, so no stored field can say which one a legacy record was. Such a record +// still decodes — clearing, reconciliation, and stale settlement need its principal — but it +// states no kind, so a migrated record can never cover a command. +test('a migrated record normalizes to the current schema and states no claim kind', () => { for (const raw of [ legacyClaim({ session: 'transient:install' }), v2Claim({ session: 'transient:install' }), ]) { const record = decodeStoredDeviceClaim(raw); - expect(record?.transientCommand).toBeUndefined(); - expect(record?.session).toBe('transient:install'); + expect(record).not.toBeNull(); + if (!record) throw new Error('expected a decoded claim'); + expect(record.schemaVersion).toBe(3); + expect(record.session).toBe('transient:install'); + expect(record.claimOwnerKind).toBe('unstated'); + expect(record.transientCommand).toBeUndefined(); } }); -// The kind is a stored field from v3 on. A v3 open session may be named after a command, and a -// v3 record without the field is exactly that session's claim; a malformed kind fails the record -// closed instead of guessing. -test('a v3 claim kind comes only from its stored field', () => { - expect( - decodeStoredDeviceClaim(currentClaim({ session: 'transient:install' }))?.transientCommand, - ).toBeUndefined(); +// A current record states its kind, and the kind fields have to agree. An open session may +// legitimately be named after a command; a malformed kind fails the record closed. +test('a current-version claim kind comes only from its stored fields', () => { + const named = decodeStoredDeviceClaim(currentClaim({ session: 'transient:install' })); + expect(named).not.toBeNull(); + if (!named) throw new Error('expected a decoded claim'); + expect(named.claimOwnerKind).toBe('open-session'); + expect(named.transientCommand).toBeUndefined(); + + const scoped = decodeStoredDeviceClaim( + currentClaim({ claimOwnerKind: 'transient-command', transientCommand: 'install' }), + ); + expect(scoped).not.toBeNull(); + if (!scoped) throw new Error('expected a decoded claim'); + expect(scoped.claimOwnerKind).toBe('transient-command'); + expect(scoped.transientCommand).toBe('install'); + + expect(decodeStoredDeviceClaim(currentClaim({ transientCommand: 'install' }))).toBeNull(); expect( - decodeStoredDeviceClaim(currentClaim({ transientCommand: 'install' }))?.transientCommand, - ).toBe('install'); - expect(decodeStoredDeviceClaim(currentClaim({ transientCommand: '' }))).toBeNull(); - expect(decodeStoredDeviceClaim(currentClaim({ transientCommand: 7 }))).toBeNull(); + decodeStoredDeviceClaim( + currentClaim({ claimOwnerKind: 'transient-command', transientCommand: '' }), + ), + ).toBeNull(); + expect(decodeStoredDeviceClaim(currentClaim({ claimOwnerKind: 'bogus' }))).toBeNull(); }); test('a record of an unknown schema version is unreadable', () => { diff --git a/packages/daemon-contracts/src/device-claim-record.ts b/packages/daemon-contracts/src/device-claim-record.ts index d89051e9aa..bce2d0b752 100644 --- a/packages/daemon-contracts/src/device-claim-record.ts +++ b/packages/daemon-contracts/src/device-claim-record.ts @@ -8,9 +8,24 @@ import { isRecord } from '@agent-device/kernel/record'; import { canonicalLocalDeviceKey } from './device-claim-paths.ts'; -/** Schema version of a process-owned claim record; v1/v2 records migrate to it on read. */ +/** Schema version of a process-owned claim record; v1/v2 records normalize to it on read. */ export const DEVICE_CLAIM_SCHEMA_VERSION = 3; +/** + * What a claim's record states about who owns it. A current writer always states one. + * `unstated` is what a record migrated from the released v1/v2 schemas decodes to, because those + * schemas wrote a command-scoped claim and an open session *named* `transient:` + * identically; the session label is never re-read as the discriminant. A claim that states no kind + * can hold and fence a device but can never cover a command. + */ +export type DeviceClaimOwnerKind = 'open-session' | 'transient-command' | 'unstated'; + +const DEVICE_CLAIM_OWNER_KINDS: readonly DeviceClaimOwnerKind[] = [ + 'open-session', + 'transient-command', + 'unstated', +]; + /** * A claim owned by one process: the session or the sessionless command that took it. Its principal * is `ownerPid`/`ownerStartTime`/`ownerToken`, which is what every clearing surface matches on. @@ -28,13 +43,13 @@ export type DeviceClaim = { createdAtMs: number; updatedAtMs: number; /** - * Set when a `transient-exclusive` command took the claim: the command it is scoped to. This is - * the claim-kind discriminant (#3345) — a command-scoped claim is never an open session's claim, - * even when its `session` label reads `transient:` and a caller copies that label into - * `--session`. Absent means an open session owns it, including every legacy record: a v1/v2 - * record cannot prove which it was, and no kind decision is ever taken on one (see - * {@link decodeStoredDeviceClaim}). + * The persisted claim-kind discriminant (#3345). Coverage reads this and never the `session` + * label: an open session may legitimately be named `transient:`, and any printed + * address can be pasted back into `--session`. It is stored rather than derived from the schema + * version so a record renewed or abandoned after migration keeps saying the same thing. */ + claimOwnerKind: DeviceClaimOwnerKind; + /** The command a `transient-command` claim is scoped to; present for exactly that kind. */ transientCommand?: string; /** Set by `abandonDeviceClaim`; absent while the claim still holds the device for its owner. */ abandonedAtMs?: number; @@ -62,50 +77,53 @@ export function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwners } /** - * Legacy (v1/v2) records decode kindless — as open-session claims — because they cannot state a - * kind: released v1/v2 recorded a command-scoped claim and an open session *named* - * `transient:` identically, so the label is no proof of kind. Decoding kindless is safe - * by construction, not by guess: every consumer of a claim's kind first requires - * `isClaimOwnedByThisDaemon` (same stateDir, pid, and process start time), and a current process - * writes only v3 records whose kind is stored — a legacy record can only be one this process - * could not have written, so its owner identity is proven not to match. Recovery for a legacy - * record needs nothing special: the recording process still holds its token, and any other owner - * is settled or conflicted by the principal-based paths, none of which reads a kind. + * Every stored record normalizes to the current schema so that a renewal or an abandon can write a + * decoded claim back without changing its shape. A v1/v2 record has no kind field, and the session + * label cannot supply one, so it decodes as `unstated`: it keeps its full principal for clearing, + * reconciliation, and stale settlement, and states no ownership kind. */ export function decodeStoredDeviceClaim(value: unknown): DeviceClaim | null { if (!isRecord(value)) return null; if (value.schemaVersion === DEVICE_CLAIM_SCHEMA_VERSION) return decodeCurrentClaim(value); - if (value.schemaVersion === 2) return decodeClaimBody(value); + if (value.schemaVersion === 2) return decodeClaimBody(value, 'unstated'); if (value.schemaVersion === 1) return migrateLegacyClaim(value); return null; } /** - * The current schema states its claim kind explicitly: a missing `transientCommand` is an - * open-session claim, never a pattern re-derived from the session label. + * A current record states its kind, and the two kind fields have to agree: a command claim names + * its command, any other kind names none. Anything else fails the record closed rather than + * guessing which owner a session label suggests. */ function decodeCurrentClaim(raw: Record): DeviceClaim | null { - const claim = decodeClaimBody(raw); - if (!claim) return null; - if (raw.transientCommand === undefined) return claim; - return isNonEmptyString(raw.transientCommand) - ? { ...claim, transientCommand: raw.transientCommand } - : null; + const { claimOwnerKind, transientCommand } = raw; + if (!isKnownOwnerKind(claimOwnerKind)) return null; + if (claimOwnerKind === 'transient-command') { + if (!isNonEmptyString(transientCommand)) return null; + const claim = decodeClaimBody(raw, claimOwnerKind); + return claim ? { ...claim, transientCommand } : null; + } + if (transientCommand !== undefined) return null; + return decodeClaimBody(raw, claimOwnerKind); } -type DecodedClaimBody = Omit & { - schemaVersion: 3; - abandonedAtMs?: number; -}; +function isKnownOwnerKind(value: unknown): value is DeviceClaimOwnerKind { + return ( + typeof value === 'string' && (DEVICE_CLAIM_OWNER_KINDS as readonly string[]).includes(value) + ); +} -function decodeClaimBody(raw: Record): DecodedClaimBody | null { +function decodeClaimBody( + raw: Record, + claimOwnerKind: DeviceClaimOwnerKind, +): DeviceClaim | null { if (!isRecord(raw.device) || !isNonEmptyString(raw.device.name)) return null; const identity = decodeDeviceIdentity(raw.device); - return identity ? decodeClaimFields(raw, identity, raw.device.name) : null; + return identity ? decodeClaimFields(raw, identity, raw.device.name, claimOwnerKind) : null; } -/** Released schema-v1 advisory claims are normalized into the canonical v3 model on read. */ -function migrateLegacyClaim(raw: Record): DecodedClaimBody | null { +/** Released schema-v1 claims differ only in device shape; they state no claim kind. */ +function migrateLegacyClaim(raw: Record): DeviceClaim | null { if (!isRecord(raw.device)) return null; const legacy = raw.device; const name = legacy.name; @@ -126,14 +144,15 @@ function migrateLegacyClaim(raw: Record): DecodedClaimBody | nu ? {} : { iosPhysicalDeviceBackend: legacy.iosPhysicalDeviceBackend }), }); - return identity ? decodeClaimFields(raw, identity, name) : null; + return identity ? decodeClaimFields(raw, identity, name, 'unstated') : null; } function decodeClaimFields( raw: Record, identity: DeviceIdentity, name: string, -): DecodedClaimBody | null { + claimOwnerKind: DeviceClaimOwnerKind, +): DeviceClaim | null { const location = decodeClaimLocation(raw); const owner = decodeClaimOwner(raw); const timestamps = decodeClaimTimestamps(raw); @@ -145,13 +164,13 @@ function decodeClaimFields( device: { ...identity, name }, ...owner, ...timestamps, + claimOwnerKind, }; } /** * The claim `session` recorded for a command-scoped claim. Lives with the record so the label and - * its inverse stay one owner; admission decides on {@link DeviceClaim.transientCommand}, never on - * this shape. + * its inverse stay one owner; `claimOwnerKind` is what decides a kind, never this shape. */ export function transientDeviceClaimSession(command: string): string { return `${TRANSIENT_CLAIM_LABEL_PREFIX}${command}`; diff --git a/packages/daemon-contracts/src/device-claims.ts b/packages/daemon-contracts/src/device-claims.ts index 1546d1c66a..b1932096aa 100644 --- a/packages/daemon-contracts/src/device-claims.ts +++ b/packages/daemon-contracts/src/device-claims.ts @@ -128,8 +128,8 @@ export async function acquireTransientDeviceClaim(params: { * Whether the live claim under {@link coveredBySession} was taken by this request's * workspace rather than merely recorded under the same session string. Session * addresses are copyable (#3345), so session equality alone lets a caller that - * pasted another workspace's address act as that workspace's owner. Only open - * sessions' claims are ever candidates: a claim's `transientCommand` kind, not its + * pasted another workspace's address act as that workspace's owner. Only an + * `open-session` claim is ever a candidate: the record's `claimOwnerKind`, not its * label, decides whether it can cover anything. */ coveredClaimIsOwnedByRequest: (claim: DeviceClaim) => boolean; @@ -146,18 +146,19 @@ export async function acquireTransientDeviceClaim(params: { !isAbandonedDeviceClaim(existing.claim) && isClaimOwnedByThisDaemon(existing.claim, params.stateDir, readCurrentOwnerIdentity()) ) { + const claimKind = existing.claim.claimOwnerKind; if ( - existing.claim.transientCommand === undefined && + claimKind === 'open-session' && existing.claim.session === params.coveredBySession && params.coveredClaimIsOwnedByRequest(existing.claim) ) { return { status: 'covered-by-owned-claim' }; } - // A command-scoped claim belongs to the one request in flight, and the request that holds - // it is covered by its release token at the admission layer — never by an address match. - // The kind decides this, so a caller that copies the claim's `transient:` label - // into `--session` is refused instead of adopting it (#3345). - if (existing.claim.transientCommand !== undefined) { + // Only a record that states its kind can take part in a kind decision. A migrated record + // states none, and `isClaimOwnedByThisDaemon` matches pid plus start time — which two + // processes can share once a start time is unreadable — so coverage has to refuse rather + // than assume an old record was not some other owner's command claim (#3345). + if (claimKind !== 'open-session') { return { status: 'conflict', conflict: existing }; } // A session literally named this command's label is not this command's claim either: @@ -217,6 +218,7 @@ async function claimHeldDevice(params: { ownerToken: crypto.randomUUID(), createdAtMs: now, updatedAtMs: now, + claimOwnerKind: params.transientCommand === undefined ? 'open-session' : 'transient-command', ...(params.transientCommand === undefined ? {} : { transientCommand: params.transientCommand }), }; writeDeviceClaim(claim); diff --git a/packages/daemon/src/core/caller-scope-root.ts b/packages/daemon/src/core/caller-scope-root.ts deleted file mode 100644 index 7b93e4c6fb..0000000000 --- a/packages/daemon/src/core/caller-scope-root.ts +++ /dev/null @@ -1,39 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -/** - * The canonical workspace root a caller's cwd belongs to: the git worktree root when the - * cwd sits inside one, otherwise the realpath of the directory. Two commands issued from - * different directories of one checkout resolve to the same root, so it is the comparison - * key for anything that asks "did this caller and that record come from the same - * workspace?" — session routing hashes it into the implicit session address, and - * device-claim coverage (#3345) compares covered claims by it rather than by the raw cwd, - * which would refuse an owner that ran `open` from the checkout root and `install` from a - * subdirectory of it. - */ -export function resolveCallerScopeRoot(rawCwd: string | undefined): string | undefined { - if (!rawCwd || rawCwd.trim().length === 0) return undefined; - const cwd = resolveExistingPath(rawCwd); - return findGitWorktreeRoot(cwd) ?? cwd; -} - -function resolveExistingPath(rawPath: string): string { - const resolved = path.resolve(rawPath); - try { - return fs.realpathSync.native(resolved); - } catch { - return resolved; - } -} - -function findGitWorktreeRoot(startDir: string): string | undefined { - let current = startDir; - while (true) { - if (fs.existsSync(path.join(current, '.git'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) return undefined; - current = parent; - } -} diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts index dd82e7485c..078d435d27 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts @@ -8,7 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { asAppError, type AppError } from '@agent-device/kernel/errors'; -import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import * as hostProcess from '@agent-device/host-kit/process'; import { isolatedDeviceClaimStores, retainOrphanedDeviceClaims, @@ -21,7 +21,10 @@ import { canonicalLocalDeviceKey, } from '@agent-device/daemon-contracts/device-claims'; import { createRequestExecutionScope } from '../../../server/request-execution-scope.ts'; -import { resolveEffectiveSessionName } from '../../../sessions/session-routing.ts'; +import { + resolveCallerScopeRoot, + resolveEffectiveSessionName, +} from '../../../sessions/session-routing.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; import { SessionStore } from '../../../core/session-store.ts'; import { unavailableDeviceRuntimeGateway } from '../../../__tests__/support/unavailable-device-runtime-gateway.ts'; @@ -46,6 +49,7 @@ function makeAdmission( session, workspace: callerCwd ?? '/worktrees/current', callerCwd: callerCwd ?? undefined, + resolveScopeRoot: resolveCallerScopeRoot, stateDir, reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, }); @@ -234,52 +238,81 @@ test("an open session named after a command's label is not renew-adopted by that expect(await abandonDeviceClaim(open.ownership)).toBe('abandoned'); }); -// A legacy record carries no kind, so decoding it kindless is only safe while no kind decision -// can reach one. That decision sits behind `isClaimOwnedByThisDaemon`, which matches stateDir, -// pid, and process start time; a record this process wrote states its kind. A record that names -// this live process identity without stating a kind is therefore forged or a PID-reuse survivor, -// and here it is written by hand to pin what such a record may never do: act as a command claim. -test('a kindless legacy record under this process identity reads as the named session, never as a command claim', async () => { - const { stateDir, claimsDir } = setup(); - fs.mkdirSync(claimsDir, { recursive: true }); - const identity = readCurrentOwnerIdentity(); - fs.writeFileSync( - deviceClaimFilePath(canonicalLocalDeviceKey(ANDROID_EMULATOR)), - JSON.stringify({ - schemaVersion: 2, - deviceKey: canonicalLocalDeviceKey(ANDROID_EMULATOR), - device: { - family: 'android', - id: ANDROID_EMULATOR.id, - name: ANDROID_EMULATOR.name, - kind: ANDROID_EMULATOR.kind, - }, - session: 'transient:install', - workspace: '/worktrees/current', - stateDir, - ownerPid: identity.pid, - ownerStartTime: identity.startTime, - ownerToken: 'legacy-token', - createdAtMs: 1, - updatedAtMs: 1, - }), - ); +// A legacy record states no kind, so coverage cannot treat it as an open session's claim. The +// proof it would lean on — `isClaimOwnedByThisDaemon` — matches pid plus start time, and two +// different processes agree on both whenever a start time is unreadable (`null === null`), so a +// leftover v1/v2 command claim can outlive PID reuse and present as this daemon's own session. +// Each case writes the record by hand with the identity this process reports. +test('a legacy record under this process identity covers nothing, not even under an unreadable start time', async () => { + for (const startTimeUnavailable of [false, true]) { + const { stateDir, claimsDir } = setup(); + fs.mkdirSync(claimsDir, { recursive: true }); + // A host this process cannot read its own start time from is the window PID reuse walks + // through, so pin the identity the daemon reads rather than only the one on disk. + const identity = startTimeUnavailable + ? { pid: process.pid, startTime: null } + : hostProcess.readCurrentOwnerIdentity(); + const owner = vi.spyOn(hostProcess, 'readCurrentOwnerIdentity').mockReturnValue(identity); + try { + fs.writeFileSync( + deviceClaimFilePath(canonicalLocalDeviceKey(ANDROID_EMULATOR)), + JSON.stringify({ + schemaVersion: 2, + deviceKey: canonicalLocalDeviceKey(ANDROID_EMULATOR), + device: { + family: 'android', + id: ANDROID_EMULATOR.id, + name: ANDROID_EMULATOR.name, + kind: ANDROID_EMULATOR.kind, + }, + session: 'transient:install', + workspace: '/worktrees/current', + stateDir, + ownerPid: identity.pid, + ownerStartTime: identity.startTime, + ownerToken: 'legacy-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); + + // Addressed exactly as the v2 record's own session, from that record's workspace: the most a + // caller can do without stating a kind, and still not enough to take the device. + const claimOwner = makeAdmission( + 'transient-exclusive', + stateDir, + 'install', + 'transient:install', + ); + const error = asAppError( + await claimOwner.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), + ); + expect(error.code).toBe('DEVICE_IN_USE'); + expect(error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + await claimOwner[Symbol.asyncDispose](); + expect(inspectDeviceClaims({})[0]?.claim?.ownerToken).toBe('legacy-token'); + } finally { + owner.mockRestore(); + } + } +}); - // Another session's `install` must not adopt or renew it: the label guard refuses outright. - const foreign = makeAdmission('transient-exclusive', stateDir, 'install', 'other-session'); - const error = asAppError( - await foreign.admit(ANDROID_EMULATOR, localAndroid).catch((error: unknown) => error), - ); - expect(error.code).toBe('DEVICE_IN_USE'); - await foreign[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})[0]?.claim?.ownerToken).toBe('legacy-token'); - - // Addressed as the name itself from the claim's workspace, it covers like the v3 colon-named - // session's claim does — the schema version alone decides nothing about the kind. - const named = makeAdmission('transient-exclusive', stateDir, 'install', 'transient:install'); - await named.admit(ANDROID_EMULATOR, localAndroid); - await named[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})[0]?.claim?.ownerToken).toBe('legacy-token'); +// The same workspace and session address do cover once the record can state its kind, so the +// refusal above is about an unstated kind rather than about the identity or workspace checks. +test('a current-version open-session claim of this workspace still covers', async () => { + const { stateDir } = setup(); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'transient:install', + workspace: '/worktrees/current', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + const admission = makeAdmission('transient-exclusive', stateDir, 'install', 'transient:install'); + await admission.admit(ANDROID_EMULATOR, localAndroid); + await admission[Symbol.asyncDispose](); + expect(claimedSessions()).toEqual(['transient:install']); }); // Binding dedupes a request's devices by `deviceIdentityKey`, which carries `target`, while the diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts index 6398799401..884bbf5799 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts @@ -21,6 +21,7 @@ function conflict(classification: InspectedDeviceClaim['classification']): Inspe classification, claim: { schemaVersion: 3, + claimOwnerKind: 'open-session', deviceKey: 'local:android:none:emulator-5554', device: { family: 'android', id: device.id, name: device.name, kind: device.kind }, session: 'checkout', diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts index 849822b2e1..812cd20325 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts @@ -94,6 +94,44 @@ test('preserves and reports a live foreign claim without overwriting it', async assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'first'); }); +// Renewal, abandon, and clear write a decoded claim back. A migrated record must therefore carry +// the current schema version: a legacy version on the migrated device shape reads back as +// inconsistent and locks the owner out of its own next command. +test('a renewal of a migrated record writes back the shape the reader expects', async () => { + const root = useClaimsRoot(); + fs.writeFileSync( + claimPath(root), + JSON.stringify({ + schemaVersion: 1, + deviceKey: canonicalLocalDeviceKey(device), + device: { platform: 'android', id: device.id, name: device.name, kind: device.kind }, + session: 'legacy-owner', + workspace: '/worktrees/legacy', + stateDir: root, + ownerPid: process.pid, + ownerStartTime: 'test-process-start-time', + ownerToken: 'legacy-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); + + const held = await acquireDeviceClaim({ + device, + session: 'legacy-owner', + workspace: '/worktrees/legacy', + stateDir: root, + }); + assert.equal(held.status, 'acquired'); + + const persisted = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; + assert.equal(persisted.schemaVersion, 3); + const reread = inspectDeviceClaims({ serial: device.id })[0]; + assert.equal(reread?.classification, 'live'); + assert.equal(reread?.claim?.session, 'legacy-owner'); + assert.equal(reread?.claim?.claimOwnerKind, 'unstated'); +}); + test('does not treat a same-named session in another worktree as its claim owner', async () => { const root = useClaimsRoot(); const first = await acquireDeviceClaim({ diff --git a/packages/daemon/src/resources/device/device-claim-admission.ts b/packages/daemon/src/resources/device/device-claim-admission.ts index 753dafeaf4..27c1b39406 100644 --- a/packages/daemon/src/resources/device/device-claim-admission.ts +++ b/packages/daemon/src/resources/device/device-claim-admission.ts @@ -2,7 +2,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; import type { DeviceClaimPolicy } from '@agent-device/command-registry/types'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { resolveCallerScopeRoot } from '../../core/caller-scope-root.ts'; import { deviceClaimConflictError } from './device-claim-conflict.ts'; import { deviceClaimRuleForOwner } from './device-claim-rule.ts'; import { @@ -54,15 +53,20 @@ export function createDeviceClaimAdmission(params: { * exactly the SDK requests whose own session claims should cover them. */ callerCwd: string | undefined; + /** + * Canonicalizes a workspace to the root session routing hashes into a session address. + * The claim layer cannot reach routing itself, so the composition root hands over the same + * function routing uses: comparing raw directories would separate a caller that ran `open` + * at the checkout root from its own claim recorded against that root. + */ + resolveScopeRoot: (workspace: string | undefined) => string | undefined; stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; }): DeviceClaimAdmission { // The caller admits once per device binding, so this only has to remember what // it took in order to give it back. const acquired: DeviceClaimSessionOwnership[] = []; - // Comparing both sides through resolveCallerScopeRoot — the root routing hashes into - // the implicit address — keeps one checkout's commands together across directories. - const requestScopeRoot = resolveCallerScopeRoot(params.callerCwd); + const requestScopeRoot = params.resolveScopeRoot(params.callerCwd); /** * Only `transient-exclusive` writes the claim store here: `observe`/`require-owner` never do, @@ -76,7 +80,7 @@ export function createDeviceClaimAdmission(params: { command: params.command, coveredBySession: params.session, coveredClaimIsOwnedByRequest: (claim) => - !requestScopeRoot || resolveCallerScopeRoot(claim.workspace) === requestScopeRoot, + !requestScopeRoot || params.resolveScopeRoot(claim.workspace) === requestScopeRoot, workspace: params.workspace, stateDir: params.stateDir, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, diff --git a/packages/daemon/src/server/device/__tests__/device-claim-owner-recovery.test.ts b/packages/daemon/src/server/device/__tests__/device-claim-owner-recovery.test.ts index 6ed997f1ce..0f993a6e50 100644 --- a/packages/daemon/src/server/device/__tests__/device-claim-owner-recovery.test.ts +++ b/packages/daemon/src/server/device/__tests__/device-claim-owner-recovery.test.ts @@ -20,6 +20,7 @@ const scope = { function makeClaim(stateDir: string, session: string): DeviceClaim { return { schemaVersion: 3, + claimOwnerKind: 'open-session', deviceKey: `local:android:none:${session}`, device: { family: 'android', id: session, name: 'Pixel', kind: 'emulator' }, session, diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index 478aca0d04..bffe64b459 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -21,7 +21,10 @@ import { contextFromFlags as contextFromFlagsWithLog, } from '../core/context.ts'; import { assertSessionSelectorMatches } from '../core/session-selector.ts'; -import { resolveEffectiveSessionName } from '../sessions/session-routing.ts'; +import { + resolveCallerScopeRoot, + resolveEffectiveSessionName, +} from '../sessions/session-routing.ts'; import { assertRequestLeaseAdmissionPreflight, scopeRequestSession, @@ -416,6 +419,7 @@ function createRequestDeviceAccess(params: { session: params.sessionName, workspace: params.workspace, callerCwd: params.callerCwd, + resolveScopeRoot: resolveCallerScopeRoot, stateDir: params.stateDir, reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler({ scope: platformRequestScope, diff --git a/packages/daemon/src/sessions/session-routing.ts b/packages/daemon/src/sessions/session-routing.ts index 68fcc1a701..4caed91518 100644 --- a/packages/daemon/src/sessions/session-routing.ts +++ b/packages/daemon/src/sessions/session-routing.ts @@ -1,7 +1,8 @@ import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { resolveCallerScopeRoot } from '../core/caller-scope-root.ts'; import { PLATFORM_SELECTORS, type PlatformSelector, @@ -303,3 +304,37 @@ function buildAmbiguousWorkspaceSessionError(candidates: SessionRef[]): AppError function hashScopeRoot(scopeRoot: string): string { return crypto.createHash('sha256').update(scopeRoot).digest('hex').slice(0, 16); } + +/** + * The canonical workspace root a caller's cwd belongs to: the git worktree root when the cwd sits + * inside one, otherwise the realpath of the directory. It is the identity the implicit session + * address hashes, and therefore the key anything else that asks "did this caller and that record + * come from the same workspace?" must compare on — comparing raw directories instead would + * separate `open` run at the checkout root from `install` run in a subdirectory of it. + */ +export function resolveCallerScopeRoot(rawCwd: string | undefined): string | undefined { + if (!rawCwd || rawCwd.trim().length === 0) return undefined; + const cwd = resolveExistingPath(rawCwd); + return findGitWorktreeRoot(cwd) ?? cwd; +} + +function resolveExistingPath(rawPath: string): string { + const resolved = path.resolve(rawPath); + try { + return fs.realpathSync.native(resolved); + } catch { + return resolved; + } +} + +function findGitWorktreeRoot(startDir: string): string | undefined { + let current = startDir; + while (true) { + if (fs.existsSync(path.join(current, '.git'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} From 00e495278ec3b3d2b1af932902b8edba115078f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 13:36:28 +0200 Subject: [PATCH 7/7] fix(daemon): hold an existing claim only for its own claim kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An acquirer now holds an existing claim only when the record states the very kind of claim the acquirer would write itself — the same transientCommand for transient admission — under the same session, workspace, and daemon identity. An unstated record matches no acquirer, so acquisition never adopts a migrated v1/v2 record, and an open session named after a command label never inherits an in-flight command's owner token. claimHeldDevice passes the acquirer's typed kind and command to resolveExistingClaim, the boundary every acquire route goes through, which retires the label guard in acquireTransientDeviceClaim. Ref: https://github.com/callstack/agent-device/pull/3714#issuecomment-6232426189 --- .../src/device-claim-settlement.ts | 34 ++++- .../daemon-contracts/src/device-claims.ts | 17 ++- .../__tests__/device-claim-settlement.test.ts | 142 +++++++++++++++++- 3 files changed, 176 insertions(+), 17 deletions(-) diff --git a/packages/daemon-contracts/src/device-claim-settlement.ts b/packages/daemon-contracts/src/device-claim-settlement.ts index 93a4ff3c0f..cadc58543f 100644 --- a/packages/daemon-contracts/src/device-claim-settlement.ts +++ b/packages/daemon-contracts/src/device-claim-settlement.ts @@ -21,6 +21,7 @@ import { resolveDeviceClaimPath } from './device-claim-paths.ts'; import { ownershipFromClaim, type DeviceClaim, + type DeviceClaimOwnerKind, type DeviceClaimSessionOwnership, } from './device-claim-record.ts'; import { writeDeviceClaim } from './device-claim-store.ts'; @@ -43,6 +44,13 @@ export type DeviceClaimReconciler = ( claim: DeviceClaim, ) => Promise; +/** + * The acquirer whose ownership `resolveExistingClaim` judges: the kind of claim it could have + * written itself — never `unstated`, which no acquirer can be — plus the command a transient + * acquirer scopes to. + */ +export type DeviceClaimAcquirerKind = Exclude; + /** * Settles the claim file an acquisition found, and decides whether this caller may write its own. * The recorded owner's own state answers first — a dead, unreachable, or superseded owner is @@ -57,6 +65,10 @@ export async function resolveExistingClaim(params: { session: string; workspace: string; stateDir: string; + /** The kind of claim this acquirer would write, so `isCurrentClaimOwner` can compare kinds. */ + claimOwnerKind: DeviceClaimAcquirerKind; + /** The command a `transient-command` acquirer scopes to; absent exactly when the kind is. */ + transientCommand?: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; }): Promise { @@ -187,11 +199,31 @@ function isAbandonedClaimOfThisDaemon( return isAbandonedDeviceClaim(claim) && isClaimOwnedByThisDaemon(claim, stateDir, owner); } +/** + * An acquirer holds an existing claim only when the record states the very kind of claim this + * acquirer would write itself — the same `transientCommand` for transient admission — under the + * same session, workspace, and daemon. `unstated` matches no acquirer, so a migrated record can + * hold and fence the device but can never be adopted at acquisition, however exactly its session + * label or copyable `--session` address lines up (#3345). + */ function isCurrentClaimOwner( claim: DeviceClaim, - params: { session: string; workspace: string; stateDir: string }, + params: { + session: string; + workspace: string; + stateDir: string; + claimOwnerKind: DeviceClaimAcquirerKind; + transientCommand?: string; + }, owner: ReturnType, ): boolean { + if (claim.claimOwnerKind !== params.claimOwnerKind) return false; + if ( + params.claimOwnerKind === 'transient-command' && + claim.transientCommand !== params.transientCommand + ) { + return false; + } return ( claim.session === params.session && claim.workspace === params.workspace && diff --git a/packages/daemon-contracts/src/device-claims.ts b/packages/daemon-contracts/src/device-claims.ts index b1932096aa..012ea48437 100644 --- a/packages/daemon-contracts/src/device-claims.ts +++ b/packages/daemon-contracts/src/device-claims.ts @@ -38,6 +38,7 @@ import { isClaimOwnedByThisDaemon, resolveExistingClaim, settleVerifiedOrphanedClaim, + type DeviceClaimAcquirerKind, type DeviceClaimReconciler, } from './device-claim-settlement.ts'; import { withDeviceClaimLock, writeDeviceClaim } from './device-claim-store.ts'; @@ -157,16 +158,13 @@ export async function acquireTransientDeviceClaim(params: { // Only a record that states its kind can take part in a kind decision. A migrated record // states none, and `isClaimOwnedByThisDaemon` matches pid plus start time — which two // processes can share once a start time is unreadable — so coverage has to refuse rather - // than assume an old record was not some other owner's command claim (#3345). + // than assume an old record was not some other owner's command claim (#3345). A live claim + // of any other kind — another session's or a command's, including one labeled exactly like + // this command — refuses here, because renewing it would hand this request a token it + // never took. if (claimKind !== 'open-session') { return { status: 'conflict', conflict: existing }; } - // A session literally named this command's label is not this command's claim either: - // below, `claimHeldDevice` matches holders by the session string it is handed, and - // renewing that claim would hand this request the open session's release token. - if (existing.claim.session === transientDeviceClaimSession(params.command)) { - return { status: 'conflict', conflict: existing }; - } } return await claimHeldDevice({ device: params.device, @@ -196,9 +194,12 @@ async function claimHeldDevice(params: { }): Promise { const { deviceKey, identity } = params; const owner = readCurrentOwnerIdentity(); + const claimOwnerKind: DeviceClaimAcquirerKind = + params.transientCommand === undefined ? 'open-session' : 'transient-command'; const existing = await resolveExistingClaim({ ...params, owner, + claimOwnerKind, }); if (existing.status === 'conflict') return existing; if (existing.status === 'held') return { status: 'acquired', ownership: existing.ownership }; @@ -218,7 +219,7 @@ async function claimHeldDevice(params: { ownerToken: crypto.randomUUID(), createdAtMs: now, updatedAtMs: now, - claimOwnerKind: params.transientCommand === undefined ? 'open-session' : 'transient-command', + claimOwnerKind, ...(params.transientCommand === undefined ? {} : { transientCommand: params.transientCommand }), }; writeDeviceClaim(claim); diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts index 812cd20325..35fe383fb7 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts @@ -6,8 +6,10 @@ import { afterEach, test, vi } from 'vitest'; import * as hostProcess from '@agent-device/host-kit/process'; import { acquireDeviceClaim as acquireProductionDeviceClaim, + acquireTransientDeviceClaim, canonicalLocalDeviceKey, inspectDeviceClaims, + renewDeviceClaim, } from '@agent-device/daemon-contracts/device-claims'; import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -94,10 +96,134 @@ test('preserves and reports a live foreign claim without overwriting it', async assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'first'); }); +// The maintainer's trace on #3714: `install` holds a transient claim under the label +// `transient:install`; an `open` from the same workspace addressed at that label must not be +// handed the command's ownership token. Any session string can be typed into `--session`; only +// the stored kind says who holds the claim, so an open-session acquirer never holds a +// transient-command record and vice versa. +test('an open session addressed at a command label never adopts the in-flight command claim', async () => { + const root = useClaimsRoot(); + const holder = await acquireTransientDeviceClaim({ + device, + command: 'install', + coveredBySession: 'open-a', + coveredClaimIsOwnedByRequest: () => false, + workspace: '/worktrees/first', + stateDir: root, + reconcileOrphanedDeviceClaim: async () => ({ status: 'retained', reason: 'test-no-recovery' }), + }); + assert.equal(holder.status, 'acquired'); + if (holder.status !== 'acquired') return; + + const open = await acquireDeviceClaim({ + device, + session: 'transient:install', + workspace: '/worktrees/first', + stateDir: root, + }); + + assert.equal(open.status, 'conflict'); + if (open.status !== 'conflict') return; + assert.equal(open.conflict.classification, 'live'); + // The refusal wrote nothing: the command still holds the device with its own token. + assert.equal(storedClaim().ownerToken, holder.ownership.ownerToken); +}); + +// The mirror direction, pinned at the settlement boundary every acquire route goes through. Two +// live requests can never share one claim: a second `reinstall` or `install` reaching the running +// `install`'s claim — same workspace, label as session — is refused, and only the request that +// took the claim re-enters it, through the token the admission layer already pins. +test('a second request never holds an in-flight command claim, under its own label or another’s', async () => { + const root = useClaimsRoot(); + const holder = await acquireTransientDeviceClaim({ + device, + command: 'install', + coveredBySession: 'open-a', + coveredClaimIsOwnedByRequest: () => false, + workspace: '/worktrees/first', + stateDir: root, + reconcileOrphanedDeviceClaim: async () => ({ status: 'retained', reason: 'test-no-recovery' }), + }); + assert.equal(holder.status, 'acquired'); + if (holder.status !== 'acquired') return; + + const intruder = await acquireTransientDeviceClaim({ + device, + command: 'reinstall', + coveredBySession: 'transient:install', + coveredClaimIsOwnedByRequest: () => true, + workspace: '/worktrees/first', + stateDir: root, + reconcileOrphanedDeviceClaim: async () => ({ status: 'retained', reason: 'test-no-recovery' }), + }); + assert.equal(intruder.status, 'conflict'); + assert.equal(storedClaim().ownerToken, holder.ownership.ownerToken); + + const sameCommand = await acquireTransientDeviceClaim({ + device, + command: 'install', + coveredBySession: 'some-other-session', + coveredClaimIsOwnedByRequest: () => false, + workspace: '/worktrees/first', + stateDir: root, + reconcileOrphanedDeviceClaim: async () => ({ status: 'retained', reason: 'test-no-recovery' }), + }); + // A different request of the SAME command still refuses: only the request holding the claim's + // release token re-enters it (the admission layer pins that), so a live command claim is never + // disposed from under its holder by a second same-named request. + assert.equal(sameCommand.status, 'conflict'); + assert.equal(storedClaim().ownerToken, holder.ownership.ownerToken); +}); + +// A migrated record states no kind, so no acquirer's kind can equal it: even the same principal +// in the same workspace cannot hold it at acquisition, and a record whose start time is unreadable +// (`null === null` against this process) is the case that most needs the refusal. Acquisition +// never renews it either — the token the record itself names is the only renewal route left. +test('a migrated record states no kind, so acquisition never holds it', async () => { + for (const startTimeUnavailable of [false, true]) { + const root = useClaimsRoot(); + const identity = startTimeUnavailable + ? { pid: process.pid, startTime: null } + : hostProcess.readCurrentOwnerIdentity(); + const owner = vi.spyOn(hostProcess, 'readCurrentOwnerIdentity').mockReturnValue(identity); + try { + fs.writeFileSync( + claimPath(root), + JSON.stringify({ + schemaVersion: 2, + deviceKey: canonicalLocalDeviceKey(device), + device: { family: 'android', id: device.id, name: device.name, kind: device.kind }, + session: 'legacy-owner', + workspace: '/worktrees/legacy', + stateDir: root, + ownerPid: identity.pid, + ownerStartTime: identity.startTime, + ownerToken: 'legacy-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); + + const held = await acquireDeviceClaim({ + device, + session: 'legacy-owner', + workspace: '/worktrees/legacy', + stateDir: root, + }); + + assert.equal(held.status, 'conflict'); + assert.equal(storedClaim().ownerToken, 'legacy-token'); + assert.equal(storedClaim().updatedAtMs, 1); + } finally { + owner.mockRestore(); + } + } +}); + // Renewal, abandon, and clear write a decoded claim back. A migrated record must therefore carry // the current schema version: a legacy version on the migrated device shape reads back as -// inconsistent and locks the owner out of its own next command. -test('a renewal of a migrated record writes back the shape the reader expects', async () => { +// inconsistent and locks the device out of any settlement path that reads it. +test('a renewal through a migrated record’s own token writes back the shape the reader expects', async () => { const root = useClaimsRoot(); fs.writeFileSync( claimPath(root), @@ -116,13 +242,13 @@ test('a renewal of a migrated record writes back the shape the reader expects', }), ); - const held = await acquireDeviceClaim({ - device, - session: 'legacy-owner', - workspace: '/worktrees/legacy', - stateDir: root, + const renewal = await renewDeviceClaim({ + deviceKey: canonicalLocalDeviceKey(device), + ownerToken: 'legacy-token', + ownerPid: process.pid, + ownerStartTime: 'test-process-start-time', }); - assert.equal(held.status, 'acquired'); + assert.equal(renewal.status, 'renewed'); const persisted = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; assert.equal(persisted.schemaVersion, 3);