Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/capture-kit/src/recording/output-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ test('preserves platform-specific default recording extensions', () => {
const defaults = resolveRecordingOutputPaths({ platform: 'web', cwd: '/workspace' });
expect(defaults.requestedPath).toMatch(/^\.\/recording-\d+\.webm$/);
expect(defaults.outputPath).toMatch(/^\/workspace\/recording-\d+\.webm$/);
expect(resolveRecordingOutputPaths({ requestedPath: '/tmp/capture', platform: 'web' })).toEqual({
expect(
resolveRecordingOutputPaths({
cwd: process.cwd(),
requestedPath: '/tmp/capture',
platform: 'web',
}),
).toEqual({
requestedPath: '/tmp/capture.webm',
outputPath: '/tmp/capture.webm',
});
Expand All @@ -24,6 +30,7 @@ test('keeps display paths distinct from expanded native output paths', () => {
});

const homePaths = resolveRecordingOutputPaths({
cwd: process.cwd(),
requestedPath: '~/capture.mp4',
platform: 'android',
});
Expand Down
2 changes: 1 addition & 1 deletion packages/capture-kit/src/recording/output-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type RecordingOutputPaths = Readonly<{
export function resolveRecordingOutputPaths(params: {
requestedPath?: string;
platform: Platform;
cwd?: string;
cwd: string;
}): RecordingOutputPaths {
const requestedPath =
params.requestedPath === undefined
Expand Down
19 changes: 14 additions & 5 deletions packages/daemon-contracts/src/daemon-policy-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,29 @@ import { DAEMON_POLICY_ENV, loadDaemonPolicy, parseDaemonPolicy } from './daemon
const SOURCE = '/etc/agent-device/policy.json';

test('no configured policy leaves the daemon unrestricted', () => {
expect(loadDaemonPolicy({})).toBeUndefined();
expect(loadDaemonPolicy({}, process.cwd())).toBeUndefined();
});

test('a policy file that cannot be read or parsed fails loading', () => {
const dir = mkdtempForTestSync('agent-device-daemon-policy-load-');
const policyPath = path.join(dir, 'policy.json');
fs.writeFileSync(policyPath, '{ not json');

expect(() => loadDaemonPolicy({ [DAEMON_POLICY_ENV]: policyPath })).toThrow(
expect(() => loadDaemonPolicy({ [DAEMON_POLICY_ENV]: policyPath }, dir)).toThrow(
/Invalid daemon policy/,
);
expect(() => loadDaemonPolicy({ [DAEMON_POLICY_ENV]: path.join(dir, 'missing.json') })).toThrow(
/cannot read policy JSON/,
);
expect(() =>
loadDaemonPolicy({ [DAEMON_POLICY_ENV]: path.join(dir, 'missing.json') }, dir),
).toThrow(/cannot read policy JSON/);
});

test('a relative policy path resolves against the host working directory, not the process', () => {
const dir = mkdtempForTestSync('agent-device-daemon-policy-relative-');
fs.writeFileSync(path.join(dir, 'policy.json'), JSON.stringify({ version: 1 }));

const policy = loadDaemonPolicy({ [DAEMON_POLICY_ENV]: 'policy.json' }, dir);

expect(policy?.sourcePath).toBe(path.join(dir, 'policy.json'));
});

test.each([
Expand Down
5 changes: 3 additions & 2 deletions packages/daemon-contracts/src/daemon-policy-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import { commandDescriptors } from '@agent-device/command-registry/registry';
import type { CommandDescriptor } from '@agent-device/command-registry/types';
import { AppError, errorMessage } from '@agent-device/kernel/errors';
import type { EnvMap } from '@agent-device/kernel/source-value';

/**
* ADR 0029: the operator-owned rules one daemon enforces for every request it admits. This module
Expand Down Expand Up @@ -72,10 +73,10 @@ export function resolveDaemonPolicyCommandName(command: string): string | undefi

const POLICY_KEYS = new Set(['version', 'devices', 'commands', 'capabilities', 'leases']);

export function loadDaemonPolicy(env: NodeJS.ProcessEnv = process.env): DaemonPolicy | undefined {
export function loadDaemonPolicy(env: EnvMap, cwd: string): DaemonPolicy | undefined {
const configured = env[DAEMON_POLICY_ENV]?.trim();
if (!configured) return undefined;
const sourcePath = path.resolve(configured);
const sourcePath = path.resolve(cwd, configured);
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
Expand Down
8 changes: 7 additions & 1 deletion packages/daemon-contracts/src/daemon-registration-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { runCmdDetachedMonitored, type ExecDetachedExit } from '@agent-device/host-kit/command';
import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors';
import type { EnvMap } from '@agent-device/kernel/source-value';
import {
stopDaemonProcess,
waitForDaemonExit,
Expand Down Expand Up @@ -246,6 +247,10 @@ export function launchDaemonProcess(
paths: DaemonPaths;
args: string[];
serverMode: DaemonServerMode;
/** The environment the daemon process starts with, before the state dir and mode overrides. */
env: EnvMap;
/** The daemon's working directory: the one its client resolves relative paths against. */
cwd: string;
ownedStateDir?: OwnedReplayStateDir;
}>,
): DaemonStartupLaunch {
Expand All @@ -258,8 +263,9 @@ export function launchDaemonProcess(
const logFd = fs.openSync(input.paths.logPath, 'a');
try {
const monitored = runCmdDetachedMonitored(process.execPath, input.args, {
cwd: input.cwd,
env: {
...process.env,
...input.env,
AGENT_DEVICE_STATE_DIR: input.paths.baseDir,
AGENT_DEVICE_DAEMON_SERVER_MODE: input.serverMode,
},
Expand Down
22 changes: 22 additions & 0 deletions packages/daemon-contracts/src/daemon-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import path from 'node:path';
import { expect, test } from 'vitest';
import { resolveDaemonPaths } from './daemon-resolution.ts';

test('a relative state dir resolves against the working directory it is given', () => {
const paths = resolveDaemonPaths('state/here', { cwd: '/workspace/project' });

expect(paths.baseDir).toBe(path.resolve('/workspace/project', 'state/here'));
});

test('a relative state dir without a working directory is refused with a typed reason', () => {
expect(() => resolveDaemonPaths('state/here')).toThrow(
expect.objectContaining({
code: 'INVALID_ARGS',
details: expect.objectContaining({ reason: 'relative_state_dir_without_cwd' }),
}),
);
});

test('an absolute state dir needs no working directory', () => {
expect(resolveDaemonPaths('/var/agent-device/state').baseDir).toBe('/var/agent-device/state');
});
16 changes: 15 additions & 1 deletion packages/daemon-contracts/src/daemon-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { expandUserHomePath, resolveUserPath } from '@agent-device/host-kit/file';
import { findProjectRoot, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version';
import { AppError } from '@agent-device/kernel/errors';
import { type EnvMap } from '@agent-device/kernel/source-value';

import type { DaemonServerMode, DaemonTransportPreference } from '@agent-device/kernel/contracts';
Expand All @@ -27,6 +28,8 @@ export type DaemonPaths = {

type ResolveDaemonPathsOptions = {
env?: EnvMap;
/** Resolves a relative state dir; a relative one without it is refused. */
cwd?: string;
projectRoot?: string;
};

Expand All @@ -50,7 +53,18 @@ function resolveStateDir(raw: string | undefined, options: ResolveDaemonPathsOpt
if (!value) {
return resolveDefaultDaemonStateDir(options);
}
return resolveUserPath(value, { env: options.env });
const expanded = expandUserHomePath(value, { env: options.env });
if (path.isAbsolute(expanded)) return expanded;
if (options.cwd === undefined) {
Comment thread
thymikee marked this conversation as resolved.
throw new AppError(
'INVALID_ARGS',
`State dir "${value}" is relative and no working directory was given.`,
{
reason: 'relative_state_dir_without_cwd',
},
);
}
return resolveUserPath(value, { env: options.env, cwd: options.cwd });
}

function resolveDefaultDaemonStateDir(options: ResolveDaemonPathsOptions = {}): string {
Expand Down
8 changes: 6 additions & 2 deletions packages/daemon-contracts/src/device-claim-inspection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import type { EnvMap } from '@agent-device/kernel/source-value';
import { matchesPlatformSelector, type PlatformSelector } from '@agent-device/kernel/device';
import {
classifyOwnerLivenessFromObservation,
Expand Down Expand Up @@ -29,8 +30,11 @@ export type DeviceClaimSelectors = {
serial?: string;
};

export function inspectDeviceClaims(selectors: DeviceClaimSelectors): InspectedDeviceClaim[] {
const root = resolveDeviceClaimRoot();
export function inspectDeviceClaims(
selectors: DeviceClaimSelectors,
env: EnvMap,
): InspectedDeviceClaim[] {
const root = resolveDeviceClaimRoot(env);
const listed = readClaimEntries(root);
if ('claims' in listed) return listed.claims;
const parsed = listed.entries
Expand Down
16 changes: 12 additions & 4 deletions packages/daemon-contracts/src/device-claim-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import path from 'node:path';
import { expect, test, vi } from 'vitest';
import { deviceClaimFilePath } from '@agent-device/test-kit';
import { resolveDeviceClaimPath } from './device-claim-paths.ts';
import { resolveDeviceClaimPath, resolveDeviceClaimRoot } from './device-claim-paths.ts';

const DEVICE_KEYS = ['local:android:none:emulator-5554', 'local:ios:ios:ABCD-1234'];

test('the claim file path test-kit restates matches the production resolver', () => {
vi.stubEnv('AGENT_DEVICE_CLAIMS_DIR', '');
for (const key of DEVICE_KEYS) {
expect(deviceClaimFilePath(key)).toBe(resolveDeviceClaimPath(key));
expect(deviceClaimFilePath(key)).toBe(resolveDeviceClaimPath(key, process.env));
}
});

test('the restated path follows the claims directory override like the resolver', () => {
vi.stubEnv('AGENT_DEVICE_CLAIMS_DIR', path.join('relative', 'claims'));
vi.stubEnv('AGENT_DEVICE_CLAIMS_DIR', path.join(path.sep, 'tmp', 'agent-device-claims-override'));
Comment thread
thymikee marked this conversation as resolved.
for (const key of DEVICE_KEYS) {
expect(deviceClaimFilePath(key)).toBe(resolveDeviceClaimPath(key));
expect(deviceClaimFilePath(key)).toBe(resolveDeviceClaimPath(key, process.env));
expect(path.isAbsolute(deviceClaimFilePath(key))).toBe(true);
}
});

test('a relative claims dir is refused instead of following the working directory', () => {
expect(() => resolveDeviceClaimRoot({ AGENT_DEVICE_CLAIMS_DIR: 'claims' })).toThrow(
expect.objectContaining({
details: expect.objectContaining({ reason: 'relative_claims_dir' }),
}),
);
});
26 changes: 19 additions & 7 deletions packages/daemon-contracts/src/device-claim-paths.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import type { EnvMap } from '@agent-device/kernel/source-value';
import {
type AppleOS,
type DeviceIdentity,
Expand All @@ -24,14 +26,24 @@ function formatLocalDeviceKey(
return `local:${platform}:${appleOs ?? 'none'}:${id}`;
}

export function resolveDeviceClaimRoot(): string {
const override = process.env.AGENT_DEVICE_CLAIMS_DIR?.trim();
return override
? path.resolve(override)
: path.join(os.homedir(), '.agent-device', 'device-claims');
export function resolveDeviceClaimRoot(env: EnvMap): string {
Comment thread
thymikee marked this conversation as resolved.
const override = env.AGENT_DEVICE_CLAIMS_DIR?.trim();
if (!override) return path.join(os.homedir(), '.agent-device', 'device-claims');
// The store is shared by every daemon on the host, so a directory that depends on where one of
// them happened to start is not a place they can agree on.
if (!path.isAbsolute(override)) {
throw new AppError(
'INVALID_ARGS',
`AGENT_DEVICE_CLAIMS_DIR must be an absolute path: ${override}`,
{
reason: 'relative_claims_dir',
},
);
}
return path.resolve(override);
}

export function resolveDeviceClaimPath(deviceKey: string): string {
export function resolveDeviceClaimPath(deviceKey: string, env: EnvMap): string {
const hash = crypto.createHash('sha256').update(deviceKey).digest('hex');
return path.join(resolveDeviceClaimRoot(), `${hash}.json`);
return path.join(resolveDeviceClaimRoot(env), `${hash}.json`);
}
15 changes: 10 additions & 5 deletions packages/daemon-contracts/src/device-claim-settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import type { EnvMap } from '@agent-device/kernel/source-value';
import {
ownerIdentityMatches,
type readCurrentOwnerIdentity,
Expand Down Expand Up @@ -59,8 +60,9 @@ export async function resolveExistingClaim(params: {
stateDir: string;
reconcileOrphanedDeviceClaim: DeviceClaimReconciler;
observeDeviceBoot?: DeviceBootObservationService;
env: EnvMap;
}): Promise<ExistingClaimResolution> {
const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(params.deviceKey));
const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(params.deviceKey, params.env));
if (!existing) return { status: 'available' };
if (
existing.claim &&
Expand All @@ -70,7 +72,7 @@ export async function resolveExistingClaim(params: {
return { status: 'available' };
}
if (existing.claim && isCurrentClaimOwner(existing.claim, params, params.owner)) {
return { status: 'held', ownership: renewHeldClaim(existing.claim) };
return { status: 'held', ownership: renewHeldClaim(existing.claim, params.env) };
}
return await settleForeignClaim(existing, params);
}
Expand All @@ -81,10 +83,10 @@ export async function resolveExistingClaim(params: {
* keep a claim stamped before the reboot, and the next foreign `open` would read that stamp as
* proof of a device nobody owns and take it out from under a session that is demonstrably running.
*/
function renewHeldClaim(claim: DeviceClaim): DeviceClaimSessionOwnership {
function renewHeldClaim(claim: DeviceClaim, env: EnvMap): DeviceClaimSessionOwnership {
if (claim.updatedAtMs >= Date.now()) return ownershipFromClaim(claim);
const renewed: DeviceClaim = { ...claim, updatedAtMs: Date.now() };
writeDeviceClaim(renewed);
writeDeviceClaim(renewed, env);
return ownershipFromClaim(renewed);
}

Expand All @@ -100,6 +102,7 @@ async function settleForeignClaim(
deviceKey: string;
reconcileOrphanedDeviceClaim: DeviceClaimReconciler;
observeDeviceBoot?: DeviceBootObservationService;
env: EnvMap;
},
): Promise<ExistingClaimResolution> {
const claim = existing.claim;
Expand All @@ -120,6 +123,7 @@ async function settleForeignClaim(
const reconciliation = await settleVerifiedOrphanedClaim(
claim,
params.reconcileOrphanedDeviceClaim,
params.env,
);
if (reconciliation.status === 'retained') {
emitClaimConflict(params.deviceKey, existing, reconciliation.reason);
Expand Down Expand Up @@ -152,11 +156,12 @@ async function foreignClaimSettlement(params: {
export async function settleVerifiedOrphanedClaim(
claim: DeviceClaim,
reconcile: DeviceClaimReconciler,
env: EnvMap,
): Promise<DeviceClaimReconciliationResult> {
const result = await reconcile(claim);
if (result.status === 'retained') return result;
try {
fs.unlinkSync(resolveDeviceClaimPath(claim.deviceKey));
fs.unlinkSync(resolveDeviceClaimPath(claim.deviceKey, env));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
Expand Down
8 changes: 5 additions & 3 deletions packages/daemon-contracts/src/device-claim-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import fs from 'node:fs';
import path from 'node:path';
import { publishFileSync, acquireProcessLock, withProcessLock } from '@agent-device/host-kit/file';
import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process';
import type { EnvMap } from '@agent-device/kernel/source-value';

import { resolveDeviceClaimPath } from './device-claim-paths.ts';
import type { DeviceClaim } from './device-claim-record.ts';

const DEVICE_CLAIM_LOCK_TIMEOUT_MS = 30_000;

/** The single writer of a claim file. */
export function writeDeviceClaim(claim: DeviceClaim): void {
const claimPath = resolveDeviceClaimPath(claim.deviceKey);
export function writeDeviceClaim(claim: DeviceClaim, env: EnvMap): void {
const claimPath = resolveDeviceClaimPath(claim.deviceKey, env);
fs.mkdirSync(path.dirname(claimPath), { recursive: true, mode: 0o700 });
publishFileSync({
destination: claimPath,
Expand All @@ -22,13 +23,14 @@ export function writeDeviceClaim(claim: DeviceClaim): void {
/** The host-global per-device mutual exclusion every claim write and clear runs under. */
export async function withDeviceClaimLock<T>(
deviceKey: string,
env: EnvMap,
task: () => Promise<T>,
): Promise<T> {
const owner = readCurrentOwnerIdentity();
return await withProcessLock({
acquire: () =>
acquireProcessLock({
lockDirPath: `${resolveDeviceClaimPath(deviceKey)}.lock`,
lockDirPath: `${resolveDeviceClaimPath(deviceKey, env)}.lock`,
owner: { pid: owner.pid, startTime: owner.startTime, acquiredAtMs: Date.now() },
timeoutMs: DEVICE_CLAIM_LOCK_TIMEOUT_MS,
description: `device claim for ${deviceKey}`,
Expand Down
Loading
Loading