Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/host-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-device': patch
---

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.
1 change: 1 addition & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 6 additions & 5 deletions docs/adr/0034-provider-plugin-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
42 changes: 42 additions & 0 deletions examples/sdk/host-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* 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`.
*
* 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';

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',
});

try {
console.log(`state dir: ${host.clientConfig.stateDir}`);
// 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)}`);
}
}
29 changes: 29 additions & 0 deletions packages/contracts/src/client-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type {
DaemonConnection,
DaemonIdentity,
DaemonLifecycle,
DaemonBinding,
DeviceCommandBaseOptions,
EnsureDaemonOptions,
} from '../client-connection.ts';
Expand Down
2 changes: 1 addition & 1 deletion packages/daemon-contracts/src/daemon-registration-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
1 change: 1 addition & 0 deletions packages/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
27 changes: 21 additions & 6 deletions packages/daemon/src/server/http/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,13 @@ 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,
reapOwnedProcessRecordsAtStartup,
type OwnedProcessRecordStore,
type OwnerIdentity,
} from '@agent-device/host-kit/process';
import { isEnvTruthy, sleep } from '@agent-device/host-kit/retry';
import {
Expand Down Expand Up @@ -227,9 +229,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: DaemonCodeOrigin; codeSignature?: string };
};

export type DaemonRuntimeController = {
/** The runtime process lifetime used in its registration and lifecycle protocol. */
identity: Readonly<OwnerIdentity>;
httpPort?: number;
socketPort?: number;
/**
Expand Down Expand Up @@ -262,14 +268,21 @@ export function daemonStartupExitCode(error: unknown): number {
export async function startDaemonRuntime(
options: DaemonRuntimeOptions,
): Promise<DaemonRuntimeController> {
const runtimeOptions = {
...options,
codeIdentity: options.codeIdentity ?? {
origin: resolveDaemonCodeOrigin(),
codeSignature: resolveDaemonCodeSignature(),
},
};
const runtimeState = createRuntimeStateHandle();
return await runtimeState.run(
async () =>
await withCommandHost({ host: options.host, requestEnv: undefined }, async () => {
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;
Expand All @@ -279,7 +292,9 @@ export async function startDaemonRuntime(
}

async function startDaemonRuntimeOnHost(
options: DaemonRuntimeOptions,
options: DaemonRuntimeOptions & {
codeIdentity: NonNullable<DaemonRuntimeOptions['codeIdentity']>;
},
rollback: StartupRollback,
runtimeState: RuntimeStateHandle,
): Promise<DaemonRuntimeController> {
Expand Down Expand Up @@ -328,8 +343,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
Expand Down Expand Up @@ -586,8 +600,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,
Expand Down Expand Up @@ -952,5 +966,6 @@ async function startDaemonRuntimeOnHost(
run: runtimeState.run,
socketPort,
token,
identity: Object.freeze({ ...daemonIdentity }),
};
}
1 change: 1 addition & 0 deletions scripts/layering/process-globals-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions scripts/layering/process-globals-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ 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',
'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',
Expand All @@ -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([
Expand Down
2 changes: 1 addition & 1 deletion scripts/layering/root-module-zones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* `src/daemon-client/` root — keep reading the path, not this declaration.
*/
export const ROOT_MODULE_ZONES: Readonly<Record<string, readonly string[]>> = {
'(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
Expand Down
96 changes: 96 additions & 0 deletions src/__tests__/agent-device-client-daemon-binding.test.ts
Original file line number Diff line number Diff line change
@@ -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<AgentDeviceDaemonTransportContext | undefined> = [];
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));
});
Loading
Loading