From 3ca095c6a9ce4fdbbc9e0d1d8d2a20c5ba112615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 11:15:24 +0200 Subject: [PATCH 01/10] feat(client): pin a client to one daemon instance Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/client-connection.ts | 29 +++ packages/contracts/src/facades/client.ts | 1 + ...agent-device-client-daemon-binding.test.ts | 96 +++++++++ src/agent-device-client.ts | 39 ++++ .../daemon-client-embedded-lifecycle.test.ts | 194 ++++++++++++++++++ src/daemon-client/daemon-client-attach.ts | 37 +++- src/daemon-client/daemon-client-binding.ts | 79 +++++++ src/daemon-client/daemon-client-lifecycle.ts | 30 ++- src/daemon-client/daemon-client.ts | 1 + website/docs/docs/client-api.md | 1 + 10 files changed, 500 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/agent-device-client-daemon-binding.test.ts create mode 100644 src/daemon-client/daemon-client-binding.ts diff --git a/packages/contracts/src/client-connection.ts b/packages/contracts/src/client-connection.ts index 4d8599dcc8..77b42b9f68 100644 --- a/packages/contracts/src/client-connection.ts +++ b/packages/contracts/src/client-connection.ts @@ -42,6 +42,27 @@ export type DaemonIdentity = { processStartTime?: string; }; +/** + * The one daemon instance a client is pinned to: its credentials and its process lifetime together. + * + * A pin is checked against the registered daemon before a request is dispatched, and the pinned + * token is what authenticates the request — never a token discovered on the way. So a state + * directory whose daemon was replaced fails with `details.reason: 'daemon-binding-mismatch'` instead + * of sending a caller's work to a different instance, and a replacement that lands between that + * check and the write is refused by the new instance's own token check. A pinned client never + * starts, replaces, retires, or redirects a daemon. + * + * The token is a bearer capability for that instance: treat the object that carries it as a + * credential, and keep it inside the host that owns the daemon. + */ +export type DaemonBinding = { + /** The instance's daemon token, as its `daemon.json` published it. */ + token: string; + pid: number; + /** The instance's process start time; a pid alone cannot tell a lifetime from its successor. */ + processStartTime: string; +}; + export type EnsureDaemonOptions = { /** The state directory the daemon owns; defaults to `AGENT_DEVICE_STATE_DIR`, then the user default. */ stateDir?: string; @@ -70,6 +91,8 @@ export type AgentDeviceDaemonTransportContext = { authToken?: string; /** How this request treats the local daemon; `managed` when omitted. */ daemonLifecycle?: DaemonLifecycle; + /** See {@link DaemonBinding}. A client built with one sends this same pin on every request. */ + daemonBinding?: DaemonBinding; /** * Cancels this one in-flight request. A built-in transport that sees an abort destroys the * request's connection, which makes the daemon mark the request canceled; the promise rejects @@ -88,6 +111,12 @@ export type AgentDeviceClientConfig = RemoteConnectionProfileFields & CloudProviderProfileFields & { /** See `DaemonLifecycle`. */ daemonLifecycle?: DaemonLifecycle; + /** + * Pins this client to one daemon instance: every request addresses that instance, authenticates + * with its token, and refuses a state directory whose daemon is any other instance. See + * {@link DaemonBinding}. + */ + daemonBinding?: DaemonBinding; session?: string; lockPolicy?: DaemonLockPolicy; lockPlatform?: PlatformSelector; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index 5af90eef2f..f604012665 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -37,6 +37,7 @@ export type { DaemonConnection, DaemonIdentity, DaemonLifecycle, + DaemonBinding, DeviceCommandBaseOptions, EnsureDaemonOptions, } from '../client-connection.ts'; diff --git a/src/__tests__/agent-device-client-daemon-binding.test.ts b/src/__tests__/agent-device-client-daemon-binding.test.ts new file mode 100644 index 0000000000..4b8f5648b8 --- /dev/null +++ b/src/__tests__/agent-device-client-daemon-binding.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { + AgentDeviceDaemonTransport, + AgentDeviceDaemonTransportContext, + DaemonBinding, +} from '@agent-device/contracts/client'; +import { AppError } from '@agent-device/kernel/errors'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; + +// A pin on one daemon instance is a credential and an identity, so the client's job is to read it once +// and hand it to every request of that client, and to refuse one that names no instance. + +function recordingTransport() { + const contexts: Array = []; + const transport: AgentDeviceDaemonTransport = async (_request, context) => { + contexts.push(context); + return { ok: true, data: { devices: [] } }; + }; + return { contexts, transport }; +} + +const PIN: DaemonBinding = { + token: 'daemon-token', + pid: 4242, + processStartTime: 'Sun Oct 18 09:00:00 2026', +}; + +test('a pinned client sends the pin with every request', async () => { + const { contexts, transport } = recordingTransport(); + const client = createAgentDeviceClient({ daemonBinding: PIN }, { transport }); + + await client.devices.list(); + await client.devices.list(); + + assert.deepEqual( + contexts.map((context) => context?.daemonBinding), + [PIN, PIN], + ); +}); + +test('a client that names no pin sends none', async () => { + const { contexts, transport } = recordingTransport(); + const client = createAgentDeviceClient({}, { transport }); + + await client.devices.list(); + + assert.equal(contexts[0]?.daemonBinding, undefined); +}); + +test('a pin that does not name one instance is refused where the config is read', () => { + const { transport } = recordingTransport(); + + for (const malformed of [ + // A pid alone cannot tell a process lifetime from the process that recycled its number. + { token: 't', pid: 1 }, + { pid: 1, processStartTime: 'Sun Oct 18 09:00:00 2026' }, + { token: 't', processStartTime: 'Sun Oct 18 09:00:00 2026' }, + { token: '', pid: 1, processStartTime: 'Sun Oct 18 09:00:00 2026' }, + { token: 't', pid: 1, processStartTime: ' ' }, + { token: 't', pid: 1.5, processStartTime: 'Sun Oct 18 09:00:00 2026' }, + { token: 't', pid: 0, processStartTime: 'Sun Oct 18 09:00:00 2026' }, + 'daemon-token', + 4242, + ]) { + assert.throws( + () => createAgentDeviceClient({ daemonBinding: malformed as DaemonBinding }, { transport }), + (error: { code?: string; details?: unknown }) => { + assert.equal(error.code, 'INVALID_ARGS'); + return true; + }, + JSON.stringify(malformed), + ); + } +}); + +test('a refused pin names the field that failed and never its credential', () => { + const { transport } = recordingTransport(); + const secret = 'a-token-nobody-must-see'; + + let refusal: AppError | undefined; + try { + // A partial pin: the credential itself is valid, so this is the shape that could leak it. + createAgentDeviceClient( + { daemonBinding: { token: secret, pid: 4242 } as DaemonBinding }, + { transport }, + ); + assert.fail('expected the config to be refused'); + } catch (error) { + assert.ok(error instanceof AppError, String(error)); + refusal = error; + } + + assert.equal(refusal.details?.daemonBindingField, 'processStartTime'); + assert.doesNotMatch(JSON.stringify(refusal.details ?? {}), new RegExp(secret)); +}); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 9c08f76857..9cead088d5 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -16,6 +16,7 @@ import type { CaptureScreenshotResult, CaptureSnapshotOptions, CaptureSnapshotResult, + DaemonBinding, DaemonLifecycle, DragOptions, FlingOptions, @@ -84,6 +85,9 @@ export function createAgentDeviceClient( const transport = deps.transport ?? createDaemonTransport(host); // A typo in a JSON or JS config must not fall through to the default and manage a daemon. readDaemonLifecycle(config.daemonLifecycle); + // A pin on one daemon instance is read once, here: every request of this client authenticates with + // it, so a value that never validated must never reach a request. + const daemonBinding = readDaemonBinding(config.daemonBinding); // The dev-server helpers take the client's host explicitly. const prepareMetroRuntime = (input: Parameters[0]) => devServer.prepareMetroRuntime(input, host); @@ -144,6 +148,7 @@ export function createAgentDeviceClient( authToken: merged.daemonAuthToken, signal: merged.signal, daemonLifecycle: merged.daemonLifecycle, + ...(daemonBinding ? { daemonBinding } : {}), }), ); if (!response.ok) { @@ -623,6 +628,40 @@ function readDaemonLifecycle(value: unknown): DaemonLifecycle | undefined { }); } +/** + * Validates a pin on one daemon instance where a client config is read, so a malformed pin cannot + * become a request that silently addresses a different instance. + * + * `processStartTime` is required rather than optional: a pin that names only a pid cannot tell a + * process lifetime from the process that recycled its number, which is the mistake the pin exists to + * refuse. + */ +function readDaemonBinding(value: unknown): DaemonBinding | undefined { + if (value === undefined) return undefined; + const record = + typeof value === 'object' && value !== null ? (value as Partial) : undefined; + const token = record?.token; + if (typeof token !== 'string' || token.length === 0) throw daemonBindingFieldError('token'); + const pid = record?.pid; + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) { + throw daemonBindingFieldError('pid'); + } + const processStartTime = record?.processStartTime; + if (typeof processStartTime !== 'string' || processStartTime.trim().length === 0) { + throw daemonBindingFieldError('processStartTime'); + } + return { token, pid, processStartTime }; +} + +function daemonBindingFieldError(field: keyof DaemonBinding): AppError { + return new AppError('INVALID_ARGS', `daemonBinding is missing or invalid: ${field}.`, { + // A pin is a bearer credential, and this detail is echoed by `--json` and by any diagnostics an + // embedder writes, so the refusal names the field that failed and never a value. + daemonBindingField: field, + hint: 'Use the `clientConfig.daemonBinding` a host returned; a pin built by hand needs the daemon token and the pid and process start time its record published.', + }); +} + function mergeClientOptions( config: AgentDeviceClientConfig, options: InternalRequestOptions, diff --git a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts index c1b16d37c7..571abc6dac 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts @@ -4,12 +4,15 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import net from 'node:net'; import { afterEach, test, vi } from 'vitest'; import { closeLoopbackServer, createTestHostEnvironment, skipWhenLoopbackUnavailable, + listenOnLoopback, } from '@agent-device/test-kit'; +import type { DaemonBinding } from '@agent-device/contracts/client'; import { DAEMON_LIFECYCLE_REASONS } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; import { isProcessAlive } from '@agent-device/host-kit/process'; @@ -48,6 +51,10 @@ function request(stateDir: string) { } as Parameters[0]; } +function pinTo(info: { token: string; pid: number; processStartTime?: string }): DaemonBinding { + return { token: info.token, pid: info.pid, processStartTime: info.processStartTime ?? '' }; +} + /** * The signals this code sent at a process. `0` is the liveness probe and is not one; a call with no * signal argument is SIGTERM, so an omitted signal counts. @@ -65,6 +72,23 @@ async function refusalFrom(promise: Promise): Promise { return refusal; } +/** A socket stand-in that keeps every request line it was sent, token included. */ +async function startRecordingSocketDaemon() { + const requests: Record[] = []; + const server = net.createServer((socket) => { + socket.setEncoding('utf8'); + socket.on('error', () => {}); + let buffered = ''; + socket.on('data', (chunk) => { + buffered += chunk; + if (!buffered.includes('\n')) return; + requests.push(JSON.parse(buffered) as Record); + socket.write(`${JSON.stringify({ ok: true, data: { devices: [] } })}\n`); + }); + }); + return { server, port: await listenOnLoopback(server), requests }; +} + /** * A stale daemon of an incompatible version: the shape that makes a managed client want this state * directory. The ownership field is then the only thing that decides whether it may take it. The @@ -218,3 +242,173 @@ test('timeout recovery asks only whether this client may, and attach never may', assert.equal(resolveTimeoutRecovery(managed), 'retire-unresponsive'); assert.equal(resolveTimeoutRecovery(attach), 'none'); }); + +test('a pinned client dispatches to the instance it pins and starts nothing', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const { stateDir, paths } = stateDirAndPaths(); + const daemon = await startRecordingSocketDaemon(); + const { child, info } = await registerDaemon(paths, { + socketPort: daemon.port, + ownership: 'embedded', + }); + + try { + const transport = createDaemonTransport(createTestHostEnvironment()); + const response = await transport( + { + session: 'default', + command: 'devices', + positionals: [], + flags: { stateDir, daemonTransport: 'socket' }, + } as Parameters[0], + { daemonBinding: pinTo(info) }, + ); + + assert.equal(response.ok, true); + // The credential that went out is the one the pin names, not one re-read from a record that may + // have changed since the pin was handed out. + assert.deepEqual( + daemon.requests.map((entry) => entry.command), + ['devices'], + ); + assert.equal(daemon.requests[0]?.token, info.token); + assert.equal(spawn.mock.calls.length, 0, 'a pinned client starts no daemon'); + assert.equal(isProcessAlive(child.pid), true); + } finally { + await closeLoopbackServer(daemon.server); + } +}); + +test('a pinned client refuses an instance that is not the one it pins', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const { stateDir, paths, daemon, child, info } = await startStaleRegistration('embedded'); + // A runtime that took the state directory since the pin was handed out: same place, other instance. + const pin = { ...pinTo(info), token: 'a-different-instance' }; + + try { + const transport = createDaemonTransport(createTestHostEnvironment()); + const refusal = await refusalFrom(transport(request(stateDir), { daemonBinding: pin })); + + assert.equal(refusal.details?.reason, 'daemon-binding-mismatch'); + assert.equal(refusal.details?.mismatch, 'replaced'); + // The refusal names the instance that is really there so an operator can tell which host to ask, + // and never the credentials of either side. + assert.equal((refusal.details as { registered: { pid: number } }).registered.pid, child.pid); + assert.doesNotMatch(JSON.stringify(refusal.details), /local-secret|a-different-instance/); + assert.deepEqual(daemon.rpcCommands, [], 'another instance is never dispatched to'); + assert.equal(spawn.mock.calls.length, 0, 'a pin is never satisfied by starting a daemon'); + assert.equal(isProcessAlive(child.pid), true); + // The refusal read the record and changed nothing about it: the pinned-away instance still owns + // the state directory, which is what a client that pins must leave behind. + assert.equal(readDaemonInfo(paths.infoPath)?.pid, child.pid); + } finally { + await closeLoopbackServer(daemon.server); + } +}); + +test('a pinned client refuses a state directory that registers no daemon', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const { stateDir, paths } = stateDirAndPaths(); + const daemon = await startRecordingSocketDaemon(); + const { info } = await registerDaemon(paths, { socketPort: daemon.port }); + const pin = pinTo(info); + await finishRegisteredDaemonFixtures(); + assert.equal(readDaemonInfo(paths.infoPath), null); + + try { + const transport = createDaemonTransport(createTestHostEnvironment()); + const refusal = await refusalFrom(transport(request(stateDir), { daemonBinding: pin })); + + assert.equal(refusal.details?.reason, 'daemon-binding-mismatch'); + assert.equal(refusal.details?.mismatch, 'absent'); + assert.equal(daemon.requests.length, 0, 'an absent instance is not replaced by a live one'); + assert.equal(spawn.mock.calls.length, 0, 'a pin names an instance, not a place to start one'); + } finally { + await closeLoopbackServer(daemon.server); + } +}); + +// A pin is a local instance's identity, and a base URL sends requests somewhere else. Silently +// preferring either one would make the pin a way to redirect a remote client, or to ignore it. +test('a client that pins a local daemon and names a base URL is refused where settings are read', () => { + const pin: DaemonBinding = { token: 'token', pid: 4242, processStartTime: 'start' }; + // Loopback so the row reads as the combination under test, not as the separate rule that a remote + // URL outside the machine needs an auth token. + const remote = 'http://127.0.0.1:9'; + + for (const [label, resolve] of [ + [ + 'from the request flags', + () => + resolveClientSettings( + { ...request(stateDirAndPaths().stateDir), flags: { daemonBaseUrl: remote } }, + undefined, + createTestHostEnvironment(), + 'managed', + pin, + ), + ], + // The env form is the one that bites: a pin handed to a client on a machine that exports the URL. + [ + 'from the environment', + () => + resolveClientSettings( + request(stateDirAndPaths().stateDir), + undefined, + createTestHostEnvironment({ env: { AGENT_DEVICE_DAEMON_BASE_URL: remote } }), + 'managed', + pin, + ), + ], + ] as const) { + let refusal: AppError | undefined; + try { + resolve(); + assert.fail(`${label}: expected the combination to be refused`); + } catch (error) { + assert.ok(error instanceof AppError, `${label}: ${String(error)}`); + refusal = error; + } + assert.equal(refusal.code, 'INVALID_ARGS', label); + assert.equal(refusal.details?.reason, 'daemon_binding_with_remote_base_url', label); + assert.match(String(refusal.details?.hint), /Drop the base URL/, label); + } +}); + +// `absent` tells an operator the host stopped; this says something holds the state directory and the +// client cannot name it. Folding the two together would send someone to start a new host for a daemon +// that may be serving right now. +test('a pinned client refuses a record it cannot read as an address', async () => { + const { stateDir, paths } = stateDirAndPaths(); + const pin: DaemonBinding = { token: 'token', pid: 4242, processStartTime: 'start' }; + fs.mkdirSync(paths.baseDir, { recursive: true }); + fs.writeFileSync(paths.infoPath, '{not json', { mode: 0o600 }); + + const refusal = await refusalFrom( + createDaemonTransport(createTestHostEnvironment())(request(stateDir), { daemonBinding: pin }), + ); + + assert.equal(refusal.details?.reason, 'daemon-binding-mismatch'); + assert.equal(refusal.details?.mismatch, 'unaddressable'); + assert.match(String(refusal.details?.hint), /daemon\.json/); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), '{not json', 'the refusal changed nothing'); +}); + +test('a pinned client keeps its hands off the state directory it was given', async () => { + const { stateDir, paths } = stateDirAndPaths(); + const pin: DaemonBinding = { token: 'token', pid: 4242, processStartTime: 'start' }; + + const settings = resolveClientSettings( + request(stateDir), + undefined, + createTestHostEnvironment(), + 'managed', + pin, + ); + + // A private state directory would hold a daemon nobody started, and a pinned client starts none. + assert.equal(settings.ownedStateDir, undefined); + assert.deepEqual(settings.daemonBinding, pin); + assert.equal(settings.paths.baseDir, paths.baseDir); +}); + diff --git a/src/daemon-client/daemon-client-attach.ts b/src/daemon-client/daemon-client-attach.ts index 975c80eb95..843ff29d3b 100644 --- a/src/daemon-client/daemon-client-attach.ts +++ b/src/daemon-client/daemon-client-attach.ts @@ -1,8 +1,30 @@ import { ensureManagedDaemon } from './daemon-client-lifecycle.ts'; import { assertDaemonPolicyMatches, observeLocalDaemon } from './daemon-client-startup.ts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; +import type { DaemonBinding } from '@agent-device/contracts/client'; +import { readDaemonRecordAddress } from './daemon-client-metadata.ts'; +import { daemonBindingMismatchError, readPinnedDaemonRecord } from './daemon-client-binding.ts'; import { daemonIncompatibleError, daemonNotRunningError } from './daemon-lifecycle-errors.ts'; +/** + * Connects to the one daemon instance this client is pinned to, and to no other. It compares the + * state directory's record before dispatch and never starts, replaces, retires, or redirects: a pin + * is the host's answer to "which daemon", so any other instance is a refusal, not a candidate. + */ +async function attachBoundDaemon( + binding: DaemonBinding, + settings: DaemonClientSettings, +): Promise { + const pinned = readPinnedDaemonRecord(binding, readDaemonRecordAddress(settings.paths.infoPath)); + if ('mismatch' in pinned) { + throw daemonBindingMismatchError(binding, pinned, settings.paths.baseDir); + } + // The record matched the pin, so it is the pinned instance. The request goes out with the pin's own + // token anyway: a replacement that lands after this read then fails authentication at the daemon + // rather than being served by a token this client never agreed to use. + return { info: { ...pinned.registered, token: binding.token }, startedByClient: false }; +} + /** * Connects to the local daemon registered in the state directory and nothing else: it never starts, * replaces or kills one. What the registration says is judged by the same ladder a managed client @@ -25,13 +47,20 @@ async function attachLocalDaemon(settings: DaemonClientSettings): Promise { - if (settings.remoteBaseUrl || settings.daemonLifecycle !== 'attach') { + if (settings.remoteBaseUrl) { return await ensureManagedDaemon(settings); } - const attached = await attachLocalDaemon(settings); - // Checked on this branch too: attaching must not hand a policy-bound caller a daemon without it. + const binding = settings.daemonBinding; + const attached = binding + ? await attachBoundDaemon(binding, settings) + : settings.daemonLifecycle === 'attach' + ? await attachLocalDaemon(settings) + : await ensureManagedDaemon(settings); + // A pin never reaches the remote branch: a client that pins an instance and names a base URL is + // refused where its settings are read. Checked on this branch too: attaching must not hand a + // policy-bound caller a daemon without it. await assertDaemonPolicyMatches(attached.info, settings.paths.baseDir, settings.host); return attached; } diff --git a/src/daemon-client/daemon-client-binding.ts b/src/daemon-client/daemon-client-binding.ts new file mode 100644 index 0000000000..231930cae1 --- /dev/null +++ b/src/daemon-client/daemon-client-binding.ts @@ -0,0 +1,79 @@ +import type { DaemonBinding } from '@agent-device/contracts/client'; +import { AppError } from '@agent-device/kernel/errors'; +import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; +import type { DaemonInfo, DaemonRecordAddress } from './daemon-client-metadata.ts'; +import { daemonIdentityOf } from './daemon-lifecycle-errors.ts'; + +/** + * A client's pin checked against the daemon a state directory registers. + * + * A pin instructs the client to use one instance and no other, so a mismatch is a refusal and never a + * reason to start, replace, or redirect. `absent` says the state directory names no daemon at all, + * which is not a reason to start one either: the caller pinned an instance, not a place to look. + * `unaddressable` says a record is there and cannot be read as one, which is a different problem from + * an instance having gone away: something may well be running, and this client cannot name it. + */ +export type DaemonBindingMismatch = 'absent' | 'unaddressable' | 'replaced'; + +/** + * What the state directory's record means for a pin: either a refusal that names what it found, or the + * record itself — narrowed, so a caller that was not refused has the pinned instance in hand. + */ +export type PinnedDaemonRecord = + | Readonly<{ mismatch: DaemonBindingMismatch; registered: DaemonInfo | null }> + | Readonly<{ registered: DaemonInfo }>; + +export function readPinnedDaemonRecord( + binding: DaemonBinding, + record: DaemonRecordAddress, +): PinnedDaemonRecord { + if (record.status === 'absent') return { mismatch: 'absent', registered: null }; + if (record.status !== 'addressable') { + return { mismatch: 'unaddressable', registered: null }; + } + const registered = record.info; + // Every field is compared, the token included: a replacement that somehow reused the pid and its + // start time is still a different runtime, and the token is what proves it. + if ( + registered.token !== binding.token || + registered.pid !== binding.pid || + registered.processStartTime !== binding.processStartTime + ) { + return { mismatch: 'replaced', registered }; + } + return { registered }; +} + +/** + * The refusal a pin produces when a state directory does not name the instance it pins. Nothing has + * been started, retired, or redirected by the time this is thrown, which is what lets the message + * state the situation as a fact. + */ +export function daemonBindingMismatchError( + binding: DaemonBinding, + refusal: Extract, + stateDir: string, +): AppError { + const { mismatch, registered } = refusal; + return new AppError( + 'COMMAND_FAILED', + mismatch === 'absent' + ? 'The daemon this client is bound to is no longer registered.' + : mismatch === 'unaddressable' + ? 'The record on this state directory cannot be read as a daemon record, so the instance this client is bound to cannot be confirmed.' + : 'A different daemon instance is registered on this state directory.', + { + reason: 'daemon-binding-mismatch', + mismatch, + stateDir, + // The bound token is a credential and goes in a message an operator may paste, so the pin is + // named by its instance alone; a mismatch is a mismatch whatever the token says. + bound: { pid: binding.pid, processStartTime: binding.processStartTime }, + ...(registered ? { registered: daemonIdentityOf(registered) } : {}), + hint: + mismatch === 'unaddressable' + ? `Inspect ${shellQuoteIfNeeded(`${stateDir}/daemon.json`)}: a pin is refused against an unreadable record rather than answered with another instance.` + : 'Bind a client to what a running host returns. A client bound to a host that has stopped will not start or replace a daemon.', + }, + ); +} diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 875e855ea5..14360566c1 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -1,5 +1,9 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; -import type { DaemonLifecycle, EnsureDaemonOptions } from '@agent-device/contracts/client'; +import type { + DaemonBinding, + DaemonLifecycle, + EnsureDaemonOptions, +} from '@agent-device/contracts/client'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import net from 'node:net'; import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; @@ -48,6 +52,8 @@ export type DaemonClientSettings = { ownedStateDir?: OwnedReplayStateDir; remoteBaseUrl?: string; remoteAuthToken?: string; + /** See {@link DaemonBinding}. Present on a client that is pinned to one daemon instance. */ + daemonBinding?: DaemonBinding; }; export type EnsuredDaemon = { @@ -68,7 +74,7 @@ LOOPBACK_BLOCK_LIST.addSubnet('::ffff:127.0.0.0', 104, 'ipv6'); export function resolveTimeoutRecovery( settings: DaemonClientSettings, ): 'retire-unresponsive' | 'none' { - if (settings.daemonLifecycle === 'attach') return 'none'; + if (settings.daemonBinding || settings.daemonLifecycle === 'attach') return 'none'; return 'retire-unresponsive'; } @@ -77,13 +83,30 @@ export function resolveClientSettings( suppliedAuthToken: string | undefined, host: HostEnvironment, daemonLifecycle: DaemonLifecycle = 'managed', + daemonBinding: DaemonBinding | undefined = undefined, ): DaemonClientSettings { const explicitStateDir = resolveExplicitStateDir(req, host.env); const remote = resolveRemoteClientSettings(req, suppliedAuthToken, host.env); + // A pin names one local daemon instance: its token, pid and start time are read from a state + // directory's record. A base URL sends requests somewhere else entirely, so a client that sets both + // has stated two different answers to "which daemon". Refusing here beats picking one silently. + if (daemonBinding && remote.remoteBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'daemonBinding names a local daemon instance and cannot be combined with a daemon base URL.', + { + reason: 'daemon_binding_with_remote_base_url', + daemonBaseUrl: remote.remoteBaseUrl, + hint: 'Drop the base URL to reach the pinned daemon on its own state dir, or drop the pin to reach a remote daemon.', + }, + ); + } const transport = resolveTransportClientSettings(req.flags, remote.remoteBaseUrl, host.env); - // An attach client owns no daemon, and a private state directory would hold one nobody started. + // An attach client owns no daemon, and a private state directory would hold one nobody started. A + // pinned client owns one neither: it uses the instance it was given and starts nothing. const ownedStateDir = daemonLifecycle !== 'attach' && + !daemonBinding && shouldUseOwnedReplayStateDir(req, explicitStateDir, remote.rawBaseUrl) ? createOwnedReplayStateDir() : undefined; @@ -99,6 +122,7 @@ export function resolveClientSettings( ownedStateDir, remoteBaseUrl: remote.remoteBaseUrl, remoteAuthToken: remote.authToken, + ...(daemonBinding ? { daemonBinding } : {}), }; } diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index 8eca223f52..ddbe6e9611 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -82,6 +82,7 @@ async function sendToDaemonOnHost( options.authToken ?? flagAuthToken, host, options.daemonLifecycle, + options.daemonBinding, ); const requestTimeoutMs = resolveCommandRequestTimeoutMs( resolveCommandTimeoutPolicy(requestWithoutAuthFlag.command), diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 9174343b84..9b7840611e 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -125,6 +125,7 @@ These failures carry a typed `error.details.reason`: | `daemon-drain-timeout` | A daemon accepted the request to leave but was still running 60 s later and could not be force-retired, either because it still answers or because its process cannot be proven to be a daemon (an embedded host). It was not signaled. Retry once it has exited, or stop it with `agent-device daemon stop --state-dir `. | | `daemon-owner-embedded` | A host process owns the daemon on `stateDir`, so this client neither replaced it nor retired it, and signaled nothing. A daemon that matches the client is still usable. Close that host, or stop it with `agent-device daemon stop --state-dir `. | | `daemon-owner-unrecognized` | The record on `stateDir` states an owner this version cannot read, so this client replaced nothing and signaled nothing. Update agent-device if the record comes from a newer build. Otherwise check what owns that process: a host that started it closes it with its own close, and otherwise remove `daemon.json` once that process is gone. | +| `daemon-binding-mismatch` | A client pinned to one daemon instance reached another one instead. `details.mismatch` says which: `replaced`, `absent`, or `unaddressable`. Nothing was dispatched, started, or signaled. Take a fresh config from the host that owns the daemon, or start a host of your own. | `ensureDaemon()` only ever manages the local daemon on `stateDir`. A client with `daemonBaseUrl` never starts or stops a local daemon in either mode, and `daemonLifecycle` does not change `daemonTransport` or `daemonServerMode`. From 9fb06c6c422491b1860bb7b12df55cf2be077c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 11:21:10 +0200 Subject: [PATCH 02/10] feat(host): expose an embedder-owned runtime and packed consumer coverage Part of #3747. Refs #3757. Co-Authored-By: Claude Opus 5.5 --- .changeset/host-entry.md | 5 + .fallowrc.json | 1 + examples/sdk/host-runtime.ts | 44 +++ examples/sdk/tsconfig.json | 1 + package.json | 4 + packages/daemon/src/index.ts | 1 + .../daemon/src/server/http/daemon-runtime.ts | 6 + .../daemon-client-embedded-lifecycle.test.ts | 8 +- src/daemon-client/daemon-client-attach.ts | 4 +- src/daemon-client/daemon-client-binding.ts | 9 +- src/daemon-composition.ts | 23 +- src/host.test.ts | 335 ++++++++++++++++++ src/host.ts | 255 +++++++++++++ src/provider-device-runtimes.ts | 27 +- test/integration/fixtures/host-consumer.mjs | 175 +++++++++ .../installed-package-metro.test.ts | 22 ++ test/repo-guards/package-exports.test.ts | 2 + tsdown.config.ts | 1 + website/docs/docs/client-api.md | 38 +- 19 files changed, 940 insertions(+), 21 deletions(-) create mode 100644 .changeset/host-entry.md create mode 100644 examples/sdk/host-runtime.ts create mode 100644 src/host.test.ts create mode 100644 src/host.ts create mode 100644 test/integration/fixtures/host-consumer.mjs diff --git a/.changeset/host-entry.md b/.changeset/host-entry.md new file mode 100644 index 0000000000..2bf9183186 --- /dev/null +++ b/.changeset/host-entry.md @@ -0,0 +1,5 @@ +--- +'agent-device': minor +--- + +Add `agent-device/host` with `startAgentDevice()` to own a daemon inside a Node.js process. Its client configuration pins attaching processes to that runtime, and `close()` returns the shutdown report. diff --git a/.fallowrc.json b/.fallowrc.json index b3ecb0e4af..a2960dab98 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -41,6 +41,7 @@ "examples/sdk/contracts-result.ts", "examples/sdk/batch-orchestration.ts", "examples/sdk/ai-sdk-tools.ts", + "examples/sdk/host-runtime.ts", "test/integration/**/*.test.ts", "website/docs/404.mdx", "website/rspress.config.ts" diff --git a/examples/sdk/host-runtime.ts b/examples/sdk/host-runtime.ts new file mode 100644 index 0000000000..a138e14be7 --- /dev/null +++ b/examples/sdk/host-runtime.ts @@ -0,0 +1,44 @@ +/** + * Host-owned runtime: start the daemon inside this process, drive it through the + * client it hands back, and end it with one `close()`. + * + * Demonstrates: `startAgentDevice` from `agent-device/host`. + * + * Prerequisites: none — the runtime is this process's own, so no daemon needs to + * be running and no device is touched here. `sessions.list()` asks that runtime + * for its sessions, which is empty until something opens one. + * + * Run: node --experimental-strip-types examples/sdk/host-runtime.ts + */ +import { startAgentDevice } from 'agent-device/host'; + +const host = await startAgentDevice({ + // Required: a host claims a directory of its own rather than attaching to + // whatever an ambient AGENT_DEVICE_STATE_DIR happens to name. + stateDir: './.host-runtime-state', + // `log` receives the runtime's stdout and stderr lines; omitted keeps them silent. +}); + +try { + console.log(`state dir: ${host.clientConfig.stateDir}`); + console.log(`providers: ${host.plugins.map((plugin) => plugin.provider).join(', ')}`); + // The pin names exactly this instance — token, pid and process start time — so a + // second process handed this config reaches this runtime and refuses anything + // that later replaced it. It carries a bearer token: keep it inside this host. + console.log(`pinned pid: ${host.clientConfig.daemonBinding?.pid}`); + const sessions = await host.client.sessions.list(); + console.log(`sessions: ${sessions.length}`); +} finally { + // One close, safe to repeat or overlap, and it never exits this process. What the + // runtime could not finish comes back in the report instead of being swallowed. + const report = await host.close(); + const unfinished = [ + ...report.providerReleases.pending, + ...report.providerReleases.unknown, + ...report.runners.handedOff, + ...report.plugins.pending, + ]; + if (report.requests.abandoned > 0 || report.sessions.failed > 0 || unfinished.length > 0) { + console.log(`closed with unfinished work: ${JSON.stringify(report)}`); + } +} diff --git a/examples/sdk/tsconfig.json b/examples/sdk/tsconfig.json index d395b543db..78ee783a20 100644 --- a/examples/sdk/tsconfig.json +++ b/examples/sdk/tsconfig.json @@ -6,6 +6,7 @@ "tsBuildInfoFile": "../../.tmp/examples-sdk.tsbuildinfo", "paths": { "agent-device": ["../../src/sdk/index.ts"], + "agent-device/host": ["../../src/host.ts"], "agent-device/io": ["../../src/sdk/io.ts"], "agent-device/artifacts": ["../../src/sdk/artifacts.ts"], "agent-device/metro": ["../../src/sdk/metro.ts"], diff --git a/package.json b/package.json index 41f384ed90..e2fa63ac7a 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,10 @@ "types": "./dist/src/ai-sdk.d.ts", "import": "./dist/src/ai-sdk.js" }, + "./host": { + "types": "./dist/src/host.d.ts", + "import": "./dist/src/host.js" + }, "./plugins": { "types": "./dist/src/plugins.d.ts", "import": "./dist/src/plugins.js" diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index a31a68d697..536c153a8f 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -5,6 +5,7 @@ export type { DaemonRuntimeController, DaemonRuntimeDeps, } from './server/http/daemon-runtime.ts'; +export type { ShutdownReport } from './server/http/server-shutdown.ts'; export type { DaemonInvokeFn, DaemonRequest } from './core/daemon-request.ts'; export type { DaemonPlatformServices } from './core/platform-services.ts'; export type { PlatformOwnerLifecycle } from './core/platform-owner-lifecycle.ts'; diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index cf1cd49ed5..ca5feb6074 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -242,6 +242,11 @@ export type DaemonRuntimeController = { /** Runs `fn` as this runtime, for code that serves it from outside its own callbacks. */ run: (fn: () => T) => T; token: string; + /** + * Every plugin this runtime saw, active or failed. A host's caller reads this to learn that a plugin + * it asked for did not register; the daemon publishes the same list in its record. + */ + plugins: readonly PluginInventoryEntry[]; }; /** @@ -952,5 +957,6 @@ async function startDaemonRuntimeOnHost( run: runtimeState.run, socketPort, token, + plugins: deps.pluginInventory, }; } diff --git a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts index 571abc6dac..87cb7c3e15 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts @@ -241,6 +241,13 @@ test('timeout recovery asks only whether this client may, and attach never may', // daemon-registration-embedded-owner.test.ts pins at that seam for both modes. assert.equal(resolveTimeoutRecovery(managed), 'retire-unresponsive'); assert.equal(resolveTimeoutRecovery(attach), 'none'); + assert.equal( + resolveTimeoutRecovery({ + ...managed, + daemonBinding: { token: 'pin', pid: 4242, processStartTime: 'start' }, + }), + 'none', + ); }); test('a pinned client dispatches to the instance it pins and starts nothing', async (t) => { @@ -411,4 +418,3 @@ test('a pinned client keeps its hands off the state directory it was given', asy assert.deepEqual(settings.daemonBinding, pin); assert.equal(settings.paths.baseDir, paths.baseDir); }); - diff --git a/src/daemon-client/daemon-client-attach.ts b/src/daemon-client/daemon-client-attach.ts index 843ff29d3b..50e9587f08 100644 --- a/src/daemon-client/daemon-client-attach.ts +++ b/src/daemon-client/daemon-client-attach.ts @@ -2,7 +2,7 @@ import { ensureManagedDaemon } from './daemon-client-lifecycle.ts'; import { assertDaemonPolicyMatches, observeLocalDaemon } from './daemon-client-startup.ts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import type { DaemonBinding } from '@agent-device/contracts/client'; -import { readDaemonRecordAddress } from './daemon-client-metadata.ts'; +import { readDaemonRecord } from '@agent-device/daemon-contracts/daemon-resolution'; import { daemonBindingMismatchError, readPinnedDaemonRecord } from './daemon-client-binding.ts'; import { daemonIncompatibleError, daemonNotRunningError } from './daemon-lifecycle-errors.ts'; @@ -15,7 +15,7 @@ async function attachBoundDaemon( binding: DaemonBinding, settings: DaemonClientSettings, ): Promise { - const pinned = readPinnedDaemonRecord(binding, readDaemonRecordAddress(settings.paths.infoPath)); + const pinned = readPinnedDaemonRecord(binding, readDaemonRecord(settings.paths.infoPath)); if ('mismatch' in pinned) { throw daemonBindingMismatchError(binding, pinned, settings.paths.baseDir); } diff --git a/src/daemon-client/daemon-client-binding.ts b/src/daemon-client/daemon-client-binding.ts index 231930cae1..af875384e0 100644 --- a/src/daemon-client/daemon-client-binding.ts +++ b/src/daemon-client/daemon-client-binding.ts @@ -1,7 +1,8 @@ import type { DaemonBinding } from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; -import type { DaemonInfo, DaemonRecordAddress } from './daemon-client-metadata.ts'; +import { readDaemonRecord } from '@agent-device/daemon-contracts/daemon-resolution'; +import { daemonInfoFromRecord, type DaemonInfo } from './daemon-client-metadata.ts'; import { daemonIdentityOf } from './daemon-lifecycle-errors.ts'; /** @@ -25,13 +26,13 @@ export type PinnedDaemonRecord = export function readPinnedDaemonRecord( binding: DaemonBinding, - record: DaemonRecordAddress, + record: ReturnType, ): PinnedDaemonRecord { if (record.status === 'absent') return { mismatch: 'absent', registered: null }; - if (record.status !== 'addressable') { + const registered = record.status === 'present' ? daemonInfoFromRecord(record.record) : null; + if (!registered) { return { mismatch: 'unaddressable', registered: null }; } - const registered = record.info; // Every field is compared, the token included: a replacement that somehow reused the pid and its // start time is still a different runtime, and the token is what proves it. if ( diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 9001658e62..f9ec8839de 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -17,7 +17,10 @@ import { createProviderDeviceRuntimeRequestProviders, isActiveProviderDevice, } from './provider-device-runtime.ts'; -import { createDaemonProviderRuntimeComposition } from './provider-device-runtimes.ts'; +import { + createDaemonProviderRuntimeComposition, + createEmbeddedProviderRuntimeComposition, +} from './provider-device-runtimes.ts'; import { readDaemonProviderCredentials } from './provider-credential-fingerprint.ts'; import { getInteractor } from './core/interactors.ts'; import { @@ -35,6 +38,7 @@ import { } from '@agent-device/host-kit/process'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { AgentDeviceRuntimeConfig } from '@agent-device/contracts/runtime-contract'; +import type { PluginCandidate } from '@agent-device/plugins/registry'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { RunnerContext } from '@agent-device/contracts/interactor-types'; @@ -73,10 +77,20 @@ export function createDaemonStartupWebOrphanCleanup( * factory (ADR 0019). The session-scoped callbacks are the daemon's own data seen as questions — * recording artifact paths, session directories, and which web sessions are open — so no daemon * value crosses here. + * + * `providerCandidates` replaces the plugin store as the second provider source. The daemon + * executable omits it; a host that embeds the runtime passes what its own process imported. */ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( input, ): Promise => { + return await composeDaemonRuntimeDeps(input); +}; + +export async function composeDaemonRuntimeDeps( + input: Parameters[0], + providerCandidates?: readonly PluginCandidate[], +): Promise { const { host, baseDir, @@ -93,7 +107,10 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( resolveSessionDir, }); const hostDiagnostics = createHostDiagnostics(host); - const providerComposition = await createDaemonProviderRuntimeComposition(host); + const providerComposition = + providerCandidates === undefined + ? await createDaemonProviderRuntimeComposition(host) + : await createEmbeddedProviderRuntimeComposition(host, providerCandidates); // The registry has set up every plugin. Until this function returns, nothing else owns its // cleanup, so any later failure closes it here; success transfers ownership to the daemon. try { @@ -183,4 +200,4 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( await providerComposition.shutdown(); throw error; } -}; +} diff --git a/src/host.test.ts b/src/host.test.ts new file mode 100644 index 0000000000..259f1dce8e --- /dev/null +++ b/src/host.test.ts @@ -0,0 +1,335 @@ +// `agent-device/host`, end to end: an embedder starts the runtime inside its own process and gets a +// client, a config another process attaches with, and one close. The host here is the test process, +// which is what makes the central assertion cheap and final: a code path that reached for a signal, or +// for `process.exit`, would take the test runner down with it. + +import fs from 'node:fs'; +import { expect, test, vi } from 'vitest'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { readDaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; +import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import { builtinPluginInventory } from './__tests__/test-utils/builtin-plugin-inventory.ts'; +import { stopRecordedDaemon } from './daemon-client/daemon-client-stop.ts'; +import { startAgentDevice } from './host.ts'; + +const composition = vi.hoisted(() => ({ + /** The candidates a host handed the embedded composition, so a row can see what it passed on. */ + embedderCandidates: undefined as unknown[] | undefined, +})); + +// The platform and provider modules are stand-ins in this file: what these rows judge is the entry's +// own decisions — what it publishes, what it refuses, what it never touches — not the device runtimes. +vi.mock('./platform-runtime.ts', () => ({ + androidObservation: {}, + createRequestPlatformProviders: () => ({ + run: async (_context: unknown, task: () => Promise) => await task(), + }), + createPlatformRuntimeGateway: () => ({ + applicationLifecycle: { + recoverStartupResources: async () => {}, + detachForDaemonShutdown: async () => [], + finalizeDaemonShutdown: async () => [], + }, + inspectFacts: async () => { + throw new Error('unused'); + }, + bind: async () => { + throw new Error('unused'); + }, + shutdown: async () => [], + }), + createPlatformDeviceInventoryGateways: () => ({}), +})); + +vi.mock('./provider-device-runtimes.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createDaemonProviderRuntimeComposition: async () => { + // A host names its plugins in code, so the store that `plugin add` writes is not one of its + // sources. Reaching it would let somebody else's install change what this process runs. + throw new Error('a host never reads the plugin store'); + }, + createEmbeddedProviderRuntimeComposition: async ( + _env: unknown, + embedderCandidates: readonly unknown[], + ) => { + composition.embedderCandidates = [...embedderCandidates]; + return { + runtimes: [], + platformModules: [], + shutdown: async () => [], + inventory: await ( + await import('./__tests__/test-utils/builtin-plugin-inventory.ts') + ).builtinPluginInventory(), + }; + }, + }; +}); + +import type { AgentDeviceHost } from './host.ts'; + +type Booted = { + stateDir: string; + paths: ReturnType; + host: AgentDeviceHost; +}; + +async function bootHost( + options: { + plugins?: readonly import('@agent-device/plugins/contract').AgentDevicePlugin[]; + env?: Record; + } = {}, +): Promise { + const { env, ...startOptions } = options; + const stateDir = mkdtempForTestSync('agent-device-host-entry-'); + const host = await startAgentDevice({ + stateDir, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + ...(env ?? {}), + }, + }), + ...startOptions, + }); + return { stateDir, paths: resolveDaemonPaths(host.clientConfig.stateDir!), host }; +} + +function removeStateDir(stateDir: string) { + fs.rmSync(stateDir, { recursive: true, force: true }); +} + +test('a host publishes its own process as the runtime an outside stop must not signal', async () => { + const { stateDir, paths, host } = await bootHost(); + try { + const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')) as { + pid: number; + ownership?: string; + transport?: string; + }; + // The record names the test process, which is why every stop route must ask instead of signaling. + expect(record.pid).toBe(process.pid); + expect(record.ownership).toBe('embedded'); + expect(readDaemonStopIdentity(paths.infoPath).ownership).toBe('embedded'); + } finally { + await host.close(); + removeStateDir(stateDir); + } +}); + +test('a host hands out the pin that reaches exactly its own instance', async () => { + const { stateDir, paths, host } = await bootHost(); + try { + const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')) as { + token: string; + pid: number; + processStartTime?: string; + }; + // The pin is the record's own triple, so an attacher that resolves a different working directory + // still addresses this instance rather than whatever holds the directory next. + expect(host.clientConfig.daemonBinding).toEqual({ + token: record.token, + pid: record.pid, + processStartTime: readProcessStartTime(process.pid), + }); + expect(host.clientConfig.daemonLifecycle).toBe('attach'); + // The resolved absolute directory, so an attacher with another working directory addresses this + // daemon rather than resolving a relative path against its own cwd. + expect(host.clientConfig.stateDir).toBe(paths.baseDir); + } finally { + await host.close(); + removeStateDir(stateDir); + } +}); + +test('a host runs its runtime on the socket its client is given, whatever the process exports', async () => { + // An embedder's process may carry a server mode for other purposes. The host cannot honour it: the + // config it hands out names a socket, and a runtime started in `http` mode opens no socket listener + // at all, so the host's own client could not reach the runtime this call started. + const { stateDir, paths, host } = await bootHost({ + env: { AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }, + }); + try { + const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')) as { + transport?: string; + port?: number; + httpPort?: number; + }; + expect(record.transport).toBe('socket'); + expect(typeof record.port).toBe('number'); + expect(record.httpPort).toBeUndefined(); + expect(host.clientConfig.daemonServerMode).toBe('socket'); + // The client of the host that started this runtime reaches it over that socket, which is the whole + // promise. A record that names no socket makes this fail instead. + await expect(host.client.sessions.list()).resolves.toBeInstanceOf(Array); + } finally { + await host.close(); + removeStateDir(stateDir); + } +}); + +test('a host registers the plugins its embedder imported and reports the inventory', async () => { + const plugin = { + apiVersion: 2 as const, + provider: 'embedder-plugin', + credentials: { variables: [] }, + setup: async () => { + throw new Error('not invoked by composition fixture'); + }, + }; + const { stateDir, host } = await bootHost({ plugins: [plugin] }); + try { + // The embedder's objects reach the composition as candidates the registry claims in order, and the + // store is never consulted: a plugin somebody installed later cannot change this process. + expect(composition.embedderCandidates).toEqual([ + { source: 'embedder', load: expect.any(Function) }, + ]); + expect(host.plugins).toEqual(await builtinPluginInventory()); + } finally { + await host.close(); + removeStateDir(stateDir); + } +}); + +test('a plugin that is not a plugin is refused before anything starts', async () => { + const stateDir = mkdtempForTestSync('agent-device-host-entry-'); + try { + const refusal = await startAgentDevice({ + stateDir, + plugins: [ + // @ts-expect-error malformed JavaScript input + 'not-a-plugin', + ], + }).then( + () => null, + (error: unknown) => error as { code?: string; details?: { reason?: string } }, + ); + + expect(refusal).toMatchObject({ + code: 'INVALID_ARGS', + details: { reason: 'invalid_embedder_plugin' }, + }); + // Refused at the boundary the embedder can still fix: no state directory was claimed. + expect(fs.existsSync(resolveDaemonPaths(stateDir).infoPath)).toBe(false); + } finally { + removeStateDir(stateDir); + } +}); + +test('a host without a state directory is refused, never a daemon in an inherited one', async () => { + for (const stateDir of [undefined, '', ' ']) { + const refusal = await startAgentDevice({ + stateDir: stateDir as string, + host: createTestHostEnvironment({ + env: { AGENT_DEVICE_STATE_DIR: '/tmp/agent-device-host-entry-inherited' }, + }), + }).then( + () => null, + (error: unknown) => error as { code?: string; details?: { reason?: string } }, + ); + + // An ambient variable is not a choice: a host that inherited one would attach to, or claim, a + // directory nobody picked for this process. + expect(refusal, JSON.stringify(stateDir)).toMatchObject({ + code: 'INVALID_ARGS', + details: { reason: 'state_dir_required' }, + }); + } + expect(fs.existsSync('/tmp/agent-device-host-entry-inherited/daemon.json')).toBe(false); +}); + +test('a host ends its own runtime once, and an outside stop ends it the same way', async () => { + const { stateDir, paths, host } = await bootHost(); + const signals = vi.spyOn(process, 'kill'); + const exit = vi.spyOn(process, 'exit'); + + try { + // Concurrent and repeated closes share one teardown, and none of them exits the process: a library + // a test runner imported cannot claim the executable's lifetime. + const [first, second] = await Promise.all([host.close(), host.close()]); + await expect(host.close()).resolves.toEqual(first); + + expect(first).toEqual(second); + expect(exit).not.toHaveBeenCalled(); + await expect(host[Symbol.asyncDispose]()).resolves.toBeUndefined(); + expect(first).toMatchObject({ + requests: { drained: 0, abandoned: 0 }, + plugins: { failed: [], pending: [] }, + }); + // One close wrote one answer: the registration is gone and nothing is left to stop. + expect(fs.existsSync(paths.infoPath)).toBe(false); + expect(hostSignals(signals)).toEqual([]); + + // A stop that arrives afterwards finds an empty state directory, which is the whole point of the + // record being the host's own: it reports what the host already did. + const stopped = await stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { + paths, + host: createTestHostEnvironment(), + }); + expect(stopped).toMatchObject({ stopped: false, mode: 'not-running' }); + } finally { + signals.mockRestore(); + exit.mockRestore(); + await host.close(); + removeStateDir(stateDir); + } +}); + +test("an operator's stop tears a host's runtime down exactly as its own close would", async () => { + const { stateDir, paths, host } = await bootHost(); + const signals = vi.spyOn(process, 'kill'); + + try { + const stopped = await stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { + paths, + host: createTestHostEnvironment(), + }); + + // The runtime answered with its own close: asked over its transport, never signaled. + expect(stopped).toMatchObject({ stopped: true, mode: 'graceful' }); + expect(hostSignals(signals)).toEqual([]); + expect(fs.existsSync(paths.infoPath)).toBe(false); + // The host learns the same report it would have from closing itself, and closing again is not an + // error — the case of a scope that closes after an operator already stopped the runtime. + await expect(host.close()).resolves.toBeDefined(); + } finally { + signals.mockRestore(); + await host.close(); + removeStateDir(stateDir); + } +}); + +test('a host never takes a state directory somebody else holds', async () => { + const held = await bootHost(); + try { + const refusal = await startAgentDevice({ + stateDir: held.stateDir, + host: createTestHostEnvironment(), + }).then( + () => null, + (error: unknown) => + error as { code?: string; details?: { reason?: string; stateDir?: string } }, + ); + + expect(refusal).toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'state-dir-in-use', stateDir: resolveDaemonPaths(held.stateDir).baseDir }, + }); + // Refusing left the first host untouched: same runtime, same record, still attachable. + expect(readDaemonStopIdentity(held.paths.infoPath).ownership).toBe('embedded'); + } finally { + await held.host.close(); + removeStateDir(held.stateDir); + } +}); + +/** + * The signals this code sent at a process. `0` is a liveness probe and is not one; a call with no + * signal argument is SIGTERM, so an omitted signal counts as the signal it really is. + */ +function hostSignals(killSpy: { mock: { calls: Array } }) { + return killSpy.mock.calls.filter(([, signal]) => signal !== 0); +} diff --git a/src/host.ts b/src/host.ts new file mode 100644 index 0000000000..b4ee0fe046 --- /dev/null +++ b/src/host.ts @@ -0,0 +1,255 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { captureHostEnvironment, readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import type { AgentDeviceClientConfig } from '@agent-device/contracts/client'; +import type { AgentDeviceClient } from './client/client-types.ts'; +import type { AgentDevicePlugin } from '@agent-device/plugins/contract'; +import type { PluginCandidate } from '@agent-device/plugins/registry'; +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; +import type { DaemonRuntimeController, ShutdownReport } from '@agent-device/daemon'; + +/** One line of runtime output, with the stream it came from. */ +export type LogEntry = Readonly<{ + stream: 'stdout' | 'stderr'; + text: string; +}>; + +export type StartAgentDeviceOptions = Readonly<{ + /** + * The state directory this host owns; resolved against `host`'s working directory when relative. It + * is required: a daemon's identity on disk is its state directory, and a host that inherited an + * ambient `AGENT_DEVICE_STATE_DIR` would attach to — or replace — a runtime nobody chose. + */ + stateDir: string; + /** The environment and working directory to run with. Defaults to one captured copy of this process's. */ + host?: HostEnvironment; + /** + * Plugin objects to register after the built-ins, in the order given. The plugin store is not read, + * so the set is fixed for the runtime's lifetime. Defaults to none. + */ + plugins?: readonly AgentDevicePlugin[]; + /** Receives this runtime's stdout and stderr lines. Silent when omitted. */ + log?: (entry: LogEntry) => void; +}>; + +/** + * The config that reproduces {@link AgentDeviceHost.client} in another process: local socket attach, + * pinned to this instance, carrying its token. + * + * It is plain JSON, and the token in it is a bearer credential for this instance: keep the object + * inside the host that owns the daemon. Attach-only is the point — a client built from it dispatches to + * this instance and can neither start, replace, nor retire a daemon. + */ +export type HostClientConfig = AgentDeviceClientConfig; + +export type AgentDeviceHost = Readonly<{ + /** A client pinned to this instance. */ + client: AgentDeviceClient; + /** See {@link HostClientConfig}. */ + clientConfig: HostClientConfig; + /** What this runtime registered, active and failed, as `daemon status` shows it. */ + plugins: readonly PluginInventoryEntry[]; + /** + * Closes the runtime and reports what it could not finish. Repeated and concurrent calls share one + * close, so closing after a `daemon stop`, or from two overlapping scopes, is not an error. This + * never exits the process. + */ + close: () => Promise; + /** Awaits {@link AgentDeviceHost.close} and returns nothing, as `await using` expects. */ + [Symbol.asyncDispose]: () => Promise; +}>; + +/** + * Starts a daemon inside this process and returns the client pinned to it. + * + * It is the daemon executable's own runtime: same registration claim, same authenticated socket + * protocol on an ephemeral loopback listener, same close. Two things differ. The record it publishes + * says `embedded`, so no other process signals or replaces it. And the shutdown request that record + * advertises is answered by the runtime's own `close()` — the same call {@link AgentDeviceHost.close} + * makes — so an operator's `agent-device daemon stop` and an embedder's `host.close()` tear the + * instance down identically. + * + * A state directory somebody else holds is refused, never taken: `details.reason` is + * `state-dir-in-use` for a live occupant, and `state-dir-ownership-unproven` when a record is there + * whose owner cannot be proven dead. + */ +export async function startAgentDevice(options: StartAgentDeviceOptions): Promise { + const sourceHost = options.host ?? captureHostEnvironment(process); + const host = { env: { ...sourceHost.env }, cwd: sourceHost.cwd }; + if (typeof options.stateDir !== 'string' || options.stateDir.trim().length === 0) { + throw new AppError('INVALID_ARGS', 'startAgentDevice requires a stateDir.', { + reason: 'state_dir_required', + hint: 'Pass the state directory this host owns; it is what every attaching process addresses.', + }); + } + // This process's lifetime is knowable before the runtime starts, and the runtime publishes the same + // pair when it claims the state directory. Reading it first is what lets a host that could never + // hand out a pin refuse up front instead of after acquiring one. + const identity = readCurrentOwnerIdentity(); + if (identity.startTime === null) { + throw new AppError( + 'COMMAND_FAILED', + 'This host cannot pin a daemon instance: its own process start time cannot be read.', + { + reason: 'host_instance_unreadable', + pid: identity.pid, + hint: 'Use a detached daemon (`agent-device daemon start`, or let a client start one) on this platform.', + }, + ); + } + const candidates = embedderPluginCandidates(options.plugins); + // The runtime reads its state directory and its server mode from the environment like every other + // consumer of the composition. `runtimeHost` is a copy, so these apply to the runtime alone; the + // process's own environment is never touched. The mode is pinned because `host.client` is handed the + // socket address this call publishes: an ambient `http` would start a runtime with no socket + // listener at all, and the client of the host that started it could not reach it. + const runtimeHost: HostEnvironment = { + env: { + ...host.env, + AGENT_DEVICE_STATE_DIR: options.stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'socket', + }, + cwd: host.cwd, + }; + // The resolved absolute directory, so a refusal and the config an attacher gets name the same place + // this process actually claimed, whichever working directory each of them has. + const stateDir = resolveDaemonPaths(options.stateDir, { + env: runtimeHost.env, + cwd: runtimeHost.cwd, + }).baseDir; + const [{ startDaemonRuntime }, { composeDaemonRuntimeDeps }] = await Promise.all([ + import('@agent-device/daemon'), + import('./daemon-composition.ts'), + ]); + const streams = createHostLogStreams(options.log); + let runtime: DaemonRuntimeController; + try { + runtime = await startDaemonRuntime({ + host: runtimeHost, + stdout: streams.stdout, + stderr: streams.stderr, + ownership: 'embedded', + composeDeps: async (input) => await composeDaemonRuntimeDeps(input, candidates), + }); + } catch (error) { + throw hostStartupError(error, stateDir); + } + try { + const { createAgentDeviceClient } = await import('./sdk/index.ts'); + const clientConfig: HostClientConfig = Object.freeze({ + // The resolved absolute directory, so an attacher with a different working directory addresses + // the same daemon rather than resolving a relative path against its own cwd. + stateDir, + daemonLifecycle: 'attach', + daemonTransport: 'socket', + daemonServerMode: 'socket', + cwd: host.cwd, + daemonBaseUrl: '', + daemonBinding: Object.freeze({ + token: runtime.token, + pid: identity.pid, + processStartTime: identity.startTime, + }), + }); + return Object.freeze({ + client: createAgentDeviceClient(clientConfig, { host }), + clientConfig, + plugins: runtime.plugins, + close: () => runtime.close(), + [Symbol.asyncDispose]: async () => { + await runtime.close(); + }, + }); + } catch (error) { + // A host nobody can attach to is not a host: close what was started before handing the refusal + // back, or the state directory stays claimed by a runtime its owner cannot reach. + await runtime.close({ cause: error }).catch(() => {}); + throw error; + } +} + +/** + * The embedder's plugin objects as registry candidates. Something that is not a plugin is refused here, + * at the boundary where the embedder can still fix it, rather than becoming a runtime that silently + * registers nothing. + */ +function embedderPluginCandidates(plugins: readonly unknown[] | undefined): PluginCandidate[] { + return (plugins ?? []).map((plugin, index) => { + if (!isPluginLike(plugin)) { + throw new AppError('INVALID_ARGS', `plugins[${index}] is not an agent-device plugin.`, { + reason: 'invalid_embedder_plugin', + hint: 'Pass what a plugin package default factory returns, e.g. `testmu({ credentials })`.', + }); + } + return { source: 'embedder', load: () => plugin }; + }); +} + +function isPluginLike(plugin: unknown): plugin is Record { + return ( + typeof plugin === 'object' && + plugin !== null && + typeof (plugin as { provider?: unknown }).provider === 'string' && + typeof (plugin as { setup?: unknown }).setup === 'function' + ); +} + +/** + * Names, in the vocabulary an embedder acts on, the two refusals that mean "this state directory is not + * yours". The executable reads an exit code; a library caller has none, so those two statuses are + * re-stated here with a hint that says what to do instead. Every other refusal keeps what the runtime + * said, because the runtime already owns the reason. + * + * `stateDir` is the directory this call resolved. The runtime's refusal names its status and not the + * directory — the executable already knows which one it passed — so the host adds the one fact its own + * caller needs to act on. + */ +function hostStartupError(error: unknown, stateDir: string): unknown { + if (!(error instanceof AppError) || error.details?.reason !== 'daemon_registration_refused') { + return error; + } + const status = error.details.status; + if (status !== 'busy' && status !== 'unproven') return error; + return new AppError( + 'COMMAND_FAILED', + status === 'unproven' + ? `Cannot start a host on ${stateDir}: the record there belongs to an owner that cannot be proven dead.` + : `Cannot start a host on ${stateDir}: another daemon holds the state directory.`, + { + reason: status === 'unproven' ? 'state-dir-ownership-unproven' : 'state-dir-in-use', + stateDir, + hint: 'Stop the daemon that owns it, or give this host its own state directory. A host never replaces an occupant.', + }, + ); +} + +/** + * The runtime's two output streams, kept as two writers and handed to the embedder one line at a time. + * A writer that throws is not this runtime's problem to fix, so a sink that fails is dropped rather + * than reaching the daemon's own write path. + */ +function createHostLogStreams(log: ((entry: LogEntry) => void) | undefined): { + stdout: { write: (chunk: string) => void }; + stderr: { write: (chunk: string) => void }; +} { + const partial: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }; + const writer = (stream: 'stdout' | 'stderr') => ({ + write: (chunk: string): void => { + if (!log) return; + const combined = `${partial[stream]}${chunk}`; + const lines = combined.split('\n'); + partial[stream] = lines.pop() ?? ''; + for (const line of lines) { + try { + log({ stream, text: `${line}\n` }); + } catch { + // An embedder's sink owns its own failures; the runtime keeps serving either way. + } + } + }, + }); + return { stdout: writer('stdout'), stderr: writer('stderr') }; +} + +export type { HostEnvironment, ShutdownReport }; diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 4b2e8e1077..ee678d0c93 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -4,6 +4,7 @@ import type { PluginCleanupOutcome, PluginInventoryEntry, } from '@agent-device/contracts/plugin-inventory'; +import type { PluginCandidate } from '@agent-device/plugins/registry'; import type { PlatformRuntimeProviderRegistration } from './platform-runtime-gateway.ts'; import { builtinProviderCandidates } from './builtin-provider-plugins.ts'; import { PROVIDER_PLUGIN_POLICY } from './provider-plugin-policy.ts'; @@ -24,19 +25,25 @@ export type ProviderRuntimeComposition = Readonly<{ export async function createDaemonProviderRuntimeComposition( host: HostEnvironment, ): Promise { - const { env } = host; - // This daemon's sources: the built-in providers, then the plugin store. A host that embeds the - // daemon picks its own candidates; the registry takes whatever candidates it is given. - const [{ resolveProviderPlugins, startProviderRegistry }, { storePluginCandidates }] = - await Promise.all([ - import('@agent-device/plugins/registry'), - import('@agent-device/plugins/store'), - ]); + const { storePluginCandidates } = await import('@agent-device/plugins/store'); + return await createEmbeddedProviderRuntimeComposition( + host, + await storePluginCandidates(host.env), + ); +} + +/** Composes built-ins followed by supplied candidates without reading the plugin store. */ +export async function createEmbeddedProviderRuntimeComposition( + host: HostEnvironment, + candidates: readonly PluginCandidate[], +): Promise { + const { resolveProviderPlugins, startProviderRegistry } = + await import('@agent-device/plugins/registry'); const resolved = await resolveProviderPlugins([ ...builtinProviderCandidates(host), - ...(await storePluginCandidates(env)), + ...candidates, ]); - const registry = await startProviderRegistry(resolved, env, PROVIDER_PLUGIN_POLICY); + const registry = await startProviderRegistry(resolved, host.env, PROVIDER_PLUGIN_POLICY); return Object.freeze({ runtimes: Object.freeze(registry.registrations.map(({ runtime }) => runtime)), platformModules: Object.freeze( diff --git a/test/integration/fixtures/host-consumer.mjs b/test/integration/fixtures/host-consumer.mjs new file mode 100644 index 0000000000..29298c0574 --- /dev/null +++ b/test/integration/fixtures/host-consumer.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { createAgentDeviceClient } from 'agent-device'; +import { startAgentDevice } from 'agent-device/host'; + +const exec = promisify(execFile); +const cwd = process.cwd(); +const beforeEnv = { ...process.env }; +const listeners = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection']; +const beforeListeners = listeners.map((event) => process.listeners(event)); +const disposed = []; +const setups = []; +function fixture(provider) { + return { + apiVersion: 2, + provider, + credentials: { variables: [] }, + setup: (host) => { + setups.push([provider, host.env.HOST_MARKER]); + return { + runtime: { + provider, + leaseLifecycle: {}, + ownsDevice: () => false, + getInteractor: () => undefined, + deviceInventoryProvider: () => ({ listDevices: async () => [] }), + shutdown: async () => { + disposed.push(`${provider}:shutdown`); + }, + }, + platformModule: { + owner: { kind: 'provider-runtime', provider, instance: provider }, + loadRuntime: async () => ({}), + }, + dispose: () => { + disposed.push(`${provider}:dispose`); + }, + }; + }, + }; +} +fs.mkdirSync(path.join(cwd, '.agent-device'), { recursive: true }); +fs.writeFileSync(path.join(cwd, '.agent-device/config.json'), '{broken plugin store'); +const supplied = { + cwd, + env: { + HOME: cwd, + PATH: process.env.PATH, + HOST_MARKER: 'A', + AGENT_DEVICE_DAEMON_BASE_URL: 'http://127.0.0.1:9', + }, +}; +const a = await startAgentDevice({ + stateDir: 'host-a', + host: supplied, + plugins: [ + fixture('fixture-a'), + fixture('limrun'), + { + ...fixture('broken'), + setup() { + throw new Error('fixture setup failure'); + }, + }, + ], +}); +const b = await startAgentDevice({ + stateDir: 'host-b', + host: { cwd, env: { ...supplied.env, HOST_MARKER: 'B' } }, + plugins: [fixture('fixture-b')], +}); +try { + supplied.env.HOST_MARKER = 'mutated'; + assert.deepEqual(await a.client.sessions.list(), []); + assert.deepEqual(await b.client.sessions.list(), []); + assert.deepEqual(setups, [ + ['fixture-a', 'A'], + ['fixture-b', 'B'], + ]); + assert.equal(a.plugins.find((p) => p.provider === 'fixture-a').state, 'active'); + assert.equal( + a.plugins.filter((p) => p.provider === 'limrun' && p.source === 'embedder')[0].state, + 'failed', + ); + assert.equal(a.plugins.find((p) => p.provider === 'broken').state, 'failed'); + assert.equal(process.cwd(), cwd); + assert.deepEqual({ ...process.env }, beforeEnv); + assert.deepEqual( + listeners.map((event) => process.listeners(event)), + beforeListeners, + ); + const config = JSON.stringify(a.clientConfig); + const child = await exec( + process.execPath, + [ + '--input-type=module', + '-e', + `import { createAgentDeviceClient } from 'agent-device'; console.log(JSON.stringify(await createAgentDeviceClient(${config}).sessions.list()));`, + ], + { cwd }, + ); + assert.deepEqual(JSON.parse(child.stdout), []); + const cli = path.join(cwd, 'node_modules/agent-device/bin/agent-device.mjs'); + await assert.rejects( + exec( + process.execPath, + [cli, 'daemon', 'stop', '--clean', '--state-dir', a.clientConfig.stateDir, '--json'], + { cwd }, + ), + (error) => { + assert.equal(JSON.parse(error.stdout).error.details.reason, 'daemon-clean-not-detached'); + return true; + }, + ); + assert.deepEqual(await a.client.sessions.list(), []); + const stopped = await exec( + process.execPath, + [cli, 'daemon', 'stop', '--state-dir', a.clientConfig.stateDir, '--json'], + { cwd }, + ); + assert.equal(JSON.parse(stopped.stdout).data.stopped, true); + const [first, second, disposal] = await Promise.all([ + a.close(), + a.close(), + a[Symbol.asyncDispose](), + ]); + assert.strictEqual(first, second); + assert.equal(disposal, undefined); + assert.deepEqual(first.requests, { drained: 0, abandoned: 0 }); + assert.deepEqual(disposed, ['fixture-a:shutdown', 'fixture-a:dispose']); + assert.deepEqual(await b.client.sessions.list(), []); + const replacement = await startAgentDevice({ + stateDir: a.clientConfig.stateDir, + host: { cwd, env: supplied.env }, + }); + try { + await assert.rejects( + createAgentDeviceClient(a.clientConfig).sessions.list(), + (error) => error.details?.reason === 'daemon-binding-mismatch', + ); + const wrongToken = createAgentDeviceClient({ + ...replacement.clientConfig, + daemonBinding: { ...replacement.clientConfig.daemonBinding, token: 'wrong-token' }, + }); + await assert.rejects( + wrongToken.sessions.list(), + (error) => error.details?.reason === 'daemon-binding-mismatch', + ); + } finally { + await replacement.close(); + } + console.log( + JSON.stringify({ + attachedFromChild: true, + cleanRefused: true, + externalStop: true, + siblingPreserved: true, + replacementRefused: true, + report: first, + }), + ); +} finally { + await a.close(); + await b.close(); +} +assert.deepEqual(disposed, [ + 'fixture-a:shutdown', + 'fixture-a:dispose', + 'fixture-b:shutdown', + 'fixture-b:dispose', +]); +assert.equal(fs.existsSync(path.join(cwd, 'host-a', 'daemon.json')), false); diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index d1210e8280..7ffc672ab5 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -96,6 +96,13 @@ async function typeCheckInstalledPluginContract(consumerRoot: string): Promise { + const running: AgentDeviceHost = await startAgentDevice({ stateDir: './state', host, plugins: [await example()], log: (entry: LogEntry) => { void entry.text; } }); + return await running.close(); +} + type ExampleOptions = { credentials?: { username: string; accessKey: string } }; @@ -126,6 +133,8 @@ export default example; '--strict', '--target', 'es2022', + '--lib', + 'es2022,dom,esnext.disposable', '--module', 'nodenext', '--moduleResolution', @@ -292,6 +301,16 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi ); } await typeCheckInstalledPluginContract(consumerRoot); + const hostConsumer = path.join(consumerRoot, 'host-consumer.mjs'); + fs.copyFileSync( + path.join(repoRoot, 'test/integration/fixtures/host-consumer.mjs'), + hostConsumer, + ); + const hosted = readJson( + await execFileText(process.execPath, [hostConsumer], { cwd: consumerRoot }), + ); + assert.equal(hosted.attachedFromChild, true); + assert.equal(hosted.replacementRefused, true); metroPort = await listenOnLoopback(metroServer); t.after(async () => { await closeLoopbackServer(metroServer); @@ -381,6 +400,8 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi }, './contracts': (mod) => mod.centerOfRect({ x: 2, y: 2, width: 6, height: 6 }), './finders': (mod) => mod.findBestMatchesByLocator([], 'text', 'anything').matches.length, + // Importing the host entry must be cheap: it reaches the daemon only when a caller starts one. + './host': (mod) => typeof mod.startAgentDevice, './install-source': (mod) => typeof mod.isTrustedInstallSourceUrl('https://example.test/app.apk'), './io': (mod) => typeof mod.createLocalArtifactAdapter({ cwd: process.cwd() }).reserveOutput, './limrun': async (mod) => { @@ -466,6 +487,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi './batch': 'INVALID_ARGS', './contracts': { x: 5, y: 5 }, './finders': 0, + './host': 'function', './install-source': 'boolean', './io': 'function', './limrun': 'limrun', diff --git a/test/repo-guards/package-exports.test.ts b/test/repo-guards/package-exports.test.ts index c5259141bc..4edbda9442 100644 --- a/test/repo-guards/package-exports.test.ts +++ b/test/repo-guards/package-exports.test.ts @@ -34,6 +34,8 @@ const supportedSubpaths = [ './selectors', './finders', './ai-sdk', + // The embedder's entry: it starts a daemon in the caller's process, which no other subpath does. + './host', ]; const typeOnlySubpaths = new Set(['./remote-config']); diff --git a/tsdown.config.ts b/tsdown.config.ts index 853698fa50..3ad1949ce3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -108,6 +108,7 @@ export default defineConfig({ selectors: 'src/sdk/selectors.ts', finders: 'src/sdk/finders.ts', 'ai-sdk': 'src/ai-sdk/index.ts', + host: 'src/host.ts', 'internal/bin': 'src/bin.ts', 'internal/companion-tunnel': 'packages/react-native/src/companion/companion-tunnel.ts', 'internal/daemon': 'src/daemon.ts', diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 9b7840611e..8326503606 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -40,7 +40,7 @@ Entry points for driving devices from Node.js: platform, backend, and mechanism failure. - `centerOfRect(rect)` - types: `AgentDeviceClient`, `AgentDeviceClientConfig`, `AgentDeviceDevice` - - types: `DaemonLifecycle`, `EnsureDaemonOptions`, `DaemonConnection`, `DaemonIdentity` + - types: `DaemonBinding`, `DaemonLifecycle`, `EnsureDaemonOptions`, `DaemonConnection`, `DaemonIdentity` - types: every option and result type of the client's methods, among them `AppOpenOptions`, `AppOpenResult`, `CaptureSnapshotOptions`, `CaptureSnapshotResult`, `CaptureScreenshotResult`, `PressOptions` @@ -54,6 +54,9 @@ Entry points for driving devices from Node.js: - `agent-device/ai-sdk` - `createAgentDeviceTools(options)` - types: `AgentDeviceToolSet`, `AgentDeviceTools`, `CreateAgentDeviceToolsOptions` +- `agent-device/host` + - `startAgentDevice(options)` + - types: `AgentDeviceHost`, `StartAgentDeviceOptions`, `HostClientConfig`, `LogEntry`, `HostEnvironment`, `ShutdownReport` ## Basic usage @@ -129,6 +132,39 @@ These failures carry a typed `error.details.reason`: `ensureDaemon()` only ever manages the local daemon on `stateDir`. A client with `daemonBaseUrl` never starts or stops a local daemon in either mode, and `daemonLifecycle` does not change `daemonTransport` or `daemonServerMode`. +## Running the daemon inside your process + +`startAgentDevice()` on `agent-device/host` starts the daemon inside your own process and returns a client for it. Use it when you want the daemon's lifetime to be exactly your process's, or when you import plugin objects you already have. + +```ts +import { startAgentDevice } from 'agent-device/host'; + +const host = await startAgentDevice({ + stateDir: './.my-app/agent-device-state', + log: (line) => console.log(line.stream, line.text), +}); + +const devices = await host.client.devices.list(); +await host.close(); +``` + +`stateDir` is required, and a host never takes one somebody else holds: a live occupant is refused with `state-dir-in-use`, and a record whose owner cannot be proven dead with `state-dir-ownership-unproven`. Pass plugin objects you imported in `plugins`, after the built-ins and in that order: the plugin store is not read, so `plugin add` never changes a running host. `host.plugins` is the inventory `agent-device daemon status` shows, so a plugin that failed to register is visible. `close()` reports what it could not finish; repeated and concurrent calls share one close, and closing after `agent-device daemon stop` is not an error. The object works with `await using`, and `close()` never exits your process. + +The runtime stays alive until you close it or send `agent-device daemon stop --state-dir `; idle reaping is disabled. `daemon stop --clean` is refused because cleanup must stay within this runtime. If the shutdown report lists abandoned requests or unfinished cleanup, inspect those outcomes before reusing the state directory: the runtime may retain its registration to protect work still in progress. + +`host.clientConfig` is serializable JSON: pass it to another process or worker and build an attach client from it. It pins that client to exactly one daemon instance, and a state directory whose daemon is any other instance is refused rather than attached to: + +```ts +import { createAgentDeviceClient } from 'agent-device'; +import { startAgentDevice } from 'agent-device/host'; + +const host = await startAgentDevice({ stateDir: './.my-app/agent-device-state' }); +// Hand the config to a worker or another process: it is plain JSON, no build step. +const client = createAgentDeviceClient(host.clientConfig); +``` + +A client can also pin one instance on its own with `daemonBinding: { token, pid, processStartTime }`. Every request then goes to that instance and authenticates with its token. The client never starts, replaces, retires or redirects a daemon to satisfy the pin, so a state directory that registers any other instance is refused with `details.reason: 'daemon-binding-mismatch'`, and so is one that registers none or holds a record it cannot read as an address. The pin holds the daemon's token, so treat it as a credential: share it only with trusted processes that should control this runtime. + ## Device cloud sessions You drive Limrun, BrowserStack, AWS Device Farm, and TestMu AI devices with the same typed client methods. Use the CLI `connect` flow to persist connection state locally. Pass provider settings in the client config when your Node.js integration already owns credentials and provider selectors. From 7c9a39e30376e7dcb03712eb7128d692e1d69872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 11:21:11 +0200 Subject: [PATCH 03/10] chore(gates): declare the public host process boundary Co-Authored-By: Claude Opus 5.5 --- scripts/layering/process-globals-policy.ts | 3 ++- scripts/layering/root-module-zones.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/layering/process-globals-policy.ts b/scripts/layering/process-globals-policy.ts index 036168bed5..e9986c86fb 100644 --- a/scripts/layering/process-globals-policy.ts +++ b/scripts/layering/process-globals-policy.ts @@ -11,6 +11,7 @@ const PROCESS_ENTRIES = new Set([ 'src/cli.ts', 'src/daemon.ts', 'src/agent-device-client.ts', + 'src/host.ts', 'packages/capture-kit/src/png-worker.ts', 'packages/react-native/src/companion/companion-tunnel.ts', 'packages/react-native/src/companion/client-companion-tunnel-worker.ts', @@ -24,7 +25,7 @@ const PROCESS_ENTRIES = new Set([ 'src/daemon-client/daemon-client-ensuring.ts', ]); // src/cli/** and scripts/** are entry trees. Apple native helper executables live outside the -// TypeScript source trees. Reserve agent-device/host's entry here when #3757 supplies its path. +// TypeScript source trees. /** Only command execution and spawn adapters may consult the ambient command-host scope. */ const COMMAND_HOST_BOUNDARIES = new Set([ diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index 630cc40d5d..e0918f4c7b 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -18,7 +18,7 @@ * `src/daemon-client/` root — keep reading the path, not this declaration. */ export const ROOT_MODULE_ZONES: Readonly> = { - '(root)': ['src/bin.ts', 'src/cli.ts', 'src/daemon.ts'], + '(root)': ['src/bin.ts', 'src/cli.ts', 'src/daemon.ts', 'src/host.ts'], // Imported by core's interactor resolution, so they sit at core's rank. core: ['src/platform-runtime-android-adb-host.ts', 'src/provider-device-runtime.ts'], // The in-process command runtime: the local artifact-adapter implementation and the assembly From 5c76bd5f38c77e249ee6acff7a8fca23d9e617da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 11:24:33 +0200 Subject: [PATCH 04/10] test(host): cover startup rollback and instance replacement races Use distinct host working directories and exercise disposal through the packed consumer. Keep the subprocess source with its integration test. Co-Authored-By: Claude Opus 5.5 --- .changeset/host-entry.md | 2 +- .../daemon-client-embedded-lifecycle.test.ts | 57 ++++- test/integration/fixtures/host-consumer.mjs | 175 --------------- .../installed-package-metro.test.ts | 204 +++++++++++++++++- 4 files changed, 253 insertions(+), 185 deletions(-) delete mode 100644 test/integration/fixtures/host-consumer.mjs diff --git a/.changeset/host-entry.md b/.changeset/host-entry.md index 2bf9183186..5b2348da1d 100644 --- a/.changeset/host-entry.md +++ b/.changeset/host-entry.md @@ -1,5 +1,5 @@ --- -'agent-device': minor +'agent-device': patch --- Add `agent-device/host` with `startAgentDevice()` to own a daemon inside a Node.js process. Its client configuration pins attaching processes to that runtime, and `close()` returns the shutdown report. diff --git a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts index 87cb7c3e15..d50d9f15de 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts @@ -12,6 +12,7 @@ import { skipWhenLoopbackUnavailable, listenOnLoopback, } from '@agent-device/test-kit'; +import * as metadata from '../daemon-client-metadata.ts'; import type { DaemonBinding } from '@agent-device/contracts/client'; import { DAEMON_LIFECYCLE_REASONS } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; @@ -73,7 +74,7 @@ async function refusalFrom(promise: Promise): Promise { } /** A socket stand-in that keeps every request line it was sent, token included. */ -async function startRecordingSocketDaemon() { +async function startRecordingSocketDaemon(expectedToken?: () => string) { const requests: Record[] = []; const server = net.createServer((socket) => { socket.setEncoding('utf8'); @@ -82,8 +83,13 @@ async function startRecordingSocketDaemon() { socket.on('data', (chunk) => { buffered += chunk; if (!buffered.includes('\n')) return; - requests.push(JSON.parse(buffered) as Record); - socket.write(`${JSON.stringify({ ok: true, data: { devices: [] } })}\n`); + const request = JSON.parse(buffered) as Record; + requests.push(request); + const response = + expectedToken && request.token !== expectedToken() + ? { ok: false, error: { code: 'UNAUTHORIZED', message: 'Invalid daemon token' } } + : { ok: true, data: { devices: [] } }; + socket.write(`${JSON.stringify(response)}\n`); }); }); return { server, port: await listenOnLoopback(server), requests }; @@ -418,3 +424,48 @@ test('a pinned client keeps its hands off the state directory it was given', asy assert.deepEqual(settings.daemonBinding, pin); assert.equal(settings.paths.baseDir, paths.baseDir); }); + +test('replacement after binding lookup cannot authenticate a pinned request with the new token', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const { stateDir, paths } = stateDirAndPaths(); + let token = 'local-secret'; + const daemon = await startRecordingSocketDaemon(() => token); + const { info } = await registerDaemon(paths, { socketPort: daemon.port, ownership: 'embedded' }); + token = info.token; + const readInfo = metadata.daemonInfoFromRecord; + const lookup = vi.spyOn(metadata, 'daemonInfoFromRecord').mockImplementation((record) => { + const observed = readInfo(record); + // The replacement reuses the endpoint and process lifetime after this lookup. Its auth token + // changes, so a request can only succeed here if the client incorrectly adopts the new token. + token = 'replacement-token'; + const replaced = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')); + fs.writeFileSync(paths.infoPath, JSON.stringify({ ...replaced, token })); + return observed; + }); + try { + const response = await createDaemonTransport(createTestHostEnvironment())( + { + session: 'default', + command: 'devices', + positionals: [], + flags: { stateDir, daemonTransport: 'socket' }, + }, + { daemonBinding: pinTo(info) }, + ); + assert.equal(lookup.mock.calls.length, 1); + assert.deepEqual(response, { + ok: false, + error: { code: 'UNAUTHORIZED', message: 'Invalid daemon token' }, + }); + assert.equal( + daemon.requests.length, + 1, + 'authentication failure never retries against a replacement', + ); + assert.equal(daemon.requests[0]?.token, info.token); + assert.equal(spawn.mock.calls.length, 0); + } finally { + lookup.mockRestore(); + await closeLoopbackServer(daemon.server); + } +}); diff --git a/test/integration/fixtures/host-consumer.mjs b/test/integration/fixtures/host-consumer.mjs deleted file mode 100644 index 29298c0574..0000000000 --- a/test/integration/fixtures/host-consumer.mjs +++ /dev/null @@ -1,175 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { createAgentDeviceClient } from 'agent-device'; -import { startAgentDevice } from 'agent-device/host'; - -const exec = promisify(execFile); -const cwd = process.cwd(); -const beforeEnv = { ...process.env }; -const listeners = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection']; -const beforeListeners = listeners.map((event) => process.listeners(event)); -const disposed = []; -const setups = []; -function fixture(provider) { - return { - apiVersion: 2, - provider, - credentials: { variables: [] }, - setup: (host) => { - setups.push([provider, host.env.HOST_MARKER]); - return { - runtime: { - provider, - leaseLifecycle: {}, - ownsDevice: () => false, - getInteractor: () => undefined, - deviceInventoryProvider: () => ({ listDevices: async () => [] }), - shutdown: async () => { - disposed.push(`${provider}:shutdown`); - }, - }, - platformModule: { - owner: { kind: 'provider-runtime', provider, instance: provider }, - loadRuntime: async () => ({}), - }, - dispose: () => { - disposed.push(`${provider}:dispose`); - }, - }; - }, - }; -} -fs.mkdirSync(path.join(cwd, '.agent-device'), { recursive: true }); -fs.writeFileSync(path.join(cwd, '.agent-device/config.json'), '{broken plugin store'); -const supplied = { - cwd, - env: { - HOME: cwd, - PATH: process.env.PATH, - HOST_MARKER: 'A', - AGENT_DEVICE_DAEMON_BASE_URL: 'http://127.0.0.1:9', - }, -}; -const a = await startAgentDevice({ - stateDir: 'host-a', - host: supplied, - plugins: [ - fixture('fixture-a'), - fixture('limrun'), - { - ...fixture('broken'), - setup() { - throw new Error('fixture setup failure'); - }, - }, - ], -}); -const b = await startAgentDevice({ - stateDir: 'host-b', - host: { cwd, env: { ...supplied.env, HOST_MARKER: 'B' } }, - plugins: [fixture('fixture-b')], -}); -try { - supplied.env.HOST_MARKER = 'mutated'; - assert.deepEqual(await a.client.sessions.list(), []); - assert.deepEqual(await b.client.sessions.list(), []); - assert.deepEqual(setups, [ - ['fixture-a', 'A'], - ['fixture-b', 'B'], - ]); - assert.equal(a.plugins.find((p) => p.provider === 'fixture-a').state, 'active'); - assert.equal( - a.plugins.filter((p) => p.provider === 'limrun' && p.source === 'embedder')[0].state, - 'failed', - ); - assert.equal(a.plugins.find((p) => p.provider === 'broken').state, 'failed'); - assert.equal(process.cwd(), cwd); - assert.deepEqual({ ...process.env }, beforeEnv); - assert.deepEqual( - listeners.map((event) => process.listeners(event)), - beforeListeners, - ); - const config = JSON.stringify(a.clientConfig); - const child = await exec( - process.execPath, - [ - '--input-type=module', - '-e', - `import { createAgentDeviceClient } from 'agent-device'; console.log(JSON.stringify(await createAgentDeviceClient(${config}).sessions.list()));`, - ], - { cwd }, - ); - assert.deepEqual(JSON.parse(child.stdout), []); - const cli = path.join(cwd, 'node_modules/agent-device/bin/agent-device.mjs'); - await assert.rejects( - exec( - process.execPath, - [cli, 'daemon', 'stop', '--clean', '--state-dir', a.clientConfig.stateDir, '--json'], - { cwd }, - ), - (error) => { - assert.equal(JSON.parse(error.stdout).error.details.reason, 'daemon-clean-not-detached'); - return true; - }, - ); - assert.deepEqual(await a.client.sessions.list(), []); - const stopped = await exec( - process.execPath, - [cli, 'daemon', 'stop', '--state-dir', a.clientConfig.stateDir, '--json'], - { cwd }, - ); - assert.equal(JSON.parse(stopped.stdout).data.stopped, true); - const [first, second, disposal] = await Promise.all([ - a.close(), - a.close(), - a[Symbol.asyncDispose](), - ]); - assert.strictEqual(first, second); - assert.equal(disposal, undefined); - assert.deepEqual(first.requests, { drained: 0, abandoned: 0 }); - assert.deepEqual(disposed, ['fixture-a:shutdown', 'fixture-a:dispose']); - assert.deepEqual(await b.client.sessions.list(), []); - const replacement = await startAgentDevice({ - stateDir: a.clientConfig.stateDir, - host: { cwd, env: supplied.env }, - }); - try { - await assert.rejects( - createAgentDeviceClient(a.clientConfig).sessions.list(), - (error) => error.details?.reason === 'daemon-binding-mismatch', - ); - const wrongToken = createAgentDeviceClient({ - ...replacement.clientConfig, - daemonBinding: { ...replacement.clientConfig.daemonBinding, token: 'wrong-token' }, - }); - await assert.rejects( - wrongToken.sessions.list(), - (error) => error.details?.reason === 'daemon-binding-mismatch', - ); - } finally { - await replacement.close(); - } - console.log( - JSON.stringify({ - attachedFromChild: true, - cleanRefused: true, - externalStop: true, - siblingPreserved: true, - replacementRefused: true, - report: first, - }), - ); -} finally { - await a.close(); - await b.close(); -} -assert.deepEqual(disposed, [ - 'fixture-a:shutdown', - 'fixture-a:dispose', - 'fixture-b:shutdown', - 'fixture-b:dispose', -]); -assert.equal(fs.existsSync(path.join(cwd, 'host-a', 'daemon.json')), false); diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index 7ffc672ab5..0cfd70d234 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -301,13 +301,205 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi ); } await typeCheckInstalledPluginContract(consumerRoot); - const hostConsumer = path.join(consumerRoot, 'host-consumer.mjs'); - fs.copyFileSync( - path.join(repoRoot, 'test/integration/fixtures/host-consumer.mjs'), - hostConsumer, - ); const hosted = readJson( - await execFileText(process.execPath, [hostConsumer], { cwd: consumerRoot }), + await execFileText( + process.execPath, + [ + '--input-type=module', + '-e', + String.raw` +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { createAgentDeviceClient } from 'agent-device'; +import { startAgentDevice } from 'agent-device/host'; + +const exec = promisify(execFile); +const cwd = process.cwd(); +const beforeEnv = { ...process.env }; +const listeners = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection']; +const beforeListeners = listeners.map((event) => process.listeners(event)); +const disposed = []; +const setups = []; +function fixture(provider) { + return { + apiVersion: 2, + provider, + credentials: { variables: [] }, + setup: (host) => { + setups.push([provider, host.env.HOST_MARKER]); + return { + runtime: { + provider, + leaseLifecycle: {}, + ownsDevice: () => false, + getInteractor: () => undefined, + deviceInventoryProvider: () => ({ listDevices: async () => [] }), + shutdown: async () => { + disposed.push(provider + ':shutdown'); + }, + }, + platformModule: { + owner: { kind: 'provider-runtime', provider, instance: provider }, + loadRuntime: async () => ({}), + }, + dispose: () => { + disposed.push(provider + ':dispose'); + }, + }; + }, + }; +} +fs.mkdirSync(path.join(cwd, '.agent-device'), { recursive: true }); +fs.writeFileSync(path.join(cwd, '.agent-device/config.json'), '{broken plugin store'); +const supplied = { + cwd, + env: { + HOME: cwd, + PATH: process.env.PATH, + HOST_MARKER: 'A', + AGENT_DEVICE_DAEMON_BASE_URL: 'http://127.0.0.1:9', + }, +}; +const a = await startAgentDevice({ + stateDir: 'host-a', + host: supplied, + plugins: [ + fixture('fixture-a'), + fixture('limrun'), + { + ...fixture('broken'), + setup() { + throw new Error('fixture setup failure'); + }, + }, + ], +}); +const bCwd = path.join(cwd, 'b-cwd'); +fs.mkdirSync(bCwd); +const b = await startAgentDevice({ + log() { throw new Error('sink failed'); }, + stateDir: 'host-b', + host: { cwd: bCwd, env: { ...supplied.env, HOST_MARKER: 'B' } }, + plugins: [fixture('fixture-b')], +}); +try { + supplied.env.HOST_MARKER = 'mutated'; + assert.deepEqual(await a.client.sessions.list(), []); + assert.deepEqual(await b.client.sessions.list(), []); + assert.equal(b.clientConfig.cwd, bCwd); + assert.deepEqual(setups, [ + ['fixture-a', 'A'], + ['fixture-b', 'B'], + ]); + assert.equal(a.plugins.find((p) => p.provider === 'fixture-a').state, 'active'); + assert.equal( + a.plugins.filter((p) => p.provider === 'limrun' && p.source === 'embedder')[0].state, + 'failed', + ); + assert.equal(a.plugins.find((p) => p.provider === 'broken').state, 'failed'); + assert.equal(process.cwd(), cwd); + assert.deepEqual({ ...process.env }, beforeEnv); + assert.deepEqual( + listeners.map((event) => process.listeners(event)), + beforeListeners, + ); + await assert.rejects(startAgentDevice({ stateDir: a.clientConfig.stateDir, host: supplied, plugins: [fixture('rollback')] }), (error) => error.details?.reason === 'state-dir-in-use'); + assert.deepEqual(disposed.splice(0), ['rollback:shutdown', 'rollback:dispose']); + const unprovenDir = path.join(cwd, 'unproven'); + fs.mkdirSync(unprovenDir); + fs.writeFileSync(path.join(unprovenDir, 'daemon.lock'), 'legacy lock'); + await assert.rejects(startAgentDevice({ stateDir: unprovenDir, host: supplied, plugins: [fixture('unproven')] }), (error) => error.details?.reason === 'state-dir-ownership-unproven'); + assert.equal(fs.readFileSync(path.join(unprovenDir, 'daemon.lock'), 'utf8'), 'legacy lock'); + assert.deepEqual(disposed.splice(0), ['unproven:shutdown', 'unproven:dispose']); + const config = JSON.stringify(a.clientConfig); + const child = await exec( + process.execPath, + [ + '--input-type=module', + '-e', + "import { createAgentDeviceClient } from 'agent-device'; console.log(JSON.stringify(await createAgentDeviceClient(" + config + ").sessions.list()));", + ], + { cwd }, + ); + assert.deepEqual(JSON.parse(child.stdout), []); + const cli = path.join(cwd, 'node_modules/agent-device/bin/agent-device.mjs'); + await assert.rejects( + exec( + process.execPath, + [cli, 'daemon', 'stop', '--clean', '--state-dir', a.clientConfig.stateDir, '--json'], + { cwd }, + ), + (error) => { + assert.equal(JSON.parse(error.stdout).error.details.reason, 'daemon-clean-not-detached'); + return true; + }, + ); + assert.deepEqual(await a.client.sessions.list(), []); + const stopped = await exec( + process.execPath, + [cli, 'daemon', 'stop', '--state-dir', a.clientConfig.stateDir, '--json'], + { cwd }, + ); + assert.equal(JSON.parse(stopped.stdout).data.stopped, true); + const [first, second, disposal] = await Promise.all([ + a.close(), + a.close(), + a[Symbol.asyncDispose](), + ]); + assert.strictEqual(first, second); + assert.equal(disposal, undefined); + assert.deepEqual(first.requests, { drained: 0, abandoned: 0 }); + assert.deepEqual(disposed, ['fixture-a:shutdown', 'fixture-a:dispose']); + assert.deepEqual(await b.client.sessions.list(), []); + const replacement = await startAgentDevice({ + stateDir: a.clientConfig.stateDir, + host: { cwd, env: supplied.env }, + }); + try { + await assert.rejects( + createAgentDeviceClient(a.clientConfig).sessions.list(), + (error) => error.details?.reason === 'daemon-binding-mismatch', + ); + const wrongToken = createAgentDeviceClient({ + ...replacement.clientConfig, + daemonBinding: { ...replacement.clientConfig.daemonBinding, token: 'wrong-token' }, + }); + await assert.rejects( + wrongToken.sessions.list(), + (error) => error.details?.reason === 'daemon-binding-mismatch', + ); + } finally { + await replacement.close(); + } + console.log( + JSON.stringify({ + attachedFromChild: true, + cleanRefused: true, + externalStop: true, + siblingPreserved: true, + replacementRefused: true, + report: first, + }), + ); +} finally { + await a.close(); + await b[Symbol.asyncDispose](); +} +assert.deepEqual(disposed, [ + 'fixture-a:shutdown', + 'fixture-a:dispose', + 'fixture-b:shutdown', + 'fixture-b:dispose', +]); +assert.equal(fs.existsSync(path.join(cwd, 'host-a', 'daemon.json')), false); + +`, + ], + { cwd: consumerRoot }, + ), ); assert.equal(hosted.attachedFromChild, true); assert.equal(hosted.replacementRefused, true); From 7c2dfc79fc9fe38ca318b78be2c59eb2d72e4de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 11:24:33 +0200 Subject: [PATCH 05/10] chore(gates): verify host entry process capture is permitted Co-Authored-By: Claude Opus 5.5 --- scripts/layering/process-globals-policy.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/layering/process-globals-policy.test.ts b/scripts/layering/process-globals-policy.test.ts index 5441ab405f..a7fae0bacf 100644 --- a/scripts/layering/process-globals-policy.test.ts +++ b/scripts/layering/process-globals-policy.test.ts @@ -54,6 +54,7 @@ test('entry declarations permit process reads but do not permit ambient command- 'src/cli/commands/example.ts', 'src/daemon.ts', 'src/agent-device-client.ts', + 'src/host.ts', 'packages/capture-kit/src/png-worker.ts', 'packages/maestro/src/daemon-port/run-script-http-child.ts', 'packages/react-native/src/companion/client-companion-tunnel-worker.ts', From d7ece9964d8b07e5e929671977729432fffb3b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 11:35:57 +0200 Subject: [PATCH 06/10] test(host): import the packed consumer plugin as a standalone module Co-Authored-By: Claude Opus 5.5 --- .../installed-package-metro.test.ts | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index 0cfd70d234..f1d469920d 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -301,29 +301,12 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi ); } await typeCheckInstalledPluginContract(consumerRoot); - const hosted = readJson( - await execFileText( - process.execPath, - [ - '--input-type=module', - '-e', - String.raw` -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { createAgentDeviceClient } from 'agent-device'; -import { startAgentDevice } from 'agent-device/host'; - -const exec = promisify(execFile); -const cwd = process.cwd(); -const beforeEnv = { ...process.env }; -const listeners = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection']; -const beforeListeners = listeners.map((event) => process.listeners(event)); -const disposed = []; -const setups = []; -function fixture(provider) { + fs.writeFileSync( + path.join(consumerRoot, 'fixture-plugin.mjs'), + String.raw` +export const disposed = []; +export const setups = []; +export function fixture(provider) { return { apiVersion: 2, provider, @@ -352,6 +335,29 @@ function fixture(provider) { }, }; } +`, + ); + const hosted = readJson( + await execFileText( + process.execPath, + [ + '--input-type=module', + '-e', + String.raw` +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { createAgentDeviceClient } from 'agent-device'; +import { startAgentDevice } from 'agent-device/host'; + +const exec = promisify(execFile); +const cwd = process.cwd(); +const beforeEnv = { ...process.env }; +const listeners = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection']; +const beforeListeners = listeners.map((event) => process.listeners(event)); +import { fixture, disposed, setups } from './fixture-plugin.mjs'; fs.mkdirSync(path.join(cwd, '.agent-device'), { recursive: true }); fs.writeFileSync(path.join(cwd, '.agent-device/config.json'), '{broken plugin store'); const supplied = { From c59deb41049a4fe52359702e3e7fbeb3e730e0db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 12:34:30 +0200 Subject: [PATCH 07/10] test(ios): wait for the automation sheet control before clicking Co-Authored-By: Claude Opus 5.5 --- test/integration/ios-simulator-e2e/live-automation-scenario.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/ios-simulator-e2e/live-automation-scenario.ts b/test/integration/ios-simulator-e2e/live-automation-scenario.ts index f5bd7992fc..482ea10a18 100644 --- a/test/integration/ios-simulator-e2e/live-automation-scenario.ts +++ b/test/integration/ios-simulator-e2e/live-automation-scenario.ts @@ -9,6 +9,7 @@ import { assertElementText, assertElementTextAfterScrolling, assertJsonContains, + assertWaitSelector, assertWaitText, } from './live-assertions.ts'; import { waitForDeepLinkDestination } from './live-deep-link-destination.ts'; @@ -90,6 +91,7 @@ export async function assertAutomationInput(context: LiveContext): Promise await assertWaitText(context, 'Automation lab'); verifyCommand(context, C.click, 'selector click opens the automation route'); + await assertWaitSelector(context, 'id="automation-open-sheet" visible=true'); await runStep(context, 'open fixture sheet', ['click', 'id="automation-open-sheet"']); await assertWaitText(context, 'Automation sheet'); await runStep(context, 'close fixture sheet', ['click', 'id="automation-close-sheet"']); From fa67e0d9bead934e6ac8968e8200c766e6462fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 13:41:29 +0200 Subject: [PATCH 08/10] feat(sdk): expose lazy in-process host from main entry Route pinned clients through attach compatibility before checking their instance pin. Publish the embedded runtime's package code identity rather than the host application's entry identity. Co-Authored-By: Claude Opus 5.5 --- .changeset/host-entry.md | 2 +- docs/adr/0034-provider-plugin-registry.md | 11 +- examples/sdk/host-runtime.ts | 6 +- examples/sdk/tsconfig.json | 1 - package.json | 4 - .../daemon/src/server/http/daemon-runtime.ts | 32 ++- .../daemon-client-embedded-lifecycle.test.ts | 47 ++-- src/daemon-client/daemon-client-attach.ts | 68 ++--- src/daemon-client/daemon-client-binding.ts | 67 +---- src/daemon-client/daemon-client-lifecycle.ts | 4 +- src/host.test.ts | 87 ++++--- src/host.ts | 245 +++--------------- src/sdk/host-types.ts | 23 ++ src/sdk/index.ts | 9 + .../installed-package-metro.test.ts | 41 +-- test/repo-guards/package-exports.test.ts | 3 +- tsdown.config.ts | 1 - website/docs/docs/client-api.md | 17 +- 18 files changed, 234 insertions(+), 434 deletions(-) create mode 100644 src/sdk/host-types.ts diff --git a/.changeset/host-entry.md b/.changeset/host-entry.md index 5b2348da1d..08609656a8 100644 --- a/.changeset/host-entry.md +++ b/.changeset/host-entry.md @@ -2,4 +2,4 @@ 'agent-device': patch --- -Add `agent-device/host` with `startAgentDevice()` to own a daemon inside a Node.js process. Its client configuration pins attaching processes to that runtime, and `close()` returns the shutdown report. +Add `startAgentDevice()` to the main `agent-device` entry to own a daemon inside a Node.js process. Its client configuration pins attaching processes to that runtime, and `close()` returns the shutdown report. diff --git a/docs/adr/0034-provider-plugin-registry.md b/docs/adr/0034-provider-plugin-registry.md index 5e1f971ce4..e922901439 100644 --- a/docs/adr/0034-provider-plugin-registry.md +++ b/docs/adr/0034-provider-plugin-registry.md @@ -7,7 +7,7 @@ Status: accepted. Umbrella #3747; contract and registry #3755. - One contract. A provider plugin is a factory that returns `{ apiVersion: 2, provider, credentials, connection?, setup(host) }`. `setup` returns `{ runtime, platformModule, dispose? }` or `{ webDriver, dispose? }`. A `{ webDriver }` result stays a data descriptor the host interprets. -- One registry. Sources compose in the order built-in, store, embedder; the embedder slot arrives with #3757. Built-in providers use the +- One registry. Composition selects built-in, store, or embedder sources. Built-in providers use the same shape as an installed plugin, and one function validates what every factory returns. - Plugins import only types from agent-device. The type-only contract is `agent-device/plugins`; there is no runtime helper, and the contract does not depend on a plugin's module URL. @@ -58,10 +58,11 @@ had to fake the store to load a plugin from its own dependencies (#3747). ## Daemon ownership -The target is that starting or replacing a daemon is an explicit act, not a side effect of a client -call. Today a client call can still start a daemon; #3754 makes that explicit for the client -lifecycle, and #3757 adds `agent-device/host`, where an embedder owns its daemon and passes its -plugins as the third source. +Clients retain managed and attach lifecycles. Managed clients may start a daemon; attach clients cannot start or replace one. An embedder calls `startAgentDevice({ stateDir, plugins?, host? })` from `agent-device` to own a runtime in its process. The main entry loads the runtime when called. The returned client and serializable `clientConfig` identify that exact instance. Attaching checks normal version and code compatibility before checking the instance pin. `close()` shuts the runtime down. The CLI cannot signal or replace an embedder-owned runtime. + +The embedder supplies plugin objects directly. They follow the built-ins through the same registry validation; this runtime does not read the plugin store. Plugin failures remain inventory data. Logging adapters, a separate package entry, and disposal protocols are not required to own a daemon: the library exposes the client, its attach configuration, and `close()`. + +Decision history: the former explicit-start target is dropped; the client lifecycle continues to choose startup behavior. The main-entry API replaces the proposed host subpath and optional host controls, keeping ownership and attachment on the existing runtime and client paths (#3757). ## Alternatives refused diff --git a/examples/sdk/host-runtime.ts b/examples/sdk/host-runtime.ts index a138e14be7..898261b89b 100644 --- a/examples/sdk/host-runtime.ts +++ b/examples/sdk/host-runtime.ts @@ -2,7 +2,7 @@ * Host-owned runtime: start the daemon inside this process, drive it through the * client it hands back, and end it with one `close()`. * - * Demonstrates: `startAgentDevice` from `agent-device/host`. + * Demonstrates: `startAgentDevice` from `agent-device`. * * Prerequisites: none — the runtime is this process's own, so no daemon needs to * be running and no device is touched here. `sessions.list()` asks that runtime @@ -10,18 +10,16 @@ * * Run: node --experimental-strip-types examples/sdk/host-runtime.ts */ -import { startAgentDevice } from 'agent-device/host'; +import { startAgentDevice } from 'agent-device'; const host = await startAgentDevice({ // Required: a host claims a directory of its own rather than attaching to // whatever an ambient AGENT_DEVICE_STATE_DIR happens to name. stateDir: './.host-runtime-state', - // `log` receives the runtime's stdout and stderr lines; omitted keeps them silent. }); try { console.log(`state dir: ${host.clientConfig.stateDir}`); - console.log(`providers: ${host.plugins.map((plugin) => plugin.provider).join(', ')}`); // The pin names exactly this instance — token, pid and process start time — so a // second process handed this config reaches this runtime and refuses anything // that later replaced it. It carries a bearer token: keep it inside this host. diff --git a/examples/sdk/tsconfig.json b/examples/sdk/tsconfig.json index 78ee783a20..d395b543db 100644 --- a/examples/sdk/tsconfig.json +++ b/examples/sdk/tsconfig.json @@ -6,7 +6,6 @@ "tsBuildInfoFile": "../../.tmp/examples-sdk.tsbuildinfo", "paths": { "agent-device": ["../../src/sdk/index.ts"], - "agent-device/host": ["../../src/host.ts"], "agent-device/io": ["../../src/sdk/io.ts"], "agent-device/artifacts": ["../../src/sdk/artifacts.ts"], "agent-device/metro": ["../../src/sdk/metro.ts"], diff --git a/package.json b/package.json index e2fa63ac7a..41f384ed90 100644 --- a/package.json +++ b/package.json @@ -70,10 +70,6 @@ "types": "./dist/src/ai-sdk.d.ts", "import": "./dist/src/ai-sdk.js" }, - "./host": { - "types": "./dist/src/host.d.ts", - "import": "./dist/src/host.js" - }, "./plugins": { "types": "./dist/src/plugins.d.ts", "import": "./dist/src/plugins.js" diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index ca5feb6074..9d23cf56e7 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -79,6 +79,7 @@ import { readCurrentOwnerIdentity, reapOwnedProcessRecordsAtStartup, type OwnedProcessRecordStore, + type OwnerIdentity, } from '@agent-device/host-kit/process'; import { isEnvTruthy, sleep } from '@agent-device/host-kit/retry'; import { @@ -227,9 +228,13 @@ export type DaemonRuntimeOptions = { * record it cannot place, and a runtime always knows which of these two it is. */ ownership?: DaemonRuntimeOwnership; + /** The daemon code an embedder loaded; its process entry belongs to the host application. */ + codeIdentity?: { origin: 'installed' | 'checkout'; codeSignature: string }; }; export type DaemonRuntimeController = { + /** The runtime process lifetime used in its registration and lifecycle protocol. */ + identity: Readonly; httpPort?: number; socketPort?: number; /** @@ -242,11 +247,6 @@ export type DaemonRuntimeController = { /** Runs `fn` as this runtime, for code that serves it from outside its own callbacks. */ run: (fn: () => T) => T; token: string; - /** - * Every plugin this runtime saw, active or failed. A host's caller reads this to learn that a plugin - * it asked for did not register; the daemon publishes the same list in its record. - */ - plugins: readonly PluginInventoryEntry[]; }; /** @@ -267,6 +267,13 @@ export function daemonStartupExitCode(error: unknown): number { export async function startDaemonRuntime( options: DaemonRuntimeOptions, ): Promise { + const runtimeOptions = { + ...options, + codeIdentity: options.codeIdentity ?? { + origin: resolveDaemonCodeOrigin(), + codeSignature: resolveDaemonCodeSignature(), + }, + }; const runtimeState = createRuntimeStateHandle(); return await runtimeState.run( async () => @@ -274,7 +281,7 @@ export async function startDaemonRuntime( const rollback = createStartupRollback(); rollback.onRollback(() => runtimeState.dispose()); try { - return await startDaemonRuntimeOnHost(options, rollback, runtimeState); + return await startDaemonRuntimeOnHost(runtimeOptions, rollback, runtimeState); } catch (error) { await rollback.run(); throw error; @@ -284,7 +291,9 @@ export async function startDaemonRuntime( } async function startDaemonRuntimeOnHost( - options: DaemonRuntimeOptions, + options: DaemonRuntimeOptions & { + codeIdentity: NonNullable; + }, rollback: StartupRollback, runtimeState: RuntimeStateHandle, ): Promise { @@ -333,8 +342,7 @@ async function startDaemonRuntimeOnHost( const version = readVersion(); const token = crypto.randomBytes(24).toString('hex'); const daemonIdentity = readCurrentOwnerIdentity(); - const daemonCodeOrigin = resolveDaemonCodeOrigin(); - const daemonCodeSignature = resolveDaemonCodeSignature(); + const { codeIdentity } = options; const requestDrainTimeoutMs = options.shutdownBudgets?.requestDrainMs ?? DAEMON_REQUEST_DRAIN_TIMEOUT_MS; // The registry is the one plugin cleanup owner: closing it shuts each runtime down, then disposes @@ -591,8 +599,8 @@ async function startDaemonRuntimeOnHost( httpPort, token, version, - codeOrigin: daemonCodeOrigin, - codeSignature: daemonCodeSignature, + codeOrigin: codeIdentity.origin, + codeSignature: codeIdentity.codeSignature, policyDigest: daemonPolicy?.digest, capabilities: DAEMON_CAPABILITIES, plugins: deps.pluginInventory, @@ -957,6 +965,6 @@ async function startDaemonRuntimeOnHost( run: runtimeState.run, socketPort, token, - plugins: deps.pluginInventory, + identity: Object.freeze({ ...daemonIdentity }), }; } diff --git a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts index d50d9f15de..d2c021232f 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts @@ -12,7 +12,7 @@ import { skipWhenLoopbackUnavailable, listenOnLoopback, } from '@agent-device/test-kit'; -import * as metadata from '../daemon-client-metadata.ts'; +import * as startup from '../daemon-client-startup.ts'; import type { DaemonBinding } from '@agent-device/contracts/client'; import { DAEMON_LIFECYCLE_REASONS } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; @@ -247,13 +247,23 @@ test('timeout recovery asks only whether this client may, and attach never may', // daemon-registration-embedded-owner.test.ts pins at that seam for both modes. assert.equal(resolveTimeoutRecovery(managed), 'retire-unresponsive'); assert.equal(resolveTimeoutRecovery(attach), 'none'); - assert.equal( - resolveTimeoutRecovery({ - ...managed, - daemonBinding: { token: 'pin', pid: 4242, processStartTime: 'start' }, - }), - 'none', - ); +}); + +test('a matching pin does not bypass attach compatibility checks', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const { stateDir, daemon, info } = await startStaleRegistration('embedded'); + try { + const refusal = await refusalFrom( + createDaemonTransport(createTestHostEnvironment())(request(stateDir), { + daemonBinding: pinTo(info), + }), + ); + assert.equal(refusal.details?.reason, 'daemon-incompatible'); + assert.deepEqual(daemon.rpcCommands, []); + assert.equal(spawn.mock.calls.length, 0); + } finally { + await closeLoopbackServer(daemon.server); + } }); test('a pinned client dispatches to the instance it pins and starts nothing', async (t) => { @@ -294,7 +304,12 @@ test('a pinned client dispatches to the instance it pins and starts nothing', as test('a pinned client refuses an instance that is not the one it pins', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; - const { stateDir, paths, daemon, child, info } = await startStaleRegistration('embedded'); + const { stateDir, paths } = stateDirAndPaths(); + const daemon = await startScriptedDaemon(okResult({ devices: [] })); + const { child, info } = await registerDaemon(paths, { + httpPort: daemon.port, + ownership: 'embedded', + }); // A runtime that took the state directory since the pin was handed out: same place, other instance. const pin = { ...pinTo(info), token: 'a-different-instance' }; @@ -303,7 +318,6 @@ test('a pinned client refuses an instance that is not the one it pins', async (t const refusal = await refusalFrom(transport(request(stateDir), { daemonBinding: pin })); assert.equal(refusal.details?.reason, 'daemon-binding-mismatch'); - assert.equal(refusal.details?.mismatch, 'replaced'); // The refusal names the instance that is really there so an operator can tell which host to ask, // and never the credentials of either side. assert.equal((refusal.details as { registered: { pid: number } }).registered.pid, child.pid); @@ -333,7 +347,6 @@ test('a pinned client refuses a state directory that registers no daemon', async const refusal = await refusalFrom(transport(request(stateDir), { daemonBinding: pin })); assert.equal(refusal.details?.reason, 'daemon-binding-mismatch'); - assert.equal(refusal.details?.mismatch, 'absent'); assert.equal(daemon.requests.length, 0, 'an absent instance is not replaced by a live one'); assert.equal(spawn.mock.calls.length, 0, 'a pin names an instance, not a place to start one'); } finally { @@ -388,9 +401,7 @@ test('a client that pins a local daemon and names a base URL is refused where se } }); -// `absent` tells an operator the host stopped; this says something holds the state directory and the -// client cannot name it. Folding the two together would send someone to start a new host for a daemon -// that may be serving right now. +// An unreadable record cannot authorize a pinned client to dispatch or replace its owner. test('a pinned client refuses a record it cannot read as an address', async () => { const { stateDir, paths } = stateDirAndPaths(); const pin: DaemonBinding = { token: 'token', pid: 4242, processStartTime: 'start' }; @@ -402,7 +413,6 @@ test('a pinned client refuses a record it cannot read as an address', async () = ); assert.equal(refusal.details?.reason, 'daemon-binding-mismatch'); - assert.equal(refusal.details?.mismatch, 'unaddressable'); assert.match(String(refusal.details?.hint), /daemon\.json/); assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), '{not json', 'the refusal changed nothing'); }); @@ -420,6 +430,7 @@ test('a pinned client keeps its hands off the state directory it was given', asy ); // A private state directory would hold a daemon nobody started, and a pinned client starts none. + assert.equal(settings.daemonLifecycle, 'attach'); assert.equal(settings.ownedStateDir, undefined); assert.deepEqual(settings.daemonBinding, pin); assert.equal(settings.paths.baseDir, paths.baseDir); @@ -432,9 +443,9 @@ test('replacement after binding lookup cannot authenticate a pinned request with const daemon = await startRecordingSocketDaemon(() => token); const { info } = await registerDaemon(paths, { socketPort: daemon.port, ownership: 'embedded' }); token = info.token; - const readInfo = metadata.daemonInfoFromRecord; - const lookup = vi.spyOn(metadata, 'daemonInfoFromRecord').mockImplementation((record) => { - const observed = readInfo(record); + const observe = startup.observeLocalDaemon; + const lookup = vi.spyOn(startup, 'observeLocalDaemon').mockImplementation(async (...args) => { + const observed = await observe(...args); // The replacement reuses the endpoint and process lifetime after this lookup. Its auth token // changes, so a request can only succeed here if the client incorrectly adopts the new token. token = 'replacement-token'; diff --git a/src/daemon-client/daemon-client-attach.ts b/src/daemon-client/daemon-client-attach.ts index 50e9587f08..3091c298a1 100644 --- a/src/daemon-client/daemon-client-attach.ts +++ b/src/daemon-client/daemon-client-attach.ts @@ -1,43 +1,26 @@ import { ensureManagedDaemon } from './daemon-client-lifecycle.ts'; import { assertDaemonPolicyMatches, observeLocalDaemon } from './daemon-client-startup.ts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; -import type { DaemonBinding } from '@agent-device/contracts/client'; -import { readDaemonRecord } from '@agent-device/daemon-contracts/daemon-resolution'; -import { daemonBindingMismatchError, readPinnedDaemonRecord } from './daemon-client-binding.ts'; +import { daemonBindingMismatchError } from './daemon-client-binding.ts'; import { daemonIncompatibleError, daemonNotRunningError } from './daemon-lifecycle-errors.ts'; -/** - * Connects to the one daemon instance this client is pinned to, and to no other. It compares the - * state directory's record before dispatch and never starts, replaces, retires, or redirects: a pin - * is the host's answer to "which daemon", so any other instance is a refusal, not a candidate. - */ -async function attachBoundDaemon( - binding: DaemonBinding, - settings: DaemonClientSettings, -): Promise { - const pinned = readPinnedDaemonRecord(binding, readDaemonRecord(settings.paths.infoPath)); - if ('mismatch' in pinned) { - throw daemonBindingMismatchError(binding, pinned, settings.paths.baseDir); +/** Resolves a managed daemon, or observes and verifies the one an attach client may use. */ +export async function connectToDaemon(settings: DaemonClientSettings): Promise { + if (settings.remoteBaseUrl || settings.daemonLifecycle !== 'attach') { + return await ensureManagedDaemon(settings); } - // The record matched the pin, so it is the pinned instance. The request goes out with the pin's own - // token anyway: a replacement that lands after this read then fails authentication at the daemon - // rather than being served by a token this client never agreed to use. - return { info: { ...pinned.registered, token: binding.token }, startedByClient: false }; -} - -/** - * Connects to the local daemon registered in the state directory and nothing else: it never starts, - * replaces or kills one. What the registration says is judged by the same ladder a managed client - * uses, so a daemon an attach client accepts is one a managed client would also reuse. - */ -async function attachLocalDaemon(settings: DaemonClientSettings): Promise { const stateDir = settings.paths.baseDir; const observed = await observeLocalDaemon(settings, { refineUnreachable: true }); - if (!observed) throw daemonNotRunningError(stateDir); + if (!observed) { + if (settings.daemonBinding) { + throw daemonBindingMismatchError(settings.daemonBinding, null, stateDir); + } + throw daemonNotRunningError(stateDir); + } const { info, decision } = observed; switch (decision.kind) { case 'reuse': - return { info, startedByClient: false }; + break; case 'refuseNewer': throw await daemonIncompatibleError({ info, mismatch: 'newer', stateDir }); case 'replace': @@ -45,22 +28,15 @@ async function attachLocalDaemon(settings: DaemonClientSettings): Promise { - if (settings.remoteBaseUrl) { - return await ensureManagedDaemon(settings); - } const binding = settings.daemonBinding; - const attached = binding - ? await attachBoundDaemon(binding, settings) - : settings.daemonLifecycle === 'attach' - ? await attachLocalDaemon(settings) - : await ensureManagedDaemon(settings); - // A pin never reaches the remote branch: a client that pins an instance and names a base URL is - // refused where its settings are read. Checked on this branch too: attaching must not hand a - // policy-bound caller a daemon without it. - await assertDaemonPolicyMatches(attached.info, settings.paths.baseDir, settings.host); - return attached; + if ( + binding && + (info.token !== binding.token || + info.pid !== binding.pid || + info.processStartTime !== binding.processStartTime) + ) { + throw daemonBindingMismatchError(binding, info, stateDir); + } + await assertDaemonPolicyMatches(info, stateDir, settings.host); + return { info, startedByClient: false }; } diff --git a/src/daemon-client/daemon-client-binding.ts b/src/daemon-client/daemon-client-binding.ts index af875384e0..8a89d7bba9 100644 --- a/src/daemon-client/daemon-client-binding.ts +++ b/src/daemon-client/daemon-client-binding.ts @@ -1,80 +1,23 @@ import type { DaemonBinding } from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; -import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; -import { readDaemonRecord } from '@agent-device/daemon-contracts/daemon-resolution'; -import { daemonInfoFromRecord, type DaemonInfo } from './daemon-client-metadata.ts'; +import type { DaemonInfo } from './daemon-client-metadata.ts'; import { daemonIdentityOf } from './daemon-lifecycle-errors.ts'; -/** - * A client's pin checked against the daemon a state directory registers. - * - * A pin instructs the client to use one instance and no other, so a mismatch is a refusal and never a - * reason to start, replace, or redirect. `absent` says the state directory names no daemon at all, - * which is not a reason to start one either: the caller pinned an instance, not a place to look. - * `unaddressable` says a record is there and cannot be read as one, which is a different problem from - * an instance having gone away: something may well be running, and this client cannot name it. - */ -export type DaemonBindingMismatch = 'absent' | 'unaddressable' | 'replaced'; - -/** - * What the state directory's record means for a pin: either a refusal that names what it found, or the - * record itself — narrowed, so a caller that was not refused has the pinned instance in hand. - */ -export type PinnedDaemonRecord = - | Readonly<{ mismatch: DaemonBindingMismatch; registered: DaemonInfo | null }> - | Readonly<{ registered: DaemonInfo }>; - -export function readPinnedDaemonRecord( - binding: DaemonBinding, - record: ReturnType, -): PinnedDaemonRecord { - if (record.status === 'absent') return { mismatch: 'absent', registered: null }; - const registered = record.status === 'present' ? daemonInfoFromRecord(record.record) : null; - if (!registered) { - return { mismatch: 'unaddressable', registered: null }; - } - // Every field is compared, the token included: a replacement that somehow reused the pid and its - // start time is still a different runtime, and the token is what proves it. - if ( - registered.token !== binding.token || - registered.pid !== binding.pid || - registered.processStartTime !== binding.processStartTime - ) { - return { mismatch: 'replaced', registered }; - } - return { registered }; -} - -/** - * The refusal a pin produces when a state directory does not name the instance it pins. Nothing has - * been started, retired, or redirected by the time this is thrown, which is what lets the message - * state the situation as a fact. - */ +/** Refuses a pin without exposing either instance's credential. */ export function daemonBindingMismatchError( binding: DaemonBinding, - refusal: Extract, + registered: DaemonInfo | null, stateDir: string, ): AppError { - const { mismatch, registered } = refusal; return new AppError( 'COMMAND_FAILED', - mismatch === 'absent' - ? 'The daemon this client is bound to is no longer registered.' - : mismatch === 'unaddressable' - ? 'The record on this state directory cannot be read as a daemon record, so the instance this client is bound to cannot be confirmed.' - : 'A different daemon instance is registered on this state directory.', + 'The registered daemon does not match this client instance.', { reason: 'daemon-binding-mismatch', - mismatch, stateDir, - // The bound token is a credential and goes in a message an operator may paste, so the pin is - // named by its instance alone; a mismatch is a mismatch whatever the token says. bound: { pid: binding.pid, processStartTime: binding.processStartTime }, ...(registered ? { registered: daemonIdentityOf(registered) } : {}), - hint: - mismatch === 'unaddressable' - ? `Inspect ${shellQuoteIfNeeded(`${stateDir}/daemon.json`)}: a pin is refused against an unreadable record rather than answered with another instance.` - : 'Bind a client to what a running host returns. A client bound to a host that has stopped will not start or replace a daemon.', + hint: `Check ${stateDir}/daemon.json or obtain clientConfig from a running host. A pinned client never starts or replaces a daemon.`, }, ); } diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 14360566c1..dad095ea49 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -74,7 +74,7 @@ LOOPBACK_BLOCK_LIST.addSubnet('::ffff:127.0.0.0', 104, 'ipv6'); export function resolveTimeoutRecovery( settings: DaemonClientSettings, ): 'retire-unresponsive' | 'none' { - if (settings.daemonBinding || settings.daemonLifecycle === 'attach') return 'none'; + if (settings.daemonLifecycle === 'attach') return 'none'; return 'retire-unresponsive'; } @@ -101,12 +101,12 @@ export function resolveClientSettings( }, ); } + if (daemonBinding) daemonLifecycle = 'attach'; const transport = resolveTransportClientSettings(req.flags, remote.remoteBaseUrl, host.env); // An attach client owns no daemon, and a private state directory would hold one nobody started. A // pinned client owns one neither: it uses the instance it was given and starts nothing. const ownedStateDir = daemonLifecycle !== 'attach' && - !daemonBinding && shouldUseOwnedReplayStateDir(req, explicitStateDir, remote.rawBaseUrl) ? createOwnedReplayStateDir() : undefined; diff --git a/src/host.test.ts b/src/host.test.ts index 259f1dce8e..e76c4c9002 100644 --- a/src/host.test.ts +++ b/src/host.test.ts @@ -1,4 +1,4 @@ -// `agent-device/host`, end to end: an embedder starts the runtime inside its own process and gets a +// `startAgentDevice`, end to end: an embedder starts the runtime inside its own process and gets a // client, a config another process attaches with, and one close. The host here is the test process, // which is what makes the central assertion cheap and final: a code path that reached for a signal, or // for `process.exit`, would take the test runner down with it. @@ -9,6 +9,7 @@ import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolu import { readDaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import type { PluginCandidate } from '@agent-device/plugins/registry'; import { builtinPluginInventory } from './__tests__/test-utils/builtin-plugin-inventory.ts'; import { stopRecordedDaemon } from './daemon-client/daemon-client-stop.ts'; import { startAgentDevice } from './host.ts'; @@ -53,22 +54,27 @@ vi.mock('./provider-device-runtimes.ts', async (importOriginal) => { }, createEmbeddedProviderRuntimeComposition: async ( _env: unknown, - embedderCandidates: readonly unknown[], + embedderCandidates: readonly PluginCandidate[], ) => { composition.embedderCandidates = [...embedderCandidates]; + const { resolveProviderPlugins } = await import('@agent-device/plugins/registry'); + const resolved = await resolveProviderPlugins(embedderCandidates); return { runtimes: [], platformModules: [], shutdown: async () => [], - inventory: await ( - await import('./__tests__/test-utils/builtin-plugin-inventory.ts') - ).builtinPluginInventory(), + inventory: [ + ...(await ( + await import('./__tests__/test-utils/builtin-plugin-inventory.ts') + ).builtinPluginInventory()), + ...resolved.inventory(), + ], }; }, }; }); -import type { AgentDeviceHost } from './host.ts'; +import type { AgentDeviceHost } from './sdk/host-types.ts'; type Booted = { stateDir: string; @@ -161,7 +167,6 @@ test('a host runs its runtime on the socket its client is given, whatever the pr expect(record.transport).toBe('socket'); expect(typeof record.port).toBe('number'); expect(record.httpPort).toBeUndefined(); - expect(host.clientConfig.daemonServerMode).toBe('socket'); // The client of the host that started this runtime reaches it over that socket, which is the whole // promise. A record that names no socket makes this fail instead. await expect(host.client.sessions.list()).resolves.toBeInstanceOf(Array); @@ -180,44 +185,48 @@ test('a host registers the plugins its embedder imported and reports the invento throw new Error('not invoked by composition fixture'); }, }; - const { stateDir, host } = await bootHost({ plugins: [plugin] }); + const { stateDir, paths, host } = await bootHost({ plugins: [plugin] }); try { // The embedder's objects reach the composition as candidates the registry claims in order, and the // store is never consulted: a plugin somebody installed later cannot change this process. expect(composition.embedderCandidates).toEqual([ { source: 'embedder', load: expect.any(Function) }, ]); - expect(host.plugins).toEqual(await builtinPluginInventory()); + expect(JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).plugins).toEqual([ + ...(await builtinPluginInventory()), + expect.objectContaining({ provider: 'embedder-plugin', state: 'active', source: 'embedder' }), + ]); } finally { await host.close(); removeStateDir(stateDir); } }); -test('a plugin that is not a plugin is refused before anything starts', async () => { - const stateDir = mkdtempForTestSync('agent-device-host-entry-'); - try { - const refusal = await startAgentDevice({ - stateDir, - plugins: [ - // @ts-expect-error malformed JavaScript input - 'not-a-plugin', - ], - }).then( - () => null, - (error: unknown) => error as { code?: string; details?: { reason?: string } }, - ); - - expect(refusal).toMatchObject({ - code: 'INVALID_ARGS', - details: { reason: 'invalid_embedder_plugin' }, +test.each([ + { plugin: 'not-a-plugin', reason: 'invalid_plugin' }, + { plugin: { apiVersion: 1, provider: 'wrong-version' }, reason: 'incompatible_plugin' }, +])( + 'plugin validation reports $reason through the registry inventory', + async ({ plugin, reason }) => { + const { stateDir, paths, host } = await bootHost({ + // @ts-expect-error malformed JavaScript input + plugins: [plugin], }); - // Refused at the boundary the embedder can still fix: no state directory was claimed. - expect(fs.existsSync(resolveDaemonPaths(stateDir).infoPath)).toBe(false); - } finally { - removeStateDir(stateDir); - } -}); + try { + expect(JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).plugins).toContainEqual( + expect.objectContaining({ + source: 'embedder', + state: 'failed', + error: expect.objectContaining({ reason }), + }), + ); + expect(fs.existsSync(paths.infoPath)).toBe(true); + } finally { + await host.close(); + removeStateDir(stateDir); + } + }, +); test('a host without a state directory is refused, never a daemon in an inherited one', async () => { for (const stateDir of [undefined, '', ' ']) { @@ -235,7 +244,6 @@ test('a host without a state directory is refused, never a daemon in an inherite // directory nobody picked for this process. expect(refusal, JSON.stringify(stateDir)).toMatchObject({ code: 'INVALID_ARGS', - details: { reason: 'state_dir_required' }, }); } expect(fs.existsSync('/tmp/agent-device-host-entry-inherited/daemon.json')).toBe(false); @@ -254,7 +262,6 @@ test('a host ends its own runtime once, and an outside stop ends it the same way expect(first).toEqual(second); expect(exit).not.toHaveBeenCalled(); - await expect(host[Symbol.asyncDispose]()).resolves.toBeUndefined(); expect(first).toMatchObject({ requests: { drained: 0, abandoned: 0 }, plugins: { failed: [], pending: [] }, @@ -294,7 +301,15 @@ test("an operator's stop tears a host's runtime down exactly as its own close wo expect(fs.existsSync(paths.infoPath)).toBe(false); // The host learns the same report it would have from closing itself, and closing again is not an // error — the case of a scope that closes after an operator already stopped the runtime. - await expect(host.close()).resolves.toBeDefined(); + const report = await host.close(); + expect(report).toMatchObject({ + requests: { drained: 0, abandoned: 0 }, + platformOwners: 'closed', + sessions: { closed: 0, failed: 0 }, + runners: { stopped: [], handedOff: [] }, + plugins: { failed: [], pending: [] }, + }); + await expect(host.close()).resolves.toEqual(report); } finally { signals.mockRestore(); await host.close(); @@ -316,7 +331,7 @@ test('a host never takes a state directory somebody else holds', async () => { expect(refusal).toMatchObject({ code: 'COMMAND_FAILED', - details: { reason: 'state-dir-in-use', stateDir: resolveDaemonPaths(held.stateDir).baseDir }, + details: { reason: 'daemon_registration_refused', status: 'busy' }, }); // Refusing left the first host untouched: same runtime, same record, still attachable. expect(readDaemonStopIdentity(held.paths.infoPath).ownership).toBe('embedded'); diff --git a/src/host.ts b/src/host.ts index b4ee0fe046..c5e43641c1 100644 --- a/src/host.ts +++ b/src/host.ts @@ -1,109 +1,23 @@ +import { createAgentDeviceClient } from './agent-device-client.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { captureHostEnvironment, readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { AgentDeviceClientConfig } from '@agent-device/contracts/client'; -import type { AgentDeviceClient } from './client/client-types.ts'; -import type { AgentDevicePlugin } from '@agent-device/plugins/contract'; import type { PluginCandidate } from '@agent-device/plugins/registry'; -import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; -import type { DaemonRuntimeController, ShutdownReport } from '@agent-device/daemon'; +import type { AgentDeviceHost, StartAgentDeviceOptions } from './sdk/host-types.ts'; -/** One line of runtime output, with the stream it came from. */ -export type LogEntry = Readonly<{ - stream: 'stdout' | 'stderr'; - text: string; -}>; - -export type StartAgentDeviceOptions = Readonly<{ - /** - * The state directory this host owns; resolved against `host`'s working directory when relative. It - * is required: a daemon's identity on disk is its state directory, and a host that inherited an - * ambient `AGENT_DEVICE_STATE_DIR` would attach to — or replace — a runtime nobody chose. - */ - stateDir: string; - /** The environment and working directory to run with. Defaults to one captured copy of this process's. */ - host?: HostEnvironment; - /** - * Plugin objects to register after the built-ins, in the order given. The plugin store is not read, - * so the set is fixed for the runtime's lifetime. Defaults to none. - */ - plugins?: readonly AgentDevicePlugin[]; - /** Receives this runtime's stdout and stderr lines. Silent when omitted. */ - log?: (entry: LogEntry) => void; -}>; - -/** - * The config that reproduces {@link AgentDeviceHost.client} in another process: local socket attach, - * pinned to this instance, carrying its token. - * - * It is plain JSON, and the token in it is a bearer credential for this instance: keep the object - * inside the host that owns the daemon. Attach-only is the point — a client built from it dispatches to - * this instance and can neither start, replace, nor retire a daemon. - */ -export type HostClientConfig = AgentDeviceClientConfig; - -export type AgentDeviceHost = Readonly<{ - /** A client pinned to this instance. */ - client: AgentDeviceClient; - /** See {@link HostClientConfig}. */ - clientConfig: HostClientConfig; - /** What this runtime registered, active and failed, as `daemon status` shows it. */ - plugins: readonly PluginInventoryEntry[]; - /** - * Closes the runtime and reports what it could not finish. Repeated and concurrent calls share one - * close, so closing after a `daemon stop`, or from two overlapping scopes, is not an error. This - * never exits the process. - */ - close: () => Promise; - /** Awaits {@link AgentDeviceHost.close} and returns nothing, as `await using` expects. */ - [Symbol.asyncDispose]: () => Promise; -}>; - -/** - * Starts a daemon inside this process and returns the client pinned to it. - * - * It is the daemon executable's own runtime: same registration claim, same authenticated socket - * protocol on an ephemeral loopback listener, same close. Two things differ. The record it publishes - * says `embedded`, so no other process signals or replaces it. And the shutdown request that record - * advertises is answered by the runtime's own `close()` — the same call {@link AgentDeviceHost.close} - * makes — so an operator's `agent-device daemon stop` and an embedder's `host.close()` tear the - * instance down identically. - * - * A state directory somebody else holds is refused, never taken: `details.reason` is - * `state-dir-in-use` for a live occupant, and `state-dir-ownership-unproven` when a record is there - * whose owner cannot be proven dead. - */ +/** Starts an embedded runtime and returns its pinned client and close handle. */ export async function startAgentDevice(options: StartAgentDeviceOptions): Promise { const sourceHost = options.host ?? captureHostEnvironment(process); const host = { env: { ...sourceHost.env }, cwd: sourceHost.cwd }; if (typeof options.stateDir !== 'string' || options.stateDir.trim().length === 0) { - throw new AppError('INVALID_ARGS', 'startAgentDevice requires a stateDir.', { - reason: 'state_dir_required', - hint: 'Pass the state directory this host owns; it is what every attaching process addresses.', - }); + throw new AppError('INVALID_ARGS', 'startAgentDevice requires a stateDir.'); } - // This process's lifetime is knowable before the runtime starts, and the runtime publishes the same - // pair when it claims the state directory. Reading it first is what lets a host that could never - // hand out a pin refuse up front instead of after acquiring one. - const identity = readCurrentOwnerIdentity(); - if (identity.startTime === null) { - throw new AppError( - 'COMMAND_FAILED', - 'This host cannot pin a daemon instance: its own process start time cannot be read.', - { - reason: 'host_instance_unreadable', - pid: identity.pid, - hint: 'Use a detached daemon (`agent-device daemon start`, or let a client start one) on this platform.', - }, - ); - } - const candidates = embedderPluginCandidates(options.plugins); - // The runtime reads its state directory and its server mode from the environment like every other - // consumer of the composition. `runtimeHost` is a copy, so these apply to the runtime alone; the - // process's own environment is never touched. The mode is pinned because `host.client` is handed the - // socket address this call publishes: an ambient `http` would start a runtime with no socket - // listener at all, and the client of the host that started it could not reach it. + const candidates: PluginCandidate[] = (options.plugins ?? []).map((plugin) => ({ + source: 'embedder', + load: () => plugin, + })); const runtimeHost: HostEnvironment = { env: { ...host.env, @@ -112,38 +26,39 @@ export async function startAgentDevice(options: StartAgentDeviceOptions): Promis }, cwd: host.cwd, }; - // The resolved absolute directory, so a refusal and the config an attacher gets name the same place - // this process actually claimed, whichever working directory each of them has. const stateDir = resolveDaemonPaths(options.stateDir, { env: runtimeHost.env, cwd: runtimeHost.cwd, }).baseDir; - const [{ startDaemonRuntime }, { composeDaemonRuntimeDeps }] = await Promise.all([ - import('@agent-device/daemon'), - import('./daemon-composition.ts'), - ]); - const streams = createHostLogStreams(options.log); - let runtime: DaemonRuntimeController; - try { - runtime = await startDaemonRuntime({ - host: runtimeHost, - stdout: streams.stdout, - stderr: streams.stderr, - ownership: 'embedded', - composeDeps: async (input) => await composeDaemonRuntimeDeps(input, candidates), - }); - } catch (error) { - throw hostStartupError(error, stateDir); - } + const [{ startDaemonRuntime }, { composeDaemonRuntimeDeps }, { resolveLocalDaemonCodeIdentity }] = + await Promise.all([ + import('@agent-device/daemon'), + import('./daemon-composition.ts'), + import('./daemon-client/daemon-launch-spec.ts'), + ]); + const codeIdentity = await resolveLocalDaemonCodeIdentity(); + const output = { write: () => {} }; + const runtime = await startDaemonRuntime({ + host: runtimeHost, + stdout: output, + stderr: output, + ownership: 'embedded', + codeIdentity: { codeSignature: 'unknown', ...codeIdentity }, + composeDeps: async (input) => await composeDaemonRuntimeDeps(input, candidates), + }); try { - const { createAgentDeviceClient } = await import('./sdk/index.ts'); - const clientConfig: HostClientConfig = Object.freeze({ - // The resolved absolute directory, so an attacher with a different working directory addresses - // the same daemon rather than resolving a relative path against its own cwd. + const identity = runtime.identity; + if (identity.startTime === null) { + throw new AppError('COMMAND_FAILED', 'This host cannot pin its runtime identity.', { + reason: 'host_instance_unreadable', + pid: identity.pid, + hint: 'Use a detached daemon on this platform.', + }); + } + const clientConfig: AgentDeviceClientConfig = Object.freeze({ stateDir, daemonLifecycle: 'attach', daemonTransport: 'socket', - daemonServerMode: 'socket', cwd: host.cwd, daemonBaseUrl: '', daemonBinding: Object.freeze({ @@ -155,101 +70,11 @@ export async function startAgentDevice(options: StartAgentDeviceOptions): Promis return Object.freeze({ client: createAgentDeviceClient(clientConfig, { host }), clientConfig, - plugins: runtime.plugins, close: () => runtime.close(), - [Symbol.asyncDispose]: async () => { - await runtime.close(); - }, }); } catch (error) { - // A host nobody can attach to is not a host: close what was started before handing the refusal - // back, or the state directory stays claimed by a runtime its owner cannot reach. + // A failed client construction must not leave its runtime holding the state directory. await runtime.close({ cause: error }).catch(() => {}); throw error; } } - -/** - * The embedder's plugin objects as registry candidates. Something that is not a plugin is refused here, - * at the boundary where the embedder can still fix it, rather than becoming a runtime that silently - * registers nothing. - */ -function embedderPluginCandidates(plugins: readonly unknown[] | undefined): PluginCandidate[] { - return (plugins ?? []).map((plugin, index) => { - if (!isPluginLike(plugin)) { - throw new AppError('INVALID_ARGS', `plugins[${index}] is not an agent-device plugin.`, { - reason: 'invalid_embedder_plugin', - hint: 'Pass what a plugin package default factory returns, e.g. `testmu({ credentials })`.', - }); - } - return { source: 'embedder', load: () => plugin }; - }); -} - -function isPluginLike(plugin: unknown): plugin is Record { - return ( - typeof plugin === 'object' && - plugin !== null && - typeof (plugin as { provider?: unknown }).provider === 'string' && - typeof (plugin as { setup?: unknown }).setup === 'function' - ); -} - -/** - * Names, in the vocabulary an embedder acts on, the two refusals that mean "this state directory is not - * yours". The executable reads an exit code; a library caller has none, so those two statuses are - * re-stated here with a hint that says what to do instead. Every other refusal keeps what the runtime - * said, because the runtime already owns the reason. - * - * `stateDir` is the directory this call resolved. The runtime's refusal names its status and not the - * directory — the executable already knows which one it passed — so the host adds the one fact its own - * caller needs to act on. - */ -function hostStartupError(error: unknown, stateDir: string): unknown { - if (!(error instanceof AppError) || error.details?.reason !== 'daemon_registration_refused') { - return error; - } - const status = error.details.status; - if (status !== 'busy' && status !== 'unproven') return error; - return new AppError( - 'COMMAND_FAILED', - status === 'unproven' - ? `Cannot start a host on ${stateDir}: the record there belongs to an owner that cannot be proven dead.` - : `Cannot start a host on ${stateDir}: another daemon holds the state directory.`, - { - reason: status === 'unproven' ? 'state-dir-ownership-unproven' : 'state-dir-in-use', - stateDir, - hint: 'Stop the daemon that owns it, or give this host its own state directory. A host never replaces an occupant.', - }, - ); -} - -/** - * The runtime's two output streams, kept as two writers and handed to the embedder one line at a time. - * A writer that throws is not this runtime's problem to fix, so a sink that fails is dropped rather - * than reaching the daemon's own write path. - */ -function createHostLogStreams(log: ((entry: LogEntry) => void) | undefined): { - stdout: { write: (chunk: string) => void }; - stderr: { write: (chunk: string) => void }; -} { - const partial: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }; - const writer = (stream: 'stdout' | 'stderr') => ({ - write: (chunk: string): void => { - if (!log) return; - const combined = `${partial[stream]}${chunk}`; - const lines = combined.split('\n'); - partial[stream] = lines.pop() ?? ''; - for (const line of lines) { - try { - log({ stream, text: `${line}\n` }); - } catch { - // An embedder's sink owns its own failures; the runtime keeps serving either way. - } - } - }, - }); - return { stdout: writer('stdout'), stderr: writer('stderr') }; -} - -export type { HostEnvironment, ShutdownReport }; diff --git a/src/sdk/host-types.ts b/src/sdk/host-types.ts new file mode 100644 index 0000000000..85c30ef59f --- /dev/null +++ b/src/sdk/host-types.ts @@ -0,0 +1,23 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import type { AgentDeviceClientConfig } from '@agent-device/contracts/client'; +import type { AgentDeviceClient } from '../client/client-types.ts'; +import type { AgentDevicePlugin } from '@agent-device/plugins/contract'; +import type { ShutdownReport } from '@agent-device/daemon'; + +export type StartAgentDeviceOptions = Readonly<{ + /** Required state directory to own, resolved against the host working directory. */ + stateDir: string; + /** The environment and working directory to run with. Defaults to one captured copy of this process's. */ + host?: HostEnvironment; + /** Imported plugins to register after the built-ins. The plugin store is not read. */ + plugins?: readonly AgentDevicePlugin[]; +}>; + +export type AgentDeviceHost = Readonly<{ + /** A client pinned to this instance. */ + client: AgentDeviceClient; + /** Serializable attach config containing this instance's bearer token. Keep it inside the host. */ + clientConfig: AgentDeviceClientConfig; + /** Reports unfinished teardown. Repeated calls share one close; never exits the process. */ + close: () => Promise; +}>; diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 2f6d65414a..00c6e95944 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -1,4 +1,13 @@ +import type { AgentDeviceHost, StartAgentDeviceOptions } from './host-types.ts'; + export { createAgentDeviceClient } from '../agent-device-client.ts'; +export type { AgentDeviceHost, StartAgentDeviceOptions } from './host-types.ts'; + +/** Starts an in-process daemon, loading its runtime only when called. */ +export async function startAgentDevice(options: StartAgentDeviceOptions): Promise { + const { startAgentDevice } = await import('../host.ts'); + return await startAgentDevice(options); +} export { ensureDaemon } from './ensure-daemon.ts'; export { createLocalArtifactAdapter } from '../io.ts'; export { diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index f1d469920d..64b47eecb8 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -96,10 +96,13 @@ async function typeCheckInstalledPluginContract(consumerRoot: string): Promise { - const running: AgentDeviceHost = await startAgentDevice({ stateDir: './state', host, plugins: [await example()], log: (entry: LogEntry) => { void entry.text; } }); +export async function hostConsumer(host: NonNullable) { + const options: StartAgentDeviceOptions = { stateDir: './state', host, plugins: [await example()] }; + const running: AgentDeviceHost = await startAgentDevice(options); + const config: AgentDeviceClientConfig = running.clientConfig; + void config; return await running.close(); } @@ -350,7 +353,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { promisify } from 'node:util'; import { createAgentDeviceClient } from 'agent-device'; -import { startAgentDevice } from 'agent-device/host'; +import { startAgentDevice } from 'agent-device'; const exec = promisify(execFile); const cwd = process.cwd(); @@ -375,6 +378,8 @@ const a = await startAgentDevice({ plugins: [ fixture('fixture-a'), fixture('limrun'), + null, + { ...fixture('wrong-version'), apiVersion: 1 }, { ...fixture('broken'), setup() { @@ -386,7 +391,6 @@ const a = await startAgentDevice({ const bCwd = path.join(cwd, 'b-cwd'); fs.mkdirSync(bCwd); const b = await startAgentDevice({ - log() { throw new Error('sink failed'); }, stateDir: 'host-b', host: { cwd: bCwd, env: { ...supplied.env, HOST_MARKER: 'B' } }, plugins: [fixture('fixture-b')], @@ -400,24 +404,27 @@ try { ['fixture-a', 'A'], ['fixture-b', 'B'], ]); - assert.equal(a.plugins.find((p) => p.provider === 'fixture-a').state, 'active'); + const inventory = JSON.parse(fs.readFileSync(path.join(a.clientConfig.stateDir, 'daemon.json'), 'utf8')).plugins; + assert.equal(inventory.find((p) => p.provider === 'fixture-a').state, 'active'); assert.equal( - a.plugins.filter((p) => p.provider === 'limrun' && p.source === 'embedder')[0].state, + inventory.filter((p) => p.provider === 'limrun' && p.source === 'embedder')[0].state, 'failed', ); - assert.equal(a.plugins.find((p) => p.provider === 'broken').state, 'failed'); + assert.equal(inventory.find((p) => p.provider === 'broken').state, 'failed'); + assert.equal(inventory.find((p) => p.source === 'embedder' && p.provider === undefined).error.reason, 'invalid_plugin'); + assert.equal(inventory.find((p) => p.provider === 'wrong-version').error.reason, 'incompatible_plugin'); assert.equal(process.cwd(), cwd); assert.deepEqual({ ...process.env }, beforeEnv); assert.deepEqual( listeners.map((event) => process.listeners(event)), beforeListeners, ); - await assert.rejects(startAgentDevice({ stateDir: a.clientConfig.stateDir, host: supplied, plugins: [fixture('rollback')] }), (error) => error.details?.reason === 'state-dir-in-use'); + await assert.rejects(startAgentDevice({ stateDir: a.clientConfig.stateDir, host: supplied, plugins: [fixture('rollback')] }), (error) => error.details?.reason === 'daemon_registration_refused' && error.details?.status === 'busy'); assert.deepEqual(disposed.splice(0), ['rollback:shutdown', 'rollback:dispose']); const unprovenDir = path.join(cwd, 'unproven'); fs.mkdirSync(unprovenDir); fs.writeFileSync(path.join(unprovenDir, 'daemon.lock'), 'legacy lock'); - await assert.rejects(startAgentDevice({ stateDir: unprovenDir, host: supplied, plugins: [fixture('unproven')] }), (error) => error.details?.reason === 'state-dir-ownership-unproven'); + await assert.rejects(startAgentDevice({ stateDir: unprovenDir, host: supplied, plugins: [fixture('unproven')] }), (error) => error.details?.reason === 'daemon_registration_refused' && error.details?.status === 'unproven'); assert.equal(fs.readFileSync(path.join(unprovenDir, 'daemon.lock'), 'utf8'), 'legacy lock'); assert.deepEqual(disposed.splice(0), ['unproven:shutdown', 'unproven:dispose']); const config = JSON.stringify(a.clientConfig); @@ -450,13 +457,8 @@ try { { cwd }, ); assert.equal(JSON.parse(stopped.stdout).data.stopped, true); - const [first, second, disposal] = await Promise.all([ - a.close(), - a.close(), - a[Symbol.asyncDispose](), - ]); + const [first, second] = await Promise.all([a.close(), a.close()]); assert.strictEqual(first, second); - assert.equal(disposal, undefined); assert.deepEqual(first.requests, { drained: 0, abandoned: 0 }); assert.deepEqual(disposed, ['fixture-a:shutdown', 'fixture-a:dispose']); assert.deepEqual(await b.client.sessions.list(), []); @@ -492,7 +494,7 @@ try { ); } finally { await a.close(); - await b[Symbol.asyncDispose](); + await b.close(); } assert.deepEqual(disposed, [ 'fixture-a:shutdown', @@ -598,8 +600,6 @@ assert.equal(fs.existsSync(path.join(cwd, 'host-a', 'daemon.json')), false); }, './contracts': (mod) => mod.centerOfRect({ x: 2, y: 2, width: 6, height: 6 }), './finders': (mod) => mod.findBestMatchesByLocator([], 'text', 'anything').matches.length, - // Importing the host entry must be cheap: it reaches the daemon only when a caller starts one. - './host': (mod) => typeof mod.startAgentDevice, './install-source': (mod) => typeof mod.isTrustedInstallSourceUrl('https://example.test/app.apk'), './io': (mod) => typeof mod.createLocalArtifactAdapter({ cwd: process.cwd() }).reserveOutput, './limrun': async (mod) => { @@ -622,6 +622,7 @@ assert.equal(fs.existsSync(path.join(cwd, 'host-a', 'daemon.json')), false); subpathSmokeResults[subpath] = await subpathSmoke[subpath](mod); } const removedSubpaths = await Promise.all([ + 'agent-device/host', 'agent-device/backend', 'agent-device/commands', 'agent-device/testing/conformance', @@ -664,6 +665,7 @@ assert.equal(fs.existsSync(path.join(cwd, 'host-a', 'daemon.json')), false); 'isAgentDeviceError', 'isUnreadableCaptureContentError', 'normalizeAgentDeviceError', + 'startAgentDevice', ]); assert.equal(imports.rootClientSnapshot, 'function'); assert.equal(imports.rootArtifactAdapter, 'function'); @@ -685,7 +687,6 @@ assert.equal(fs.existsSync(path.join(cwd, 'host-a', 'daemon.json')), false); './batch': 'INVALID_ARGS', './contracts': { x: 5, y: 5 }, './finders': 0, - './host': 'function', './install-source': 'boolean', './io': 'function', './limrun': 'limrun', diff --git a/test/repo-guards/package-exports.test.ts b/test/repo-guards/package-exports.test.ts index 4edbda9442..39740e47ed 100644 --- a/test/repo-guards/package-exports.test.ts +++ b/test/repo-guards/package-exports.test.ts @@ -34,8 +34,6 @@ const supportedSubpaths = [ './selectors', './finders', './ai-sdk', - // The embedder's entry: it starts a daemon in the caller's process, which no other subpath does. - './host', ]; const typeOnlySubpaths = new Set(['./remote-config']); @@ -70,6 +68,7 @@ test('package exports only supported public subpaths', () => { assert.equal(pkg.exports['./android-apps'], undefined); assert.equal(pkg.exports['./daemon'], undefined); + assert.equal(pkg.exports['./host'], undefined); }); test('every public subpath is backed by a configured tsdown build entry', () => { diff --git a/tsdown.config.ts b/tsdown.config.ts index 3ad1949ce3..853698fa50 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -108,7 +108,6 @@ export default defineConfig({ selectors: 'src/sdk/selectors.ts', finders: 'src/sdk/finders.ts', 'ai-sdk': 'src/ai-sdk/index.ts', - host: 'src/host.ts', 'internal/bin': 'src/bin.ts', 'internal/companion-tunnel': 'packages/react-native/src/companion/companion-tunnel.ts', 'internal/daemon': 'src/daemon.ts', diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 8326503606..8bdbafc5de 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -31,6 +31,8 @@ Entry points for driving devices from Node.js: - `agent-device` - `createAgentDeviceClient(options?)` + - `startAgentDevice(options)` + - types: `AgentDeviceHost`, `StartAgentDeviceOptions` - `ensureDaemon(options?)` - `createLocalArtifactAdapter(options?)` - `AppError`, `isAgentDeviceError(error)`, `normalizeAgentDeviceError(error)` @@ -54,9 +56,6 @@ Entry points for driving devices from Node.js: - `agent-device/ai-sdk` - `createAgentDeviceTools(options)` - types: `AgentDeviceToolSet`, `AgentDeviceTools`, `CreateAgentDeviceToolsOptions` -- `agent-device/host` - - `startAgentDevice(options)` - - types: `AgentDeviceHost`, `StartAgentDeviceOptions`, `HostClientConfig`, `LogEntry`, `HostEnvironment`, `ShutdownReport` ## Basic usage @@ -134,29 +133,27 @@ These failures carry a typed `error.details.reason`: ## Running the daemon inside your process -`startAgentDevice()` on `agent-device/host` starts the daemon inside your own process and returns a client for it. Use it when you want the daemon's lifetime to be exactly your process's, or when you import plugin objects you already have. +`startAgentDevice()` starts the daemon inside your own process and returns a client for it. Use it when you want the daemon's lifetime to be exactly your process's, or when you import plugin objects you already have. ```ts -import { startAgentDevice } from 'agent-device/host'; +import { startAgentDevice } from 'agent-device'; const host = await startAgentDevice({ stateDir: './.my-app/agent-device-state', - log: (line) => console.log(line.stream, line.text), }); const devices = await host.client.devices.list(); await host.close(); ``` -`stateDir` is required, and a host never takes one somebody else holds: a live occupant is refused with `state-dir-in-use`, and a record whose owner cannot be proven dead with `state-dir-ownership-unproven`. Pass plugin objects you imported in `plugins`, after the built-ins and in that order: the plugin store is not read, so `plugin add` never changes a running host. `host.plugins` is the inventory `agent-device daemon status` shows, so a plugin that failed to register is visible. `close()` reports what it could not finish; repeated and concurrent calls share one close, and closing after `agent-device daemon stop` is not an error. The object works with `await using`, and `close()` never exits your process. +`stateDir` is required. If another daemon owns it, startup refuses with `daemon_registration_refused`; choose a separate directory or close its owner first. Pass imported plugin objects in `plugins`. They register after the built-ins without reading the plugin store, so `plugin add` does not change a running host. Plugin failures appear in `agent-device daemon status --state-dir `. `close()` reports unfinished teardown. Repeated calls share one close, including after an operator stops the runtime, and never exit your process. The runtime stays alive until you close it or send `agent-device daemon stop --state-dir `; idle reaping is disabled. `daemon stop --clean` is refused because cleanup must stay within this runtime. If the shutdown report lists abandoned requests or unfinished cleanup, inspect those outcomes before reusing the state directory: the runtime may retain its registration to protect work still in progress. -`host.clientConfig` is serializable JSON: pass it to another process or worker and build an attach client from it. It pins that client to exactly one daemon instance, and a state directory whose daemon is any other instance is refused rather than attached to: +`host.clientConfig` is serializable JSON: pass it to another process or worker and build an attach client from it. The client must pass normal version and code compatibility checks, then match the pinned instance. An incompatible or replacement daemon is refused: ```ts -import { createAgentDeviceClient } from 'agent-device'; -import { startAgentDevice } from 'agent-device/host'; +import { createAgentDeviceClient, startAgentDevice } from 'agent-device'; const host = await startAgentDevice({ stateDir: './.my-app/agent-device-state' }); // Hand the config to a worker or another process: it is plain JSON, no build step. From 78ce25c6d15c5a5bd41bbfd4e371e7b4d8bbe41c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 13:41:29 +0200 Subject: [PATCH 09/10] chore(gates): name main-entry host in process capture policy Keep src/host.ts as the actual capture boundary behind the SDK's lazy factory. Co-Authored-By: Claude Opus 5.5 --- scripts/layering/process-globals-policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/layering/process-globals-policy.ts b/scripts/layering/process-globals-policy.ts index e9986c86fb..386dd1817a 100644 --- a/scripts/layering/process-globals-policy.ts +++ b/scripts/layering/process-globals-policy.ts @@ -16,7 +16,7 @@ const PROCESS_ENTRIES = new Set([ 'packages/react-native/src/companion/companion-tunnel.ts', 'packages/react-native/src/companion/client-companion-tunnel-worker.ts', 'packages/maestro/src/daemon-port/run-script-http-child.ts', - // Released SDK defaults; #3759 removes these captures after agent-device/host ships (#3757). + // Released SDK defaults; #3759 removes these captures after startAgentDevice ships (#3757). 'src/io.ts', 'src/sdk/host.ts', 'src/mcp/command-tools.ts', From 0eaa324c85d14397041a6c4ef31fc4cf5b61a0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 15:56:31 +0200 Subject: [PATCH 10/10] fix(host): preserve refusal and code identity metadata Co-Authored-By: Claude Opus 5.5 --- .../src/daemon-registration-owner.ts | 2 +- .../daemon/src/server/http/daemon-runtime.ts | 3 ++- src/daemon-client/daemon-client-binding.ts | 3 ++- src/host.test.ts | 24 +++++++++++++++++++ src/host.ts | 7 ++++-- 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/daemon-contracts/src/daemon-registration-owner.ts b/packages/daemon-contracts/src/daemon-registration-owner.ts index db0f4720f2..bde85047a6 100644 --- a/packages/daemon-contracts/src/daemon-registration-owner.ts +++ b/packages/daemon-contracts/src/daemon-registration-owner.ts @@ -56,7 +56,7 @@ export type DaemonRegistrationFields = Readonly<{ token: string; version: string; codeOrigin: DaemonCodeOrigin; - codeSignature: string; + codeSignature?: string; policyDigest?: string; /** What this daemon can do beyond the base RPC contract; absent from daemons that predate it. */ capabilities?: readonly DaemonCapability[]; diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 9d23cf56e7..45db80179f 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -74,6 +74,7 @@ import { type RuntimeStateHandle, } from '@agent-device/host-kit/runtime-slots'; import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readCurrentOwnerIdentity, @@ -229,7 +230,7 @@ export type DaemonRuntimeOptions = { */ ownership?: DaemonRuntimeOwnership; /** The daemon code an embedder loaded; its process entry belongs to the host application. */ - codeIdentity?: { origin: 'installed' | 'checkout'; codeSignature: string }; + codeIdentity?: { origin: DaemonCodeOrigin; codeSignature?: string }; }; export type DaemonRuntimeController = { diff --git a/src/daemon-client/daemon-client-binding.ts b/src/daemon-client/daemon-client-binding.ts index 8a89d7bba9..099215ee93 100644 --- a/src/daemon-client/daemon-client-binding.ts +++ b/src/daemon-client/daemon-client-binding.ts @@ -1,4 +1,5 @@ import type { DaemonBinding } from '@agent-device/contracts/client'; +import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonInfo } from './daemon-client-metadata.ts'; import { daemonIdentityOf } from './daemon-lifecycle-errors.ts'; @@ -17,7 +18,7 @@ export function daemonBindingMismatchError( stateDir, bound: { pid: binding.pid, processStartTime: binding.processStartTime }, ...(registered ? { registered: daemonIdentityOf(registered) } : {}), - hint: `Check ${stateDir}/daemon.json or obtain clientConfig from a running host. A pinned client never starts or replaces a daemon.`, + hint: `Check ${shellQuoteIfNeeded(`${stateDir}/daemon.json`)} or obtain clientConfig from a running host. A pinned client never starts or replaces a daemon.`, }, ); } diff --git a/src/host.test.ts b/src/host.test.ts index e76c4c9002..eb42f47af9 100644 --- a/src/host.test.ts +++ b/src/host.test.ts @@ -12,6 +12,7 @@ import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/tes import type { PluginCandidate } from '@agent-device/plugins/registry'; import { builtinPluginInventory } from './__tests__/test-utils/builtin-plugin-inventory.ts'; import { stopRecordedDaemon } from './daemon-client/daemon-client-stop.ts'; +import * as daemonLaunchSpec from './daemon-client/daemon-launch-spec.ts'; import { startAgentDevice } from './host.ts'; const composition = vi.hoisted(() => ({ @@ -126,6 +127,25 @@ test('a host publishes its own process as the runtime an outside stop must not s } }); +test('an installed host publishes no checkout code signature', async () => { + const identity = vi + .spyOn(daemonLaunchSpec, 'resolveLocalDaemonCodeIdentity') + .mockResolvedValue({ origin: 'installed' }); + try { + const { stateDir, paths, host } = await bootHost(); + try { + const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')); + expect(record.codeOrigin).toBe('installed'); + expect(record).not.toHaveProperty('codeSignature'); + } finally { + await host.close(); + removeStateDir(stateDir); + } + } finally { + identity.mockRestore(); + } +}); + test('a host hands out the pin that reaches exactly its own instance', async () => { const { stateDir, paths, host } = await bootHost(); try { @@ -244,6 +264,10 @@ test('a host without a state directory is refused, never a daemon in an inherite // directory nobody picked for this process. expect(refusal, JSON.stringify(stateDir)).toMatchObject({ code: 'INVALID_ARGS', + details: { + reason: 'host_state_dir_required', + hint: 'Pass an explicit stateDir to startAgentDevice({ stateDir }).', + }, }); } expect(fs.existsSync('/tmp/agent-device-host-entry-inherited/daemon.json')).toBe(false); diff --git a/src/host.ts b/src/host.ts index c5e43641c1..96217b1592 100644 --- a/src/host.ts +++ b/src/host.ts @@ -12,7 +12,10 @@ export async function startAgentDevice(options: StartAgentDeviceOptions): Promis const sourceHost = options.host ?? captureHostEnvironment(process); const host = { env: { ...sourceHost.env }, cwd: sourceHost.cwd }; if (typeof options.stateDir !== 'string' || options.stateDir.trim().length === 0) { - throw new AppError('INVALID_ARGS', 'startAgentDevice requires a stateDir.'); + throw new AppError('INVALID_ARGS', 'startAgentDevice requires a stateDir.', { + reason: 'host_state_dir_required', + hint: 'Pass an explicit stateDir to startAgentDevice({ stateDir }).', + }); } const candidates: PluginCandidate[] = (options.plugins ?? []).map((plugin) => ({ source: 'embedder', @@ -43,7 +46,7 @@ export async function startAgentDevice(options: StartAgentDeviceOptions): Promis stdout: output, stderr: output, ownership: 'embedded', - codeIdentity: { codeSignature: 'unknown', ...codeIdentity }, + codeIdentity, composeDeps: async (input) => await composeDaemonRuntimeDeps(input, candidates), }); try {