Skip to content
3 changes: 2 additions & 1 deletion packages/daemon-contracts/src/device-claim-reboot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const device: DeviceInfo = {
};

const CLAIM: DeviceClaim = {
schemaVersion: 2,
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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ const unavailable = Object.freeze({

function makeClaim(stateDir: string): DeviceClaim {
return {
schemaVersion: 2,
schemaVersion: 3,
claimOwnerKind: 'open-session',
deviceKey: 'local:android:none:emulator-5554',
device: { family: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' },
session: 'claimed-session',
Expand Down
66 changes: 59 additions & 7 deletions packages/daemon-contracts/src/device-claim-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function legacyClaim(overrides: Record<string, unknown> = {}): Record<string, un
};
}

function currentClaim(overrides: Record<string, unknown> = {}): Record<string, unknown> {
function v2Claim(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
...legacyClaim(),
schemaVersion: 2,
Expand All @@ -29,24 +29,76 @@ function currentClaim(overrides: Record<string, unknown> = {}): Record<string, u
};
}

test('v1 and v2 records decode to the process-owned claim with its process principal intact', () => {
for (const raw of [legacyClaim(), currentClaim()]) {
function currentClaim(overrides: Record<string, unknown> = {}): Record<string, unknown> {
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', () => {
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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect(record.session).toBe('work');
expect(record.ownerPid).toBe(4242);
expect(record.ownerToken).toBe('token');
expect(record.transientCommand).toBeUndefined();
}
});

// Released v1/v2 recorded a command-scoped claim and an open session *named* `transient:<command>`
// 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).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();
}
});

// 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(legacyClaim({ session: 'transient:install' }))?.schemaVersion,
).toBe(2);
decodeStoredDeviceClaim(
currentClaim({ claimOwnerKind: 'transient-command', transientCommand: '' }),
),
).toBeNull();
expect(decodeStoredDeviceClaim(currentClaim({ claimOwnerKind: 'bogus' }))).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();
Expand Down
83 changes: 76 additions & 7 deletions packages/daemon-contracts/src/device-claim-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,30 @@ 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 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:<command>`
* 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.
*/
export type DeviceClaim = {
schemaVersion: 2;
schemaVersion: 3;
deviceKey: string;
device: DeviceIdentity & { name: string };
session: string;
Expand All @@ -27,6 +42,15 @@ export type DeviceClaim = {
ownerToken: string;
createdAtMs: number;
updatedAtMs: number;
/**
* The persisted claim-kind discriminant (#3345). Coverage reads this and never the `session`
* label: an open session may legitimately be named `transient:<command>`, 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;
};
Expand All @@ -52,20 +76,53 @@ export function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwners
};
}

/**
* 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, 'unstated');
if (value.schemaVersion === 1) return migrateLegacyClaim(value);
return null;
}

/**
* 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<string, unknown>): DeviceClaim | 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);
}

function isKnownOwnerKind(value: unknown): value is DeviceClaimOwnerKind {
return (
typeof value === 'string' && (DEVICE_CLAIM_OWNER_KINDS as readonly string[]).includes(value)
);
}

function decodeClaimBody(
raw: Record<string, unknown>,
claimOwnerKind: DeviceClaimOwnerKind,
): DeviceClaim | 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, claimOwnerKind) : null;
}

/** Released schema-v1 advisory claims are normalized into the canonical v2 model on read. */
/** Released schema-v1 claims differ only in device shape; they state no claim kind. */
function migrateLegacyClaim(raw: Record<string, unknown>): DeviceClaim | null {
if (!isRecord(raw.device)) return null;
const legacy = raw.device;
Expand All @@ -87,13 +144,14 @@ function migrateLegacyClaim(raw: Record<string, unknown>): DeviceClaim | null {
? {}
: { iosPhysicalDeviceBackend: legacy.iosPhysicalDeviceBackend }),
});
return identity ? buildDecodedClaim(raw, identity, name) : null;
return identity ? decodeClaimFields(raw, identity, name, 'unstated') : null;
}

function buildDecodedClaim(
function decodeClaimFields(
raw: Record<string, unknown>,
identity: DeviceIdentity,
name: string,
claimOwnerKind: DeviceClaimOwnerKind,
): DeviceClaim | null {
const location = decodeClaimLocation(raw);
const owner = decodeClaimOwner(raw);
Expand All @@ -106,9 +164,20 @@ function buildDecodedClaim(
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; `claimOwnerKind` is what decides a kind, never this shape.
*/
export function transientDeviceClaimSession(command: string): string {
return `${TRANSIENT_CLAIM_LABEL_PREFIX}${command}`;
}

const TRANSIENT_CLAIM_LABEL_PREFIX = 'transient:';

function decodeClaimLocation(
raw: Record<string, unknown>,
): Pick<DeviceClaim, 'deviceKey' | 'session' | 'workspace' | 'stateDir'> | null {
Expand Down
34 changes: 33 additions & 1 deletion packages/daemon-contracts/src/device-claim-settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -43,6 +44,13 @@ export type DeviceClaimReconciler = (
claim: DeviceClaim,
) => Promise<DeviceClaimReconciliationResult>;

/**
* 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<DeviceClaimOwnerKind, 'unstated'>;

/**
* 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
Expand All @@ -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<ExistingClaimResolution> {
Expand Down Expand Up @@ -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<typeof readCurrentOwnerIdentity>,
): 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 &&
Expand Down
Loading
Loading