From 74ed5402865b4729d32be1591640f751c20abe7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 13:11:10 +0100 Subject: [PATCH 01/17] refactor(plugins): colocate provider credential fingerprint --- docs/adr/0033-collocation-decision-table.md | 2 +- packages/plugins/package.json | 4 + .../plugins/src/credential-fingerprint.ts | 53 +++++++++++ packages/plugins/src/store-legacy.ts | 83 ++++++++++++++++ packages/plugins/src/store.ts | 95 +++---------------- ...daemon-client-provider-credentials.test.ts | 2 +- src/daemon-client/daemon-client.ts | 2 +- src/daemon-composition-credentials.test.ts | 2 +- src/provider-credential-fingerprint.test.ts | 6 +- src/provider-credential-fingerprint.ts | 54 +---------- 10 files changed, 160 insertions(+), 143 deletions(-) create mode 100644 packages/plugins/src/credential-fingerprint.ts create mode 100644 packages/plugins/src/store-legacy.ts diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index bcf480b7ab..3f1bd35440 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -163,7 +163,7 @@ They are not independent proof that the placement is optimal. | `src/platform-runtime-daemon-lifecycle.ts` | retain for this batch | The same path-keyed R13/ADR 0022 seam applies. Relocation is possible only with coordinated changes to those declarations. | | `src/platform-runtime-device-boot.ts` | retain for this batch | The same path-keyed R13/ADR 0022 seam applies. | | `src/platform-runtime-resource-cleanup.ts` | retain for this batch | The same path-keyed R13/ADR 0022 seam applies. | -| `src/provider-credential-fingerprint.ts` | keep (type extracted) | Like `daemon-policy-file.ts`, this shared daemon/client contract follows #2559. #3288 declares its zone beside the provider composition that reads the credentials this file fingerprints. Daemon extraction PR 6 moved only the `DaemonProviderCredentials` declaration to `packages/daemon-contracts` at `./provider-credentials`. This is the same shared-below-both landing as the policy file. The implementation remains here because it reads the installed-plugin manifest. | +| `src/provider-credential-fingerprint.ts` | keep (type extracted) | Like `daemon-policy-file.ts`, this shared daemon/client contract follows #2559. #3288 declares its zone beside the provider composition that reads the credentials this file fingerprints. Daemon extraction PR 6 moved only the `DaemonProviderCredentials` declaration to `packages/daemon-contracts` at `./provider-credentials`. This is the same shared-below-both landing as the policy file. Credential fingerprinting resides beside the installed-plugin manifest lookup in `packages/plugins/src/credential-fingerprint.ts`. The root retains daemon credential composition. | | `src/provider-limrun-runtime.ts` | keep | The proposed provider-limrun destination has no seam. Its constructor builds the root dependency factory `src/sdk/limrun-runtime-dependencies.ts`, which is the ADR 0019 composition seam. This loads the root adb-host binder, core Android interactor, and platform-runtime app-state helpers, all prohibited package imports. Moving the class also moves that composition. That is a design change, not a move. | | `src/request-progress-protocol.ts` | moved → `packages/daemon-contracts/src/request-progress-protocol.ts` | This follows #2559's shared-contract decision. The client reads it statically through `daemon-client-progress.ts`; the server reads it through `packages/daemon/src/server/`. Only shared placement and wire-compat ledgers constrain it. #3288 declared `daemon-contracts`; extraction made it physical and moved wire-ledger keys with it. Contracts was rejected because R18 excludes envelope validation mechanics. | | `src/runtime-command-surface.ts` | moved → `packages/command-runtime/src/index.ts` (after [#3299](https://github.com/callstack/agent-device/pull/3299) into `src/command-runtime/`) | The binding now owns the `@agent-device/command-runtime` entry. It retains the three-family daemon load shape. | diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 610f873555..319a6e2fb0 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -41,6 +41,10 @@ "./store": { "types": "./src/store.ts", "default": "./src/store.ts" + }, + "./credential-fingerprint": { + "types": "./src/credential-fingerprint.ts", + "default": "./src/credential-fingerprint.ts" } }, "devDependencies": { diff --git a/packages/plugins/src/credential-fingerprint.ts b/packages/plugins/src/credential-fingerprint.ts new file mode 100644 index 0000000000..0070eca543 --- /dev/null +++ b/packages/plugins/src/credential-fingerprint.ts @@ -0,0 +1,53 @@ +import crypto from 'node:crypto'; +import { + pluginCredentialVariables, + type PluginInventoryEntry, +} from '@agent-device/contracts/plugin-inventory'; +import type { EnvMap } from '@agent-device/kernel/source-value'; +import { legacyStoreManifests } from './store-legacy.ts'; + +type CredentialValues = Readonly>; + +/** + * A versioned, non-reversible digest of the credentials a lease on `leaseBackend` reads from `env`, + * or undefined when `env` holds none of them or the provider's credentials do not come from the + * environment. Variable names come from the daemon's plugin inventory, then from v1 manifests. + */ +export function providerCredentialFingerprint( + provider: string, + env: EnvMap, + leaseBackend?: string, + inventory?: readonly PluginInventoryEntry[], +): string | undefined { + const variables = declaredCredentialVariables(provider, env, leaseBackend, inventory); + // Whitespace-only counts as unset and other values are kept as is, as the provider's requireEnv does. + return variables + ? digest( + Object.fromEntries( + variables.map((name) => [name, env[name]?.trim() ? env[name] : undefined]), + ), + ) + : undefined; +} + +// Read from the inventory or manifest, so neither the client nor the daemon evaluates provider code. +function declaredCredentialVariables( + provider: string, + env: EnvMap, + leaseBackend: string | undefined, + inventory: readonly PluginInventoryEntry[] | undefined, +): readonly string[] | undefined { + const published = inventory && pluginCredentialVariables(inventory, provider, leaseBackend); + if (published) return published; + return legacyStoreManifests(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) + ?.agentDevicePlugin.credentialVariables; +} + +function digest(values: CredentialValues): string | undefined { + const pairs = Object.entries(values) + .filter((pair): pair is [string, string] => pair[1] !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + if (pairs.length === 0) return undefined; + const hash = crypto.createHash('sha256').update(JSON.stringify(pairs)).digest('hex'); + return `v1:${hash.slice(0, 16)}`; +} diff --git a/packages/plugins/src/store-legacy.ts b/packages/plugins/src/store-legacy.ts new file mode 100644 index 0000000000..8d82695809 --- /dev/null +++ b/packages/plugins/src/store-legacy.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { isRecord } from '@agent-device/kernel/record'; +import { resolveUserConfigPath } from '@agent-device/host-kit/file'; +import { readPluginManifest, type PluginManifest } from './manifest.ts'; + +export type PluginSelection = { + installation: string; + version?: string; + options?: Record; +}; +type PluginConfig = Record & { plugins?: Record }; +export function readPluginConfig(env: NodeJS.ProcessEnv): PluginConfig { + const configPath = resolveUserConfigPath(env); + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as PluginConfig; + if (!isRecord(config) || (config.plugins !== undefined && !isRecord(config.plugins))) + throw new AppError('INVALID_ARGS', 'Plugin config must contain an object'); + return config; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + if (error instanceof SyntaxError) + throw new AppError('INVALID_ARGS', `Invalid plugin JSON: ${configPath}`, {}, error); + throw error; + } +} + +export function validatePluginSelection(name: string, selection: PluginSelection): void { + if ( + !isPackageName(name) || + !isRecord(selection) || + typeof selection.installation !== 'string' || + !/^[a-f0-9-]{36}$/.test(selection.installation) || + (selection.version !== undefined && !isVersionSelector(selection.version)) || + (selection.options !== undefined && !isRecord(selection.options)) + ) { + throw new AppError('INVALID_ARGS', `Invalid plugin selection: ${name}`); + } +} + +export function storeHome(env: NodeJS.ProcessEnv): string { + return path.dirname(resolveUserConfigPath(env)); +} + +export function installationDirectory(home: string, name: string, installation: string): string { + return path.join(home, 'plugins', installation, 'node_modules', name); +} + +/** + * The v1 manifests in the store, read without executing anything, including those of a package + * that also exports `./agent-device`; unreadable selections are skipped. + */ +export function legacyStoreManifests(env: NodeJS.ProcessEnv): PluginManifest[] { + const home = storeHome(env); + let selections: [string, PluginSelection][]; + try { + selections = Object.entries(readPluginConfig(env).plugins ?? {}); + } catch { + return []; + } + return selections.flatMap(([name, selection]) => { + try { + validatePluginSelection(name, selection); + const manifest = readPluginManifest( + installationDirectory(home, name, selection.installation), + ); + return manifest.name === name ? [manifest] : []; + } catch { + return []; + } + }); +} + +function isPackageName(value: string): boolean { + return /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value); +} + +export function isVersionSelector(value: unknown): value is string { + return ( + typeof value === 'string' && /^[a-zA-Z0-9*^~<>=| .+-]+$/.test(value) && value.trim().length > 0 + ); +} diff --git a/packages/plugins/src/store.ts b/packages/plugins/src/store.ts index 406ae3e4f7..391a53d89c 100644 --- a/packages/plugins/src/store.ts +++ b/packages/plugins/src/store.ts @@ -1,9 +1,19 @@ +import { + legacyStoreManifests, + readPluginConfig, + validatePluginSelection, + storeHome, + installationDirectory, + isVersionSelector, + type PluginSelection, +} from './store-legacy.ts'; +export { legacyStoreManifests }; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { AppError } from '@agent-device/kernel/errors'; import { isRecord } from '@agent-device/kernel/record'; +import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; import { acquireProcessLock, @@ -12,47 +22,9 @@ import { } from '@agent-device/host-kit/file'; import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; import type { PluginPackageEntry } from './discovery.ts'; -import { - readPluginManifest, - ROUTING_PROVIDER_IDENTITIES, - type PluginManifest, -} from './manifest.ts'; +import { ROUTING_PROVIDER_IDENTITIES, type PluginManifest } from './manifest.ts'; import type { PluginCandidate } from './registry.ts'; -type PluginSelection = { - installation: string; - version?: string; - options?: Record; -}; -type PluginConfig = Record & { plugins?: Record }; -function readPluginConfig(env: NodeJS.ProcessEnv): PluginConfig { - const configPath = resolveUserConfigPath(env); - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as PluginConfig; - if (!isRecord(config) || (config.plugins !== undefined && !isRecord(config.plugins))) - throw new AppError('INVALID_ARGS', 'Plugin config must contain an object'); - return config; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; - if (error instanceof SyntaxError) - throw new AppError('INVALID_ARGS', `Invalid plugin JSON: ${configPath}`, {}, error); - throw error; - } -} - -function validatePluginSelection(name: string, selection: PluginSelection): void { - if ( - !isPackageName(name) || - !isRecord(selection) || - typeof selection.installation !== 'string' || - !/^[a-f0-9-]{36}$/.test(selection.installation) || - (selection.version !== undefined && !isVersionSelector(selection.version)) || - (selection.options !== undefined && !isRecord(selection.options)) - ) { - throw new AppError('INVALID_ARGS', `Invalid plugin selection: ${name}`); - } -} - type StoreInstallation = { name: string; version: string; @@ -61,14 +33,6 @@ type StoreInstallation = { entry: PluginPackageEntry; }; -function storeHome(env: NodeJS.ProcessEnv): string { - return path.dirname(resolveUserConfigPath(env)); -} - -function installationDirectory(home: string, name: string, installation: string): string { - return path.join(home, 'plugins', installation, 'node_modules', name); -} - async function describeInstallation( name: string, directory: string, @@ -167,31 +131,6 @@ async function readSelectionVersion( } } -/** - * The v1 manifests in the store, read without executing anything, including those of a package - * that also exports `./agent-device`; unreadable selections are skipped. - */ -export function legacyStoreManifests(env: NodeJS.ProcessEnv): PluginManifest[] { - const home = storeHome(env); - let selections: [string, PluginSelection][]; - try { - selections = Object.entries(readPluginConfig(env).plugins ?? {}); - } catch { - return []; - } - return selections.flatMap(([name, selection]) => { - try { - validatePluginSelection(name, selection); - const manifest = readPluginManifest( - installationDirectory(home, name, selection.installation), - ); - return manifest.name === name ? [manifest] : []; - } catch { - return []; - } - }); -} - export async function listPlugins(env: NodeJS.ProcessEnv, builtinProviders: readonly string[]) { const { claimProviderIds, resolveProviderPlugins } = await import('./registry.ts'); const resolved = await resolveProviderPlugins([ @@ -339,13 +278,3 @@ function assertUniqueLegacyProviders( providers.add(agentDevicePlugin.provider); } } - -function isPackageName(value: string): boolean { - return /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value); -} - -function isVersionSelector(value: unknown): value is string { - return ( - typeof value === 'string' && /^[a-zA-Z0-9*^~<>=| .+-]+$/.test(value) && value.trim().length > 0 - ); -} diff --git a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts b/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts index c3e2798d3e..bbef7902a9 100644 --- a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts +++ b/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts @@ -15,7 +15,7 @@ import { mkdtempForTestSync, supportsLoopbackBind, } from '@agent-device/test-kit'; -import { providerCredentialFingerprint as fingerprintFor } from '../../provider-credential-fingerprint.ts'; +import { providerCredentialFingerprint as fingerprintFor } from '@agent-device/plugins/credential-fingerprint'; import { builtinPluginInventory } from '../../__tests__/test-utils/builtin-plugin-inventory.ts'; import type { EnvMap } from '@agent-device/kernel/source-value'; import { LIMRUN_CREDENTIAL_VARIABLES } from '../../provider-limrun-credentials.ts'; diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index ddbe6e9611..141d875f96 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -214,7 +214,7 @@ async function readLocalProviderCredentialFingerprint( const { leaseProvider: provider, leaseBackend } = leaseScopeFromRequest(request); if (!provider) return undefined; const [{ providerCredentialFingerprint }, { readPluginInventory }] = await Promise.all([ - import('../provider-credential-fingerprint.ts'), + import('@agent-device/plugins/credential-fingerprint'), import('@agent-device/contracts/plugin-inventory'), ]); return providerCredentialFingerprint( diff --git a/src/daemon-composition-credentials.test.ts b/src/daemon-composition-credentials.test.ts index bbc5033d32..5609f0f392 100644 --- a/src/daemon-composition-credentials.test.ts +++ b/src/daemon-composition-credentials.test.ts @@ -4,7 +4,7 @@ import { startDaemonRuntime } from '@agent-device/daemon'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { createDaemonRuntimeDeps } from './daemon-composition.ts'; -import { providerCredentialFingerprint } from './provider-credential-fingerprint.ts'; +import { providerCredentialFingerprint } from '@agent-device/plugins/credential-fingerprint'; import { builtinPluginInventory } from './__tests__/test-utils/builtin-plugin-inventory.ts'; vi.mock('./platform-runtime.ts', () => ({ diff --git a/src/provider-credential-fingerprint.test.ts b/src/provider-credential-fingerprint.test.ts index 374d66a4d3..9b269ae8e2 100644 --- a/src/provider-credential-fingerprint.test.ts +++ b/src/provider-credential-fingerprint.test.ts @@ -1,10 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { - providerCredentialFingerprint as fingerprintFor, - readDaemonProviderCredentials as daemonCredentialsFor, -} from './provider-credential-fingerprint.ts'; +import { providerCredentialFingerprint as fingerprintFor } from '@agent-device/plugins/credential-fingerprint'; +import { readDaemonProviderCredentials as daemonCredentialsFor } from './provider-credential-fingerprint.ts'; import type { EnvMap } from '@agent-device/kernel/source-value'; import { builtinPluginInventory } from './__tests__/test-utils/builtin-plugin-inventory.ts'; import { pluginHome, selectPluginManifest } from '@agent-device/test-kit'; diff --git a/src/provider-credential-fingerprint.ts b/src/provider-credential-fingerprint.ts index ae316448eb..03004b9577 100644 --- a/src/provider-credential-fingerprint.ts +++ b/src/provider-credential-fingerprint.ts @@ -1,48 +1,7 @@ -import crypto from 'node:crypto'; import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; -import { - pluginCredentialVariables, - type PluginInventoryEntry, -} from '@agent-device/contracts/plugin-inventory'; +import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; import type { EnvMap } from '@agent-device/kernel/source-value'; -import { legacyStoreManifests } from '@agent-device/plugins/store'; - -type CredentialValues = Readonly>; - -/** - * A versioned, non-reversible digest of the credentials a lease on `leaseBackend` reads from `env`, - * or undefined when `env` holds none of them or the provider's credentials do not come from the - * environment. Variable names come from the daemon's plugin inventory, then from v1 manifests. - */ -export function providerCredentialFingerprint( - provider: string, - env: EnvMap, - leaseBackend?: string, - inventory?: readonly PluginInventoryEntry[], -): string | undefined { - const variables = declaredCredentialVariables(provider, env, leaseBackend, inventory); - // Whitespace-only counts as unset and other values are kept as is, as the provider's requireEnv does. - return variables - ? digest( - Object.fromEntries( - variables.map((name) => [name, env[name]?.trim() ? env[name] : undefined]), - ), - ) - : undefined; -} - -// Read from the inventory or manifest, so neither the client nor the daemon evaluates provider code. -function declaredCredentialVariables( - provider: string, - env: EnvMap, - leaseBackend: string | undefined, - inventory: readonly PluginInventoryEntry[] | undefined, -): readonly string[] | undefined { - const published = inventory && pluginCredentialVariables(inventory, provider, leaseBackend); - if (published) return published; - return legacyStoreManifests(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) - ?.agentDevicePlugin.credentialVariables; -} +import { providerCredentialFingerprint } from '@agent-device/plugins/credential-fingerprint'; export function readDaemonProviderCredentials( env: EnvMap, @@ -56,12 +15,3 @@ export function readDaemonProviderCredentials( stateDir, }; } - -function digest(values: CredentialValues): string | undefined { - const pairs = Object.entries(values) - .filter((pair): pair is [string, string] => pair[1] !== undefined) - .sort(([left], [right]) => left.localeCompare(right)); - if (pairs.length === 0) return undefined; - const hash = crypto.createHash('sha256').update(JSON.stringify(pairs)).digest('hex'); - return `v1:${hash.slice(0, 16)}`; -} From 7b2f11a75b02716b281c36dfdf1567e98978e8aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 13:54:35 +0100 Subject: [PATCH 02/17] refactor(daemon-client): extract src/daemon-client into @agent-device/daemon-client Move the daemon client into a private workspace package with one explicit entry. The CLI, typed client, MCP, SDK and daemon command reach lifecycle, ensure, stop and proxy upstream resolution through lazily loaded wrappers. Module-owned tests move with the package; root-composed tests use the entry; shared HTTP daemon, registered daemon and stream-capture fixtures move to test-kit. Co-Authored-By: Claude Sonnet 5.5 --- .../0016-active-session-script-publication.md | 2 +- docs/adr/0033-collocation-decision-table.md | 2 +- docs/agents/code-ownership.md | 2 +- .../ios-keyboard-assistant-press/README.md | 4 +- examples/sdk/tsconfig.json | 1 + fallow-baselines/health.json | 4 +- oxlint.config.ts | 6 +- package.json | 1 + packages/daemon-client/package.json | 27 +++ .../boundary-fault-acceptance.test.ts | 0 .../boundary-fault-transport.test.ts | 0 .../src/__tests__/code-signature-fixture.ts | 11 + .../daemon-client-abort-timeout.test.ts | 0 .../daemon-client-address-hints.test.ts | 0 .../daemon-client-developer-dir.test.ts | 4 +- .../daemon-client-embedded-lifecycle.test.ts | 4 +- .../daemon-client-ensure-drain.test.ts | 2 +- ...emon-client-ensure-scripted-daemon.test.ts | 0 .../__tests__/daemon-client-ensuring.test.ts | 4 +- .../daemon-client-explicit-lifecycle.test.ts | 6 +- .../daemon-client-health-compat.test.ts | 0 .../src/__tests__/daemon-client-host.test.ts | 44 ++++ .../daemon-client-lease-beat.test.ts | 0 .../daemon-client-lease-header.test.ts | 2 +- .../__tests__/daemon-client-lifecycle.test.ts | 14 +- .../daemon-client-liveness-probe.test.ts | 0 .../__tests__/daemon-client-metadata.test.ts | 0 .../daemon-client-newer-daemon.test.ts | 6 +- .../daemon-client-policy-reuse.test.ts | 8 +- .../__tests__/daemon-client-progress.test.ts | 4 +- .../daemon-client-proxy-upstream.test.ts | 41 ++++ .../daemon-client-request-guard.test.ts | 0 .../src/__tests__}/daemon-client-rpc.test.ts | 2 +- .../daemon-client-stalled-probe.test.ts | 2 +- .../daemon-client-startup-budget.test.ts | 10 +- .../daemon-client-startup-race.test.ts | 12 +- .../src/__tests__/daemon-client-stop.test.ts | 4 +- .../daemon-client-timeout-route.test.ts | 2 +- .../__tests__/daemon-client-timeout.test.ts | 2 +- .../__tests__/daemon-client-transport.test.ts | 2 +- .../src}/__tests__/daemon-client.test.ts | 8 +- .../src}/__tests__/daemon-launch-spec.test.ts | 0 .../__tests__/explicit-lifecycle-fixture.ts | 16 +- .../__tests__/registered-daemon-fixture.ts | 27 +++ .../src}/daemon-client-address-hints.ts | 2 +- .../src}/daemon-client-attach.ts | 0 .../src}/daemon-client-binding.ts | 0 .../src}/daemon-client-ensure.ts | 0 .../src}/daemon-client-ensuring.ts | 2 +- .../src}/daemon-client-lease-beat.ts | 0 .../src}/daemon-client-lifecycle-request.ts | 0 .../src}/daemon-client-lifecycle.ts | 0 .../src}/daemon-client-liveness-probe.ts | 0 .../src}/daemon-client-metadata.ts | 0 .../src}/daemon-client-progress.ts | 0 .../src/daemon-client-proxy-upstream.ts | 35 +++ .../daemon-client/src}/daemon-client-rpc.ts | 0 .../src}/daemon-client-startup.ts | 0 .../daemon-client/src}/daemon-client-stop.ts | 0 .../src}/daemon-client-timeout.ts | 2 +- .../src}/daemon-client-transport.ts | 0 .../daemon-client/src}/daemon-launch-spec.ts | 2 +- .../src}/daemon-lifecycle-errors.ts | 0 .../daemon-client/src/index.ts | 46 +++- packages/daemon-client/tsconfig.json | 25 ++ .../session-replay-repair-transaction.test.ts | 2 +- .../session-replay-runtime.test.ts | 2 +- .../host-kit/src/internal/project-root.ts | 2 +- .../test-kit/src/boundary-fault-matrix.ts | 34 ++- .../test-kit/src/http-daemon-fixture.ts | 17 +- packages/test-kit/src/index.ts | 19 ++ .../test-kit/src/registered-daemon-fixture.ts | 215 ++++++++++++++++++ .../test-kit/src}/stream-capture.test.ts | 0 .../test-kit/src}/stream-capture.ts | 0 pnpm-lock.yaml | 37 +++ .../help-conformance-sample-producers.ts | 2 +- scripts/check-affected/run.test.ts | 2 +- scripts/coverage-changed/run.test.ts | 5 +- scripts/coverage-crap/run.test.ts | 5 +- scripts/fuzz/worker.ts | 2 +- .../ios-snapshot-benchmark/cell-admission.ts | 4 +- scripts/layering/architecture-ownership.ts | 8 +- scripts/layering/daemon-modularity.test.ts | 2 +- scripts/layering/model.ts | 4 +- .../layering/package-entry-surface.test.ts | 72 ++++-- scripts/layering/package-entry-surface.ts | 8 +- scripts/layering/process-globals-policy.ts | 6 +- scripts/layering/root-module-zones.test.ts | 5 +- scripts/layering/root-module-zones.ts | 2 +- scripts/lib/recorded-daemon.ts | 104 +++++++++ scripts/perf/forced-backend.ts | 15 +- .../agent-device-client-abort-signal.test.ts | 2 +- src/__tests__/cli-agent-cdp-session.test.ts | 2 +- src/__tests__/cli-capture.ts | 14 +- src/__tests__/cli-client-commands.test.ts | 13 +- src/__tests__/cli-close.test.ts | 2 +- src/__tests__/cli-diagnostics.test.ts | 2 +- src/__tests__/cli-diff.test.ts | 2 +- src/__tests__/cli-exit-paths.test.ts | 5 +- .../cli-react-devtools-session.test.ts | 2 +- src/__tests__/cli-run-transport.test.ts | 2 +- src/__tests__/cloud-connect-auth.test.ts | 2 +- src/__tests__/cloud-connect-profile.test.ts | 2 +- ...iptor-platform-execution-cli-route.test.ts | 2 +- ...mon-client-abort-default-transport.test.ts | 4 +- .../__tests__/daemon-client-abort.test.ts | 4 +- src/__tests__/daemon-client-host.test.ts | 70 ++++++ ...daemon-client-provider-credentials.test.ts | 20 +- .../daemon-registration-owner.fixtures.ts | 10 - .../daemon-registration-owner.test.ts | 12 +- .../remote-connection-macos-app.test.ts | 7 +- src/__tests__/remote-connection.test.ts | 2 +- src/__tests__/test-utils/connect-command.ts | 3 +- .../test-utils/public-daemon-runtime.ts | 5 +- .../test-utils/registered-daemon-fixture.ts | 178 +++------------ src/agent-device-client.ts | 3 +- src/cli.ts | 2 +- src/cli/__tests__/update-check.test.ts | 3 +- src/cli/commands/__tests__/generic.test.ts | 7 +- src/cli/commands/__tests__/plugins.test.ts | 3 +- .../__tests__/proxy-lease-device-spec.test.ts | 7 +- src/cli/commands/__tests__/proxy.test.ts | 39 ++-- src/cli/commands/__tests__/screenshot.test.ts | 7 +- src/cli/commands/daemon.ts | 2 +- src/cli/commands/proxy.ts | 28 +-- .../replay-test/__tests__/progress.test.ts | 2 +- src/commands/batch/cli.test.ts | 2 +- src/commands/command-progress.test.ts | 2 +- src/commands/output/error.test.ts | 2 +- src/daemon-client/daemon-client-host.test.ts | 80 ------- src/host.test.ts | 23 +- src/host.ts | 2 +- .../command-tools-daemon-lifecycle.test.ts | 77 +++---- src/mcp/command-tools.ts | 2 +- src/sdk/ensure-daemon.ts | 2 +- .../live-observability-scenario.ts | 4 +- .../ios-simulator-e2e/live-runner.ts | 19 +- .../cli-record-publication.test.ts | 2 +- .../cloud-webdriver-lease-http.test.ts | 4 +- .../provider-scenarios/harness-proxy.ts | 4 +- .../provider-scenarios/harness-rpc.ts | 47 ++++ .../integration/provider-scenarios/harness.ts | 52 ++--- .../provider-scenarios/ios-fold.test.ts | 16 +- .../remote-proxy-parity.test.ts | 2 +- .../remote-upload-lease-beat.test.ts | 2 +- .../smoke-open-remote-config.test.ts | 6 +- test/wire-compat/README.md | 2 +- test/wire-compat/closure-policy.ts | 2 +- test/wire-compat/ledger.json | 80 +++---- test/wire-compat/surface.ts | 6 +- test/wire-compat/wire-mutations.test.ts | 8 +- tsconfig.typecheck.json | 1 + 152 files changed, 1204 insertions(+), 689 deletions(-) create mode 100644 packages/daemon-client/package.json rename {src/daemon-client => packages/daemon-client/src}/__tests__/boundary-fault-acceptance.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/boundary-fault-transport.test.ts (100%) create mode 100644 packages/daemon-client/src/__tests__/code-signature-fixture.ts rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-abort-timeout.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-address-hints.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-developer-dir.test.ts (96%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-embedded-lifecycle.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-ensure-drain.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-ensure-scripted-daemon.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-ensuring.test.ts (98%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-explicit-lifecycle.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-health-compat.test.ts (100%) create mode 100644 packages/daemon-client/src/__tests__/daemon-client-host.test.ts rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-lease-beat.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-lease-header.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-lifecycle.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-liveness-probe.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-metadata.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-newer-daemon.test.ts (95%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-policy-reuse.test.ts (97%) rename {src => packages/daemon-client/src}/__tests__/daemon-client-progress.test.ts (98%) create mode 100644 packages/daemon-client/src/__tests__/daemon-client-proxy-upstream.test.ts rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-request-guard.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src/__tests__}/daemon-client-rpc.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-stalled-probe.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-startup-budget.test.ts (98%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-startup-race.test.ts (98%) rename src/daemon-client-embedded-stop.test.ts => packages/daemon-client/src/__tests__/daemon-client-stop.test.ts (97%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-timeout-route.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-timeout.test.ts (98%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client-transport.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-client.test.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/daemon-launch-spec.test.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/__tests__/explicit-lifecycle-fixture.ts (93%) create mode 100644 packages/daemon-client/src/__tests__/registered-daemon-fixture.ts rename {src/daemon-client => packages/daemon-client/src}/daemon-client-address-hints.ts (96%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-attach.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-binding.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-ensure.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-ensuring.ts (98%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-lease-beat.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-lifecycle-request.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-lifecycle.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-liveness-probe.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-metadata.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-progress.ts (100%) create mode 100644 packages/daemon-client/src/daemon-client-proxy-upstream.ts rename {src/daemon-client => packages/daemon-client/src}/daemon-client-rpc.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-startup.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-stop.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-timeout.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/daemon-client-transport.ts (100%) rename {src/daemon-client => packages/daemon-client/src}/daemon-launch-spec.ts (99%) rename {src/daemon-client => packages/daemon-client/src}/daemon-lifecycle-errors.ts (100%) rename src/daemon-client/daemon-client.ts => packages/daemon-client/src/index.ts (87%) create mode 100644 packages/daemon-client/tsconfig.json rename src/__tests__/test-utils/daemon-http-fixture.ts => packages/test-kit/src/http-daemon-fixture.ts (72%) create mode 100644 packages/test-kit/src/registered-daemon-fixture.ts rename {src/__tests__/test-utils => packages/test-kit/src}/stream-capture.test.ts (100%) rename {src/__tests__/test-utils => packages/test-kit/src}/stream-capture.ts (100%) create mode 100644 scripts/lib/recorded-daemon.ts rename src/{daemon-client => }/__tests__/daemon-client-abort-default-transport.test.ts (95%) rename src/{daemon-client => }/__tests__/daemon-client-abort.test.ts (96%) create mode 100644 src/__tests__/daemon-client-host.test.ts rename src/{daemon-client => }/__tests__/daemon-client-provider-credentials.test.ts (90%) delete mode 100644 src/daemon-client/daemon-client-host.test.ts diff --git a/docs/adr/0016-active-session-script-publication.md b/docs/adr/0016-active-session-script-publication.md index e0136ef5b4..ccc156c41f 100644 --- a/docs/adr/0016-active-session-script-publication.md +++ b/docs/adr/0016-active-session-script-publication.md @@ -242,7 +242,7 @@ The daemon never computes it by a re-parse of the script for `close`. This field lets the real CLI/IPC client (not only the in-process handler) actually honor "the session stays active". Before this field, the response had no explicit signal. The one-shot `replay`/`test` teardown of the client had no way to distinguish a still-active handoff from a finished one. -It tore the owning daemon down regardless (`src/daemon-client/daemon-client-lifecycle.ts`). +It tore the owning daemon down regardless (`packages/daemon-client/src/daemon-client-lifecycle.ts`). `sessionActive` is `true` for every close-less run, including a `--from` resume. It is `false` once the terminal `close` of the script, or the deferred equivalent of a repair-armed run, has executed. diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 3f1bd35440..4c61f0a6b2 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -143,7 +143,7 @@ The `client-side` logical-module policy prohibits every edge into `packages/daem - CLI. - Typed client. - Remote transports. -- `src/daemon-client/`. +- `packages/daemon-client/src/`. R78 also prohibits access through any root module reachable by the CLI process. #3288's `daemon-contracts` zone declared ownership below both daemon and client. diff --git a/docs/agents/code-ownership.md b/docs/agents/code-ownership.md index 42312c23ec..e4ddc21f50 100644 --- a/docs/agents/code-ownership.md +++ b/docs/agents/code-ownership.md @@ -21,7 +21,7 @@ For SDK entries `android-adb`, `artifacts`, `limrun`, `metro`, and `remote-confi For command-host readers, use `withSdkHost`. Keep provider callbacks on their supplied host. Request cancellation and progress belong in `@agent-device/host-kit/request`. -Per-call abort guards belong beside transports in `src/daemon-client/daemon-client-transport.ts`. +Per-call abort guards belong beside transports in `packages/daemon-client/src/daemon-client-transport.ts`. Contracts shared across layers belong in `packages/contracts/src`. Schema composition belongs in `packages/command-surface/src/schema`. A command facet declares no output formatter. Register the formatter in `src/commands/cli-output.ts`. diff --git a/docs/evidence/ios-keyboard-assistant-press/README.md b/docs/evidence/ios-keyboard-assistant-press/README.md index 82bda3f754..559bce3bb7 100644 --- a/docs/evidence/ios-keyboard-assistant-press/README.md +++ b/docs/evidence/ios-keyboard-assistant-press/README.md @@ -57,8 +57,8 @@ import { pathToFileURL } from 'node:url'; const root = process.cwd(); const load = path => import(pathToFileURL(`${root}/${path}`).href); const { createAgentDeviceClient } = await load('dist/src/index.js'); -const { readDaemonInfo } = await load('src/daemon-client/daemon-client-metadata.ts'); -const { sendRequest } = await load('src/daemon-client/daemon-client-transport.ts'); +const { readDaemonInfo } = await load('packages/daemon-client/src/daemon-client-metadata.ts'); +const { sendRequest } = await load('packages/daemon-client/src/daemon-client-transport.ts'); const { resolveDaemonPaths } = await load('packages/daemon-contracts/src/daemon-resolution.ts'); const { createSnapshotBackendConformanceTransport } = await load('test/integration/ios-simulator-e2e/snapshot-backend-conformance.ts'); const { STATE_DIR: stateDir, UDID: udid } = process.env; diff --git a/examples/sdk/tsconfig.json b/examples/sdk/tsconfig.json index 0ece17594a..6076fc0199 100644 --- a/examples/sdk/tsconfig.json +++ b/examples/sdk/tsconfig.json @@ -43,6 +43,7 @@ { "path": "../../packages/command-surface" }, { "path": "../../packages/contracts" }, { "path": "../../packages/daemon" }, + { "path": "../../packages/daemon-client" }, { "path": "../../packages/daemon-contracts" }, { "path": "../../packages/device-selection" }, { "path": "../../packages/host-kit" }, diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index 46defdbf75..3ea52bd8e9 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -78,7 +78,7 @@ }, "runtime_coverage_findings": [], "target_keys": [ - "src/daemon-client/daemon-client.ts:high impact", + "packages/daemon-client/src/index.ts:high impact", "src/cli/parser/args.ts:high impact", "packages/daemon/src/core/lease-context.ts:high impact", "packages/replay-port/src/daemon-port/session-replay-heal.ts:high impact", @@ -117,7 +117,7 @@ "packages/platform-android/src/app-parsers.ts:high impact", "packages/platform-android/src/ui-hierarchy.ts:high impact", "packages/platform-android/src/sdk.ts:high impact", - "src/daemon-client/daemon-client-lifecycle.ts:complexity", + "packages/daemon-client/src/daemon-client-lifecycle.ts:complexity", "packages/react-native/src/companion/client-companion-tunnel-worker.ts:complexity" ] } diff --git a/oxlint.config.ts b/oxlint.config.ts index d0f5ef8fdc..eca472b15d 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -52,8 +52,8 @@ const LEGACY_FILE_LINE_CAPS = { 'src/__tests__/cli-network.test.ts': 1092, 'src/__tests__/remote-connection.test.ts': 2324, 'packages/command-runtime/src/commands/interaction/runtime/settle.test.ts': 2094, - 'src/daemon-client/__tests__/daemon-client-lifecycle.test.ts': 1169, - 'src/daemon-client/__tests__/daemon-client.test.ts': 1340, + 'packages/daemon-client/src/__tests__/daemon-client-lifecycle.test.ts': 1169, + 'packages/daemon-client/src/__tests__/daemon-client.test.ts': 1340, 'src/mcp/__tests__/command-tools.test.ts': 1029, 'test/integration/provider-scenarios/android-lifecycle.test.ts': 1227, 'packages/host-kit/src/internal/exec.ts': 1032, @@ -174,7 +174,7 @@ export default defineConfig({ }, { files: [ - 'src/daemon-client/daemon-client-rpc.ts', + 'packages/daemon-client/src/daemon-client-rpc.ts', 'packages/daemon/src/server/downloadable-artifact-http.ts', 'packages/remote/src/remote-request-diagnostics.ts', 'packages/remote/src/upload-stream.ts', diff --git a/package.json b/package.json index f938469814..3fb82e7ed7 100644 --- a/package.json +++ b/package.json @@ -308,6 +308,7 @@ "@agent-device/command-surface": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/daemon": "workspace:*", + "@agent-device/daemon-client": "workspace:*", "@agent-device/daemon-contracts": "workspace:*", "@agent-device/device-selection": "workspace:*", "@agent-device/host-kit": "workspace:*", diff --git a/packages/daemon-client/package.json b/packages/daemon-client/package.json new file mode 100644 index 0000000000..8d0fa16303 --- /dev/null +++ b/packages/daemon-client/package.json @@ -0,0 +1,27 @@ +{ + "name": "@agent-device/daemon-client", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "The client side of the daemon wire: transport and JSON-RPC framing, request cancellation and progress, local daemon launch, ensure, attach and stop, and remote daemon health. Internal workspace package bundled into the published agent-device artifact.", + "dependencies": { + "@agent-device/ad-replay": "workspace:*", + "@agent-device/command-registry": "workspace:*", + "@agent-device/contracts": "workspace:*", + "@agent-device/daemon-contracts": "workspace:*", + "@agent-device/host-kit": "workspace:*", + "@agent-device/kernel": "workspace:*", + "@agent-device/plugins": "workspace:*", + "@agent-device/remote": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "devDependencies": { + "@agent-device/proxy": "workspace:*", + "@agent-device/test-kit": "workspace:*" + } +} diff --git a/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts b/packages/daemon-client/src/__tests__/boundary-fault-acceptance.test.ts similarity index 100% rename from src/daemon-client/__tests__/boundary-fault-acceptance.test.ts rename to packages/daemon-client/src/__tests__/boundary-fault-acceptance.test.ts diff --git a/src/daemon-client/__tests__/boundary-fault-transport.test.ts b/packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts similarity index 100% rename from src/daemon-client/__tests__/boundary-fault-transport.test.ts rename to packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts diff --git a/packages/daemon-client/src/__tests__/code-signature-fixture.ts b/packages/daemon-client/src/__tests__/code-signature-fixture.ts new file mode 100644 index 0000000000..943eeced7a --- /dev/null +++ b/packages/daemon-client/src/__tests__/code-signature-fixture.ts @@ -0,0 +1,11 @@ +import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; +import { resolveDaemonLaunchSpec } from '../daemon-launch-spec.ts'; + +/** + * The code signature a daemon started from this checkout records, read through the same launch + * spec the client uses to launch and sign its daemon, so a fixture daemon is reused. + */ +export function currentDaemonCodeSignature(): string { + const spec = resolveDaemonLaunchSpec(); + return computeDaemonCodeSignature(spec.useSrc ? spec.srcPath : spec.distPath, spec.root); +} diff --git a/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts b/packages/daemon-client/src/__tests__/daemon-client-abort-timeout.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-abort-timeout.test.ts diff --git a/src/daemon-client/__tests__/daemon-client-address-hints.test.ts b/packages/daemon-client/src/__tests__/daemon-client-address-hints.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-client-address-hints.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-address-hints.test.ts diff --git a/src/daemon-client/__tests__/daemon-client-developer-dir.test.ts b/packages/daemon-client/src/__tests__/daemon-client-developer-dir.test.ts similarity index 96% rename from src/daemon-client/__tests__/daemon-client-developer-dir.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-developer-dir.test.ts index 3785a285e6..dc2d35919d 100644 --- a/src/daemon-client/__tests__/daemon-client-developer-dir.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-developer-dir.test.ts @@ -5,9 +5,9 @@ import http from 'node:http'; import net from 'node:net'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { readVersion } from '@agent-device/host-kit/version'; -import { sendToDaemon } from '../daemon-client.ts'; +import { sendToDaemon } from '../index.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { currentDaemonCodeSignature } from '../../__tests__/test-utils/daemon-http-fixture.ts'; +import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; import { closeLoopbackServer, listenOnLoopback, diff --git a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts b/packages/daemon-client/src/__tests__/daemon-client-embedded-lifecycle.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-embedded-lifecycle.test.ts index 6432a9170e..44ac3f174d 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-embedded-lifecycle.test.ts @@ -17,7 +17,7 @@ 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'; -import { finishRegisteredDaemonFixtures } from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +import { finishRegisteredDaemonFixtures } from './registered-daemon-fixture.ts'; import { launchesFreshDaemonAt, okResult, @@ -31,7 +31,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ runCmdDetachedMonitored: vi.fn(), })); import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; -import { createDaemonTransport, type sendToDaemon } from '../daemon-client.ts'; +import { createDaemonTransport, type sendToDaemon } from '../index.ts'; import { resolveClientSettings, resolveTimeoutRecovery } from '../daemon-client-lifecycle.ts'; import { readDaemonInfo } from '../daemon-client-metadata.ts'; diff --git a/src/daemon-client/__tests__/daemon-client-ensure-drain.test.ts b/packages/daemon-client/src/__tests__/daemon-client-ensure-drain.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client-ensure-drain.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-ensure-drain.test.ts index ba62af57ec..1ee6163e51 100644 --- a/src/daemon-client/__tests__/daemon-client-ensure-drain.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-ensure-drain.test.ts @@ -6,7 +6,7 @@ import { isProcessAlive, readHostProcessIdentityObservations, } from '@agent-device/host-kit/process'; -import { finishRegisteredDaemonFixtures } from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +import { finishRegisteredDaemonFixtures } from './registered-daemon-fixture.ts'; import { draining, launchesFreshDaemonAt, diff --git a/src/daemon-client/__tests__/daemon-client-ensure-scripted-daemon.test.ts b/packages/daemon-client/src/__tests__/daemon-client-ensure-scripted-daemon.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-client-ensure-scripted-daemon.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-ensure-scripted-daemon.test.ts diff --git a/src/daemon-client/__tests__/daemon-client-ensuring.test.ts b/packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts similarity index 98% rename from src/daemon-client/__tests__/daemon-client-ensuring.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts index e62dbd5f5d..5b0f173982 100644 --- a/src/daemon-client/__tests__/daemon-client-ensuring.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts @@ -3,7 +3,7 @@ import { afterEach, test, vi } from 'vitest'; import { closeLoopbackServer, supportsLoopbackBind } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; import { isProcessAlive } from '@agent-device/host-kit/process'; -import { finishRegisteredDaemonFixtures } from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +import { finishRegisteredDaemonFixtures } from './registered-daemon-fixture.ts'; import { busy, createDaemonLeaver, @@ -22,7 +22,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; import { withEnsuredDaemon } from '../daemon-client-ensuring.ts'; import { daemonNotRunningError } from '../daemon-lifecycle-errors.ts'; -import { createEnsuredDaemonTransport, type sendToDaemon } from '../daemon-client.ts'; +import { createEnsuredDaemonTransport, type sendToDaemon } from '../index.ts'; import * as ensureModule from '../daemon-client-ensure.ts'; // The CLI and MCP reach the daemon through `withEnsuredDaemon`. The transport underneath is a diff --git a/src/daemon-client/__tests__/daemon-client-explicit-lifecycle.test.ts b/packages/daemon-client/src/__tests__/daemon-client-explicit-lifecycle.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client-explicit-lifecycle.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-explicit-lifecycle.test.ts index 0936fa2cc8..8d455673ee 100644 --- a/src/daemon-client/__tests__/daemon-client-explicit-lifecycle.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-explicit-lifecycle.test.ts @@ -12,8 +12,8 @@ import { finishRegisteredDaemonFixtures, spawnRegisteredDaemonFixture, waitForRegisteredDaemonFixture, -} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; -import { currentDaemonCodeSignature } from '../../__tests__/test-utils/daemon-http-fixture.ts'; +} from './registered-daemon-fixture.ts'; +import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; import { busy, createDaemonLeaver, @@ -37,7 +37,7 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({ import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; import { sleep } from '@agent-device/host-kit/retry'; import * as transport from '../daemon-client-transport.ts'; -import { sendToDaemon } from '../daemon-client.ts'; +import { sendToDaemon } from '../index.ts'; import { ensureDaemon } from '../daemon-client-ensure.ts'; // The explicit lifecycle: what an attach client may never do, and what `ensureDaemon` may do only diff --git a/src/daemon-client/__tests__/daemon-client-health-compat.test.ts b/packages/daemon-client/src/__tests__/daemon-client-health-compat.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-client-health-compat.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-health-compat.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-host.test.ts b/packages/daemon-client/src/__tests__/daemon-client-host.test.ts new file mode 100644 index 0000000000..a7560f3126 --- /dev/null +++ b/packages/daemon-client/src/__tests__/daemon-client-host.test.ts @@ -0,0 +1,44 @@ +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; + +const ensured = vi.hoisted(() => ({ settings: undefined as unknown })); + +vi.mock('../daemon-client-lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), + ensureManagedDaemon: async (settings: unknown) => { + ensured.settings = settings; + throw new Error('stop before talking to a daemon'); + }, +})); +vi.mock('../daemon-client-startup.ts', async (importOriginal) => ({ + ...(await importOriginal()), + observeLocalDaemon: async (settings: unknown) => { + ensured.settings = settings; + throw new Error('stop before talking to a daemon'); + }, +})); + +import { createDaemonTransport } from '../index.ts'; +import type { DaemonClientSettings } from '../daemon-client-lifecycle.ts'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +const request = { session: 'default', command: 'devices', positionals: [] }; + +test('a transport resolves its daemon from the host it was built for, not the process', async () => { + vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); + const host = { + env: { AGENT_DEVICE_STATE_DIR: 'client-state', AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }, + cwd: '/client/workspace', + }; + + await expect(createDaemonTransport(host)(request)).rejects.toThrow('stop before talking'); + + const settings = ensured.settings as DaemonClientSettings; + expect(settings.host).toBe(host); + expect(settings.paths.baseDir).toBe(path.resolve('/client/workspace', 'client-state')); + expect(settings.serverMode).toBe('http'); +}); diff --git a/src/daemon-client/__tests__/daemon-client-lease-beat.test.ts b/packages/daemon-client/src/__tests__/daemon-client-lease-beat.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-client-lease-beat.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-lease-beat.test.ts diff --git a/src/daemon-client/__tests__/daemon-client-lease-header.test.ts b/packages/daemon-client/src/__tests__/daemon-client-lease-header.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client-lease-header.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-lease-header.test.ts index 69ccb90699..05f1a92ab1 100644 --- a/src/daemon-client/__tests__/daemon-client-lease-header.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-lease-header.test.ts @@ -16,7 +16,7 @@ import { mkdtempForTestSync, skipWhenLoopbackUnavailable, } from '@agent-device/test-kit'; -import { sendToDaemon } from '../daemon-client.ts'; +import { sendToDaemon } from '../index.ts'; import { DAEMON_HTTP_LEASE_HEADER } from '@agent-device/contracts/daemon-http'; const DAEMON_TOKEN = 'remote-secret'; diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/packages/daemon-client/src/__tests__/daemon-client-lifecycle.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client-lifecycle.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-lifecycle.test.ts index 02fa06b0e0..ae7f1c8134 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-lifecycle.test.ts @@ -6,16 +6,19 @@ import net from 'node:net'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { + beginStderrCapture, closeLoopbackServer, + type HttpDaemonFixture, listenOnLoopback, mkdtempForTestSync, + startHttpDaemonFixture, supportsLoopbackBind, } from '@agent-device/test-kit'; import { spawnRegisteredDaemonFixture, finishRegisteredDaemonFixture, finishRegisteredDaemonFixtures, -} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +} from './registered-daemon-fixture.ts'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), @@ -29,13 +32,8 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({ })); import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { sendToDaemon } from '../daemon-client.ts'; -import { - startHttpDaemonFixture, - type HttpDaemonFixture, - currentDaemonCodeSignature, -} from '../../__tests__/test-utils/daemon-http-fixture.ts'; -import { beginStderrCapture } from '../../__tests__/test-utils/stream-capture.ts'; +import { sendToDaemon } from '../index.ts'; +import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { tryAcquireProcessLock, inspectProcessLock } from '@agent-device/host-kit/file'; import { runCmdDetachedMonitored, runCmdSync } from '@agent-device/host-kit/command'; diff --git a/src/daemon-client/__tests__/daemon-client-liveness-probe.test.ts b/packages/daemon-client/src/__tests__/daemon-client-liveness-probe.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-client-liveness-probe.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-liveness-probe.test.ts diff --git a/src/daemon-client/__tests__/daemon-client-metadata.test.ts b/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-client-metadata.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts diff --git a/src/daemon-client/__tests__/daemon-client-newer-daemon.test.ts b/packages/daemon-client/src/__tests__/daemon-client-newer-daemon.test.ts similarity index 95% rename from src/daemon-client/__tests__/daemon-client-newer-daemon.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-newer-daemon.test.ts index d50c07e545..962ec4a262 100644 --- a/src/daemon-client/__tests__/daemon-client-newer-daemon.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-newer-daemon.test.ts @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { afterEach, test, vi } from 'vitest'; import { + beginStderrCapture, closeLoopbackServer, mkdtempForTestSync, + startHttpDaemonFixture, supportsLoopbackBind, } from '@agent-device/test-kit'; @@ -15,9 +17,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ })); import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { sendToDaemon } from '../daemon-client.ts'; -import { startHttpDaemonFixture } from '../../__tests__/test-utils/daemon-http-fixture.ts'; -import { beginStderrCapture } from '../../__tests__/test-utils/stream-capture.ts'; +import { sendToDaemon } from '../index.ts'; import { AppError } from '@agent-device/kernel/errors'; import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; diff --git a/src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts b/packages/daemon-client/src/__tests__/daemon-client-policy-reuse.test.ts similarity index 97% rename from src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-policy-reuse.test.ts index 7645c0dde5..4f2d5358e7 100644 --- a/src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-policy-reuse.test.ts @@ -12,18 +12,16 @@ import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { readVersion } from '@agent-device/host-kit/version'; import { AppError } from '@agent-device/kernel/errors'; -import { - currentDaemonCodeSignature, - startHttpDaemonFixture, -} from '../../__tests__/test-utils/daemon-http-fixture.ts'; +import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; import { closeLoopbackServer, mkdtempForTestSync, + startHttpDaemonFixture, supportsLoopbackBind, } from '@agent-device/test-kit'; import { loadDaemonPolicy } from '@agent-device/daemon-contracts/daemon-policy-file'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { sendToDaemon } from '../daemon-client.ts'; +import { sendToDaemon } from '../index.ts'; // ADR 0029: a caller that names a daemon policy must not use a daemon that enforces another one. diff --git a/src/__tests__/daemon-client-progress.test.ts b/packages/daemon-client/src/__tests__/daemon-client-progress.test.ts similarity index 98% rename from src/__tests__/daemon-client-progress.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-progress.test.ts index cbad7b3cbd..80384342a4 100644 --- a/src/__tests__/daemon-client-progress.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-progress.test.ts @@ -5,8 +5,8 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import type { Socket } from 'node:net'; import { test } from 'vitest'; -import { readDaemonSocketProgressResponse } from '../daemon-client/daemon-client-progress.ts'; -import { beginStderrCapture } from './test-utils/stream-capture.ts'; +import { readDaemonSocketProgressResponse } from '../daemon-client-progress.ts'; +import { beginStderrCapture } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; type MockSocket = EventEmitter & { diff --git a/packages/daemon-client/src/__tests__/daemon-client-proxy-upstream.test.ts b/packages/daemon-client/src/__tests__/daemon-client-proxy-upstream.test.ts new file mode 100644 index 0000000000..b065efa2c6 --- /dev/null +++ b/packages/daemon-client/src/__tests__/daemon-client-proxy-upstream.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { expect, test, vi } from 'vitest'; +import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; + +const mocks = vi.hoisted(() => ({ ensureManagedDaemon: vi.fn() })); + +vi.mock('../daemon-client-lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), + ensureManagedDaemon: mocks.ensureManagedDaemon, +})); + +import { ensureProxyUpstreamDaemon } from '../daemon-client-proxy-upstream.ts'; + +test('the proxy upstream is the managed local HTTP daemon of the resolved state dir', async () => { + const stateDir = mkdtempForTestSync('agent-device-proxy-upstream-'); + mocks.ensureManagedDaemon.mockResolvedValue({ + info: { httpPort: 54321, token: 'daemon-secret', pid: 4242 }, + startedByClient: false, + }); + + const upstream = await ensureProxyUpstreamDaemon({ + stateDir, + host: createTestHostEnvironment({ + env: { AGENT_DEVICE_DAEMON_BASE_URL: 'http://127.0.0.1:1' }, + }), + }); + + assert.deepEqual(upstream, { + httpPort: 54321, + token: 'daemon-secret', + stateDir: resolveDaemonPaths(stateDir).baseDir, + }); + const settings = mocks.ensureManagedDaemon.mock.calls[0]![0]; + expect(settings).toMatchObject({ + daemonLifecycle: 'managed', + transportPreference: 'http', + serverMode: 'http', + }); + assert.equal(settings.remoteBaseUrl, undefined); +}); diff --git a/src/daemon-client/__tests__/daemon-client-request-guard.test.ts b/packages/daemon-client/src/__tests__/daemon-client-request-guard.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-client-request-guard.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-request-guard.test.ts diff --git a/src/daemon-client/daemon-client-rpc.test.ts b/packages/daemon-client/src/__tests__/daemon-client-rpc.test.ts similarity index 99% rename from src/daemon-client/daemon-client-rpc.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-rpc.test.ts index a8c49fdce7..243c3dd1ee 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-rpc.test.ts @@ -2,7 +2,7 @@ import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from './daemon-client-rpc.ts'; +import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from '../daemon-client-rpc.ts'; test('lease allocation transports an optional initial provider app', () => { const payload = buildHttpRpcPayload( diff --git a/src/daemon-client/__tests__/daemon-client-stalled-probe.test.ts b/packages/daemon-client/src/__tests__/daemon-client-stalled-probe.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client-stalled-probe.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-stalled-probe.test.ts index 210629a59b..2e9ed20885 100644 --- a/src/daemon-client/__tests__/daemon-client-stalled-probe.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-stalled-probe.test.ts @@ -13,7 +13,7 @@ import { } from '@agent-device/host-kit/process'; import { findProjectRoot, readVersion } from '@agent-device/host-kit/version'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { sendToDaemon } from '../daemon-client.ts'; +import { sendToDaemon } from '../index.ts'; import { closeLoopbackServer, listenOnLoopback, diff --git a/src/daemon-client/__tests__/daemon-client-startup-budget.test.ts b/packages/daemon-client/src/__tests__/daemon-client-startup-budget.test.ts similarity index 98% rename from src/daemon-client/__tests__/daemon-client-startup-budget.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-startup-budget.test.ts index 577aedb236..984f29afbc 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-budget.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-startup-budget.test.ts @@ -18,6 +18,7 @@ import { import { closeLoopbackServer, mkdtempForTestSync, + startHttpDaemonFixture, supportsLoopbackBind, } from '@agent-device/test-kit'; import { isProcessAlive } from '@agent-device/host-kit/process'; @@ -26,11 +27,8 @@ import { spawnRegisteredDaemonFixture, finishRegisteredDaemonFixtures, waitForFixtureFile, -} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; -import { - startHttpDaemonFixture, - currentDaemonCodeSignature, -} from '../../__tests__/test-utils/daemon-http-fixture.ts'; +} from './registered-daemon-fixture.ts'; +import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), @@ -51,7 +49,7 @@ vi.mock('@agent-device/host-kit/process', async (importOriginal) => { }); import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; import { sleep } from '@agent-device/host-kit/retry'; -import { sendToDaemon } from '../daemon-client.ts'; +import { sendToDaemon } from '../index.ts'; const actualRetry = await vi.importActual( '@agent-device/host-kit/retry', diff --git a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts b/packages/daemon-client/src/__tests__/daemon-client-startup-race.test.ts similarity index 98% rename from src/daemon-client/__tests__/daemon-client-startup-race.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-startup-race.test.ts index d1c1a26430..1bbcbd870d 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-startup-race.test.ts @@ -11,24 +11,22 @@ import { } from '@agent-device/host-kit/process'; import { readVersion } from '@agent-device/host-kit/version'; import { + beginStderrCapture, closeLoopbackServer, mkdtempForTestSync, + startHttpDaemonFixture, supportsLoopbackBind, } from '@agent-device/test-kit'; import { spawnRegisteredDaemonFixture, finishRegisteredDaemonFixtures, -} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; -import { - startHttpDaemonFixture, - currentDaemonCodeSignature, -} from '../../__tests__/test-utils/daemon-http-fixture.ts'; -import { beginStderrCapture } from '../../__tests__/test-utils/stream-capture.ts'; +} from './registered-daemon-fixture.ts'; +import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; import { resolveDaemonPaths, type DaemonPaths, } from '@agent-device/daemon-contracts/daemon-resolution'; -import { sendToDaemon } from '../daemon-client.ts'; +import { sendToDaemon } from '../index.ts'; import * as lifecycle from '../daemon-client-lifecycle.ts'; import { DAEMON_STARTUP_EXIT_CODES } from '@agent-device/daemon-contracts/daemon-registration-owner'; diff --git a/src/daemon-client-embedded-stop.test.ts b/packages/daemon-client/src/__tests__/daemon-client-stop.test.ts similarity index 97% rename from src/daemon-client-embedded-stop.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-stop.test.ts index 6c8c9c38c5..3d9ca6980a 100644 --- a/src/daemon-client-embedded-stop.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-stop.test.ts @@ -6,8 +6,8 @@ import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { readDaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; -import { stopRecordedDaemon } from './daemon-client/daemon-client-stop.ts'; -import * as transport from './daemon-client/daemon-client-transport.ts'; +import { stopRecordedDaemon } from '../daemon-client-stop.ts'; +import * as transport from '../daemon-client-transport.ts'; afterEach(() => vi.restoreAllMocks()); diff --git a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts b/packages/daemon-client/src/__tests__/daemon-client-timeout-route.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client-timeout-route.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-timeout-route.test.ts index 1bd7e64a9f..4e0f603fc1 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-timeout-route.test.ts @@ -55,7 +55,7 @@ import { spawnRegisteredDaemonFixture, waitForRegisteredDaemonFixture, finishRegisteredDaemonFixtures, -} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +} from './registered-daemon-fixture.ts'; async function registeredOwner(paths: DaemonPaths, port: number, transport: 'http' | 'socket') { const child = spawnRegisteredDaemonFixture( diff --git a/src/daemon-client/__tests__/daemon-client-timeout.test.ts b/packages/daemon-client/src/__tests__/daemon-client-timeout.test.ts similarity index 98% rename from src/daemon-client/__tests__/daemon-client-timeout.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-timeout.test.ts index f1559d7f5b..4c490491e1 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-timeout.test.ts @@ -1,4 +1,4 @@ -// The pure hint formatter in src/daemon-client/daemon-client-timeout.ts: what a timed-out request +// The pure hint formatter in packages/daemon-client/src/daemon-client-timeout.ts: what a timed-out request // tells the caller to do next. daemon-client-timeout-route.test.ts covers the same route at its // production seam, where the liveness-gated recovery is decided; these assertions only fix wording. diff --git a/src/daemon-client/__tests__/daemon-client-transport.test.ts b/packages/daemon-client/src/__tests__/daemon-client-transport.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client-transport.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-transport.test.ts index a1319a5676..75ac741dfe 100644 --- a/src/daemon-client/__tests__/daemon-client-transport.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-transport.test.ts @@ -20,7 +20,7 @@ import { DAEMON_HTTP_INSTANCE_MISMATCH_HEADER, DAEMON_RPC_PROTOCOL_VERSION, } from '@agent-device/contracts/daemon-http'; -import { sendToDaemon } from '../daemon-client.ts'; +import { sendToDaemon } from '../index.ts'; import { canConnect, isDaemonTransportUnavailableError, diff --git a/src/daemon-client/__tests__/daemon-client.test.ts b/packages/daemon-client/src/__tests__/daemon-client.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client.test.ts rename to packages/daemon-client/src/__tests__/daemon-client.test.ts index a7be7d788f..99bb99ed9b 100644 --- a/src/daemon-client/__tests__/daemon-client.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client.test.ts @@ -11,14 +11,12 @@ import { listenOnLoopback, mkdtempForTestSync, supportsLoopbackBind, -} from '@agent-device/test-kit'; -import { beginStderrCapture, withCapturedStderrAsync, -} from '../../__tests__/test-utils/stream-capture.ts'; +} from '@agent-device/test-kit'; import { readProcessStartTime } from '@agent-device/host-kit/process'; -import { sendToDaemon } from '../daemon-client.ts'; -import { currentDaemonCodeSignature } from '../../__tests__/test-utils/daemon-http-fixture.ts'; +import { sendToDaemon } from '../index.ts'; +import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; import { downloadRemoteArtifact } from '@agent-device/remote/daemon-artifacts'; import { resolveDaemonStartupHint } from '../daemon-client-metadata.ts'; diff --git a/src/daemon-client/__tests__/daemon-launch-spec.test.ts b/packages/daemon-client/src/__tests__/daemon-launch-spec.test.ts similarity index 100% rename from src/daemon-client/__tests__/daemon-launch-spec.test.ts rename to packages/daemon-client/src/__tests__/daemon-launch-spec.test.ts diff --git a/src/daemon-client/__tests__/explicit-lifecycle-fixture.ts b/packages/daemon-client/src/__tests__/explicit-lifecycle-fixture.ts similarity index 93% rename from src/daemon-client/__tests__/explicit-lifecycle-fixture.ts rename to packages/daemon-client/src/__tests__/explicit-lifecycle-fixture.ts index 851cdbfe3f..ed0c5644ec 100644 --- a/src/daemon-client/__tests__/explicit-lifecycle-fixture.ts +++ b/packages/daemon-client/src/__tests__/explicit-lifecycle-fixture.ts @@ -1,7 +1,11 @@ import http from 'node:http'; import net from 'node:net'; import { vi, type Mock } from 'vitest'; -import { listenOnLoopback, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + listenOnLoopback, + mkdtempForTestSync, + type RegisteredDaemonChild, +} from '@agent-device/test-kit'; import { resolveDaemonPaths, type DaemonPaths, @@ -11,8 +15,9 @@ import { readVersion } from '@agent-device/host-kit/version'; import { spawnRegisteredDaemonFixture, waitForRegisteredDaemonFixture, -} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; -import { currentDaemonCodeSignature } from '../../__tests__/test-utils/daemon-http-fixture.ts'; +} from './registered-daemon-fixture.ts'; +import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; +import type { DaemonInfo } from '../daemon-client-metadata.ts'; // Stand-ins for the daemons the explicit lifecycle judges: a loopback server that answers every // `/rpc` with one scripted result, and a real child process that owns its registration. @@ -137,7 +142,10 @@ type Registration = { }; /** A live child owning the registration, advertising `fields` at the stand-in's port. */ -export async function registerDaemon(paths: DaemonPaths, fields: Registration) { +export async function registerDaemon( + paths: DaemonPaths, + fields: Registration, +): Promise<{ child: RegisteredDaemonChild; info: DaemonInfo }> { const child = spawnRegisteredDaemonFixture( paths, { diff --git a/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts b/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts new file mode 100644 index 0000000000..2605950238 --- /dev/null +++ b/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts @@ -0,0 +1,27 @@ +import { vi } from 'vitest'; +import { createRegisteredDaemonFixtures } from '@agent-device/test-kit'; +import { stopDaemonProcess } from '@agent-device/daemon-contracts/daemon-process'; +import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import type { DaemonRegistrationFields } from '@agent-device/daemon-contracts/daemon-registration-owner'; +import { readDaemonInfo, type DaemonInfo } from '../daemon-client-metadata.ts'; + +const actualCommand = await vi.importActual( + '@agent-device/host-kit/command', +); + +export const { + registeredDaemonFixtureArgs, + launchRegisteredDaemonFixtureArgs, + spawnRegisteredDaemonFixture, + waitForFixtureFile, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, +} = createRegisteredDaemonFixtures({ + registrationOwnerUrl: import.meta + .resolve('@agent-device/daemon-contracts/daemon-registration-owner'), + startupPhasesUrl: import.meta.resolve('@agent-device/daemon-contracts/daemon-startup-phases'), + launch: actualCommand.runCmdDetachedMonitored, + stopDaemonProcess, + readDaemonInfo, +}); diff --git a/src/daemon-client/daemon-client-address-hints.ts b/packages/daemon-client/src/daemon-client-address-hints.ts similarity index 96% rename from src/daemon-client/daemon-client-address-hints.ts rename to packages/daemon-client/src/daemon-client-address-hints.ts index 731dd8b180..4943665f90 100644 --- a/src/daemon-client/daemon-client-address-hints.ts +++ b/packages/daemon-client/src/daemon-client-address-hints.ts @@ -1,4 +1,4 @@ -import type { DaemonResponse } from './daemon-client.ts'; +import type { DaemonResponse } from './index.ts'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; /** Adds copyable addressing for a kept-alive replay session, without URL credentials. */ diff --git a/src/daemon-client/daemon-client-attach.ts b/packages/daemon-client/src/daemon-client-attach.ts similarity index 100% rename from src/daemon-client/daemon-client-attach.ts rename to packages/daemon-client/src/daemon-client-attach.ts diff --git a/src/daemon-client/daemon-client-binding.ts b/packages/daemon-client/src/daemon-client-binding.ts similarity index 100% rename from src/daemon-client/daemon-client-binding.ts rename to packages/daemon-client/src/daemon-client-binding.ts diff --git a/src/daemon-client/daemon-client-ensure.ts b/packages/daemon-client/src/daemon-client-ensure.ts similarity index 100% rename from src/daemon-client/daemon-client-ensure.ts rename to packages/daemon-client/src/daemon-client-ensure.ts diff --git a/src/daemon-client/daemon-client-ensuring.ts b/packages/daemon-client/src/daemon-client-ensuring.ts similarity index 98% rename from src/daemon-client/daemon-client-ensuring.ts rename to packages/daemon-client/src/daemon-client-ensuring.ts index a43275ba5a..ce3c9ce63d 100644 --- a/src/daemon-client/daemon-client-ensuring.ts +++ b/packages/daemon-client/src/daemon-client-ensuring.ts @@ -2,7 +2,7 @@ import type { DaemonIdentity, EnsureDaemonOptions } from '@agent-device/contract import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import type { sendToDaemon } from './daemon-client.ts'; +import type { sendToDaemon } from './index.ts'; import { isDaemonNotRunningError } from './daemon-lifecycle-errors.ts'; type DaemonTransport = typeof sendToDaemon; diff --git a/src/daemon-client/daemon-client-lease-beat.ts b/packages/daemon-client/src/daemon-client-lease-beat.ts similarity index 100% rename from src/daemon-client/daemon-client-lease-beat.ts rename to packages/daemon-client/src/daemon-client-lease-beat.ts diff --git a/src/daemon-client/daemon-client-lifecycle-request.ts b/packages/daemon-client/src/daemon-client-lifecycle-request.ts similarity index 100% rename from src/daemon-client/daemon-client-lifecycle-request.ts rename to packages/daemon-client/src/daemon-client-lifecycle-request.ts diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/packages/daemon-client/src/daemon-client-lifecycle.ts similarity index 100% rename from src/daemon-client/daemon-client-lifecycle.ts rename to packages/daemon-client/src/daemon-client-lifecycle.ts diff --git a/src/daemon-client/daemon-client-liveness-probe.ts b/packages/daemon-client/src/daemon-client-liveness-probe.ts similarity index 100% rename from src/daemon-client/daemon-client-liveness-probe.ts rename to packages/daemon-client/src/daemon-client-liveness-probe.ts diff --git a/src/daemon-client/daemon-client-metadata.ts b/packages/daemon-client/src/daemon-client-metadata.ts similarity index 100% rename from src/daemon-client/daemon-client-metadata.ts rename to packages/daemon-client/src/daemon-client-metadata.ts diff --git a/src/daemon-client/daemon-client-progress.ts b/packages/daemon-client/src/daemon-client-progress.ts similarity index 100% rename from src/daemon-client/daemon-client-progress.ts rename to packages/daemon-client/src/daemon-client-progress.ts diff --git a/packages/daemon-client/src/daemon-client-proxy-upstream.ts b/packages/daemon-client/src/daemon-client-proxy-upstream.ts new file mode 100644 index 0000000000..d03f065fb8 --- /dev/null +++ b/packages/daemon-client/src/daemon-client-proxy-upstream.ts @@ -0,0 +1,35 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { ensureManagedDaemon, resolveClientSettings } from './daemon-client-lifecycle.ts'; + +export type ProxyUpstreamDaemon = Readonly<{ + httpPort: number | undefined; + token: string; + stateDir: string; +}>; + +export async function ensureProxyUpstreamDaemon(options: { + stateDir?: string; + host: HostEnvironment; +}): Promise { + const settings = resolveClientSettings( + { + session: 'default', + command: 'proxy', + positionals: [], + flags: { + stateDir: options.stateDir, + daemonBaseUrl: '', + daemonTransport: 'http', + daemonServerMode: 'http', + }, + }, + undefined, + options.host, + ); + const daemon = await ensureManagedDaemon(settings); + return { + httpPort: daemon.info.httpPort, + token: daemon.info.token, + stateDir: settings.paths.baseDir, + }; +} diff --git a/src/daemon-client/daemon-client-rpc.ts b/packages/daemon-client/src/daemon-client-rpc.ts similarity index 100% rename from src/daemon-client/daemon-client-rpc.ts rename to packages/daemon-client/src/daemon-client-rpc.ts diff --git a/src/daemon-client/daemon-client-startup.ts b/packages/daemon-client/src/daemon-client-startup.ts similarity index 100% rename from src/daemon-client/daemon-client-startup.ts rename to packages/daemon-client/src/daemon-client-startup.ts diff --git a/src/daemon-client/daemon-client-stop.ts b/packages/daemon-client/src/daemon-client-stop.ts similarity index 100% rename from src/daemon-client/daemon-client-stop.ts rename to packages/daemon-client/src/daemon-client-stop.ts diff --git a/src/daemon-client/daemon-client-timeout.ts b/packages/daemon-client/src/daemon-client-timeout.ts similarity index 99% rename from src/daemon-client/daemon-client-timeout.ts rename to packages/daemon-client/src/daemon-client-timeout.ts index 550e7d3887..f882aaf4d5 100644 --- a/src/daemon-client/daemon-client-timeout.ts +++ b/packages/daemon-client/src/daemon-client-timeout.ts @@ -154,7 +154,7 @@ function isAffirmativelyApplePlatform(platform: PlatformSelector | undefined): b // Exported for direct hint-matrix testing: handleRequestTimeout also runs the real liveness probe // and process-kill side effects, so its wording is verified through this pure sub-function // (see also the production-seam route tests in -// src/daemon-client/__tests__/daemon-client-timeout-route.test.ts, which prove the liveness-gated +// packages/daemon-client/src/__tests__/daemon-client-timeout-route.test.ts, which prove the liveness-gated // recovery this route performs — the side of the contract a pure formatter test cannot reach). export function resolveRequestTimeoutHint(params: { remote: boolean; diff --git a/src/daemon-client/daemon-client-transport.ts b/packages/daemon-client/src/daemon-client-transport.ts similarity index 100% rename from src/daemon-client/daemon-client-transport.ts rename to packages/daemon-client/src/daemon-client-transport.ts diff --git a/src/daemon-client/daemon-launch-spec.ts b/packages/daemon-client/src/daemon-launch-spec.ts similarity index 99% rename from src/daemon-client/daemon-launch-spec.ts rename to packages/daemon-client/src/daemon-launch-spec.ts index 64ca84bcc8..20007ea208 100644 --- a/src/daemon-client/daemon-launch-spec.ts +++ b/packages/daemon-client/src/daemon-launch-spec.ts @@ -70,7 +70,7 @@ export function resolveDaemonLaunchSpec(): DaemonLaunchSpec { * each other's daemon — live session with it (#2458). A source checkout is the tree * whose code moves under a version that does not, so it keeps a fingerprint. */ -type LocalDaemonCodeIdentity = +export type LocalDaemonCodeIdentity = | { origin: 'installed' } | { origin: 'checkout'; codeSignature: string }; diff --git a/src/daemon-client/daemon-lifecycle-errors.ts b/packages/daemon-client/src/daemon-lifecycle-errors.ts similarity index 100% rename from src/daemon-client/daemon-lifecycle-errors.ts rename to packages/daemon-client/src/daemon-lifecycle-errors.ts diff --git a/src/daemon-client/daemon-client.ts b/packages/daemon-client/src/index.ts similarity index 87% rename from src/daemon-client/daemon-client.ts rename to packages/daemon-client/src/index.ts index 141d875f96..5db4820414 100644 --- a/src/daemon-client/daemon-client.ts +++ b/packages/daemon-client/src/index.ts @@ -1,7 +1,14 @@ import type { DaemonResponse as SharedDaemonResponse } from '@agent-device/kernel/contracts'; import type { RequestProgressSink } from '@agent-device/contracts/progress'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; -import type { AgentDeviceDaemonTransportContext } from '@agent-device/contracts/client'; +import type { + AgentDeviceDaemonTransportContext, + DaemonConnection, + EnsureDaemonOptions, +} from '@agent-device/contracts/client'; +import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import type { DaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; +import type { DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; import { AppError } from '@agent-device/kernel/errors'; import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; @@ -21,6 +28,8 @@ import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecy import type { EnsuringTransportOptions } from './daemon-client-ensuring.ts'; import { createRequestGuard, sendRequest } from './daemon-client-transport.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; +import type { LocalDaemonCodeIdentity } from './daemon-launch-spec.ts'; +import type { ProxyUpstreamDaemon } from './daemon-client-proxy-upstream.ts'; import { leaseScopeFromRequest } from '@agent-device/contracts/lease-scope'; export type DaemonRequest = DaemonWireRequest; @@ -393,3 +402,38 @@ async function protectArtifactUploadWithLeaseBeats( callerSignal, }); } + +export { createRequestGuard }; + +/** + * Starts the local daemon on a state directory if none is usable. Loaded on first call: a client + * that only attaches never pays for the startup and replacement machinery. + */ +export async function ensureDaemon(options?: EnsureDaemonOptions): Promise { + const { ensureDaemon: ensure } = await import('./daemon-client-ensure.ts'); + return await ensure(options); +} + +/** Stops the daemon a record names, loaded on first call like the rest of the lifecycle. */ +export async function stopRecordedDaemon( + record: DaemonStopIdentity, + target: { paths: DaemonPaths; host: HostEnvironment }, +): Promise { + const { stopRecordedDaemon: stop } = await import('./daemon-client-stop.ts'); + return await stop(record, target); +} + +/** What this tree would launch a daemon from, loaded on first call for the same reason. */ +export async function resolveLocalDaemonCodeIdentity(): Promise { + const { resolveLocalDaemonCodeIdentity: resolve } = await import('./daemon-launch-spec.ts'); + return await resolve(); +} + +/** The managed local HTTP daemon `agent-device proxy` forwards to, started if none is usable. */ +export async function ensureProxyUpstreamDaemon(options: { + stateDir?: string; + host: HostEnvironment; +}): Promise { + const { ensureProxyUpstreamDaemon: ensure } = await import('./daemon-client-proxy-upstream.ts'); + return await ensure(options); +} diff --git a/packages/daemon-client/tsconfig.json b/packages/daemon-client/tsconfig.json new file mode 100644 index 0000000000..0cbd24bfa4 --- /dev/null +++ b/packages/daemon-client/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src", + "tsBuildInfoFile": "./dist-types/index.tsbuildinfo" + }, + "include": ["src"], + "references": [ + { "path": "../ad-replay" }, + { "path": "../command-registry" }, + { "path": "../contracts" }, + { "path": "../daemon-contracts" }, + { "path": "../host-kit" }, + { "path": "../kernel" }, + { "path": "../plugins" }, + { "path": "../proxy" }, + { "path": "../remote" }, + { "path": "../test-kit" } + ] +} diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.test.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.test.ts index 2b455edde1..c74b187e22 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.test.ts @@ -9,7 +9,7 @@ import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts * process-level keep-alive (Fix 1's daemon * teardown guard) is a different architectural layer — a client-side process * manager, not session/script state — and is covered separately in - * `src/daemon-client/__tests__/daemon-client-lifecycle.test.ts` + * `packages/daemon-client/src/__tests__/daemon-client-lifecycle.test.ts` * ("keeps an owned ephemeral daemon alive and hints its --state-dir..."). * * Fix 1 (session-side): a divergence never deletes the session — it stays in diff --git a/packages/daemon/src/sessions/__tests__/replay-runtime/session-replay-runtime.test.ts b/packages/daemon/src/sessions/__tests__/replay-runtime/session-replay-runtime.test.ts index dd927b80cf..cae46382f7 100644 --- a/packages/daemon/src/sessions/__tests__/replay-runtime/session-replay-runtime.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-runtime/session-replay-runtime.test.ts @@ -68,7 +68,7 @@ test('a successful replay prints one line with the step count and wall time', as // producer (`completeReplayRun`'s `sessionStore.get(sessionName)` check), not // asserted against a hand-crafted fixture — deleting that line would fail // these, unlike the client-lifecycle tests in -// `src/daemon-client/__tests__/daemon-client-lifecycle.test.ts`, which only prove the +// `packages/daemon-client/src/__tests__/daemon-client-lifecycle.test.ts`, which only prove the // CLIENT'S reaction to a `sessionActive` value it is handed. --- test('a close-less replay reports sessionActive: true (real producer, session still in the store)', async () => { diff --git a/packages/host-kit/src/internal/project-root.ts b/packages/host-kit/src/internal/project-root.ts index e29e7714b8..ffadbd0fe6 100644 --- a/packages/host-kit/src/internal/project-root.ts +++ b/packages/host-kit/src/internal/project-root.ts @@ -21,7 +21,7 @@ export function resolveAgentDeviceProjectRoot(startDirectory: string): string { /** * The daemon's own source, relative to a project root. One path answers two questions * with it: whether a tree is a source checkout at all, and which entry a checkout - * launches its daemon from (`src/daemon-client/daemon-launch-spec.ts`). + * launches its daemon from (`packages/daemon-client/src/daemon-launch-spec.ts`). */ export const DAEMON_SOURCE_ENTRY = 'src/daemon.ts'; diff --git a/packages/test-kit/src/boundary-fault-matrix.ts b/packages/test-kit/src/boundary-fault-matrix.ts index afca09a32d..7fc19b08a5 100644 --- a/packages/test-kit/src/boundary-fault-matrix.ts +++ b/packages/test-kit/src/boundary-fault-matrix.ts @@ -56,22 +56,24 @@ export const BOUNDARY_FAULT_MATRIX = { timeout: { mutation: { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-transport.test.ts:timeout'], + evidence: ['packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts:timeout'], invariants: ['bounded-deadline', 'typed-error-identity'], }, read: { kind: 'covered', - evidence: ['src/daemon-client/__tests__/daemon-client-timeout-route.test.ts:http timeout'], + evidence: [ + 'packages/daemon-client/src/__tests__/daemon-client-timeout-route.test.ts:http timeout', + ], invariants: ['bounded-deadline', 'typed-error-identity'], }, 'artifact-producing': { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-transport.test.ts:timeout'], + evidence: ['packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts:timeout'], invariants: ['bounded-deadline', 'typed-error-identity'], }, 'session-lifecycle': { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-transport.test.ts:timeout'], + evidence: ['packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts:timeout'], invariants: ['bounded-deadline', 'typed-error-identity'], }, }, @@ -100,44 +102,52 @@ export const BOUNDARY_FAULT_MATRIX = { 'response-framing': { mutation: { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-transport.test.ts:response framing'], + evidence: [ + 'packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts:response framing', + ], invariants: ['typed-error-identity', 'no-mutation-replay'], }, read: { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-transport.test.ts:response framing'], + evidence: [ + 'packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts:response framing', + ], invariants: ['typed-error-identity'], }, 'artifact-producing': { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-transport.test.ts:response framing'], + evidence: [ + 'packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts:response framing', + ], invariants: ['typed-error-identity'], }, 'session-lifecycle': { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-transport.test.ts:response framing'], + evidence: [ + 'packages/daemon-client/src/__tests__/boundary-fault-transport.test.ts:response framing', + ], invariants: ['typed-error-identity'], }, }, 'process-death': { mutation: { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-acceptance.test.ts'], + evidence: ['packages/daemon-client/src/__tests__/boundary-fault-acceptance.test.ts'], invariants: ['bounded-deadline', 'no-mutation-replay'], }, read: { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-acceptance.test.ts'], + evidence: ['packages/daemon-client/src/__tests__/boundary-fault-acceptance.test.ts'], invariants: ['bounded-deadline'], }, 'artifact-producing': { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-acceptance.test.ts'], + evidence: ['packages/daemon-client/src/__tests__/boundary-fault-acceptance.test.ts'], invariants: ['bounded-deadline'], }, 'session-lifecycle': { kind: 'covered', - evidence: ['src/daemon-client/__tests__/boundary-fault-acceptance.test.ts'], + evidence: ['packages/daemon-client/src/__tests__/boundary-fault-acceptance.test.ts'], invariants: ['bounded-deadline'], }, }, diff --git a/src/__tests__/test-utils/daemon-http-fixture.ts b/packages/test-kit/src/http-daemon-fixture.ts similarity index 72% rename from src/__tests__/test-utils/daemon-http-fixture.ts rename to packages/test-kit/src/http-daemon-fixture.ts index 0f104b6354..1db6eaaf4a 100644 --- a/src/__tests__/test-utils/daemon-http-fixture.ts +++ b/packages/test-kit/src/http-daemon-fixture.ts @@ -1,11 +1,9 @@ import http from 'node:http'; -import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; -import { resolveDaemonLaunchSpec } from '../../daemon-client/daemon-launch-spec.ts'; -import { listenOnLoopback } from '@agent-device/test-kit'; +import { listenOnLoopback } from './loopback.ts'; // A loopback stand-in for a running daemon: answers `GET /health`, echoes `responseData` as the -// result of every `POST /rpc`, and records what it was asked, for the daemon-client tests that -// decide which daemon a command keeps. +// result of every `POST /rpc`, and records what it was asked, for the client tests that decide +// which daemon a command keeps. export type HttpDaemonFixture = { server: http.Server; @@ -59,12 +57,3 @@ export async function startHttpDaemonFixture( const port = await listenOnLoopback(server); return { server, port, seenPaths, rpcRequests }; } - -/** - * The code signature a daemon started from this checkout records, read through the same launch - * spec the client uses to launch and sign its daemon, so a fixture daemon is reused. - */ -export function currentDaemonCodeSignature(): string { - const spec = resolveDaemonLaunchSpec(); - return computeDaemonCodeSignature(spec.useSrc ? spec.srcPath : spec.distPath, spec.root); -} diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index bb0b4bef16..4fffb29acf 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -157,3 +157,22 @@ export { } from './metro-process.fixtures.ts'; export { readMetroSessionFixture, writeMetroSessionFixture } from './metro-session.fixtures.ts'; + +export { startHttpDaemonFixture, type HttpDaemonFixture } from './http-daemon-fixture.ts'; +export { + createRegisteredDaemonFixtures, + type RegisteredDaemonChild, + type RegisteredDaemonComposition, + type RegisteredDaemonFixtures, +} from './registered-daemon-fixture.ts'; +export { + beginStderrCapture, + beginStdoutCapture, + withCapturedOutput, + withCapturedStderr, + withCapturedStderrAsync, + withCapturedStdoutAsync, + withStreamTty, + type StreamCapture, + type StreamCaptureOptions, +} from './stream-capture.ts'; diff --git a/packages/test-kit/src/registered-daemon-fixture.ts b/packages/test-kit/src/registered-daemon-fixture.ts new file mode 100644 index 0000000000..d12d98eb16 --- /dev/null +++ b/packages/test-kit/src/registered-daemon-fixture.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { runCmdDetachedMonitored, ExecDetachedExit } from '@agent-device/host-kit/command'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; + +/** The state-dir layout the fixture child reads: where its registration and sessions live. */ +type RegisteredDaemonPaths = Readonly<{ baseDir: string; sessionsDir: string; infoPath: string }>; + +/** Per-child behavior switches, bound into this child's entry script at write time. */ +type RegisteredDaemonFixtureOptions = Readonly<{ + /** Record `lock-acquired` after acquisition, as the daemon runtime does, for this child only. */ + emitStartupMarker?: boolean; + /** Record `modules-loaded` before acquisition, as the daemon entry does, for this child only. */ + emitStartupMarkerBeforeAcquisition?: boolean; +}>; + +/** + * What the fixture borrows from the daemon-owning packages. Test-kit sits below them, so the + * caller supplies the registration owner the child runs, how a child ends, how a published record + * reads, and the real process launch that a test's module mocks leave alone. + */ +export type RegisteredDaemonComposition = Readonly<{ + /** Module URL exporting `DAEMON_STARTUP_EXIT_CODES` and `tryAcquireDaemonRegistration`. */ + registrationOwnerUrl: string; + /** Module URL exporting the daemon startup-phase marker functions. */ + startupPhasesUrl: string; + /** Launches a child process without any mock the test installed on process launching. */ + launch: typeof runCmdDetachedMonitored; + stopDaemonProcess: ( + identity: { pid: number; startTime: string | null }, + options: { mode: 'force'; termTimeoutMs: number; killTimeoutMs: number }, + ) => Promise<{ status: string }>; + readDaemonInfo: (infoPath: string) => TInfo | null; +}>; + +/** A launched fixture child: its pid and the exit it will report. */ +export type RegisteredDaemonChild = ReturnType; + +export type RegisteredDaemonFixtures< + TInfo extends { pid: number }, + TPaths extends RegisteredDaemonPaths, + TFields extends object, +> = Readonly<{ + /** A real registration owner, advertising the caller's fields and joining before deletion. */ + registeredDaemonFixtureArgs: ( + paths: TPaths, + fields: TFields, + acquisitionBarrier?: string, + fixtureOptions?: RegisteredDaemonFixtureOptions, + ) => string[]; + launchRegisteredDaemonFixtureArgs: ( + paths: TPaths, + args: string[], + options: Parameters[2], + ) => RegisteredDaemonChild; + spawnRegisteredDaemonFixture: ( + paths: TPaths, + fields: TFields, + options: Parameters[2], + acquisitionBarrier?: string, + fixtureOptions?: RegisteredDaemonFixtureOptions, + ) => RegisteredDaemonChild; + /** Waits up to 4 s for a fixture child to publish `file`; one shared wait for every probe. */ + waitForFixtureFile: (file: string) => Promise; + waitForRegisteredDaemonFixture: (paths: TPaths, child: RegisteredDaemonChild) => Promise; + finishRegisteredDaemonFixture: (stateDir: string) => Promise; + finishRegisteredDaemonFixtures: () => Promise; +}>; + +export function createRegisteredDaemonFixtures< + TInfo extends { pid: number }, + TPaths extends RegisteredDaemonPaths, + TFields extends object, +>( + composition: RegisteredDaemonComposition, +): RegisteredDaemonFixtures { + const children = new Map< + string, + Array<{ launch: RegisteredDaemonChild; startTime: string | null }> + >(); + + function registeredDaemonFixtureArgs( + paths: TPaths, + fields: TFields, + acquisitionBarrier?: string, + fixtureOptions?: RegisteredDaemonFixtureOptions, + ): string[] { + // One directory per launch so a later build cannot overwrite a script an earlier child has + // not loaded; the path still ends in the real daemon entry name for the identity checks. + const launchDir = path.join(paths.baseDir, 'fixture-entries', crypto.randomUUID()); + const entry = path.join(launchDir, 'dist', 'src', 'internal', 'daemon.js'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(path.join(launchDir, 'package.json'), '{"type":"module"}'); + fs.writeFileSync( + entry, + `import fs from 'node:fs'; +import path from 'node:path'; +import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration } from ${JSON.stringify(composition.registrationOwnerUrl)}; +import { removeDaemonStartupPhasesFile, recordDaemonStartupMarker, resolveDaemonStartupPhasesPath } from ${JSON.stringify(composition.startupPhasesUrl)}; +const paths = ${JSON.stringify(paths)}; +const barrier = ${JSON.stringify(acquisitionBarrier)}; +const emitBefore = ${fixtureOptions?.emitStartupMarkerBeforeAcquisition ? 'true' : 'false'}; +const emitAfter = ${fixtureOptions?.emitStartupMarker ? 'true' : 'false'}; +if (barrier) { + fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready'); + while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10)); +} +if (emitBefore) { const own = resolveDaemonStartupPhasesPath(paths.baseDir, process.pid); removeDaemonStartupPhasesFile(own); recordDaemonStartupMarker(own, 'modules-loaded'); } +const acquired = await tryAcquireDaemonRegistration(paths); +if (acquired.status !== 'acquired') process.exit(DAEMON_STARTUP_EXIT_CODES[acquired.status]); +if (emitAfter) + recordDaemonStartupMarker(resolveDaemonStartupPhasesPath(paths.baseDir, process.pid), 'lock-acquired'); +process.on('SIGTERM', async () => { + if (fs.existsSync(path.join(paths.baseDir, 'ignore-sigterm'))) return; + const deferred = path.join(paths.baseDir, 'repair-on-shutdown.json'); + if (fs.existsSync(deferred)) { + const dir = path.join(paths.sessionsDir, 'default'); + fs.mkdirSync(dir, { recursive: true }); + fs.copyFileSync(deferred, path.join(dir, 'repair-tombstone.json')); + } + await acquired.owner.finish(); + process.exit(0); +}); +fs.writeFileSync(path.join(paths.baseDir, 'registration-held'), 'ready'); +fs.writeFileSync(path.join(paths.baseDir, 'registration-held-' + process.pid), 'ready'); +while (fs.existsSync(path.join(paths.baseDir, 'defer-publication'))) await new Promise(resolve => setTimeout(resolve, 10)); +acquired.owner.publish(${JSON.stringify(fields)}); +setInterval(() => {}, 1000); +`, + ); + return ['--experimental-strip-types', entry]; + } + + function launchRegisteredDaemonFixtureArgs( + paths: TPaths, + args: string[], + options: Parameters[2], + ): RegisteredDaemonChild { + const child = composition.launch(process.execPath, args, options); + const owned = children.get(paths.baseDir) ?? []; + owned.push({ launch: child, startTime: readProcessStartTime(child.pid) }); + children.set(paths.baseDir, owned); + return child; + } + + function spawnRegisteredDaemonFixture( + paths: TPaths, + fields: TFields, + options: Parameters[2], + acquisitionBarrier?: string, + fixtureOptions?: RegisteredDaemonFixtureOptions, + ): RegisteredDaemonChild { + return launchRegisteredDaemonFixtureArgs( + paths, + registeredDaemonFixtureArgs(paths, fields, acquisitionBarrier, fixtureOptions), + options, + ); + } + + async function waitForFixtureFile(file: string): Promise { + for (let attempt = 0; !fs.existsSync(file); attempt += 1) { + assert.ok(attempt < 400, `fixture did not publish ${file}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + + async function waitForRegisteredDaemonFixture( + paths: TPaths, + child: RegisteredDaemonChild, + ): Promise { + let exit: ExecDetachedExit | undefined; + void child.exited.then((result) => { + exit = result; + }); + await Promise.resolve(); + for (let attempt = 0; attempt < 400; attempt += 1) { + if (exit) + throw new Error(`Registered child exited before publication: ${JSON.stringify(exit)}`); + const info = composition.readDaemonInfo(paths.infoPath); + if (info?.pid === child.pid) return info; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Registered child ${child.pid} did not publish ${paths.infoPath} within 4s`); + } + + async function finishRegisteredDaemonFixture(stateDir: string): Promise { + for (const owned of children.get(stateDir) ?? []) { + const child = owned.launch; + const termination = await composition.stopDaemonProcess( + { pid: child.pid, startTime: owned.startTime }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 }, + ); + assert.notEqual(termination.status, 'retained', JSON.stringify(termination)); + await child.exited; + } + children.delete(stateDir); + fs.rmSync(stateDir, { recursive: true, force: true }); + } + + async function finishRegisteredDaemonFixtures(): Promise { + for (const stateDir of children.keys()) await finishRegisteredDaemonFixture(stateDir); + } + + return { + registeredDaemonFixtureArgs, + launchRegisteredDaemonFixtureArgs, + spawnRegisteredDaemonFixture, + waitForFixtureFile, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, + }; +} diff --git a/src/__tests__/test-utils/stream-capture.test.ts b/packages/test-kit/src/stream-capture.test.ts similarity index 100% rename from src/__tests__/test-utils/stream-capture.test.ts rename to packages/test-kit/src/stream-capture.test.ts diff --git a/src/__tests__/test-utils/stream-capture.ts b/packages/test-kit/src/stream-capture.ts similarity index 100% rename from src/__tests__/test-utils/stream-capture.ts rename to packages/test-kit/src/stream-capture.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7fca42c8d..2ee562b6dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@agent-device/daemon': specifier: workspace:* version: link:packages/daemon + '@agent-device/daemon-client': + specifier: workspace:* + version: link:packages/daemon-client '@agent-device/daemon-contracts': specifier: workspace:* version: link:packages/daemon-contracts @@ -488,6 +491,40 @@ importers: specifier: ^2.9.0 version: 2.9.0 + packages/daemon-client: + dependencies: + '@agent-device/ad-replay': + specifier: workspace:* + version: link:../ad-replay + '@agent-device/command-registry': + specifier: workspace:* + version: link:../command-registry + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/daemon-contracts': + specifier: workspace:* + version: link:../daemon-contracts + '@agent-device/host-kit': + specifier: workspace:* + version: link:../host-kit + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + '@agent-device/plugins': + specifier: workspace:* + version: link:../plugins + '@agent-device/remote': + specifier: workspace:* + version: link:../remote + devDependencies: + '@agent-device/proxy': + specifier: workspace:* + version: link:../proxy + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit + packages/daemon-contracts: dependencies: '@agent-device/capture-kit': diff --git a/scripts/__tests__/help-conformance-sample-producers.ts b/scripts/__tests__/help-conformance-sample-producers.ts index 70c353853e..11949c653e 100644 --- a/scripts/__tests__/help-conformance-sample-producers.ts +++ b/scripts/__tests__/help-conformance-sample-producers.ts @@ -1,5 +1,5 @@ import type { CapturedSample } from '../help-conformance-sample-outputs.mjs'; -import { withCapturedStderrAsync } from '../../src/__tests__/test-utils/stream-capture.ts'; +import { withCapturedStderrAsync } from '@agent-device/test-kit'; import { AMBIGUOUS_MATCH_SAMPLE, APP_NOT_INSTALLED_SAMPLE, diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index bf4ce003eb..0ed5743f3c 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import { test } from 'node:test'; import { runCmdSync } from '@agent-device/host-kit/command'; import { STALE_NODE_MODULES_MESSAGE } from './lockfile-install-sync.ts'; -import { beginStderrCapture } from '../../src/__tests__/test-utils/stream-capture.ts'; +import { beginStderrCapture } from '@agent-device/test-kit'; import { CHECK_CATALOG } from './checks.ts'; import { selectChecks } from './model.ts'; import { type CommandExecutor, readChangedFiles, runChecks } from './run.ts'; diff --git a/scripts/coverage-changed/run.test.ts b/scripts/coverage-changed/run.test.ts index 3341002e26..66d63d872e 100644 --- a/scripts/coverage-changed/run.test.ts +++ b/scripts/coverage-changed/run.test.ts @@ -5,10 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, test } from 'node:test'; import { run } from './run.ts'; -import { - beginStderrCapture, - beginStdoutCapture, -} from '../../src/__tests__/test-utils/stream-capture.ts'; +import { beginStderrCapture, beginStdoutCapture } from '@agent-device/test-kit'; let repo: string; diff --git a/scripts/coverage-crap/run.test.ts b/scripts/coverage-crap/run.test.ts index 665e9290b3..b5d383389d 100644 --- a/scripts/coverage-crap/run.test.ts +++ b/scripts/coverage-crap/run.test.ts @@ -12,10 +12,7 @@ import { type CoverageConfig, type RunSelection, } from './stamp.ts'; -import { - beginStderrCapture, - beginStdoutCapture, -} from '../../src/__tests__/test-utils/stream-capture.ts'; +import { beginStderrCapture, beginStdoutCapture } from '@agent-device/test-kit'; // The verdict is what CI enforces on a PR, so these run the real entrypoint against a real git // repo, a real fallow, a real stamp, and hand-built coverage reports — a stub of fallow could not diff --git a/scripts/fuzz/worker.ts b/scripts/fuzz/worker.ts index 17b93733fc..c642339572 100644 --- a/scripts/fuzz/worker.ts +++ b/scripts/fuzz/worker.ts @@ -7,7 +7,7 @@ // caught at all: only a separate process keeps that fault off the caller, whose thread may be // the unit lane's test runner (#2053). -import { beginStderrCapture } from '../../src/__tests__/test-utils/stream-capture.ts'; +import { beginStderrCapture } from '@agent-device/test-kit'; import { checkCase, type FuzzFailure } from './invariant.ts'; import { getFuzzTarget } from './registry.ts'; diff --git a/scripts/ios-snapshot-benchmark/cell-admission.ts b/scripts/ios-snapshot-benchmark/cell-admission.ts index 685eee23b5..313b394365 100644 --- a/scripts/ios-snapshot-benchmark/cell-admission.ts +++ b/scripts/ios-snapshot-benchmark/cell-admission.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { readDaemonInfo } from '../../src/daemon-client/daemon-client-metadata.ts'; +import { readRecordedDaemon } from '../lib/recorded-daemon.ts'; import { isAgentDeviceDaemonProcess } from '@agent-device/daemon-contracts/daemon-process'; import { openFixture, @@ -204,7 +204,7 @@ function assertDaemonStopped(stateDir: string): void { } function assertDaemonRunning(stateDir: string): void { - const info = readDaemonInfo(resolveDaemonPaths(stateDir).infoPath); + const info = readRecordedDaemon(resolveDaemonPaths(stateDir).infoPath); if (!info?.processStartTime || !isAgentDeviceDaemonProcess(info.pid, info.processStartTime)) { throw new BenchmarkCellAdmissionError( 'cell-state', diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index 82781be5fb..f055ed84f3 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -129,7 +129,13 @@ export const CLIENT_SIDE_POLICY_NAME = 'client-side'; export const LOGICAL_MODULE_POLICIES = [ { name: CLIENT_SIDE_POLICY_NAME, - roots: ['src/cli.ts', 'src/cli/', 'src/client/', 'packages/remote/src/', 'src/daemon-client/'], + roots: [ + 'src/cli.ts', + 'src/cli/', + 'src/client/', + 'packages/remote/src/', + 'packages/daemon-client/src/', + ], forbiddenTargetRoots: [DAEMON_SOURCE_ROOT], }, { diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 84a1024f75..e1b1960e3d 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -654,7 +654,7 @@ for (const file of [ 'src/cli/commands/daemon.ts', 'src/client/client-normalizers.ts', 'packages/remote/src/daemon-artifacts.ts', - 'src/daemon-client/daemon-client.ts', + 'packages/daemon-client/src/index.ts', ]) { for (const [kind, source] of [ [ diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 2316b62367..003005eca7 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -444,8 +444,8 @@ export function topFolder(file: string): string { } export function targetDagZone(file: string): string { - // #2342 relocated the daemon client to its own `src/daemon-client/` folder, so the - // client zone now falls out of the folder itself; `packages/daemon/src/` is server-only. + // The daemon client is its own package, so the client zone falls out of the package folder; + // `packages/daemon/src/` is server-only. if (file.startsWith(DAEMON_SOURCE_ROOT)) return 'daemon-server'; return declaredRootModuleZone(file) ?? topFolder(file); } diff --git a/scripts/layering/package-entry-surface.test.ts b/scripts/layering/package-entry-surface.test.ts index b1b4f1d85a..7d0b37d1d9 100644 --- a/scripts/layering/package-entry-surface.test.ts +++ b/scripts/layering/package-entry-surface.test.ts @@ -1,5 +1,6 @@ -// The shape of test-kit (R83): a single entry that names its exports, plus the shared isolation -// walk (package-isolation.test.ts) composed under its policy. +// The shape of the pinned package entries (R83): test-kit, daemon and daemon-client each publish a +// single entry that names its exports, and test-kit also runs the shared isolation walk +// (package-isolation.test.ts) under its policy. import assert from 'node:assert/strict'; import { test } from 'node:test'; @@ -41,6 +42,10 @@ function workspace(overrides: Record = {}): Map = {}, sources = SOURCE) { @@ -88,20 +94,36 @@ test('test-kit exports exactly "." and nothing else', () => { assert.equal(check({ 'test-kit': { exports: { './tmp-dir': entry('./x.ts') } } }).length, 1); }); -test('daemon exports exactly "." and nothing else', () => { - const extra = check({ - daemon: { - exports: { - '.': entry('./src/index.ts'), - './testing/context': entry('./src/core/context.ts'), +for (const dir of ['daemon', 'daemon-client']) { + test(`${dir} exports exactly "." and nothing else`, () => { + const extra = check({ + [dir]: { + exports: { + '.': entry('./src/index.ts'), + './testing/context': entry('./src/core/context.ts'), + }, }, - }, + }); + assert.equal(extra.length, 1); + assert.match(extra[0]!.file, new RegExp(`packages/${dir}/package\\.json`)); + assert.match(extra[0]!.message, /exports exactly "\."/); + assert.deepEqual(check(), []); }); - assert.equal(extra.length, 1); - assert.match(extra[0]!.file, /packages\/daemon\/package\.json/); - assert.match(extra[0]!.message, /exports exactly "\."/); - assert.deepEqual(check(), []); -}); + + test(`every ${dir} condition of "." points at src/index.ts`, () => { + for (const exports of [ + { '.': entry('./src/other.ts') }, + { '.': { types: './src/index.ts', default: './src/other.ts' } }, + { '.': './src/other.ts' }, + ]) { + const found = check({ [dir]: { exports } }); + assert.equal(found.length, 1, JSON.stringify(exports)); + assert.match(found[0]!.file, new RegExp(`packages/${dir}/package\\.json`)); + assert.match(found[0]!.message, /every condition of it is \.\/src\/index\.ts/); + } + assert.deepEqual(check({ [dir]: { exports: { '.': './src/index.ts' } } }), []); + }); +} test('apple-runner exports exactly "." and nothing else', () => { for (const exports of [ @@ -208,15 +230,17 @@ test('text that only mentions a star is not an export', () => { assert.deepEqual(check({}, new Map([...SOURCE, ['packages/test-kit/src/index.ts', body]])), []); }); -test('the daemon entry names its exports: export * is refused', () => { - const sources = new Map(SOURCE); - sources.set('packages/daemon/src/index.ts', "export * from './server/http/daemon-runtime.ts';"); - const found = check({}, sources); - assert.equal(found.length, 1); - assert.equal(found[0]!.file, 'packages/daemon/src/index.ts'); - assert.match(found[0]!.message, /names every export explicitly/); - assert.deepEqual(check(), []); -}); +for (const dir of ['daemon', 'daemon-client']) { + test(`the ${dir} entry names its exports: export * is refused`, () => { + const sources = new Map(SOURCE); + sources.set(`packages/${dir}/src/index.ts`, "export * from './transport.ts';"); + const found = check({}, sources); + assert.equal(found.length, 1); + assert.equal(found[0]!.file, `packages/${dir}/src/index.ts`); + assert.match(found[0]!.message, /names every export explicitly/); + assert.deepEqual(check(), []); + }); +} test('every condition of the "." entry must point at src/index.ts', () => { for (const exports of [ @@ -240,7 +264,7 @@ test('the shared isolation walk runs under the test-kit policy', () => { assert.match(found[0]!.message, /test-kit -> @agent-device\/daemon/); }); -for (const dir of ['daemon', 'test-kit', 'command-surface']) { +for (const dir of ['daemon', 'daemon-client', 'apple-runner', 'test-kit', 'command-surface']) { test(`${dir} refuses a missing or renamed entry source`, () => { for (const renamed of [false, true]) { const sources = new Map(SOURCE); diff --git a/scripts/layering/package-entry-surface.ts b/scripts/layering/package-entry-surface.ts index 0a7497dce0..91ac7a58b6 100644 --- a/scripts/layering/package-entry-surface.ts +++ b/scripts/layering/package-entry-surface.ts @@ -3,10 +3,11 @@ // one home of test helpers shared across packages, so a second entry there publishes a fixture's // surface into every test's import graph. The daemon's entry is its package boundary and the one // place a barrel is allowed, so a subpath or a star re-export there publishes the daemon graph -// under an unowned name (ADR 0033). The Apple runner and command surface entries name their +// under an unowned name (ADR 0033). The daemon client's entry names its production transport API +// and lazy lifecycle operations. The Apple runner and command surface entries name their // explicit production APIs. Subpaths or star re-exports would publish internals under unowned // names (ADR 0008). -// Evidence: test-kit, daemon, Apple runner and command surface each assert the same entry shape. +// Evidence: the pinned packages each assert the same entry shape. // Cost: one pass over the pinned manifests and their entry sources. // Kill criterion: a maintainer decision that a pinned package may expose subpath entries or a star // re-export from its entry. @@ -32,6 +33,8 @@ const APPLE_RUNNER_DIR = 'packages/apple-runner'; const APPLE_RUNNER_PACKAGE = '@agent-device/apple-runner'; const COMMAND_SURFACE_DIR = 'packages/command-surface'; const COMMAND_SURFACE_PACKAGE = '@agent-device/command-surface'; +const DAEMON_CLIENT_DIR = 'packages/daemon-client'; +const DAEMON_CLIENT_PACKAGE = '@agent-device/daemon-client'; const ENTRY_SURFACES = [ { @@ -43,6 +46,7 @@ const ENTRY_SURFACES = [ { packageName: DAEMON_PACKAGE, dir: DAEMON_DIR, entry: ENTRY_FILE }, { packageName: APPLE_RUNNER_PACKAGE, dir: APPLE_RUNNER_DIR, entry: ENTRY_FILE }, { packageName: COMMAND_SURFACE_PACKAGE, dir: COMMAND_SURFACE_DIR, entry: ENTRY_FILE }, + { packageName: DAEMON_CLIENT_PACKAGE, dir: DAEMON_CLIENT_DIR, entry: ENTRY_FILE }, ] as const; function stringLeaves(value: unknown): string[] { diff --git a/scripts/layering/process-globals-policy.ts b/scripts/layering/process-globals-policy.ts index 464384a2d1..63db54e498 100644 --- a/scripts/layering/process-globals-policy.ts +++ b/scripts/layering/process-globals-policy.ts @@ -20,9 +20,9 @@ const PROCESS_ENTRIES = new Set([ 'packages/host-kit/src/local-artifact-adapter.ts', 'src/sdk/host.ts', 'src/mcp/command-tools.ts', - 'src/daemon-client/daemon-client.ts', - 'src/daemon-client/daemon-client-ensure.ts', - 'src/daemon-client/daemon-client-ensuring.ts', + 'packages/daemon-client/src/index.ts', + 'packages/daemon-client/src/daemon-client-ensure.ts', + 'packages/daemon-client/src/daemon-client-ensuring.ts', ]); // src/cli/** and scripts/** are entry trees. Apple native helper executables live outside the // TypeScript source trees. diff --git a/scripts/layering/root-module-zones.test.ts b/scripts/layering/root-module-zones.test.ts index 549a3d2c81..6f45fb9ce1 100644 --- a/scripts/layering/root-module-zones.test.ts +++ b/scripts/layering/root-module-zones.test.ts @@ -50,7 +50,10 @@ test('R5 ranks a root module by its declared zone, so no lower zone reaches the 'src/agent-device-client.ts', "import { createRequestGuard } from './daemon-client/daemon-client-transport.ts';", ], - ['src/daemon-client/daemon-client-transport.ts', 'export const createRequestGuard = 1;'], + [ + 'packages/daemon-client/src/daemon-client-transport.ts', + 'export const createRequestGuard = 1;', + ], ]), ); diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index 8cc83a40cf..994fd92bc7 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -15,7 +15,7 @@ * * Zone membership is a layering fact only. Rules keyed on physical paths — R2's `topFolder` * zones, R13's composition files, R76's daemon-to-root inventory, the `daemon-client` logical-module policy's - * `src/daemon-client/` root — keep reading the path, not this declaration. + * `packages/daemon-client/src/` root — keep reading the path, not this declaration. */ export const ROOT_MODULE_ZONES: Readonly> = { '(root)': ['src/bin.ts', 'src/cli.ts', 'src/daemon.ts', 'src/host.ts'], diff --git a/scripts/lib/recorded-daemon.ts b/scripts/lib/recorded-daemon.ts new file mode 100644 index 0000000000..65b19275b4 --- /dev/null +++ b/scripts/lib/recorded-daemon.ts @@ -0,0 +1,104 @@ +import net from 'node:net'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import { readDaemonRecord } from '@agent-device/daemon-contracts/daemon-resolution'; +import { isDaemonProgressEnvelope } from '@agent-device/daemon-contracts/request-progress-protocol'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; + +/** + * The daemon a state directory records, as the measurement and live-lane tooling reads it. + * These callers observe or drive a daemon another build started and must not start, replace or + * compatibility-check it, which the daemon-client transport always does. + */ +export type RecordedDaemon = Readonly<{ + pid: number; + token: string; + port?: number; + httpPort?: number; + processStartTime?: string; +}>; + +export function readRecordedDaemon(infoPath: string): RecordedDaemon | null { + const read = readDaemonRecord(infoPath); + if (read.status !== 'present') return null; + const { pid, token, port, httpPort, processStartTime } = read.record; + if (typeof pid !== 'number' || typeof token !== 'string') return null; + return { + pid, + token, + ...(typeof port === 'number' ? { port } : {}), + ...(typeof httpPort === 'number' ? { httpPort } : {}), + ...(typeof processStartTime === 'string' ? { processStartTime } : {}), + }; +} + +/** + * One request to the recorded daemon: over its socket when it records one, answered by the + * response line after any progress, otherwise over its HTTP `/rpc` endpoint. + */ +export async function sendToRecordedDaemon( + daemon: RecordedDaemon, + request: Omit, + options: { timeoutMs: number; signal?: AbortSignal }, +): Promise { + const { port } = daemon; + if (port === undefined) return await sendOverHttp(daemon, request, options); + return await new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + const timer = setTimeout( + () => fail(new Error(`Daemon request timed out after ${options.timeoutMs}ms.`)), + options.timeoutMs, + ); + const fail = (error: unknown) => { + clearTimeout(timer); + socket.destroy(); + reject(error); + }; + options.signal?.addEventListener('abort', () => fail(options.signal?.reason), { once: true }); + socket.setEncoding('utf8'); + socket.on('connect', () => + socket.write(`${JSON.stringify({ ...request, token: daemon.token })}\n`), + ); + socket.on('error', fail); + let buffered = ''; + socket.on('data', (chunk: string) => { + buffered += chunk; + for (let end = buffered.indexOf('\n'); end >= 0; end = buffered.indexOf('\n')) { + const line = buffered.slice(0, end); + buffered = buffered.slice(end + 1); + const message: unknown = JSON.parse(line); + if (isDaemonProgressEnvelope(message)) continue; + clearTimeout(timer); + socket.end(); + resolve(message as DaemonResponse); + return; + } + }); + socket.on('end', () => fail(new Error('Daemon closed the connection without a response.'))); + }); +} + +async function sendOverHttp( + daemon: RecordedDaemon, + request: Omit, + options: { timeoutMs: number; signal?: AbortSignal }, +): Promise { + if (daemon.httpPort === undefined) throw new Error(`Daemon ${daemon.pid} records no endpoint.`); + const signal = AbortSignal.timeout(options.timeoutMs); + const response = await fetch(`http://127.0.0.1:${daemon.httpPort}/rpc`, { + method: 'POST', + headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: request.meta?.requestId ?? `recorded-${Date.now()}`, + method: 'agent_device.command', + params: { ...request, token: daemon.token }, + }), + signal: options.signal ? AbortSignal.any([signal, options.signal]) : signal, + }); + const envelope = (await response.json()) as { + result?: DaemonResponse; + error?: { message: string }; + }; + if (!envelope.result) throw new Error(envelope.error?.message ?? 'Daemon returned no result.'); + return envelope.result; +} diff --git a/scripts/perf/forced-backend.ts b/scripts/perf/forced-backend.ts index b72619b3e3..bb4f45c9f1 100644 --- a/scripts/perf/forced-backend.ts +++ b/scripts/perf/forced-backend.ts @@ -4,10 +4,8 @@ import { pathToFileURL } from 'node:url'; import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { createRequestId } from '@agent-device/host-kit/diagnostics'; -import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { SnapshotPreferredBackend } from '@agent-device/kernel/snapshot'; -import { readDaemonInfo } from '../../src/daemon-client/daemon-client-metadata.ts'; -import { sendRequest } from '../../src/daemon-client/daemon-client-transport.ts'; +import { readRecordedDaemon, sendToRecordedDaemon } from '../lib/recorded-daemon.ts'; import { REPO_ROOT } from './config.ts'; import type { CliResult } from './types.ts'; @@ -62,28 +60,23 @@ function forcedBackendTransport( backend: SnapshotPreferredBackend, requestId: string, ): AgentDeviceDaemonTransport { - const host = captureHostEnvironment(process); return async (request, context) => { const stateDir = request.flags?.stateDir; if (request.session === undefined || typeof stateDir !== 'string') { throw new Error('A forced-backend capture requires an explicit session and state dir.'); } const paths = resolveDaemonPaths(stateDir, { env: process.env }); - const info = readDaemonInfo(paths.infoPath); + const info = readRecordedDaemon(paths.infoPath); if (!info) throw new Error(`No daemon is running for state dir ${stateDir}.`); - return await sendRequest( + return await sendToRecordedDaemon( info, { ...request, session: request.session, - token: info.token, flags: { ...request.flags, snapshotPreferredBackend: backend }, meta: { ...request.meta, debug: true, requestId }, }, - 'auto', - paths, - FORCED_CAPTURE_TIMEOUT_MS, - { host, signal: context?.signal, timeoutRecovery: 'none' }, + { timeoutMs: FORCED_CAPTURE_TIMEOUT_MS, signal: context?.signal }, ); }; } diff --git a/src/__tests__/agent-device-client-abort-signal.test.ts b/src/__tests__/agent-device-client-abort-signal.test.ts index 33af448513..17b456ea84 100644 --- a/src/__tests__/agent-device-client-abort-signal.test.ts +++ b/src/__tests__/agent-device-client-abort-signal.test.ts @@ -6,7 +6,7 @@ * context for a transport that honors it, and a custom transport that ignores the signal still * loses the race — the caller's promise rejects with the typed canceled-request error * (`details.dispatched: 'unknown'`). The transport-into-daemon half is covered by - * `src/daemon-client/__tests__/daemon-client-abort.test.ts`. + * `src/__tests__/daemon-client-abort.test.ts`. */ import assert from 'node:assert/strict'; import { test } from 'vitest'; diff --git a/src/__tests__/cli-agent-cdp-session.test.ts b/src/__tests__/cli-agent-cdp-session.test.ts index 6d4642b29b..81dc688eff 100644 --- a/src/__tests__/cli-agent-cdp-session.test.ts +++ b/src/__tests__/cli-agent-cdp-session.test.ts @@ -19,7 +19,7 @@ import { hashRemoteConfigFile, writeRemoteConnectionState, } from '@agent-device/remote/remote-connection-state'; -import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; +import type { DaemonResponse } from '@agent-device/daemon-client'; afterEach(() => { vi.clearAllMocks(); diff --git a/src/__tests__/cli-capture.ts b/src/__tests__/cli-capture.ts index ad7c1c8c13..0f0028dba2 100644 --- a/src/__tests__/cli-capture.ts +++ b/src/__tests__/cli-capture.ts @@ -1,16 +1,16 @@ import fs from 'node:fs'; import { runCli } from '../cli.ts'; -import type { - DaemonRequest, - DaemonResponse, - sendToDaemon, -} from '../daemon-client/daemon-client.ts'; +import type { DaemonRequest, DaemonResponse, sendToDaemon } from '@agent-device/daemon-client'; import { installIsolatedCliTestEnv } from './cli-test-env.ts'; -import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + bindTestCommandHost, + mkdtempForTestSync, + beginStderrCapture, + beginStdoutCapture, +} from '@agent-device/test-kit'; // Every suite that imports this runs its tests as a command host. bindTestCommandHost(); -import { beginStderrCapture, beginStdoutCapture } from './test-utils/stream-capture.ts'; class ExitSignal extends Error { public readonly code: number; diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 4192c655c2..76cf7ed9b5 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -15,9 +15,12 @@ import type { import type { SettingsUpdateOptions } from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; import { resolveCliOptions } from '../cli/resolve-cli-options.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + withCapturedOutput, + withCapturedStdoutAsync, +} from '@agent-device/test-kit'; import { createStubClientLeases } from './test-utils/client-lease-fixtures.ts'; -import { withCapturedOutput, withCapturedStdoutAsync } from './test-utils/stream-capture.ts'; // #1802: the replay client reads the script it names, so CLI-level replay cases need a real file. const REPLAY_SCRIPT_ROOT = mkdtempForTestSync('agent-device-cli-replay-scripts-'); @@ -786,11 +789,7 @@ test('replay rejects extra plain replay paths before daemon dispatch', async () await tryRunClientBackedCommand({ command: 'replay', positionals: ['one.ad', 'two.ad'], - flags: { - json: false, - help: false, - version: false, - }, + flags: { json: false, help: false, version: false }, client, }), (error) => { diff --git a/src/__tests__/cli-close.test.ts b/src/__tests__/cli-close.test.ts index 550c877ed1..de38266549 100644 --- a/src/__tests__/cli-close.test.ts +++ b/src/__tests__/cli-close.test.ts @@ -10,7 +10,7 @@ import { type StoredConnectionSeed, } from './remote-connection.fixtures.ts'; import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state'; -import type { DaemonRequest, DaemonResponse } from '../daemon-client/daemon-client.ts'; +import type { DaemonRequest, DaemonResponse } from '@agent-device/daemon-client'; function runCliWithDaemonStartupFailure(argv: string[]): Promise { return runCliCapture(argv, async () => { diff --git a/src/__tests__/cli-diagnostics.test.ts b/src/__tests__/cli-diagnostics.test.ts index 59401c80a5..1f9cabe3fc 100644 --- a/src/__tests__/cli-diagnostics.test.ts +++ b/src/__tests__/cli-diagnostics.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; +import type { DaemonResponse } from '@agent-device/daemon-client'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { runCliCapture as captureCli, diff --git a/src/__tests__/cli-diff.test.ts b/src/__tests__/cli-diff.test.ts index 32c6252c67..db455e4040 100644 --- a/src/__tests__/cli-diff.test.ts +++ b/src/__tests__/cli-diff.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { PNG } from '@agent-device/capture-kit/png'; -import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; +import type { DaemonResponse } from '@agent-device/daemon-client'; import { runCliCapture as captureCli, type CapturedCliRun, diff --git a/src/__tests__/cli-exit-paths.test.ts b/src/__tests__/cli-exit-paths.test.ts index af8af1d4da..e2471675fd 100644 --- a/src/__tests__/cli-exit-paths.test.ts +++ b/src/__tests__/cli-exit-paths.test.ts @@ -13,9 +13,8 @@ import { runCli } from '../cli.ts'; import { runWebCommand } from '../cli/commands/web.ts'; import { installIsolatedCliTestEnv } from './cli-test-env.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { beginStderrCapture, beginStdoutCapture } from './test-utils/stream-capture.ts'; +import type { DaemonResponse } from '@agent-device/daemon-client'; +import { mkdtempForTestSync, beginStderrCapture, beginStdoutCapture } from '@agent-device/test-kit'; afterEach(() => { vi.clearAllMocks(); diff --git a/src/__tests__/cli-react-devtools-session.test.ts b/src/__tests__/cli-react-devtools-session.test.ts index 3dd382c482..be62517271 100644 --- a/src/__tests__/cli-react-devtools-session.test.ts +++ b/src/__tests__/cli-react-devtools-session.test.ts @@ -15,7 +15,7 @@ import { hashRemoteConfigFile, writeRemoteConnectionState, } from '@agent-device/remote/remote-connection-state'; -import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; +import type { DaemonResponse } from '@agent-device/daemon-client'; afterEach(() => { vi.clearAllMocks(); diff --git a/src/__tests__/cli-run-transport.test.ts b/src/__tests__/cli-run-transport.test.ts index f08b3ea6a9..06c50f618e 100644 --- a/src/__tests__/cli-run-transport.test.ts +++ b/src/__tests__/cli-run-transport.test.ts @@ -7,7 +7,7 @@ import { writeRemoteConnectionState, } from '@agent-device/remote/remote-connection-state'; import { runCli } from '../cli.ts'; -import type { DaemonRequest, DaemonResponse } from '../daemon-client/daemon-client.ts'; +import type { DaemonRequest, DaemonResponse } from '@agent-device/daemon-client'; import { makeTempWorkspace } from '@agent-device/test-kit'; afterEach(() => { diff --git a/src/__tests__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts index c5472fd85e..cc6df48daa 100644 --- a/src/__tests__/cloud-connect-auth.test.ts +++ b/src/__tests__/cloud-connect-auth.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { connectCommand } from '../cli/commands/connection.ts'; -import { beginStdoutCapture } from './test-utils/stream-capture.ts'; import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; import { readActiveConnectionState } from '@agent-device/remote/remote-connection-state'; import type { AgentDeviceClient } from '../agent-device-client.ts'; @@ -11,6 +10,7 @@ import { mkdtempForTestSync, bindTestCommandHost, createTestHostEnvironment, + beginStdoutCapture, } from '@agent-device/test-kit'; bindTestCommandHost(); diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index e10b3312f9..4c473a4046 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -4,7 +4,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { connectCommand } from '../cli/commands/connection.ts'; import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; -import { beginStdoutCapture } from './test-utils/stream-capture.ts'; import { runCliCapture } from './cli-capture.ts'; import { hashRemoteConfigFile, @@ -24,6 +23,7 @@ import { mkdtempForTestSync, bindTestCommandHost, createTestHostEnvironment, + beginStdoutCapture, } from '@agent-device/test-kit'; import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; diff --git a/src/__tests__/command-descriptor-platform-execution-cli-route.test.ts b/src/__tests__/command-descriptor-platform-execution-cli-route.test.ts index 416a21c30f..1ca4cb1107 100644 --- a/src/__tests__/command-descriptor-platform-execution-cli-route.test.ts +++ b/src/__tests__/command-descriptor-platform-execution-cli-route.test.ts @@ -221,7 +221,7 @@ describe('the CLI reaches the daemon transport only through client transport fac test('wiring the transport into a client transport factory stays allowed', () => { const wiring = ` - import { sendToDaemon } from '../daemon-client/daemon-client.ts'; + import { sendToDaemon } from '@agent-device/daemon-client'; type CliDeps = { sendToDaemon: typeof sendToDaemon }; function build(deps: CliDeps) { return createCliDaemonTransport({ transport: deps.sendToDaemon }); diff --git a/src/daemon-client/__tests__/daemon-client-abort-default-transport.test.ts b/src/__tests__/daemon-client-abort-default-transport.test.ts similarity index 95% rename from src/daemon-client/__tests__/daemon-client-abort-default-transport.test.ts rename to src/__tests__/daemon-client-abort-default-transport.test.ts index dea8cc16b2..46a6d5b73c 100644 --- a/src/daemon-client/__tests__/daemon-client-abort-default-transport.test.ts +++ b/src/__tests__/daemon-client-abort-default-transport.test.ts @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; -import { createAgentDeviceClient } from '../../agent-device-client.ts'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; import { createPublicWaitCommandSurface, startPublicDaemonRuntime, -} from '../../__tests__/test-utils/public-daemon-runtime.ts'; +} from './test-utils/public-daemon-runtime.ts'; import { type SkippableTestContext, skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; type SeenRequest = { started: boolean; canceled: boolean; requestId?: string }; diff --git a/src/daemon-client/__tests__/daemon-client-abort.test.ts b/src/__tests__/daemon-client-abort.test.ts similarity index 96% rename from src/daemon-client/__tests__/daemon-client-abort.test.ts rename to src/__tests__/daemon-client-abort.test.ts index 56620a2ec0..59a0672a82 100644 --- a/src/daemon-client/__tests__/daemon-client-abort.test.ts +++ b/src/__tests__/daemon-client-abort.test.ts @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; -import { createAgentDeviceClient } from '../../agent-device-client.ts'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; import { createPublicWaitCommandSurface, startPublicDaemonRuntime, -} from '../../__tests__/test-utils/public-daemon-runtime.ts'; +} from './test-utils/public-daemon-runtime.ts'; import { skipWhenLoopbackUnavailable, type SkippableTestContext } from '@agent-device/test-kit'; async function waitFor(condition: () => boolean, what: string): Promise { diff --git a/src/__tests__/daemon-client-host.test.ts b/src/__tests__/daemon-client-host.test.ts new file mode 100644 index 0000000000..3d87d400ad --- /dev/null +++ b/src/__tests__/daemon-client-host.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +const built = vi.hoisted(() => ({ + clientHosts: [] as unknown[], + ensuringHosts: [] as unknown[], +})); + +// The transports the client and the CLI build are stopped before they reach a daemon: which host +// each is built for is what these tests pin. That a transport reads its host is pinned in the +// daemon-client package. +vi.mock('@agent-device/daemon-client', async (importOriginal) => ({ + ...(await importOriginal()), + createDaemonTransport: (host: unknown) => { + built.clientHosts.push(host); + return async () => { + throw new Error('stop before talking to a daemon'); + }; + }, + createEnsuredDaemonTransport: (options: { host?: unknown } = {}) => { + built.ensuringHosts.push(options.host); + return async () => { + throw new Error('stop before talking to a daemon'); + }; + }, +})); + +import { runCli } from '../cli.ts'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + built.clientHosts.length = 0; + built.ensuringHosts.length = 0; +}); + +test('a client reports and uses the same state dir when its host and the process disagree', async () => { + vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); + const host = { env: { AGENT_DEVICE_STATE_DIR: '/client/state' }, cwd: '/client/workspace' }; + const client = createAgentDeviceClient({}, { host }); + + await expect(client.sessions.stateDir()).resolves.toBe('/client/state'); + await expect(client.devices.list()).rejects.toThrow('stop before talking'); + + expect(built.clientHosts).toEqual([host]); + expect(built.clientHosts[0]).toBe(host); +}); + +test('a CLI run builds its transport from the host it was given', async () => { + const host = { env: { AGENT_DEVICE_STATE_DIR: '/cli/state' }, cwd: '/cli/workspace' }; + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + const transportForHost = vi.fn(() => async () => ({ ok: true as const, data: { devices: [] } })); + + await runCli(['devices', '--json'], { sendToDaemon: vi.fn(), transportForHost }, host); + + expect(transportForHost).toHaveBeenCalledWith(host); +}); + +test('a CLI run with the default transport builds its ensuring transport from the given host', async () => { + const host = { env: { AGENT_DEVICE_STATE_DIR: '/cli/state' }, cwd: '/cli/workspace' }; + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as never); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + + await runCli(['devices', '--json'], undefined, host).catch(() => undefined); + + expect(built.ensuringHosts.at(-1)).toBe(host); +}); diff --git a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts b/src/__tests__/daemon-client-provider-credentials.test.ts similarity index 90% rename from src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts rename to src/__tests__/daemon-client-provider-credentials.test.ts index bbef7902a9..f0a75a28a7 100644 --- a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts +++ b/src/__tests__/daemon-client-provider-credentials.test.ts @@ -3,22 +3,19 @@ import fs from 'node:fs'; import net from 'node:net'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { readVersion } from '@agent-device/host-kit/version'; -import { sendToDaemon } from '../daemon-client.ts'; +import { resolveLocalDaemonCodeIdentity, sendToDaemon } from '@agent-device/daemon-client'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { - currentDaemonCodeSignature, - startHttpDaemonFixture, -} from '../../__tests__/test-utils/daemon-http-fixture.ts'; import { closeLoopbackServer, listenOnLoopback, mkdtempForTestSync, + startHttpDaemonFixture, supportsLoopbackBind, } from '@agent-device/test-kit'; import { providerCredentialFingerprint as fingerprintFor } from '@agent-device/plugins/credential-fingerprint'; -import { builtinPluginInventory } from '../../__tests__/test-utils/builtin-plugin-inventory.ts'; +import { builtinPluginInventory } from './test-utils/builtin-plugin-inventory.ts'; import type { EnvMap } from '@agent-device/kernel/source-value'; -import { LIMRUN_CREDENTIAL_VARIABLES } from '../../provider-limrun-credentials.ts'; +import { LIMRUN_CREDENTIAL_VARIABLES } from '../provider-limrun-credentials.ts'; const inventory = await builtinPluginInventory(); const providerCredentialFingerprint = (provider: string, env: EnvMap, leaseBackend?: string) => @@ -146,7 +143,7 @@ async function startLocalDaemon( ): Promise { if (transport === 'http') { const daemon = await startHttpDaemonFixture({}); - writeDaemonInfo(stateDir, { httpPort: daemon.port, transport }); + await writeDaemonInfo(stateDir, { httpPort: daemon.port, transport }); return { get bodies() { return daemon.rpcRequests.map((rpc) => JSON.stringify(rpc)); @@ -165,11 +162,12 @@ async function startLocalDaemon( socket.end(`${JSON.stringify({ ok: true, data: {} })}\n`); }); }); - writeDaemonInfo(stateDir, { port: await listenOnLoopback(server), transport }); + await writeDaemonInfo(stateDir, { port: await listenOnLoopback(server), transport }); return { bodies, close: () => closeLoopbackServer(server) }; } -function writeDaemonInfo(stateDir: string, endpoint: Record): void { +async function writeDaemonInfo(stateDir: string, endpoint: Record): Promise { + const identity = await resolveLocalDaemonCodeIdentity(); const paths = resolveDaemonPaths(stateDir); fs.mkdirSync(paths.baseDir, { recursive: true }); fs.writeFileSync( @@ -180,7 +178,7 @@ function writeDaemonInfo(stateDir: string, endpoint: Record): v token: 'local-secret', pid: process.pid, version: readVersion(), - codeSignature: currentDaemonCodeSignature(), + codeSignature: identity.origin === 'checkout' ? identity.codeSignature : undefined, processStartTime: readProcessStartTime(process.pid) ?? undefined, })}\n`, 'utf8', diff --git a/src/__tests__/daemon-registration-owner.fixtures.ts b/src/__tests__/daemon-registration-owner.fixtures.ts index 9866e87ced..c656f06884 100644 --- a/src/__tests__/daemon-registration-owner.fixtures.ts +++ b/src/__tests__/daemon-registration-owner.fixtures.ts @@ -6,8 +6,6 @@ import { readProcessStartTime } from '@agent-device/host-kit/process'; import { stopDaemonProcess } from '@agent-device/daemon-contracts/daemon-process'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { launchDaemonProcess as launchOnHost } from '@agent-device/daemon-contracts/daemon-registration-owner'; -import { createTestHostEnvironment } from '@agent-device/test-kit'; -import { resolveClientSettings } from '../daemon-client/daemon-client-lifecycle.ts'; // c237027737: server-lifecycle.ts readLockInfo/acquireDaemonLock/releaseDaemonLock. const legacyLockProtocol = ` @@ -110,11 +108,3 @@ export function launchDaemonProcess( ): ReturnType { return launchOnHost({ ...input, env: process.env, cwd: process.cwd() }); } - -export function devicesClientSettings(stateDir: string) { - return resolveClientSettings( - { session: 'default', command: 'devices', positionals: [], flags: { stateDir } }, - undefined, - createTestHostEnvironment(), - ); -} diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index 05f9ca9440..c18512ed21 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -23,9 +23,8 @@ import { readRegisteredDaemonOwnership, } from '@agent-device/daemon-contracts/daemon-registration'; import { readDaemonShutdownReport } from '@agent-device/daemon-contracts/daemon-shutdown-report'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import { - devicesClientSettings, launchDaemonProcess, spawnLegacyDaemonFixture, } from './daemon-registration-owner.fixtures.ts'; @@ -34,7 +33,7 @@ import { spawnRegisteredDaemonFixture, finishRegisteredDaemonFixture, } from './test-utils/registered-daemon-fixture.ts'; -import { ensureManagedDaemon } from '../daemon-client/daemon-client-lifecycle.ts'; +import { createDaemonTransport } from '@agent-device/daemon-client'; import { inspectProcessLock } from '@agent-device/host-kit/file'; import { AppError } from '@agent-device/kernel/errors'; import { sleep } from '@agent-device/host-kit/retry'; @@ -821,7 +820,12 @@ test('cutover refuses an already-running legacy daemon before signaling or chang assert.ok(disposition, 'a new daemon must refuse an occupied legacy file'); assert.equal(disposition.exitCode, DAEMON_STARTUP_EXIT_CODES.unproven); await assert.rejects( - ensureManagedDaemon(devicesClientSettings(paths.baseDir)), + createDaemonTransport(createTestHostEnvironment())({ + session: 'default', + command: 'devices', + positionals: [], + flags: { stateDir: paths.baseDir }, + }), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.reason, 'daemon_registration_unproven'); diff --git a/src/__tests__/remote-connection-macos-app.test.ts b/src/__tests__/remote-connection-macos-app.test.ts index ecf9582a97..f352f272a0 100644 --- a/src/__tests__/remote-connection-macos-app.test.ts +++ b/src/__tests__/remote-connection-macos-app.test.ts @@ -1,4 +1,8 @@ -import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; +import { + bindTestCommandHost, + createTestHostEnvironment, + beginStdoutCapture, +} from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; @@ -6,7 +10,6 @@ import { connectionWorkspace, createTestClient } from './remote-connection.fixtu import type { AgentDeviceClient } from '../agent-device-client.ts'; import { connectCommand, disconnectCommand } from '../cli/commands/connection.ts'; import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; -import { beginStdoutCapture } from './test-utils/stream-capture.ts'; import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state'; bindTestCommandHost(); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index d903cda234..46c7422d8a 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -1,6 +1,6 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { withCapturedStdoutAsync } from './test-utils/stream-capture.ts'; +import { withCapturedStdoutAsync } from '@agent-device/test-kit'; import fs from 'node:fs'; import path from 'node:path'; import { diff --git a/src/__tests__/test-utils/connect-command.ts b/src/__tests__/test-utils/connect-command.ts index 9ac6fb5866..602889cf35 100644 --- a/src/__tests__/test-utils/connect-command.ts +++ b/src/__tests__/test-utils/connect-command.ts @@ -1,8 +1,7 @@ -import { createTestHostEnvironment } from '@agent-device/test-kit'; +import { beginStdoutCapture, createTestHostEnvironment } from '@agent-device/test-kit'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { connectCommand } from '../../cli/commands/connection.ts'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; -import { beginStdoutCapture } from './stream-capture.ts'; /** Runs `connect` the way the CLI does, writing its generated profile under `stateDir`. */ export async function connectWithGeneratedProviderProfile(options: { diff --git a/src/__tests__/test-utils/public-daemon-runtime.ts b/src/__tests__/test-utils/public-daemon-runtime.ts index 6e8c3fcf02..023b4bf1df 100644 --- a/src/__tests__/test-utils/public-daemon-runtime.ts +++ b/src/__tests__/test-utils/public-daemon-runtime.ts @@ -6,12 +6,12 @@ import type { } from '@agent-device/contracts/daemon-command-surface'; import type { AgentDeviceRuntimeConfig } from '@agent-device/contracts/runtime-contract'; import { AppError } from '@agent-device/kernel/errors'; +import { resolveLocalDaemonCodeIdentity } from '@agent-device/daemon-client'; import { startDaemonRuntime, type DaemonRuntimeController } from '@agent-device/daemon'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import fs from 'node:fs'; import { createDaemonRuntimeDeps } from '../../daemon-composition.ts'; -import { currentDaemonCodeSignature } from './daemon-http-fixture.ts'; export type PublicDaemonRuntimeFixture = Readonly<{ stateDir: string; @@ -58,10 +58,11 @@ export async function startPublicDaemonRuntime( if (!runtime) throw new Error('The public daemon runtime did not start.'); try { const paths = resolveDaemonPaths(stateDir); + const identity = await resolveLocalDaemonCodeIdentity(); const info = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')) as Record; fs.writeFileSync( paths.infoPath, - `${JSON.stringify({ ...info, codeSignature: currentDaemonCodeSignature() })}\n`, + `${JSON.stringify({ ...info, codeSignature: identity.origin === 'checkout' ? identity.codeSignature : undefined })}\n`, ); const port = runtime.httpPort ?? runtime.socketPort; if (port === undefined) { diff --git a/src/__tests__/test-utils/registered-daemon-fixture.ts b/src/__tests__/test-utils/registered-daemon-fixture.ts index 295a78ae51..4d6b98f1b4 100644 --- a/src/__tests__/test-utils/registered-daemon-fixture.ts +++ b/src/__tests__/test-utils/registered-daemon-fixture.ts @@ -1,159 +1,37 @@ -import assert from 'node:assert/strict'; -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; import { vi } from 'vitest'; -import type { runCmdDetachedMonitored, ExecDetachedExit } from '@agent-device/host-kit/command'; -import { readDaemonInfo, type DaemonInfo } from '../../daemon-client/daemon-client-metadata.ts'; -import { readProcessStartTime } from '@agent-device/host-kit/process'; +import { createRegisteredDaemonFixtures } from '@agent-device/test-kit'; import { stopDaemonProcess } from '@agent-device/daemon-contracts/daemon-process'; -import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { + readDaemonRecord, + type DaemonPaths, +} from '@agent-device/daemon-contracts/daemon-resolution'; import type { DaemonRegistrationFields } from '@agent-device/daemon-contracts/daemon-registration-owner'; const actualCommand = await vi.importActual( '@agent-device/host-kit/command', ); -const children = new Map< - string, - Array<{ launch: ReturnType; startTime: string | null }> ->(); -/** Per-child behavior switches, bound into this child's entry script at write time. */ -export type RegisteredDaemonFixtureOptions = Readonly<{ - /** Record `lock-acquired` after acquisition, as the daemon runtime does, for this child only. */ - emitStartupMarker?: boolean; - /** Record `modules-loaded` before acquisition, as the daemon entry does, for this child only. */ - emitStartupMarkerBeforeAcquisition?: boolean; -}>; - -/** A real registration owner, advertising the caller's HTTP fixture and joining before deletion. */ -export function registeredDaemonFixtureArgs( - paths: DaemonPaths, - fields: DaemonRegistrationFields, - acquisitionBarrier?: string, - fixtureOptions?: RegisteredDaemonFixtureOptions, -): string[] { - // One directory per launch so a later build cannot overwrite a script an earlier child has - // not loaded; the path still ends in the real daemon entry name for the identity checks. - const launchDir = path.join(paths.baseDir, 'fixture-entries', crypto.randomUUID()); - const entry = path.join(launchDir, 'dist', 'src', 'internal', 'daemon.js'); - fs.mkdirSync(path.dirname(entry), { recursive: true }); - fs.writeFileSync(path.join(launchDir, 'package.json'), '{"type":"module"}'); - const registrationUrl = new URL( - '../../../packages/daemon-contracts/src/daemon-registration-owner.ts', - import.meta.url, - ).href; - const phasesUrl = new URL( - '../../../packages/daemon-contracts/src/daemon-startup-phases.ts', - import.meta.url, - ).href; - fs.writeFileSync( - entry, - `import fs from 'node:fs'; -import path from 'node:path'; -import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration } from ${JSON.stringify(registrationUrl)}; -import { removeDaemonStartupPhasesFile, recordDaemonStartupMarker, resolveDaemonStartupPhasesPath } from ${JSON.stringify(phasesUrl)}; -const paths = ${JSON.stringify(paths)}; -const barrier = ${JSON.stringify(acquisitionBarrier)}; -const emitBefore = ${fixtureOptions?.emitStartupMarkerBeforeAcquisition ? 'true' : 'false'}; -const emitAfter = ${fixtureOptions?.emitStartupMarker ? 'true' : 'false'}; -if (barrier) { - fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready'); - while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10)); -} -if (emitBefore) { const own = resolveDaemonStartupPhasesPath(paths.baseDir, process.pid); removeDaemonStartupPhasesFile(own); recordDaemonStartupMarker(own, 'modules-loaded'); } -const acquired = await tryAcquireDaemonRegistration(paths); -if (acquired.status !== 'acquired') process.exit(DAEMON_STARTUP_EXIT_CODES[acquired.status]); -if (emitAfter) - recordDaemonStartupMarker(resolveDaemonStartupPhasesPath(paths.baseDir, process.pid), 'lock-acquired'); -process.on('SIGTERM', async () => { - if (fs.existsSync(path.join(paths.baseDir, 'ignore-sigterm'))) return; - const deferred = path.join(paths.baseDir, 'repair-on-shutdown.json'); - if (fs.existsSync(deferred)) { - const dir = path.join(paths.sessionsDir, 'default'); - fs.mkdirSync(dir, { recursive: true }); - fs.copyFileSync(deferred, path.join(dir, 'repair-tombstone.json')); - } - await acquired.owner.finish(); - process.exit(0); +/** The record a registered fixture child published, as far as the root tests read it. */ +type PublishedDaemonRecord = { pid: number; processStartTime?: string } & Record; + +function readPublishedDaemonRecord(infoPath: string): PublishedDaemonRecord | null { + const read = readDaemonRecord(infoPath); + if (read.status !== 'present' || typeof read.record.pid !== 'number') return null; + return read.record as PublishedDaemonRecord; +} + +export const { + registeredDaemonFixtureArgs, + launchRegisteredDaemonFixtureArgs, + spawnRegisteredDaemonFixture, + waitForFixtureFile, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, +} = createRegisteredDaemonFixtures({ + registrationOwnerUrl: import.meta + .resolve('@agent-device/daemon-contracts/daemon-registration-owner'), + startupPhasesUrl: import.meta.resolve('@agent-device/daemon-contracts/daemon-startup-phases'), + launch: actualCommand.runCmdDetachedMonitored, + stopDaemonProcess, + readDaemonInfo: readPublishedDaemonRecord, }); -fs.writeFileSync(path.join(paths.baseDir, 'registration-held'), 'ready'); -fs.writeFileSync(path.join(paths.baseDir, 'registration-held-' + process.pid), 'ready'); -while (fs.existsSync(path.join(paths.baseDir, 'defer-publication'))) await new Promise(resolve => setTimeout(resolve, 10)); -acquired.owner.publish(${JSON.stringify(fields)}); -setInterval(() => {}, 1000); -`, - ); - return ['--experimental-strip-types', entry]; -} - -export function launchRegisteredDaemonFixtureArgs( - paths: DaemonPaths, - args: string[], - options: Parameters[2], -): ReturnType { - const child = actualCommand.runCmdDetachedMonitored(process.execPath, args, options); - const owned = children.get(paths.baseDir) ?? []; - owned.push({ launch: child, startTime: readProcessStartTime(child.pid) }); - children.set(paths.baseDir, owned); - return child; -} - -export function spawnRegisteredDaemonFixture( - paths: DaemonPaths, - fields: DaemonRegistrationFields, - options: Parameters[2], - acquisitionBarrier?: string, - fixtureOptions?: RegisteredDaemonFixtureOptions, -): ReturnType { - return launchRegisteredDaemonFixtureArgs( - paths, - registeredDaemonFixtureArgs(paths, fields, acquisitionBarrier, fixtureOptions), - options, - ); -} - -/** Waits up to 4 s for a fixture child to publish `file`; one shared wait for every probe. */ -export async function waitForFixtureFile(file: string): Promise { - for (let attempt = 0; !fs.existsSync(file); attempt += 1) { - assert.ok(attempt < 400, `fixture did not publish ${file}`); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -export async function waitForRegisteredDaemonFixture( - paths: DaemonPaths, - child: ReturnType, -): Promise { - let exit: ExecDetachedExit | undefined; - void child.exited.then((result) => { - exit = result; - }); - await Promise.resolve(); - for (let attempt = 0; attempt < 400; attempt += 1) { - if (exit) - throw new Error(`Registered child exited before publication: ${JSON.stringify(exit)}`); - const info = readDaemonInfo(paths.infoPath); - if (info?.pid === child.pid) return info; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error(`Registered child ${child.pid} did not publish ${paths.infoPath} within 4s`); -} - -export async function finishRegisteredDaemonFixture(stateDir: string): Promise { - for (const owned of children.get(stateDir) ?? []) { - const child = owned.launch; - const termination = await stopDaemonProcess( - { pid: child.pid, startTime: owned.startTime }, - { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 }, - ); - assert.notEqual(termination.status, 'retained', JSON.stringify(termination)); - await child.exited; - } - children.delete(stateDir); - fs.rmSync(stateDir, { recursive: true, force: true }); -} - -export async function finishRegisteredDaemonFixtures(): Promise { - for (const stateDir of children.keys()) await finishRegisteredDaemonFixture(stateDir); -} diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 47e00cd1ab..374f30e2ca 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -59,7 +59,7 @@ import { type DaemonCommandName, } from '@agent-device/command-surface'; import type { CommandResult } from '@agent-device/command-registry/command-result'; -import { createDaemonTransport } from './daemon-client/daemon-client.ts'; +import { createDaemonTransport, createRequestGuard } from '@agent-device/daemon-client'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import * as devServer from '@agent-device/react-native'; import { @@ -73,7 +73,6 @@ import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createLeaseClient } from './client/lease-client.ts'; import { normalizeScreenshotCaptureResult } from './client/screenshot-result.ts'; -import { createRequestGuard } from './daemon-client/daemon-client-transport.ts'; export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, diff --git a/src/cli.ts b/src/cli.ts index 3750c779a7..976195d16b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { createEnsuredDaemonTransport, resolveRunTransport, sendToDaemon, -} from './daemon-client/daemon-client.ts'; +} from '@agent-device/daemon-client'; import fs from 'node:fs'; import type { BatchStep, LeaseDevice } from '@agent-device/contracts/client'; import type { ReplayTestReporterRuntime } from './cli/replay-test/reporting.ts'; diff --git a/src/cli/__tests__/update-check.test.ts b/src/cli/__tests__/update-check.test.ts index 905902c010..03e517e44c 100644 --- a/src/cli/__tests__/update-check.test.ts +++ b/src/cli/__tests__/update-check.test.ts @@ -2,8 +2,7 @@ import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { withCapturedStderr, withStreamTty } from '../../__tests__/test-utils/stream-capture.ts'; +import { mkdtempForTestSync, withCapturedStderr, withStreamTty } from '@agent-device/test-kit'; vi.mock('@agent-device/host-kit/command', () => ({ runCmdDetached: vi.fn(), diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts index ccd6ea878a..bb99bccf90 100644 --- a/src/cli/commands/__tests__/generic.test.ts +++ b/src/cli/commands/__tests__/generic.test.ts @@ -2,8 +2,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; -import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + createTestHostEnvironment, + withCapturedStdoutAsync, +} from '@agent-device/test-kit'; import { createAgentDeviceClient } from '../../../agent-device-client.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; diff --git a/src/cli/commands/__tests__/plugins.test.ts b/src/cli/commands/__tests__/plugins.test.ts index 8819afe8ef..b6b8ac5db7 100644 --- a/src/cli/commands/__tests__/plugins.test.ts +++ b/src/cli/commands/__tests__/plugins.test.ts @@ -4,9 +4,8 @@ import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { runCli } from '../../../cli.ts'; import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; -import { pluginHome, selectPlugin } from '@agent-device/test-kit'; +import { pluginHome, selectPlugin, beginStdoutCapture } from '@agent-device/test-kit'; import { parseArgs, parseRawArgs, usageForCommand } from '../../parser/args.ts'; -import { beginStdoutCapture } from '../../../__tests__/test-utils/stream-capture.ts'; vi.mock('../../../provider-device-runtimes.ts', () => { throw new Error('provider runtimes must remain unloaded'); diff --git a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts index d0485c44eb..cfccbb22b6 100644 --- a/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts +++ b/src/cli/commands/__tests__/proxy-lease-device-spec.test.ts @@ -1,4 +1,8 @@ -import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; +import { + bindTestCommandHost, + createTestHostEnvironment, + withCapturedStdoutAsync, +} from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -25,7 +29,6 @@ import { readRemoteConnectionState, } from '@agent-device/remote/remote-connection-state'; import { connectCommand } from '../../../__tests__/test-utils/host-bound-commands.ts'; -import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; bindTestCommandHost(); diff --git a/src/cli/commands/__tests__/proxy.test.ts b/src/cli/commands/__tests__/proxy.test.ts index 1957a7164a..78effb9c6b 100644 --- a/src/cli/commands/__tests__/proxy.test.ts +++ b/src/cli/commands/__tests__/proxy.test.ts @@ -1,6 +1,6 @@ // #3420: the proxy command's startup path — daemon-backed upstream resolution, client-token // sourcing, and real loopback binding — is pinned through the command handler itself. The -// process-spawn seam (`ensureManagedDaemon`) and the output sink are mocked at the module boundary the +// daemon-resolution seam (`ensureProxyUpstreamDaemon`) and the output sink are mocked at the module boundary the // command consumes; the proxy server and its listen are the real ones. import assert from 'node:assert/strict'; import http from 'node:http'; @@ -16,15 +16,15 @@ import { import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; const mocks = vi.hoisted(() => ({ - ensureManagedDaemon: vi.fn(), + ensureProxyUpstreamDaemon: vi.fn(), writeCommandOutput: vi.fn(async (..._args: unknown[]) => undefined), createdServers: [] as http.Server[], listenArgs: [] as { port: number; host: string }[], })); -vi.mock('../../../daemon-client/daemon-client-lifecycle.ts', async (importOriginal) => ({ - ...(await importOriginal()), - ensureManagedDaemon: mocks.ensureManagedDaemon, +vi.mock('@agent-device/daemon-client', async (importOriginal) => ({ + ...(await importOriginal()), + ensureProxyUpstreamDaemon: mocks.ensureProxyUpstreamDaemon, })); vi.mock('@agent-device/proxy', async (importOriginal) => { const actual = await importOriginal(); @@ -102,13 +102,14 @@ test('proxy rejects positional arguments', async () => { }), (error: { code?: string }) => error.code === 'INVALID_ARGS', ); - assert.equal(mocks.ensureManagedDaemon.mock.calls.length, 0); + assert.equal(mocks.ensureProxyUpstreamDaemon.mock.calls.length, 0); }); test('proxy refuses to start when the local daemon has no HTTP endpoint', async () => { - mocks.ensureManagedDaemon.mockResolvedValue({ - info: { token: 'daemon-secret', pid: 4242 }, - startedByClient: false, + mocks.ensureProxyUpstreamDaemon.mockResolvedValue({ + httpPort: undefined, + token: 'daemon-secret', + stateDir: resolveDaemonPaths(undefined, { env: {}, cwd: process.cwd() }).baseDir, }); await assert.rejects( @@ -127,9 +128,10 @@ test('proxy refuses to start when the local daemon has no HTTP endpoint', async test('proxy binds a loopback listener and reports the supplied client token', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; const stateDir = mkdtempForTestSync('agent-device-proxy-start-'); - mocks.ensureManagedDaemon.mockResolvedValue({ - info: { httpPort: 54321, token: 'daemon-secret', pid: 4242 }, - startedByClient: false, + mocks.ensureProxyUpstreamDaemon.mockResolvedValue({ + httpPort: 54321, + token: 'daemon-secret', + stateDir: resolveDaemonPaths(stateDir).baseDir, }); runProxy({ @@ -154,17 +156,16 @@ test('proxy binds a loopback listener and reports the supplied client token', as ); assert.match(startupCard, /Proxy listening at/); assert.ok(startupCard.includes(proxyBaseUrl)); - // The daemon the proxy fronts is the local one of the resolved state dir, never a remote URL. - const settings = mocks.ensureManagedDaemon.mock.calls[0]![0]; - assert.equal(settings.paths.baseDir, resolveDaemonPaths(stateDir).baseDir); - assert.equal(settings.remoteBaseUrl, undefined); + // The daemon the proxy fronts is resolved from the flag's state dir. + assert.equal(mocks.ensureProxyUpstreamDaemon.mock.calls[0]![0].stateDir, stateDir); }); test('proxy honors proxy host and port flags and mints a token when none is supplied', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; - mocks.ensureManagedDaemon.mockResolvedValue({ - info: { httpPort: 54322, token: 'daemon-secret', pid: 4242 }, - startedByClient: false, + mocks.ensureProxyUpstreamDaemon.mockResolvedValue({ + httpPort: 54322, + token: 'daemon-secret', + stateDir: resolveDaemonPaths(undefined, { env: {}, cwd: process.cwd() }).baseDir, }); // Reserve a real port through an ephemeral bind so the flag value is distinguishable // from the `0` default the command would otherwise pass to listen. diff --git a/src/cli/commands/__tests__/screenshot.test.ts b/src/cli/commands/__tests__/screenshot.test.ts index 702f0d27d0..9d383ec3ff 100644 --- a/src/cli/commands/__tests__/screenshot.test.ts +++ b/src/cli/commands/__tests__/screenshot.test.ts @@ -1,7 +1,10 @@ -import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + createTestHostEnvironment, + mkdtempForTestSync, + withCapturedStdoutAsync, +} from '@agent-device/test-kit'; import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; import { createAgentDeviceClient } from '../../../agent-device-client.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index da25427bb7..348efc07a7 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -18,7 +18,7 @@ import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; import { createDaemonOwnerCleanup } from '../../platform-runtime-daemon-owner-cleanup.ts'; -import { stopRecordedDaemon } from '../../daemon-client/daemon-client-stop.ts'; +import { stopRecordedDaemon } from '@agent-device/daemon-client'; export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { const daemonOwnerCleanup = createDaemonOwnerCleanup(host); diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 7d9923c3d7..005ad03184 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -2,10 +2,7 @@ import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '@agent-device/proxy'; import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; -import { - ensureManagedDaemon, - resolveClientSettings, -} from '../../daemon-client/daemon-client-lifecycle.ts'; +import { ensureProxyUpstreamDaemon } from '@agent-device/daemon-client'; import { AppError } from '@agent-device/kernel/errors'; import { colorize, supportsColor } from '../../commands/output/color.ts'; import type { CliFlags } from '@agent-device/contracts/command'; @@ -35,27 +32,12 @@ export const proxyCommand: ClientCommandHandler = async ({ }; async function startProxy(flags: CliFlags, clientHost: HostEnvironment): Promise { - const settings = resolveClientSettings( - { - session: 'default', - command: 'proxy', - positionals: [], - flags: { - stateDir: flags.stateDir, - daemonBaseUrl: '', - daemonTransport: 'http', - daemonServerMode: 'http', - }, - }, - undefined, - clientHost, - ); - const daemon = await ensureManagedDaemon(settings); - const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.info.httpPort); + const daemon = await ensureProxyUpstreamDaemon({ stateDir: flags.stateDir, host: clientHost }); + const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.httpPort); const token = resolveProxyClientToken(flags); const server = createDaemonProxyServer({ upstreamBaseUrl, - upstreamToken: daemon.info.token, + upstreamToken: daemon.token, clientToken: token, }); const host = flags.proxyHost?.trim() || '127.0.0.1'; @@ -71,7 +53,7 @@ async function startProxy(flags: CliFlags, clientHost: HostEnvironment): Promise agentDeviceBaseUrl: buildDaemonHttpBaseUrl(proxyBaseUrl), token, upstreamBaseUrl, - stateDir: settings.paths.baseDir, + stateDir: daemon.stateDir, }; } diff --git a/src/cli/replay-test/__tests__/progress.test.ts b/src/cli/replay-test/__tests__/progress.test.ts index d48285724b..030401f14a 100644 --- a/src/cli/replay-test/__tests__/progress.test.ts +++ b/src/cli/replay-test/__tests__/progress.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { withStreamTty } from '../../../__tests__/test-utils/stream-capture.ts'; +import { withStreamTty } from '@agent-device/test-kit'; import { createReplayTestProgressRenderer } from '../progress.ts'; import type { ReplayTestResult } from '../reporters/types.ts'; diff --git a/src/commands/batch/cli.test.ts b/src/commands/batch/cli.test.ts index 8cb6a8c8e3..84a4821ed1 100644 --- a/src/commands/batch/cli.test.ts +++ b/src/commands/batch/cli.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import type { DaemonResponse } from '../../daemon-client/daemon-client.ts'; +import type { DaemonResponse } from '@agent-device/daemon-client'; import { runCliCapture as captureCli, type CapturedCliRun, diff --git a/src/commands/command-progress.test.ts b/src/commands/command-progress.test.ts index fe5b11b216..831b44258e 100644 --- a/src/commands/command-progress.test.ts +++ b/src/commands/command-progress.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { withCapturedStderr } from '../__tests__/test-utils/stream-capture.ts'; +import { withCapturedStderr } from '@agent-device/test-kit'; import { createCommandProgressState, createStderrCommandProgressSink } from './command-progress.ts'; test('the stderr sink renders command progress and records that it did', () => { diff --git a/src/commands/output/error.test.ts b/src/commands/output/error.test.ts index 0abe668bb4..3078875c61 100644 --- a/src/commands/output/error.test.ts +++ b/src/commands/output/error.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { withCapturedStderrAsync } from '../../__tests__/test-utils/stream-capture.ts'; +import { withCapturedStderrAsync } from '@agent-device/test-kit'; import { printHumanError } from './error.ts'; test('printHumanError renders a structured cause unconditionally', async () => { diff --git a/src/daemon-client/daemon-client-host.test.ts b/src/daemon-client/daemon-client-host.test.ts deleted file mode 100644 index 0902267605..0000000000 --- a/src/daemon-client/daemon-client-host.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; - -const ensured = vi.hoisted(() => ({ settings: undefined as unknown })); - -vi.mock('./daemon-client-lifecycle.ts', async (importOriginal) => ({ - ...(await importOriginal()), - ensureManagedDaemon: async (settings: unknown) => { - ensured.settings = settings; - throw new Error('stop before talking to a daemon'); - }, -})); -vi.mock('./daemon-client-startup.ts', async (importOriginal) => ({ - ...(await importOriginal()), - observeLocalDaemon: async (settings: unknown) => { - ensured.settings = settings; - throw new Error('stop before talking to a daemon'); - }, -})); - -import { runCli } from '../cli.ts'; -import { createAgentDeviceClient } from '../agent-device-client.ts'; -import { createDaemonTransport } from './daemon-client.ts'; -import type { DaemonClientSettings } from './daemon-client-lifecycle.ts'; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -const request = { session: 'default', command: 'devices', positionals: [] }; - -test('a transport resolves its daemon from the host it was built for, not the process', async () => { - vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); - const host = { - env: { AGENT_DEVICE_STATE_DIR: 'client-state', AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }, - cwd: '/client/workspace', - }; - - await expect(createDaemonTransport(host)(request)).rejects.toThrow('stop before talking'); - - const settings = ensured.settings as DaemonClientSettings; - expect(settings.host).toBe(host); - expect(settings.paths.baseDir).toBe(path.resolve('/client/workspace', 'client-state')); - expect(settings.serverMode).toBe('http'); -}); - -test('a client reports and uses the same state dir when its host and the process disagree', async () => { - vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); - const host = { env: { AGENT_DEVICE_STATE_DIR: '/client/state' }, cwd: '/client/workspace' }; - const client = createAgentDeviceClient({}, { host }); - - await expect(client.sessions.stateDir()).resolves.toBe('/client/state'); - await expect(client.devices.list()).rejects.toThrow(); - - expect((ensured.settings as DaemonClientSettings).paths.baseDir).toBe('/client/state'); -}); - -test('a CLI run builds its transport from the host it was given', async () => { - const host = { env: { AGENT_DEVICE_STATE_DIR: '/cli/state' }, cwd: '/cli/workspace' }; - vi.spyOn(process.stdout, 'write').mockReturnValue(true); - const transportForHost = vi.fn(() => async () => ({ ok: true as const, data: { devices: [] } })); - - await runCli(['devices', '--json'], { sendToDaemon: vi.fn(), transportForHost }, host); - - expect(transportForHost).toHaveBeenCalledWith(host); -}); - -test('a CLI run with the default transport resolves its daemon from the given host', async () => { - vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); - const host = { env: { AGENT_DEVICE_STATE_DIR: '/cli/state' }, cwd: '/cli/workspace' }; - vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('exit'); - }) as never); - vi.spyOn(process.stderr, 'write').mockReturnValue(true); - vi.spyOn(process.stdout, 'write').mockReturnValue(true); - - await runCli(['devices', '--json'], undefined, host).catch(() => undefined); - - expect((ensured.settings as DaemonClientSettings).paths.baseDir).toBe('/cli/state'); -}); diff --git a/src/host.test.ts b/src/host.test.ts index 2236504f50..bd4fc6fc09 100644 --- a/src/host.test.ts +++ b/src/host.test.ts @@ -12,10 +12,23 @@ 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 * as daemonLaunchSpec from './daemon-client/daemon-launch-spec.ts'; +import { stopRecordedDaemon } from '@agent-device/daemon-client'; import { startAgentDevice } from './host.ts'; +const codeIdentity = vi.hoisted(() => ({ + /** Replaces the code identity the host resolves, for the row that needs an installed tree. */ + override: undefined as { origin: 'installed' } | undefined, +})); + +vi.mock('@agent-device/daemon-client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLocalDaemonCodeIdentity: async () => + codeIdentity.override ?? (await actual.resolveLocalDaemonCodeIdentity()), + }; +}); + 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, @@ -129,9 +142,7 @@ 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' }); + codeIdentity.override = { origin: 'installed' }; try { const { stateDir, paths, host } = await bootHost(); try { @@ -143,7 +154,7 @@ test('an installed host publishes no checkout code signature', async () => { removeStateDir(stateDir); } } finally { - identity.mockRestore(); + codeIdentity.override = undefined; } }); diff --git a/src/host.ts b/src/host.ts index 89ed840d3c..8c0d6a714a 100644 --- a/src/host.ts +++ b/src/host.ts @@ -36,7 +36,7 @@ export async function startAgentDevice(options: StartAgentDeviceOptions): Promis await Promise.all([ import('@agent-device/daemon'), import('./daemon-composition.ts'), - import('./daemon-client/daemon-launch-spec.ts'), + import('@agent-device/daemon-client'), ]); const codeIdentity = await resolveLocalDaemonCodeIdentity(); const output = { write: () => {} }; diff --git a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts index 71e6763420..863a75acd2 100644 --- a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts +++ b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts @@ -1,58 +1,49 @@ import assert from 'node:assert/strict'; import { afterEach, test, vi } from 'vitest'; -import { closeLoopbackServer, supportsLoopbackBind } from '@agent-device/test-kit'; -import { finishRegisteredDaemonFixtures } from '../../__tests__/test-utils/registered-daemon-fixture.ts'; -import { - launchesFreshDaemonAt, - okResult, - startScriptedDaemon, - stateDirAndPaths, -} from '../../daemon-client/__tests__/explicit-lifecycle-fixture.ts'; - -vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ - ...(await importOriginal()), - runCmdDetachedMonitored: vi.fn(), + +const ensuring = vi.hoisted(() => ({ + hosts: [] as unknown[], + requests: [] as string[], +})); + +// The ensuring transport is the production entry's; its ensure-then-attach behavior is pinned in +// the daemon-client package. What these tests pin is what the MCP default client factory asks of it. +vi.mock('@agent-device/daemon-client', async (importOriginal) => ({ + ...(await importOriginal()), + createEnsuredDaemonTransport: (options: { host?: unknown } = {}) => { + ensuring.hosts.push(options.host); + return async (req: { command: string }) => { + ensuring.requests.push(req.command); + return { ok: true as const, data: { devices: [] } }; + }; + }, })); -import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; + import * as hostProcess from '@agent-device/host-kit/process'; -import * as ensureModule from '../../daemon-client/daemon-client-ensure.ts'; import { createCommandToolExecutor } from '../command-tools.ts'; // An MCP tool call builds its own client, so each call has to ensure the daemon it is about to use // and then attach to it. This drives the default client factory, not an injected one. -const spawn = vi.mocked(runCmdDetachedMonitored); - -afterEach(async () => { - vi.unstubAllEnvs(); - spawn.mockReset(); - await finishRegisteredDaemonFixtures(); +afterEach(() => { + vi.restoreAllMocks(); + ensuring.hosts.length = 0; + ensuring.requests.length = 0; }); -test('each MCP tool call ensures the daemon, then attaches to it', async (t) => { - if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); - const { stateDir, paths } = stateDirAndPaths(); - const daemon = await startScriptedDaemon(okResult({ devices: [] })); - launchesFreshDaemonAt(spawn, paths, daemon.port); - vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const ensure = vi.spyOn(ensureModule, 'ensureDaemon'); +test('each MCP tool call builds an ensuring transport from the host it captured', async () => { const capture = vi.spyOn(hostProcess, 'captureHostEnvironment'); const executor = createCommandToolExecutor(); - try { - const first = await executor.execute('devices', {}); - const second = await executor.execute('devices', {}); - - assert.equal(first.isError, false, JSON.stringify(first.structuredContent)); - assert.equal(second.isError, false, JSON.stringify(second.structuredContent)); - assert.equal(ensure.mock.calls.length, 2, 'one ensure per tool call'); - assert.equal(capture.mock.calls.length, 2, 'one host capture per tool call'); - // The host the ensuring transport used is the one the call captured, not a second capture. - assert.equal(ensure.mock.calls[0]?.[1], capture.mock.results[0]?.value); - assert.equal(ensure.mock.calls[1]?.[1], capture.mock.results[1]?.value); - assert.equal(spawn.mock.calls.length, 1, 'the second call reuses the daemon the first started'); - assert.deepEqual(daemon.rpcCommands, ['devices', 'devices']); - } finally { - await closeLoopbackServer(daemon.server); - } + const first = await executor.execute('devices', {}); + const second = await executor.execute('devices', {}); + + assert.equal(first.isError, false, JSON.stringify(first.structuredContent)); + assert.equal(second.isError, false, JSON.stringify(second.structuredContent)); + assert.equal(ensuring.hosts.length, 2, 'one ensuring transport per tool call'); + assert.equal(capture.mock.calls.length, 2, 'one host capture per tool call'); + // The host the ensuring transport used is the one the call captured, not a second capture. + assert.equal(ensuring.hosts[0], capture.mock.results[0]?.value); + assert.equal(ensuring.hosts[1], capture.mock.results[1]?.value); + assert.deepEqual(ensuring.requests, ['devices', 'devices']); }); diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index 66f427c41a..08677f6fa4 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -255,7 +255,7 @@ async function createClient( ): Promise { if (deps.createClient) return await deps.createClient(config); const { createAgentDeviceClient } = await import('../agent-device-client.ts'); - const { createEnsuredDaemonTransport } = await import('../daemon-client/daemon-client.ts'); + const { createEnsuredDaemonTransport } = await import('@agent-device/daemon-client'); // One client per tool call, so each call ensures the daemon it is about to use. const ensuring = createEnsuredDaemonTransport({ host }); return createAgentDeviceClient(config, { diff --git a/src/sdk/ensure-daemon.ts b/src/sdk/ensure-daemon.ts index d4328459b4..14399ede77 100644 --- a/src/sdk/ensure-daemon.ts +++ b/src/sdk/ensure-daemon.ts @@ -8,6 +8,6 @@ import type { DaemonConnection, EnsureDaemonOptions } from '@agent-device/contra * machinery. */ export async function ensureDaemon(options?: EnsureDaemonOptions): Promise { - const { ensureDaemon: ensure } = await import('../daemon-client/daemon-client-ensure.ts'); + const { ensureDaemon: ensure } = await import('@agent-device/daemon-client'); return await ensure(options); } diff --git a/test/integration/android-emulator-e2e/live-observability-scenario.ts b/test/integration/android-emulator-e2e/live-observability-scenario.ts index bd2cfc28fc..9bf1f21bce 100644 --- a/test/integration/android-emulator-e2e/live-observability-scenario.ts +++ b/test/integration/android-emulator-e2e/live-observability-scenario.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; -import { readDaemonInfo } from '../../../src/daemon-client/daemon-client-metadata.ts'; +import { readRecordedDaemon } from '../../../scripts/lib/recorded-daemon.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { collectPagedEventTimeline, @@ -203,7 +203,7 @@ async function downloadDaemonArtifact( env: context.env, projectRoot: process.cwd(), }); - const daemon = readDaemonInfo(daemonPaths.infoPath); + const daemon = readRecordedDaemon(daemonPaths.infoPath); assert.ok(daemon?.httpPort, `daemon HTTP metadata unavailable at ${daemonPaths.infoPath}`); const response = await fetch( `http://127.0.0.1:${daemon.httpPort}/artifacts/${encodeURIComponent(artifactId)}`, diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index 73874e01c1..c615f8d7ee 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -1,4 +1,3 @@ -import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import type { TestContext } from 'node:test'; @@ -7,8 +6,7 @@ import { pathToFileURL } from 'node:url'; import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; -import { readDaemonInfo } from '../../../src/daemon-client/daemon-client-metadata.ts'; -import { sendRequest } from '../../../src/daemon-client/daemon-client-transport.ts'; +import { readRecordedDaemon, sendToRecordedDaemon } from '../../../scripts/lib/recorded-daemon.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { assertPngFile } from '../provider-scenarios/assertions.ts'; import { @@ -61,19 +59,12 @@ const sendToDaemonTransport: AgentDeviceDaemonTransport = async (request, contex const stateDir = request.flags?.stateDir; assert.ok(typeof stateDir === 'string', 'backend conformance requires the smoke state directory'); const paths = resolveDaemonPaths(stateDir, { env: process.env }); - const info = readDaemonInfo(paths.infoPath); + const info = readRecordedDaemon(paths.infoPath); assert.ok(info, 'backend conformance requires the daemon prepared by the built CLI'); - return await sendRequest( + return await sendToRecordedDaemon( info, - { ...request, session: request.session, token: info.token }, - 'auto', - paths, - 90_000, - { - host: createTestHostEnvironment(), - signal: context?.signal, - timeoutRecovery: 'none', - }, + { ...request, session: request.session }, + { timeoutMs: 90_000, signal: context?.signal }, ); }; diff --git a/test/integration/provider-scenarios/cli-record-publication.test.ts b/test/integration/provider-scenarios/cli-record-publication.test.ts index 25bf08e82c..2de04abef0 100644 --- a/test/integration/provider-scenarios/cli-record-publication.test.ts +++ b/test/integration/provider-scenarios/cli-record-publication.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test, onTestFinished, vi } from 'vitest'; import * as codeSignature from '@agent-device/host-kit/code-signature'; -import { resolveLocalDaemonCodeIdentity } from '../../../src/daemon-client/daemon-launch-spec.ts'; +import { resolveLocalDaemonCodeIdentity } from '@agent-device/daemon-client'; import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import { createProviderScenarioHarness } from './harness.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; diff --git a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts index 99aac4b193..b404132c40 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts @@ -3,7 +3,7 @@ import http from 'node:http'; import { test } from 'vitest'; import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; -import { buildHttpRpcPayload } from '../../../src/daemon-client/daemon-client-rpc.ts'; +import { buildRpcPayload } from './harness-rpc.ts'; import { skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; import { @@ -54,7 +54,7 @@ test('lease_allocate over HTTP prepares the BrowserStack session end to end', as }); try { - const envelope = buildHttpRpcPayload( + const envelope = buildRpcPayload( { token: harness.token, session: 'browserstack-e2e', diff --git a/test/integration/provider-scenarios/harness-proxy.ts b/test/integration/provider-scenarios/harness-proxy.ts index 7c118eca51..9f4d185199 100644 --- a/test/integration/provider-scenarios/harness-proxy.ts +++ b/test/integration/provider-scenarios/harness-proxy.ts @@ -1,10 +1,10 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; import http from 'node:http'; import { createDaemonProxyServer, type DaemonProxyAuthenticate } from '@agent-device/proxy'; -import { buildHttpRpcPayload } from '../../../src/daemon-client/daemon-client-rpc.ts'; import { withClientReplayScriptSources } from '../../../src/__tests__/test-utils/replay-script-source.fixtures.ts'; import { closeLoopbackServer, listenOnLoopback } from '@agent-device/test-kit'; import { + buildRpcPayload, commandRequest, envelopeToRpcResult, type JsonRpcResponseEnvelope, @@ -92,7 +92,7 @@ export async function createProviderScenarioProxy( const request = await withClientReplayScriptSources( commandRequest(authToken, command, positionals, flags, commandOptions), ); - const payload = buildHttpRpcPayload(request, { includeTokenParam: true }); + const payload = buildRpcPayload(request, { includeTokenParam: true }); const result = await requestRpc(authToken, payload); const envelope = result.json as JsonRpcResponseEnvelope; const id = String(payload.id); diff --git a/test/integration/provider-scenarios/harness-rpc.ts b/test/integration/provider-scenarios/harness-rpc.ts index 34d3e86505..087884648e 100644 --- a/test/integration/provider-scenarios/harness-rpc.ts +++ b/test/integration/provider-scenarios/harness-rpc.ts @@ -1,4 +1,10 @@ +import { randomUUID } from 'node:crypto'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import { + leaseScopeFromRequest, + leaseScopeToLeaseRpcParams, + readLeaseAllocateProviderFlags, +} from '@agent-device/contracts/lease-scope'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { ProviderScenarioRpcResult } from './harness.ts'; @@ -71,3 +77,44 @@ export function envelopeToRpcResult( }, }; } + +const LEASE_RPC_METHODS = { + lease_allocate: 'agent_device.lease.allocate', + lease_heartbeat: 'agent_device.lease.heartbeat', + lease_release: 'agent_device.lease.release', +} as const; + +/** + * The JSON-RPC envelope a scenario posts to the daemon's `/rpc` itself: the wire a remote client + * speaks, built independently of the client transport so a scenario can send what no client does. + */ +export function buildRpcPayload( + req: DaemonWireRequest, + options: { includeTokenParam: boolean }, +): { + jsonrpc: '2.0'; + id: string; + method: string; + params: DaemonWireRequest | Record; +} { + const id = req.meta?.requestId ?? randomUUID(); + const leaseMethod = LEASE_RPC_METHODS[req.command as keyof typeof LEASE_RPC_METHODS]; + if (!leaseMethod) return { jsonrpc: '2.0', id, method: 'agent_device.command', params: req }; + const command = req.command as keyof typeof LEASE_RPC_METHODS; + return { + jsonrpc: '2.0', + id, + method: leaseMethod, + params: { + ...leaseScopeToLeaseRpcParams(leaseScopeFromRequest(req), command, { + includeTokenParam: options.includeTokenParam, + token: req.token, + session: req.session, + }), + ...(command === 'lease_allocate' ? readLeaseAllocateProviderFlags(req.flags) : {}), + ...(command === 'lease_allocate' && req.meta?.providerCredentialFingerprint + ? { providerCredentialFingerprint: req.meta.providerCredentialFingerprint } + : {}), + }, + }; +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index f7d5ec03e2..bc8a870c21 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { createAgentDeviceClient } from '../../../src/agent-device-client.ts'; import type { AgentDeviceClient } from '../../../src/client/client-types.ts'; -import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; +import { resolveLocalDaemonCodeIdentity } from '@agent-device/daemon-client'; import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; import { startDaemonRuntime, @@ -18,10 +18,7 @@ import { import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { resolveSessionDir } from '@agent-device/daemon-contracts/session-artifact-paths'; import { - buildHttpRpcPayload, - handleDaemonHttpResponseBody, -} from '../../../src/daemon-client/daemon-client-rpc.ts'; -import { + buildRpcPayload, commandRequest, envelopeToRpcResult, type JsonRpcResponseEnvelope, @@ -213,6 +210,7 @@ export async function createProviderScenarioHarness( try { runtime = await startDaemonRuntime({ host: createTestHostEnvironment({ env }), + codeIdentity: await resolveLocalDaemonCodeIdentity(), stdout: { write: () => undefined }, stderr: { write: () => undefined }, composeDeps, @@ -227,36 +225,16 @@ export async function createProviderScenarioHarness( } const httpPort = runtime.httpPort; const baseUrl = `http://127.0.0.1:${httpPort}`; - const transport: AgentDeviceDaemonTransport = async (request, context) => { - const wireRequest: DaemonWireRequest = { - ...request, - token: context?.authToken ?? runtime.token, - session: request.session ?? 'default', - }; - const payload = buildHttpRpcPayload(wireRequest, { includeTokenParam: true }); - const { body } = await postProviderScenarioRpc({ - port: httpPort, - token: wireRequest.token, - payload, - signal: context?.signal, - }); - return await new Promise((resolve, reject) => { - handleDaemonHttpResponseBody(body, { - host: createTestHostEnvironment(), - info: { - transport: 'http', - httpPort, - token: wireRequest.token, - pid: process.pid, - }, - req: wireRequest, - stateDir, - resolve, - reject, - }); - }); - }; - const client = createAgentDeviceClient({ daemonAuthToken: runtime.token }, { transport }); + // The production client, attached to the daemon this harness started, over its HTTP endpoint. + const client = createAgentDeviceClient( + { + stateDir, + daemonLifecycle: 'attach', + daemonTransport: 'http', + daemonAuthToken: runtime.token, + }, + { host: createTestHostEnvironment({ env }) }, + ); let closed = false; return { @@ -264,7 +242,7 @@ export async function createProviderScenarioHarness( const request = await withClientReplayScriptSources( commandRequest(runtime.token, command, positionals, flags, options), ); - const payload = buildHttpRpcPayload(request, { includeTokenParam: true }); + const payload = buildRpcPayload(request, { includeTokenParam: true }); const { statusCode, body } = await postProviderScenarioRpc({ port: httpPort, token: runtime.token, @@ -300,7 +278,7 @@ export async function createProviderScenarioHarness( async function postProviderScenarioRpc(params: { port: number; token: string; - payload: ReturnType; + payload: ReturnType; signal?: AbortSignal; }): Promise<{ statusCode: number; body: string }> { const body = JSON.stringify(params.payload); diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index eb4368ae57..4dc0f01229 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -6,9 +6,8 @@ import type { ExecResult } from '@agent-device/host-kit/command'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import { normalizeError } from '@agent-device/kernel/errors'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { createTestHostEnvironment } from '@agent-device/test-kit'; -import { sendRequest } from '../../../src/daemon-client/daemon-client-transport.ts'; +import { createDaemonTransport } from '@agent-device/daemon-client'; import { setTimeout as realSleep } from 'node:timers/promises'; import { assertRpcError, assertRpcOk } from './assertions.ts'; import assert from 'node:assert/strict'; @@ -517,8 +516,7 @@ test.each([ }); ledger.splice(0); const scriptPath = path.join(isolatedHome, 'fold.ad'); - const request: DaemonWireRequest = { - token: daemon.token, + const request: Omit = { session: 'default', command, positionals: command === 'replay' ? [scriptPath] : [], @@ -542,6 +540,8 @@ test.each([ }, }), ...(timeoutMs === undefined ? {} : { timeoutMs }), + stateDir: daemon.stateDir, + daemonTransport: 'http', }, meta: { requestId: 'composite-fold-deadline' }, }; @@ -555,13 +555,9 @@ test.each([ const start = Date.now(); tracking = true; // Exercise the real client envelope and HTTP transport, including timeout classification. - const pending: Promise = sendRequest( - { baseUrl: daemon.baseUrl, token: daemon.token, pid: 1 }, + const pending: Promise = createDaemonTransport(createTestHostEnvironment())( request, - 'http', - resolveDaemonPaths(daemon.stateDir), - envelopeMs, - { host: createTestHostEnvironment() }, + { daemonLifecycle: 'attach' }, ).catch((error) => ({ ok: false, error: normalizeError(error) })); let settled = false; void pending.then( diff --git a/test/integration/provider-scenarios/remote-proxy-parity.test.ts b/test/integration/provider-scenarios/remote-proxy-parity.test.ts index 30304ca34c..1263d230ca 100644 --- a/test/integration/provider-scenarios/remote-proxy-parity.test.ts +++ b/test/integration/provider-scenarios/remote-proxy-parity.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test, vi } from 'vitest'; -import { sendToDaemon } from '../../../src/daemon-client/daemon-client.ts'; +import { sendToDaemon } from '@agent-device/daemon-client'; import { AppError, type DaemonError } from '@agent-device/kernel/errors'; import { skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; diff --git a/test/integration/provider-scenarios/remote-upload-lease-beat.test.ts b/test/integration/provider-scenarios/remote-upload-lease-beat.test.ts index 28b2a913b5..568de309ff 100644 --- a/test/integration/provider-scenarios/remote-upload-lease-beat.test.ts +++ b/test/integration/provider-scenarios/remote-upload-lease-beat.test.ts @@ -11,7 +11,7 @@ import { countDiagnosticEventsByPhase, withDiagnosticsScope, } from '@agent-device/host-kit/diagnostics'; -import { sendToDaemon } from '../../../src/daemon-client/daemon-client.ts'; +import { sendToDaemon } from '@agent-device/daemon-client'; import { listenOnLoopback, skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts'; diff --git a/test/integration/smoke-open-remote-config.test.ts b/test/integration/smoke-open-remote-config.test.ts index b24a1afb62..c1c4cff9cf 100644 --- a/test/integration/smoke-open-remote-config.test.ts +++ b/test/integration/smoke-open-remote-config.test.ts @@ -8,12 +8,10 @@ import { closeLoopbackServer, listenOnLoopback, skipWhenLoopbackUnavailable, -} from '@agent-device/test-kit'; -import { runCli } from '../../src/cli.ts'; -import { beginStderrCapture, beginStdoutCapture, -} from '../../src/__tests__/test-utils/stream-capture.ts'; +} from '@agent-device/test-kit'; +import { runCli } from '../../src/cli.ts'; // Smoke coverage for the repo-local remote host flow: connect to a remote profile, // prepare Metro through the host bridge, and reuse connection runtime hints on open. diff --git a/test/wire-compat/README.md b/test/wire-compat/README.md index a8e7d1fa1b..6ade952faa 100644 --- a/test/wire-compat/README.md +++ b/test/wire-compat/README.md @@ -14,7 +14,7 @@ ledger honest from opposite sides. ## Why this boundary and not the local daemon -A local daemon cannot skew. `isReusableDaemonInfo` (`src/daemon-client/daemon-client-lifecycle.ts`) +A local daemon cannot skew. `isReusableDaemonInfo` (`packages/daemon-client/src/daemon-client-lifecycle.ts`) takes over any daemon whose package version differs from the client's, so the two are always the same build. diff --git a/test/wire-compat/closure-policy.ts b/test/wire-compat/closure-policy.ts index 16e892b2b9..c1ba365838 100644 --- a/test/wire-compat/closure-policy.ts +++ b/test/wire-compat/closure-policy.ts @@ -43,7 +43,7 @@ export const WIRE_CLOSURE_WAIVERS: Readonly> = { 'Error-code vocabulary is carried as a plain string in DaemonError.code; adding a code cannot change the envelope a peer parses.', 'packages/kernel/src/errors.ts#AppErrorDetails': 'Local detail bag behind AppError; the wire form is DaemonError.details, typed as Record and listed.', - 'src/daemon-client/daemon-client-metadata.ts#DaemonInfo': + 'packages/daemon-client/src/daemon-client-metadata.ts#DaemonInfo': 'Client-side record of where a daemon is listening (pid, ports, state dir). Never serialized into a request or response.', 'packages/daemon/src/core/daemon-request.ts#DaemonRequest': 'Server-side narrowing of the listed kernel DaemonRequest; the declaration that fixes the shape is packages/kernel/src/contracts.ts#DaemonRequest.', diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 1d85566aae..3e5cb803f1 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -53,25 +53,25 @@ "packages/kernel/src/errors.ts#readDiagnosticsRecordRef": "sha256:3a210f401221c0a15eb75d488450034885c7b73be39f5e93424c72874d14c395", "packages/command-surface/src/cli-grammar/types.ts#DaemonCommandRequest": "sha256:ea3f4118244711f0eaec0e471f62b0de42ca97181c085df165d37b63301a4619", "packages/contracts/src/lease-scope.ts#LeaseRpcCommand": "sha256:714f66efa725a5d0654e492a76daeb4059caad31b79562cfc9133029ea95bfe5", - "src/daemon-client/daemon-client-progress.ts#ProgressResponseFormat": "sha256:6d7a99ac13fd422671fd86f8fe093efa40ca369f4ae1f97c98d40a7132543637", - "src/daemon-client/daemon-client-progress.ts#createInvalidDaemonResponseError": "sha256:807eb96ca26de118a8e678f2ec776d17c207db4eec86ff23cfaec9cd2476b60f", - "src/daemon-client/daemon-client-progress.ts#shouldReadDaemonProgressStream": "sha256:27ac0a5299324aab39fa2c4fe0e0d32b1eb1515df47c7f4aa75a75d66b8b5a3e", - "src/daemon-client/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", - "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:bbcf00e71d3975caba4add0e85537396b5831796f1af52cc8c62a4688882ebd7", - "src/daemon-client/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", - "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", - "src/daemon-client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", - "src/daemon-client/daemon-client-rpc.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", - "src/daemon-client/daemon-client-rpc.ts#parseDaemonHttpResponseBody": "sha256:0cbe39dbd09e321ec00854a1626a7d89b6344e59c6faabe2fe02504268b2d886", - "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:a5cf6fd4a9abea34ec051f03d6e45e5aa248f480371fdf22f93de06622a57b47", - "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", - "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", - "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", - "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", - "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth": "sha256:6ce1f9be1e6a977130d0875003ac22da2de5e90b514a3b22b30b9347e63d81c7", - "src/daemon-client/daemon-client-transport.ts#readHealthLink": "sha256:f56404b94d73da57de7248719c9136b760d2be8b4a53cde5315a2311b088425b", - "src/daemon-client/daemon-client-transport.ts#readHealthPayload": "sha256:4e85ffc3e35e02379c393e9312344757e003cf1f0ad9eb8d1f77d90c81c861f1", - "src/daemon-client/daemon-client-transport.ts#readRemoteDaemonHealth": "sha256:df174d1ccf73851ca58bd75533fb4660424003b98bd9e41fe942cf3ab3698e5f", + "packages/daemon-client/src/daemon-client-progress.ts#ProgressResponseFormat": "sha256:6d7a99ac13fd422671fd86f8fe093efa40ca369f4ae1f97c98d40a7132543637", + "packages/daemon-client/src/daemon-client-progress.ts#createInvalidDaemonResponseError": "sha256:807eb96ca26de118a8e678f2ec776d17c207db4eec86ff23cfaec9cd2476b60f", + "packages/daemon-client/src/daemon-client-progress.ts#shouldReadDaemonProgressStream": "sha256:27ac0a5299324aab39fa2c4fe0e0d32b1eb1515df47c7f4aa75a75d66b8b5a3e", + "packages/daemon-client/src/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", + "packages/daemon-client/src/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:bbcf00e71d3975caba4add0e85537396b5831796f1af52cc8c62a4688882ebd7", + "packages/daemon-client/src/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", + "packages/daemon-client/src/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", + "packages/daemon-client/src/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", + "packages/daemon-client/src/daemon-client-rpc.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", + "packages/daemon-client/src/daemon-client-rpc.ts#parseDaemonHttpResponseBody": "sha256:0cbe39dbd09e321ec00854a1626a7d89b6344e59c6faabe2fe02504268b2d886", + "packages/daemon-client/src/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:a5cf6fd4a9abea34ec051f03d6e45e5aa248f480371fdf22f93de06622a57b47", + "packages/daemon-client/src/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", + "packages/daemon-client/src/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", + "packages/daemon-client/src/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", + "packages/daemon-client/src/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", + "packages/daemon-client/src/daemon-client-transport.ts#readDaemonHttpHealth": "sha256:6ce1f9be1e6a977130d0875003ac22da2de5e90b514a3b22b30b9347e63d81c7", + "packages/daemon-client/src/daemon-client-transport.ts#readHealthLink": "sha256:f56404b94d73da57de7248719c9136b760d2be8b4a53cde5315a2311b088425b", + "packages/daemon-client/src/daemon-client-transport.ts#readHealthPayload": "sha256:4e85ffc3e35e02379c393e9312344757e003cf1f0ad9eb8d1f77d90c81c861f1", + "packages/daemon-client/src/daemon-client-transport.ts#readRemoteDaemonHealth": "sha256:df174d1ccf73851ca58bd75533fb4660424003b98bd9e41fe942cf3ab3698e5f", "packages/daemon/src/server/downloadable-artifact-http.ts#DownloadableArtifactHttpAuthorizer": "sha256:1b2702a929ca9170db2ca97c08e3ab67e17edb3ee75325a576c4c1b9cdbebb44", "packages/daemon/src/server/downloadable-artifact-http.ts#DownloadableArtifactHttpRoute": "sha256:e63c4581ccde668913914149c9092d16ecf8a5cbd7ab33c6eb8617e77fc1015e", "packages/daemon/src/server/downloadable-artifact-http.ts#handleArtifactDownload": "sha256:9f51e86c19cd4f5d8097f1c5ce38dc1daa795b803a6afc89d16cd556371ea037", @@ -189,9 +189,9 @@ "packages/proxy/src/daemon-proxy.ts#sendInstanceMismatch": "sha256:7d0a889f8c7a8151a5ac7a37f52620a269b707144303cae7304825fca49e2a0d", "packages/contracts/src/daemon-http.ts#buildDaemonInstanceMismatchRpcResponse": "sha256:24e50130bbd0c4a21dfc51005cabf118525b9121f918719a2c16f73fcfcfbcf9", "packages/proxy/src/daemon-proxy.ts#buildUpstreamInstancePreconditionHeaders": "sha256:9f5d76a1f761d65fabd7244fc67295fed5934a544e86a6455451ef8520cf1615", - "src/daemon-client/daemon-client-transport.ts#buildRemoteInstancePreconditionHeaders": "sha256:70241561c4070a8ebbdbbdb4ebcdbdfcb9543501cbb649a6d71cf041aee55a08", - "src/daemon-client/daemon-client-transport.ts#isRemoteInstanceMismatchResponse": "sha256:7670f48fe0fc7344f8a000cd303e49788ec7ea9d1c729bcdc2af73ff02dae04d", - "src/daemon-client/daemon-client-transport.ts#isRemoteInstanceMismatch": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee" + "packages/daemon-client/src/daemon-client-transport.ts#buildRemoteInstancePreconditionHeaders": "sha256:70241561c4070a8ebbdbbdb4ebcdbdfcb9543501cbb649a6d71cf041aee55a08", + "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatchResponse": "sha256:7670f48fe0fc7344f8a000cd303e49788ec7ea9d1c729bcdc2af73ff02dae04d", + "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatch": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee" }, "compatibleChanges": [ { @@ -220,7 +220,7 @@ "rationale": "#2246 passes isDirectory only for the new test-artifacts type and uses the exact path the client materializer published. Existing screenshot and recording artifacts still report their requested file path; only the new directory response gains its suite-invocation segment. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { - "declaration": "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload", + "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#buildHttpRpcPayload", "digest": "sha256:bbcf00e71d3975caba4add0e85537396b5831796f1af52cc8c62a4688882ebd7", "rationale": "Adds the optional providerCredentialFingerprint parameter to agent_device.lease.allocate, forwarded only when the request meta carries one, which the client sets only for a local daemon. A released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request without a fingerprint keeps the previous payload byte-for-byte. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." }, @@ -240,12 +240,12 @@ "rationale": "details.dispatched is a new OPTIONAL field inside details, which was already an open record: an older peer ignores the key and every existing field keeps its shape. It carries no | unknown for whether a failed interaction reached the device (the dispatch-disclosure golden table)." }, { - "declaration": "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody", + "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#handleDaemonHttpResponseBody", "digest": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", "rationale": "#1801 client-side only: the RPC error branch now runs through `rejectDaemonHttpRpcError`, which reads a superset of the fields it read before and, for a remote daemon, replaces the daemon-host `logPath` with a caller-local copy. Every field a released daemon sends is still accepted; nothing new is required of the daemon. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { - "declaration": "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError", + "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#toDaemonHttpRpcError", "digest": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", "rationale": "#1801 and #1862 client-side only: this reads optional daemon error metadata, most recently the structured `cause`. Parsing of every previously read field is unchanged, so a released daemon's error payload is accepted exactly as before." }, @@ -270,17 +270,17 @@ "rationale": "#1801 and #1862 lift optional metadata onto the normalized error, most recently a redacted structured `cause`. No previously emitted field changes shape or value, and peers that do not know the field ignore it." }, { - "declaration": "src/daemon-client/daemon-client-rpc.ts#appErrorFromDaemonError", + "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#appErrorFromDaemonError", "digest": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", "rationale": "#1862 client-side only: this rehydrates the new optional structured `cause` when present. Released daemons that omit it follow the unchanged path, and no request field or existing response field is narrowed." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#readHealthPayload", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#readHealthPayload", "digest": "sha256:4e85ffc3e35e02379c393e9312344757e003cf1f0ad9eb8d1f77d90c81c861f1", "rationale": "#2198 slice B teaches the client to read the `upstream` link a proxy's /health already nests. The field is optional and additive: a daemon or proxy that does not send it parses exactly as before, and no request shape changes. readHealthPayload reads the same top-level fields through readHealthLink and additionally the nested `upstream` object when present; a payload without it yields the previous shape." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#readRemoteDaemonHealth", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#readRemoteDaemonHealth", "digest": "sha256:df174d1ccf73851ca58bd75533fb4660424003b98bd9e41fe942cf3ab3698e5f", "rationale": "#2198 checks every link a command RPC crosses for protocol skew. #2650 caps a retry health probe at the request's remaining deadline. Health payload parsing and protocol mismatch refusal are unchanged; released peers still receive the same RPCs. #3178 threads the caller's optional AbortSignal through so a canceled call cuts a probe already in flight instead of waiting it out: the signal is client-local, never serialized, and the GET /health request and payload are unchanged." }, @@ -340,17 +340,17 @@ "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#RemoteDaemonHealth", "digest": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged. A restart retry must tell a probe that ran out of time from one that failed, so the client can report the RPC deadline instead of 'Remote daemon is unavailable'. `timedOut` is client-local: the prober sets it on its own timeout and abort paths and never reads it from a /health payload. The health request and the accepted payload fields are unchanged, so a released daemon or proxy is probed and parsed exactly as before." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#RemoteDaemonHealthLink", "digest": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#readHealthLink", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#readHealthLink", "digest": "sha256:f56404b94d73da57de7248719c9136b760d2be8b4a53cde5315a2311b088425b", "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged." }, @@ -395,22 +395,22 @@ "rationale": "Moved into @agent-device/proxy and rewritten over fetch Request/Response so the proxy can run on any transport. The 409 status, instance-mismatch header, JSON-RPC error body, and forwarded upstream precondition header are unchanged on the wire, so peers on both sides parse them as before." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#buildRemoteInstancePreconditionHeaders", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#buildRemoteInstancePreconditionHeaders", "digest": "sha256:70241561c4070a8ebbdbbdb4ebcdbdfcb9543501cbb649a6d71cf041aee55a08", "rationale": "#2650 adds conditional RPC refusal and retry only for peers that advertise complete instance IDs. Released peers advertise none, so new clients keep per-command health probes and send their unchanged RPC; older clients omit the new request precondition and parse existing responses as before." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#isRemoteInstanceMismatchResponse", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatchResponse", "digest": "sha256:7670f48fe0fc7344f8a000cd303e49788ec7ea9d1c729bcdc2af73ff02dae04d", "rationale": "#2650 adds conditional RPC refusal and retry only for peers that advertise complete instance IDs. Released peers advertise none, so new clients keep per-command health probes and send their unchanged RPC; older clients omit the new request precondition and parse existing responses as before." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#isRemoteInstanceMismatch", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatch", "digest": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee", "rationale": "#2650 adds conditional RPC refusal and retry only for peers that advertise complete instance IDs. Released peers advertise none, so new clients keep per-command health probes and send their unchanged RPC; older clients omit the new request precondition and parse existing responses as before." }, { - "declaration": "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth", + "declaration": "packages/daemon-client/src/daemon-client-transport.ts#readDaemonHttpHealth", "digest": "sha256:6ce1f9be1e6a977130d0875003ac22da2de5e90b514a3b22b30b9347e63d81c7", "rationale": "The health-probe budget includes requester loading and response completion. The caller AbortSignal joins the probe timer only in-process; caller cancellation remains distinct from probe expiry. No signal, timedOut flag, request route, auth header or accepted health payload field changes on the wire, so released daemons and proxies parse and respond as before (ADR 0006)." }, @@ -425,7 +425,7 @@ "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." }, { - "declaration": "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError", + "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#rejectDaemonHttpRpcError", "digest": "sha256:a5cf6fd4a9abea34ec051f03d6e45e5aa248f480371fdf22f93de06622a57b47", "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006). The signature also names the client request as DaemonWireRequest and the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." }, @@ -460,22 +460,22 @@ "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." }, { - "declaration": "src/daemon-client/daemon-client-progress.ts#createInvalidDaemonResponseError", + "declaration": "packages/daemon-client/src/daemon-client-progress.ts#createInvalidDaemonResponseError", "digest": "sha256:807eb96ca26de118a8e678f2ec776d17c207db4eec86ff23cfaec9cd2476b60f", "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." }, { - "declaration": "src/daemon-client/daemon-client-progress.ts#shouldReadDaemonProgressStream", + "declaration": "packages/daemon-client/src/daemon-client-progress.ts#shouldReadDaemonProgressStream", "digest": "sha256:27ac0a5299324aab39fa2c4fe0e0d32b1eb1515df47c7f4aa75a75d66b8b5a3e", "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." }, { - "declaration": "src/daemon-client/daemon-client-rpc.ts#buildLeaseRpcParams", + "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#buildLeaseRpcParams", "digest": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." }, { - "declaration": "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult", + "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#resolveDaemonHttpResult", "digest": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, diff --git a/test/wire-compat/surface.ts b/test/wire-compat/surface.ts index ca06e7c573..8526c1a301 100644 --- a/test/wire-compat/surface.ts +++ b/test/wire-compat/surface.ts @@ -56,9 +56,9 @@ const SESSION_TENANT_SCOPE = 'packages/daemon/src/core/session-tenant-scope.ts'; const HTTP_REQUEST_TARGET = 'packages/daemon/src/server/http-request-target.ts'; const REMOTE_REQUEST_DIAGNOSTICS = 'packages/remote/src/remote-request-diagnostics.ts'; const PROGRESS_PROTOCOL = 'packages/daemon-contracts/src/request-progress-protocol.ts'; -const CLIENT_RPC = 'src/daemon-client/daemon-client-rpc.ts'; -const CLIENT_PROGRESS = 'src/daemon-client/daemon-client-progress.ts'; -const CLIENT_TRANSPORT = 'src/daemon-client/daemon-client-transport.ts'; +const CLIENT_RPC = 'packages/daemon-client/src/daemon-client-rpc.ts'; +const CLIENT_PROGRESS = 'packages/daemon-client/src/daemon-client-progress.ts'; +const CLIENT_TRANSPORT = 'packages/daemon-client/src/daemon-client-transport.ts'; const DAEMON_PROXY = 'packages/proxy/src/daemon-proxy.ts'; const UPLOAD_CLIENT = 'packages/remote/src/upload-client.ts'; const REMOTE_ARTIFACTS = 'packages/remote/src/daemon-artifacts.ts'; diff --git a/test/wire-compat/wire-mutations.test.ts b/test/wire-compat/wire-mutations.test.ts index 23e7c8e942..b2e04447f1 100644 --- a/test/wire-compat/wire-mutations.test.ts +++ b/test/wire-compat/wire-mutations.test.ts @@ -93,7 +93,7 @@ const MUTATIONS: readonly WireMutation[] = [ }, { breakClass: 'response parsing: the client narrows what a daemon may return', - file: 'src/daemon-client/daemon-client-rpc.ts', + file: 'packages/daemon-client/src/daemon-client-rpc.ts', name: 'parseDaemonHttpResponseBody', from: 'error?: { message?: string; data?: Record }', to: 'error?: { message: string }', @@ -166,21 +166,21 @@ const MUTATIONS: readonly WireMutation[] = [ // why claiming "both sides" required these to be listed and proved. { breakClass: 'health consumer: the client stops reading the advertised protocol version', - file: 'src/daemon-client/daemon-client-transport.ts', + file: 'packages/daemon-client/src/daemon-client-transport.ts', name: 'readHealthLink', from: "typeof parsed.rpcProtocolVersion === 'number' ? parsed.rpcProtocolVersion : undefined", to: 'undefined', }, { breakClass: 'health consumer: the mismatch refusal ADR 0006 built is weakened', - file: 'src/daemon-client/daemon-client-transport.ts', + file: 'packages/daemon-client/src/daemon-client-transport.ts', name: 'readRemoteDaemonHealth', from: 'link.rpcProtocolVersion !== DAEMON_RPC_PROTOCOL_VERSION', to: 'false', }, { breakClass: 'health consumer: the parsed health shape drops a released field', - file: 'src/daemon-client/daemon-client-transport.ts', + file: 'packages/daemon-client/src/daemon-client-transport.ts', name: 'RemoteDaemonHealth', from: 'rpcProtocolVersion?: number;', to: '', diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 71836a2c2a..953c2b3a1d 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -14,6 +14,7 @@ { "path": "packages/command-surface" }, { "path": "packages/contracts" }, { "path": "packages/daemon" }, + { "path": "packages/daemon-client" }, { "path": "packages/daemon-contracts" }, { "path": "packages/device-selection" }, { "path": "packages/host-kit" }, From 1d5857126f25deb94baafd70f39291e1232f7827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 13:58:17 +0100 Subject: [PATCH 03/17] refactor(daemon-client): name package tests for their modules and cut the type edges back into the entry Co-Authored-By: Claude Sonnet 5.5 --- oxlint.config.ts | 2 +- ...-client-ensure-explicit-lifecycle.test.ts} | 0 ... daemon-client-lifecycle-embedded.test.ts} | 0 ...on-client-transport-abort-timeout.test.ts} | 0 ...on-client-transport-health-compat.test.ts} | 2 +- ...on-client-transport-request-guard.test.ts} | 0 ...ir.test.ts => index-developer-dir.test.ts} | 0 ...client-host.test.ts => index-host.test.ts} | 0 ...der.test.ts => index-lease-header.test.ts} | 0 ...mon.test.ts => index-newer-daemon.test.ts} | 0 ...use.test.ts => index-policy-reuse.test.ts} | 0 ...be.test.ts => index-stalled-probe.test.ts} | 0 .../{daemon-client.test.ts => index.test.ts} | 0 .../src/daemon-client-address-hints.ts | 2 +- .../src/daemon-client-ensuring.ts | 4 +--- .../src/daemon-client-proxy-upstream.ts | 8 +------ .../src/daemon-client-request.ts | 16 +++++++++++++ packages/daemon-client/src/index.ts | 23 +++++++------------ 18 files changed, 29 insertions(+), 28 deletions(-) rename packages/daemon-client/src/__tests__/{daemon-client-explicit-lifecycle.test.ts => daemon-client-ensure-explicit-lifecycle.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-embedded-lifecycle.test.ts => daemon-client-lifecycle-embedded.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-abort-timeout.test.ts => daemon-client-transport-abort-timeout.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-health-compat.test.ts => daemon-client-transport-health-compat.test.ts} (97%) rename packages/daemon-client/src/__tests__/{daemon-client-request-guard.test.ts => daemon-client-transport-request-guard.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-developer-dir.test.ts => index-developer-dir.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-host.test.ts => index-host.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-lease-header.test.ts => index-lease-header.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-newer-daemon.test.ts => index-newer-daemon.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-policy-reuse.test.ts => index-policy-reuse.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client-stalled-probe.test.ts => index-stalled-probe.test.ts} (100%) rename packages/daemon-client/src/__tests__/{daemon-client.test.ts => index.test.ts} (100%) create mode 100644 packages/daemon-client/src/daemon-client-request.ts diff --git a/oxlint.config.ts b/oxlint.config.ts index eca472b15d..f865289de2 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -53,7 +53,7 @@ const LEGACY_FILE_LINE_CAPS = { 'src/__tests__/remote-connection.test.ts': 2324, 'packages/command-runtime/src/commands/interaction/runtime/settle.test.ts': 2094, 'packages/daemon-client/src/__tests__/daemon-client-lifecycle.test.ts': 1169, - 'packages/daemon-client/src/__tests__/daemon-client.test.ts': 1340, + 'packages/daemon-client/src/__tests__/index.test.ts': 1340, 'src/mcp/__tests__/command-tools.test.ts': 1029, 'test/integration/provider-scenarios/android-lifecycle.test.ts': 1227, 'packages/host-kit/src/internal/exec.ts': 1032, diff --git a/packages/daemon-client/src/__tests__/daemon-client-explicit-lifecycle.test.ts b/packages/daemon-client/src/__tests__/daemon-client-ensure-explicit-lifecycle.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-explicit-lifecycle.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-ensure-explicit-lifecycle.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-embedded-lifecycle.test.ts b/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-embedded-lifecycle.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-abort-timeout.test.ts b/packages/daemon-client/src/__tests__/daemon-client-transport-abort-timeout.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-abort-timeout.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-transport-abort-timeout.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-health-compat.test.ts b/packages/daemon-client/src/__tests__/daemon-client-transport-health-compat.test.ts similarity index 97% rename from packages/daemon-client/src/__tests__/daemon-client-health-compat.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-transport-health-compat.test.ts index 81a5e082dd..1b36abd540 100644 --- a/packages/daemon-client/src/__tests__/daemon-client-health-compat.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-transport-health-compat.test.ts @@ -11,7 +11,7 @@ import { /** * ADR 0006 health compatibility across every link a command RPC crosses. `sendToDaemon` - * runs this check before the RPC (daemon-client.test.ts pins that order); these cases pin + * runs this check before the RPC (index.test.ts pins that order); these cases pin * what the check itself accepts and refuses when a proxy sits in front of the daemon. */ diff --git a/packages/daemon-client/src/__tests__/daemon-client-request-guard.test.ts b/packages/daemon-client/src/__tests__/daemon-client-transport-request-guard.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-request-guard.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-transport-request-guard.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-developer-dir.test.ts b/packages/daemon-client/src/__tests__/index-developer-dir.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-developer-dir.test.ts rename to packages/daemon-client/src/__tests__/index-developer-dir.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-host.test.ts b/packages/daemon-client/src/__tests__/index-host.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-host.test.ts rename to packages/daemon-client/src/__tests__/index-host.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-lease-header.test.ts b/packages/daemon-client/src/__tests__/index-lease-header.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-lease-header.test.ts rename to packages/daemon-client/src/__tests__/index-lease-header.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-newer-daemon.test.ts b/packages/daemon-client/src/__tests__/index-newer-daemon.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-newer-daemon.test.ts rename to packages/daemon-client/src/__tests__/index-newer-daemon.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-policy-reuse.test.ts b/packages/daemon-client/src/__tests__/index-policy-reuse.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-policy-reuse.test.ts rename to packages/daemon-client/src/__tests__/index-policy-reuse.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client-stalled-probe.test.ts b/packages/daemon-client/src/__tests__/index-stalled-probe.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client-stalled-probe.test.ts rename to packages/daemon-client/src/__tests__/index-stalled-probe.test.ts diff --git a/packages/daemon-client/src/__tests__/daemon-client.test.ts b/packages/daemon-client/src/__tests__/index.test.ts similarity index 100% rename from packages/daemon-client/src/__tests__/daemon-client.test.ts rename to packages/daemon-client/src/__tests__/index.test.ts diff --git a/packages/daemon-client/src/daemon-client-address-hints.ts b/packages/daemon-client/src/daemon-client-address-hints.ts index 4943665f90..a07ca576c9 100644 --- a/packages/daemon-client/src/daemon-client-address-hints.ts +++ b/packages/daemon-client/src/daemon-client-address-hints.ts @@ -1,4 +1,4 @@ -import type { DaemonResponse } from './index.ts'; +import type { DaemonResponse } from './daemon-client-request.ts'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; /** Adds copyable addressing for a kept-alive replay session, without URL credentials. */ diff --git a/packages/daemon-client/src/daemon-client-ensuring.ts b/packages/daemon-client/src/daemon-client-ensuring.ts index ce3c9ce63d..f7cbb801d4 100644 --- a/packages/daemon-client/src/daemon-client-ensuring.ts +++ b/packages/daemon-client/src/daemon-client-ensuring.ts @@ -2,11 +2,9 @@ import type { DaemonIdentity, EnsureDaemonOptions } from '@agent-device/contract import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import type { sendToDaemon } from './index.ts'; +import type { DaemonTransport } from './daemon-client-request.ts'; import { isDaemonNotRunningError } from './daemon-lifecycle-errors.ts'; -type DaemonTransport = typeof sendToDaemon; - export type EnsuringTransportOptions = { /** The host the ensured daemon is resolved and started from; the process's own when omitted. */ host?: HostEnvironment; diff --git a/packages/daemon-client/src/daemon-client-proxy-upstream.ts b/packages/daemon-client/src/daemon-client-proxy-upstream.ts index d03f065fb8..d24c506af5 100644 --- a/packages/daemon-client/src/daemon-client-proxy-upstream.ts +++ b/packages/daemon-client/src/daemon-client-proxy-upstream.ts @@ -1,16 +1,10 @@ import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { ensureManagedDaemon, resolveClientSettings } from './daemon-client-lifecycle.ts'; -export type ProxyUpstreamDaemon = Readonly<{ - httpPort: number | undefined; - token: string; - stateDir: string; -}>; - export async function ensureProxyUpstreamDaemon(options: { stateDir?: string; host: HostEnvironment; -}): Promise { +}): Promise<{ httpPort: number | undefined; token: string; stateDir: string }> { const settings = resolveClientSettings( { session: 'default', diff --git a/packages/daemon-client/src/daemon-client-request.ts b/packages/daemon-client/src/daemon-client-request.ts new file mode 100644 index 0000000000..2319298e5d --- /dev/null +++ b/packages/daemon-client/src/daemon-client-request.ts @@ -0,0 +1,16 @@ +import type { DaemonResponse as SharedDaemonResponse } from '@agent-device/kernel/contracts'; +import type { RequestProgressSink } from '@agent-device/contracts/progress'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import type { AgentDeviceDaemonTransportContext } from '@agent-device/contracts/client'; + +export type DaemonRequest = DaemonWireRequest; +export type DaemonResponse = SharedDaemonResponse; +export type DaemonTransportOptions = AgentDeviceDaemonTransportContext & { + onProgress?: RequestProgressSink; +}; + +/** What a client sends one daemon request through. */ +export type DaemonTransport = ( + req: Omit, + options?: DaemonTransportOptions, +) => Promise; diff --git a/packages/daemon-client/src/index.ts b/packages/daemon-client/src/index.ts index 5db4820414..954d7a373c 100644 --- a/packages/daemon-client/src/index.ts +++ b/packages/daemon-client/src/index.ts @@ -1,11 +1,4 @@ -import type { DaemonResponse as SharedDaemonResponse } from '@agent-device/kernel/contracts'; -import type { RequestProgressSink } from '@agent-device/contracts/progress'; -import type { DaemonWireRequest } from '@agent-device/contracts/command'; -import type { - AgentDeviceDaemonTransportContext, - DaemonConnection, - EnsureDaemonOptions, -} from '@agent-device/contracts/client'; +import type { DaemonConnection, EnsureDaemonOptions } from '@agent-device/contracts/client'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { DaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; import type { DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; @@ -29,14 +22,14 @@ import type { EnsuringTransportOptions } from './daemon-client-ensuring.ts'; import { createRequestGuard, sendRequest } from './daemon-client-transport.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; import type { LocalDaemonCodeIdentity } from './daemon-launch-spec.ts'; -import type { ProxyUpstreamDaemon } from './daemon-client-proxy-upstream.ts'; +import type { + DaemonRequest, + DaemonResponse, + DaemonTransportOptions, +} from './daemon-client-request.ts'; import { leaseScopeFromRequest } from '@agent-device/contracts/lease-scope'; -export type DaemonRequest = DaemonWireRequest; -export type DaemonResponse = SharedDaemonResponse; -type DaemonTransportOptions = AgentDeviceDaemonTransportContext & { - onProgress?: RequestProgressSink; -}; +export type { DaemonRequest, DaemonResponse }; /** The released default: a process-level client, which reads the process's own host. */ export async function sendToDaemon( @@ -433,7 +426,7 @@ export async function resolveLocalDaemonCodeIdentity(): Promise { +}): Promise<{ httpPort: number | undefined; token: string; stateDir: string }> { const { ensureProxyUpstreamDaemon: ensure } = await import('./daemon-client-proxy-upstream.ts'); return await ensure(options); } From ee58ea26b9d1d6294c60864cd32d78b063789099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 14:09:36 +0100 Subject: [PATCH 04/17] test(daemon-client): bind only the registered-daemon fixtures the package tests use Co-Authored-By: Claude Sonnet 5.5 --- .../daemon-client/src/__tests__/registered-daemon-fixture.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts b/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts index 2605950238..e38072c789 100644 --- a/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts +++ b/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts @@ -10,8 +10,6 @@ const actualCommand = await vi.importActual Date: Fri, 30 Oct 2026 14:39:42 +0100 Subject: [PATCH 05/17] refactor(daemon-client): send to a recorded daemon through the client transport and restore composition proofs Replace the script-side socket client with sendToRecordedDaemon, a thin entry wrapper over sendRequest (no ensure, replacement or retirement), and reuse buildHttpRpcPayload in the provider harness. Restore the MCP and root host tests against a real registered daemon, and refuse requests that are not POST /rpc in the scripted daemon fixture. Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/index-recorded-daemon.test.ts | 126 ++++++++++++++++++ packages/daemon-client/src/index.ts | 46 ++++++- scripts/lib/recorded-daemon.ts | 81 +---------- scripts/perf/forced-backend.ts | 15 ++- src/__tests__/daemon-client-host.test.ts | 91 +++++++------ .../test-utils/registered-http-daemon.ts | 92 +++++++++++++ .../command-tools-daemon-lifecycle.test.ts | 56 ++++---- .../ios-simulator-e2e/live-runner.ts | 10 +- .../cloud-webdriver-lease-http.test.ts | 4 +- .../provider-scenarios/harness-proxy.ts | 4 +- .../provider-scenarios/harness-rpc.ts | 47 ------- .../integration/provider-scenarios/harness.ts | 7 +- 12 files changed, 358 insertions(+), 221 deletions(-) create mode 100644 packages/daemon-client/src/__tests__/index-recorded-daemon.test.ts create mode 100644 src/__tests__/test-utils/registered-http-daemon.ts diff --git a/packages/daemon-client/src/__tests__/index-recorded-daemon.test.ts b/packages/daemon-client/src/__tests__/index-recorded-daemon.test.ts new file mode 100644 index 0000000000..8be1323298 --- /dev/null +++ b/packages/daemon-client/src/__tests__/index-recorded-daemon.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import net from 'node:net'; +import { afterEach, test } from 'vitest'; +import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { + closeLoopbackServer, + createTestHostEnvironment, + listenOnLoopback, + mkdtempForTestSync, + supportsLoopbackBind, +} from '@agent-device/test-kit'; +import { sendToRecordedDaemon } from '../index.ts'; + +// `sendToRecordedDaemon` is the supported way for tooling to talk to a daemon another build +// started. It must keep every guarantee of the client transport and none of its lifecycle: nothing +// is started, replaced or retired, whatever the daemon answers. + +const REQUEST = { session: 'default', command: 'snapshot', positionals: [] as string[] }; +const servers: net.Server[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) await closeLoopbackServer(server); +}); + +async function recordedSocketDaemon(reply: ((line: string) => string | undefined) | 'silent') { + const stateDir = mkdtempForTestSync('agent-device-recorded-daemon-'); + const received: string[] = []; + let connections = 0; + const server = net.createServer((socket) => { + connections += 1; + socket.setEncoding('utf8'); + socket.on('error', () => {}); + let buffered = ''; + socket.on('data', (chunk: string) => { + buffered += chunk; + if (!buffered.includes('\n')) return; + received.push(buffered.trim()); + const answer = reply === 'silent' ? undefined : reply(buffered.trim()); + if (answer !== undefined) socket.write(`${answer}\n`); + }); + }); + servers.push(server); + const port = await listenOnLoopback(server); + const paths = resolveDaemonPaths(stateDir); + fs.mkdirSync(paths.baseDir, { recursive: true }); + fs.writeFileSync( + paths.infoPath, + JSON.stringify({ port, token: 'recorded-secret', pid: process.pid, transport: 'socket' }), + ); + return { stateDir, paths, received, connections: () => connections }; +} + +const send = ( + stateDir: string, + options: { timeoutMs?: number; signal?: AbortSignal } = {}, +): ReturnType => + sendToRecordedDaemon(REQUEST, { + host: createTestHostEnvironment(), + stateDir, + timeoutMs: options.timeoutMs ?? 2_000, + signal: options.signal, + }); + +test('a request goes to the recorded daemon with its token and returns its answer', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const daemon = await recordedSocketDaemon(() => JSON.stringify({ ok: true, data: { n: 1 } })); + + assert.deepEqual(await send(daemon.stateDir), { ok: true, data: { n: 1 } }); + + const sent = JSON.parse(daemon.received[0]!) as { token: string; command: string }; + assert.equal(sent.token, 'recorded-secret'); + assert.equal(sent.command, 'snapshot'); +}); + +test('an already-aborted signal dispatches nothing', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const daemon = await recordedSocketDaemon(() => JSON.stringify({ ok: true, data: {} })); + const controller = new AbortController(); + controller.abort(new Error('caller gave up')); + + await assert.rejects( + send(daemon.stateDir, { signal: controller.signal }), + isRequestCanceledError, + ); + + assert.equal(daemon.connections(), 0); + assert.deepEqual(daemon.received, []); +}); + +test('an invalid response rejects with a typed error and leaves the registration alone', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const daemon = await recordedSocketDaemon(() => 'this is not json'); + + await assert.rejects( + send(daemon.stateDir), + (error: unknown) => error instanceof AppError && error.message === 'Invalid daemon response', + ); + + assert.equal(fs.existsSync(daemon.paths.infoPath), true); +}); + +test('a timeout rejects without retiring the daemon', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const daemon = await recordedSocketDaemon('silent'); + + await assert.rejects(send(daemon.stateDir, { timeoutMs: 100 }), (error: unknown) => { + assert.ok(error instanceof AppError); + return true; + }); + + assert.equal(fs.existsSync(daemon.paths.infoPath), true); + assert.equal(daemon.connections(), 1, 'one attempt, no restart'); +}); + +test('a state directory that records no daemon is reported, not started', async () => { + const stateDir = mkdtempForTestSync('agent-device-recorded-none-'); + + await assert.rejects( + send(stateDir), + (error: unknown) => error instanceof AppError && error.details?.reason === 'daemon-not-running', + ); + + assert.equal(fs.existsSync(resolveDaemonPaths(stateDir).infoPath), false); +}); diff --git a/packages/daemon-client/src/index.ts b/packages/daemon-client/src/index.ts index 954d7a373c..958015e96c 100644 --- a/packages/daemon-client/src/index.ts +++ b/packages/daemon-client/src/index.ts @@ -1,5 +1,8 @@ import type { DaemonConnection, EnsureDaemonOptions } from '@agent-device/contracts/client'; -import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { + resolveDaemonPaths, + type DaemonPaths, +} from '@agent-device/daemon-contracts/daemon-resolution'; import type { DaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; import type { DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; import { AppError } from '@agent-device/kernel/errors'; @@ -20,7 +23,7 @@ import { import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import type { EnsuringTransportOptions } from './daemon-client-ensuring.ts'; import { createRequestGuard, sendRequest } from './daemon-client-transport.ts'; -import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; +import { isRemoteDaemon, readDaemonInfo, type DaemonInfo } from './daemon-client-metadata.ts'; import type { LocalDaemonCodeIdentity } from './daemon-launch-spec.ts'; import type { DaemonRequest, @@ -398,6 +401,45 @@ async function protectArtifactUploadWithLeaseBeats( export { createRequestGuard }; +/** The JSON-RPC envelope the daemon's `/rpc` endpoint takes for a request, lease commands included. */ +export { buildHttpRpcPayload } from './daemon-client-rpc.ts'; + +/** + * Sends one request to the daemon a state directory already records, for tooling that drives a + * daemon another build started. The request travels through the same transport as every other + * client request: its cancellation, request ids, typed errors, response parsing, progress and + * timeout budget. + * + * It starts nothing, replaces nothing and judges no compatibility. A timeout never reaches the + * liveness retirement (`timeoutRecovery: 'none'`), so a slow or wedged daemon is left as it is. + */ +export async function sendToRecordedDaemon( + req: Omit, + options: { + host: HostEnvironment; + stateDir: string; + timeoutMs: number; + signal?: AbortSignal; + onProgress?: DaemonTransportOptions['onProgress']; + }, +): Promise { + const paths = resolveDaemonPaths(options.stateDir, { + env: options.host.env, + cwd: options.host.cwd, + }); + const info = readDaemonInfo(paths.infoPath); + if (!info) { + const { daemonNotRunningError } = await import('./daemon-lifecycle-errors.ts'); + throw daemonNotRunningError(paths.baseDir); + } + return await sendRequest(info, { ...req, token: info.token }, 'auto', paths, options.timeoutMs, { + host: options.host, + onProgress: options.onProgress, + signal: options.signal, + timeoutRecovery: 'none', + }); +} + /** * Starts the local daemon on a state directory if none is usable. Loaded on first call: a client * that only attaches never pays for the startup and replacement machinery. diff --git a/scripts/lib/recorded-daemon.ts b/scripts/lib/recorded-daemon.ts index 65b19275b4..01b26f1185 100644 --- a/scripts/lib/recorded-daemon.ts +++ b/scripts/lib/recorded-daemon.ts @@ -1,13 +1,8 @@ -import net from 'node:net'; -import type { DaemonWireRequest } from '@agent-device/contracts/command'; import { readDaemonRecord } from '@agent-device/daemon-contracts/daemon-resolution'; -import { isDaemonProgressEnvelope } from '@agent-device/daemon-contracts/request-progress-protocol'; -import type { DaemonResponse } from '@agent-device/kernel/contracts'; /** - * The daemon a state directory records, as the measurement and live-lane tooling reads it. - * These callers observe or drive a daemon another build started and must not start, replace or - * compatibility-check it, which the daemon-client transport always does. + * The registration a state directory records, as the measurement and live-lane tooling reads it. + * Sending to that daemon goes through `sendToRecordedDaemon` in `@agent-device/daemon-client`. */ export type RecordedDaemon = Readonly<{ pid: number; @@ -30,75 +25,3 @@ export function readRecordedDaemon(infoPath: string): RecordedDaemon | null { ...(typeof processStartTime === 'string' ? { processStartTime } : {}), }; } - -/** - * One request to the recorded daemon: over its socket when it records one, answered by the - * response line after any progress, otherwise over its HTTP `/rpc` endpoint. - */ -export async function sendToRecordedDaemon( - daemon: RecordedDaemon, - request: Omit, - options: { timeoutMs: number; signal?: AbortSignal }, -): Promise { - const { port } = daemon; - if (port === undefined) return await sendOverHttp(daemon, request, options); - return await new Promise((resolve, reject) => { - const socket = net.createConnection({ host: '127.0.0.1', port }); - const timer = setTimeout( - () => fail(new Error(`Daemon request timed out after ${options.timeoutMs}ms.`)), - options.timeoutMs, - ); - const fail = (error: unknown) => { - clearTimeout(timer); - socket.destroy(); - reject(error); - }; - options.signal?.addEventListener('abort', () => fail(options.signal?.reason), { once: true }); - socket.setEncoding('utf8'); - socket.on('connect', () => - socket.write(`${JSON.stringify({ ...request, token: daemon.token })}\n`), - ); - socket.on('error', fail); - let buffered = ''; - socket.on('data', (chunk: string) => { - buffered += chunk; - for (let end = buffered.indexOf('\n'); end >= 0; end = buffered.indexOf('\n')) { - const line = buffered.slice(0, end); - buffered = buffered.slice(end + 1); - const message: unknown = JSON.parse(line); - if (isDaemonProgressEnvelope(message)) continue; - clearTimeout(timer); - socket.end(); - resolve(message as DaemonResponse); - return; - } - }); - socket.on('end', () => fail(new Error('Daemon closed the connection without a response.'))); - }); -} - -async function sendOverHttp( - daemon: RecordedDaemon, - request: Omit, - options: { timeoutMs: number; signal?: AbortSignal }, -): Promise { - if (daemon.httpPort === undefined) throw new Error(`Daemon ${daemon.pid} records no endpoint.`); - const signal = AbortSignal.timeout(options.timeoutMs); - const response = await fetch(`http://127.0.0.1:${daemon.httpPort}/rpc`, { - method: 'POST', - headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: request.meta?.requestId ?? `recorded-${Date.now()}`, - method: 'agent_device.command', - params: { ...request, token: daemon.token }, - }), - signal: options.signal ? AbortSignal.any([signal, options.signal]) : signal, - }); - const envelope = (await response.json()) as { - result?: DaemonResponse; - error?: { message: string }; - }; - if (!envelope.result) throw new Error(envelope.error?.message ?? 'Daemon returned no result.'); - return envelope.result; -} diff --git a/scripts/perf/forced-backend.ts b/scripts/perf/forced-backend.ts index bb4f45c9f1..8d213b841c 100644 --- a/scripts/perf/forced-backend.ts +++ b/scripts/perf/forced-backend.ts @@ -2,10 +2,10 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { pathToFileURL } from 'node:url'; import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { createRequestId } from '@agent-device/host-kit/diagnostics'; import type { SnapshotPreferredBackend } from '@agent-device/kernel/snapshot'; -import { readRecordedDaemon, sendToRecordedDaemon } from '../lib/recorded-daemon.ts'; +import { sendToRecordedDaemon } from '@agent-device/daemon-client'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { REPO_ROOT } from './config.ts'; import type { CliResult } from './types.ts'; @@ -65,18 +65,19 @@ function forcedBackendTransport( if (request.session === undefined || typeof stateDir !== 'string') { throw new Error('A forced-backend capture requires an explicit session and state dir.'); } - const paths = resolveDaemonPaths(stateDir, { env: process.env }); - const info = readRecordedDaemon(paths.infoPath); - if (!info) throw new Error(`No daemon is running for state dir ${stateDir}.`); return await sendToRecordedDaemon( - info, { ...request, session: request.session, flags: { ...request.flags, snapshotPreferredBackend: backend }, meta: { ...request.meta, debug: true, requestId }, }, - { timeoutMs: FORCED_CAPTURE_TIMEOUT_MS, signal: context?.signal }, + { + host: captureHostEnvironment(process), + stateDir, + timeoutMs: FORCED_CAPTURE_TIMEOUT_MS, + signal: context?.signal, + }, ); }; } diff --git a/src/__tests__/daemon-client-host.test.ts b/src/__tests__/daemon-client-host.test.ts index 3d87d400ad..9a9c72f9b7 100644 --- a/src/__tests__/daemon-client-host.test.ts +++ b/src/__tests__/daemon-client-host.test.ts @@ -1,49 +1,54 @@ +import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; +import { createTestHostEnvironment, supportsLoopbackBind } from '@agent-device/test-kit'; +import { runCli } from '../cli.ts'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { + startRegisteredHttpDaemon, + stateDirAndPaths, + type RegisteredHttpDaemon, +} from './test-utils/registered-http-daemon.ts'; -const built = vi.hoisted(() => ({ - clientHosts: [] as unknown[], - ensuringHosts: [] as unknown[], +// A client or a CLI run takes the host it is given. These tests run the real client and the real +// default CLI against a real registered daemon on the host's state directory, while the process +// names another one: a daemon resolved from the process would be started, so launching is refused. +vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ + ...(await importOriginal()), + runCmdDetachedMonitored: vi.fn(() => { + throw new Error('A daemon was launched: the host state directory was not used.'); + }), })); +import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; -// The transports the client and the CLI build are stopped before they reach a daemon: which host -// each is built for is what these tests pin. That a transport reads its host is pinned in the -// daemon-client package. -vi.mock('@agent-device/daemon-client', async (importOriginal) => ({ - ...(await importOriginal()), - createDaemonTransport: (host: unknown) => { - built.clientHosts.push(host); - return async () => { - throw new Error('stop before talking to a daemon'); - }; - }, - createEnsuredDaemonTransport: (options: { host?: unknown } = {}) => { - built.ensuringHosts.push(options.host); - return async () => { - throw new Error('stop before talking to a daemon'); - }; - }, -})); +let daemon: RegisteredHttpDaemon | undefined; -import { runCli } from '../cli.ts'; -import { createAgentDeviceClient } from '../agent-device-client.ts'; - -afterEach(() => { +afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); - built.clientHosts.length = 0; - built.ensuringHosts.length = 0; + vi.mocked(runCmdDetachedMonitored).mockClear(); + await daemon?.close(); + daemon = undefined; }); -test('a client reports and uses the same state dir when its host and the process disagree', async () => { - vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); - const host = { env: { AGENT_DEVICE_STATE_DIR: '/client/state' }, cwd: '/client/workspace' }; +async function bootHostAndProcess() { + daemon = await startRegisteredHttpDaemon(); + const { stateDir: processStateDir } = stateDirAndPaths(); + vi.stubEnv('AGENT_DEVICE_STATE_DIR', processStateDir); + const host = createTestHostEnvironment({ env: { AGENT_DEVICE_STATE_DIR: daemon.stateDir } }); + return { host, processStateDir }; +} + +test('a client reports and uses the state dir of its host when the process names another', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const { host, processStateDir } = await bootHostAndProcess(); const client = createAgentDeviceClient({}, { host }); - await expect(client.sessions.stateDir()).resolves.toBe('/client/state'); - await expect(client.devices.list()).rejects.toThrow('stop before talking'); + await expect(client.sessions.stateDir()).resolves.toBe(daemon!.paths.baseDir); + await client.devices.list(); - expect(built.clientHosts).toEqual([host]); - expect(built.clientHosts[0]).toBe(host); + expect(daemon!.commands()).toEqual(['devices']); + expect(vi.mocked(runCmdDetachedMonitored)).not.toHaveBeenCalled(); + expect(fs.existsSync(`${processStateDir}/daemon.json`)).toBe(false); }); test('a CLI run builds its transport from the host it was given', async () => { @@ -56,15 +61,15 @@ test('a CLI run builds its transport from the host it was given', async () => { expect(transportForHost).toHaveBeenCalledWith(host); }); -test('a CLI run with the default transport builds its ensuring transport from the given host', async () => { - const host = { env: { AGENT_DEVICE_STATE_DIR: '/cli/state' }, cwd: '/cli/workspace' }; - vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('exit'); - }) as never); - vi.spyOn(process.stderr, 'write').mockReturnValue(true); - vi.spyOn(process.stdout, 'write').mockReturnValue(true); +test('a default CLI run reaches the daemon on the state dir of the host it was given', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const { host, processStateDir } = await bootHostAndProcess(); + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true); - await runCli(['devices', '--json'], undefined, host).catch(() => undefined); + await runCli(['devices', '--json'], undefined, host); - expect(built.ensuringHosts.at(-1)).toBe(host); + expect(daemon!.commands()).toEqual(['devices']); + expect(stdout).toHaveBeenCalled(); + expect(vi.mocked(runCmdDetachedMonitored)).not.toHaveBeenCalled(); + expect(fs.existsSync(`${processStateDir}/daemon.json`)).toBe(false); }); diff --git a/src/__tests__/test-utils/registered-http-daemon.ts b/src/__tests__/test-utils/registered-http-daemon.ts new file mode 100644 index 0000000000..40af5fc78f --- /dev/null +++ b/src/__tests__/test-utils/registered-http-daemon.ts @@ -0,0 +1,92 @@ +import type { Mock } from 'vitest'; +import { resolveLocalDaemonCodeIdentity } from '@agent-device/daemon-client'; +import { + resolveDaemonPaths, + type DaemonPaths, +} from '@agent-device/daemon-contracts/daemon-resolution'; +import type { DaemonRegistrationFields } from '@agent-device/daemon-contracts/daemon-registration-owner'; +import type { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; +import { readVersion } from '@agent-device/host-kit/version'; +import { + closeLoopbackServer, + mkdtempForTestSync, + startHttpDaemonFixture, + type HttpDaemonFixture, +} from '@agent-device/test-kit'; +import { + finishRegisteredDaemonFixture, + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, +} from './registered-daemon-fixture.ts'; + +// A real child that owns a daemon registration and advertises a loopback HTTP stand-in, for root +// tests that drive the client, the CLI or MCP through their real daemon resolution. + +export type RegisteredHttpDaemon = { + stateDir: string; + paths: DaemonPaths; + /** The stand-in the registration advertises; it records every request it answers. */ + stub: HttpDaemonFixture; + /** The commands of the requests the stand-in answered, in order. */ + commands: () => string[]; + close: () => Promise; +}; + +async function registrationFor(httpPort: number): Promise { + const identity = await resolveLocalDaemonCodeIdentity(); + return { + httpPort, + token: 'local-secret', + version: readVersion(), + ...(identity.origin === 'checkout' + ? { codeOrigin: 'checkout' as const, codeSignature: identity.codeSignature } + : { codeOrigin: 'installed' as const }), + capabilities: ['quiesce'], + }; +} + +export function stateDirAndPaths(): { stateDir: string; paths: DaemonPaths } { + const stateDir = mkdtempForTestSync('agent-device-registered-http-daemon-'); + return { stateDir, paths: resolveDaemonPaths(stateDir) }; +} + +/** A daemon already running on its own state directory. */ +export async function startRegisteredHttpDaemon(): Promise { + const { stateDir, paths } = stateDirAndPaths(); + const stub = await startHttpDaemonFixture({ devices: [] }); + const child = spawnRegisteredDaemonFixture(paths, await registrationFor(stub.port), undefined); + await waitForRegisteredDaemonFixture(paths, child); + return { + stateDir, + paths, + stub, + commands: () => stub.rpcRequests.map((rpc) => String(rpc.params?.command)), + close: async () => { + await finishRegisteredDaemonFixture(stateDir); + await closeLoopbackServer(stub.server); + }, + }; +} + +/** + * Answers the daemon launch seam with a child that registers and advertises a stand-in, so a start + * the client performs yields a daemon it can reach. + */ +export async function launchesFreshHttpDaemon( + spawn: Mock, + paths: DaemonPaths, +): Promise<{ stub: HttpDaemonFixture; commands: () => string[]; close: () => Promise }> { + const stub = await startHttpDaemonFixture({ devices: [] }); + const fields = await registrationFor(stub.port); + spawn.mockImplementation((_command, _args, options) => + spawnRegisteredDaemonFixture(paths, fields, options), + ); + return { + stub, + commands: () => stub.rpcRequests.map((rpc) => String(rpc.params?.command)), + close: async () => { + await finishRegisteredDaemonFixture(paths.baseDir); + await closeLoopbackServer(stub.server); + }, + }; +} diff --git a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts index 863a75acd2..70ea17c170 100644 --- a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts +++ b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts @@ -1,37 +1,40 @@ import assert from 'node:assert/strict'; import { afterEach, test, vi } from 'vitest'; - -const ensuring = vi.hoisted(() => ({ - hosts: [] as unknown[], - requests: [] as string[], -})); - -// The ensuring transport is the production entry's; its ensure-then-attach behavior is pinned in -// the daemon-client package. What these tests pin is what the MCP default client factory asks of it. -vi.mock('@agent-device/daemon-client', async (importOriginal) => ({ - ...(await importOriginal()), - createEnsuredDaemonTransport: (options: { host?: unknown } = {}) => { - ensuring.hosts.push(options.host); - return async (req: { command: string }) => { - ensuring.requests.push(req.command); - return { ok: true as const, data: { devices: [] } }; - }; - }, +import { supportsLoopbackBind } from '@agent-device/test-kit'; +import { + launchesFreshHttpDaemon, + stateDirAndPaths, +} from '../../__tests__/test-utils/registered-http-daemon.ts'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ + ...(await importOriginal()), + runCmdDetachedMonitored: vi.fn(), })); - +import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; import * as hostProcess from '@agent-device/host-kit/process'; import { createCommandToolExecutor } from '../command-tools.ts'; // An MCP tool call builds its own client, so each call has to ensure the daemon it is about to use -// and then attach to it. This drives the default client factory, not an injected one. +// and then attach to it. This drives the default client factory and the real daemon-client entry +// against a real registered child. + +const spawn = vi.mocked(runCmdDetachedMonitored); +let close: (() => Promise) | undefined; -afterEach(() => { +afterEach(async () => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); - ensuring.hosts.length = 0; - ensuring.requests.length = 0; + spawn.mockReset(); + await close?.(); + close = undefined; }); -test('each MCP tool call builds an ensuring transport from the host it captured', async () => { +test('each MCP tool call ensures the daemon, then attaches to it', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const { stateDir, paths } = stateDirAndPaths(); + const daemon = await launchesFreshHttpDaemon(spawn, paths); + close = daemon.close; + vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); const capture = vi.spyOn(hostProcess, 'captureHostEnvironment'); const executor = createCommandToolExecutor(); @@ -40,10 +43,7 @@ test('each MCP tool call builds an ensuring transport from the host it captured' assert.equal(first.isError, false, JSON.stringify(first.structuredContent)); assert.equal(second.isError, false, JSON.stringify(second.structuredContent)); - assert.equal(ensuring.hosts.length, 2, 'one ensuring transport per tool call'); + assert.equal(spawn.mock.calls.length, 1, 'the second call reuses the daemon the first started'); + assert.deepEqual(daemon.commands(), ['devices', 'devices']); assert.equal(capture.mock.calls.length, 2, 'one host capture per tool call'); - // The host the ensuring transport used is the one the call captured, not a second capture. - assert.equal(ensuring.hosts[0], capture.mock.results[0]?.value); - assert.equal(ensuring.hosts[1], capture.mock.results[1]?.value); - assert.deepEqual(ensuring.requests, ['devices', 'devices']); }); diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index c615f8d7ee..85836df622 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import type { TestContext } from 'node:test'; @@ -6,8 +7,7 @@ import { pathToFileURL } from 'node:url'; import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; -import { readRecordedDaemon, sendToRecordedDaemon } from '../../../scripts/lib/recorded-daemon.ts'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { sendToRecordedDaemon } from '@agent-device/daemon-client'; import { assertPngFile } from '../provider-scenarios/assertions.ts'; import { assertFilesDiffer, @@ -58,13 +58,9 @@ const sendToDaemonTransport: AgentDeviceDaemonTransport = async (request, contex } const stateDir = request.flags?.stateDir; assert.ok(typeof stateDir === 'string', 'backend conformance requires the smoke state directory'); - const paths = resolveDaemonPaths(stateDir, { env: process.env }); - const info = readRecordedDaemon(paths.infoPath); - assert.ok(info, 'backend conformance requires the daemon prepared by the built CLI'); return await sendToRecordedDaemon( - info, { ...request, session: request.session }, - { timeoutMs: 90_000, signal: context?.signal }, + { host: createTestHostEnvironment(), stateDir, timeoutMs: 90_000, signal: context?.signal }, ); }; diff --git a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts index b404132c40..8526dd620e 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts @@ -3,7 +3,7 @@ import http from 'node:http'; import { test } from 'vitest'; import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; -import { buildRpcPayload } from './harness-rpc.ts'; +import { buildHttpRpcPayload } from '@agent-device/daemon-client'; import { skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; import { @@ -54,7 +54,7 @@ test('lease_allocate over HTTP prepares the BrowserStack session end to end', as }); try { - const envelope = buildRpcPayload( + const envelope = buildHttpRpcPayload( { token: harness.token, session: 'browserstack-e2e', diff --git a/test/integration/provider-scenarios/harness-proxy.ts b/test/integration/provider-scenarios/harness-proxy.ts index 9f4d185199..f1b812e983 100644 --- a/test/integration/provider-scenarios/harness-proxy.ts +++ b/test/integration/provider-scenarios/harness-proxy.ts @@ -2,9 +2,9 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; import http from 'node:http'; import { createDaemonProxyServer, type DaemonProxyAuthenticate } from '@agent-device/proxy'; import { withClientReplayScriptSources } from '../../../src/__tests__/test-utils/replay-script-source.fixtures.ts'; +import { buildHttpRpcPayload } from '@agent-device/daemon-client'; import { closeLoopbackServer, listenOnLoopback } from '@agent-device/test-kit'; import { - buildRpcPayload, commandRequest, envelopeToRpcResult, type JsonRpcResponseEnvelope, @@ -92,7 +92,7 @@ export async function createProviderScenarioProxy( const request = await withClientReplayScriptSources( commandRequest(authToken, command, positionals, flags, commandOptions), ); - const payload = buildRpcPayload(request, { includeTokenParam: true }); + const payload = buildHttpRpcPayload(request, { includeTokenParam: true }); const result = await requestRpc(authToken, payload); const envelope = result.json as JsonRpcResponseEnvelope; const id = String(payload.id); diff --git a/test/integration/provider-scenarios/harness-rpc.ts b/test/integration/provider-scenarios/harness-rpc.ts index 087884648e..34d3e86505 100644 --- a/test/integration/provider-scenarios/harness-rpc.ts +++ b/test/integration/provider-scenarios/harness-rpc.ts @@ -1,10 +1,4 @@ -import { randomUUID } from 'node:crypto'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; -import { - leaseScopeFromRequest, - leaseScopeToLeaseRpcParams, - readLeaseAllocateProviderFlags, -} from '@agent-device/contracts/lease-scope'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { ProviderScenarioRpcResult } from './harness.ts'; @@ -77,44 +71,3 @@ export function envelopeToRpcResult( }, }; } - -const LEASE_RPC_METHODS = { - lease_allocate: 'agent_device.lease.allocate', - lease_heartbeat: 'agent_device.lease.heartbeat', - lease_release: 'agent_device.lease.release', -} as const; - -/** - * The JSON-RPC envelope a scenario posts to the daemon's `/rpc` itself: the wire a remote client - * speaks, built independently of the client transport so a scenario can send what no client does. - */ -export function buildRpcPayload( - req: DaemonWireRequest, - options: { includeTokenParam: boolean }, -): { - jsonrpc: '2.0'; - id: string; - method: string; - params: DaemonWireRequest | Record; -} { - const id = req.meta?.requestId ?? randomUUID(); - const leaseMethod = LEASE_RPC_METHODS[req.command as keyof typeof LEASE_RPC_METHODS]; - if (!leaseMethod) return { jsonrpc: '2.0', id, method: 'agent_device.command', params: req }; - const command = req.command as keyof typeof LEASE_RPC_METHODS; - return { - jsonrpc: '2.0', - id, - method: leaseMethod, - params: { - ...leaseScopeToLeaseRpcParams(leaseScopeFromRequest(req), command, { - includeTokenParam: options.includeTokenParam, - token: req.token, - session: req.session, - }), - ...(command === 'lease_allocate' ? readLeaseAllocateProviderFlags(req.flags) : {}), - ...(command === 'lease_allocate' && req.meta?.providerCredentialFingerprint - ? { providerCredentialFingerprint: req.meta.providerCredentialFingerprint } - : {}), - }, - }; -} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index bc8a870c21..7ac94ab426 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { createAgentDeviceClient } from '../../../src/agent-device-client.ts'; import type { AgentDeviceClient } from '../../../src/client/client-types.ts'; -import { resolveLocalDaemonCodeIdentity } from '@agent-device/daemon-client'; +import { buildHttpRpcPayload, resolveLocalDaemonCodeIdentity } from '@agent-device/daemon-client'; import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; import { startDaemonRuntime, @@ -18,7 +18,6 @@ import { import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { resolveSessionDir } from '@agent-device/daemon-contracts/session-artifact-paths'; import { - buildRpcPayload, commandRequest, envelopeToRpcResult, type JsonRpcResponseEnvelope, @@ -242,7 +241,7 @@ export async function createProviderScenarioHarness( const request = await withClientReplayScriptSources( commandRequest(runtime.token, command, positionals, flags, options), ); - const payload = buildRpcPayload(request, { includeTokenParam: true }); + const payload = buildHttpRpcPayload(request, { includeTokenParam: true }); const { statusCode, body } = await postProviderScenarioRpc({ port: httpPort, token: runtime.token, @@ -278,7 +277,7 @@ export async function createProviderScenarioHarness( async function postProviderScenarioRpc(params: { port: number; token: string; - payload: ReturnType; + payload: ReturnType; signal?: AbortSignal; }): Promise<{ statusCode: number; body: string }> { const body = JSON.stringify(params.payload); From 1a561d658816899ea8d21cb5468f1db676e5c861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 15:06:33 +0100 Subject: [PATCH 06/17] refactor(daemon-client): publish the production send primitive and build the RPC envelope in contracts Replace the entry's script-only sendToRecordedDaemon and buildHttpRpcPayload re-export with the sendRequest primitive the production path already calls, exported as sendDaemonRequest. Tooling keeps only record resolution (scripts/lib/recorded-daemon.ts) and delegates to it with timeoutRecovery 'none'. The pure RPC envelope and lease-method vocabulary move to contracts/lease-scope with a caller-supplied request id; the RPC leaf keeps a thin adapter that supplies the same id as before, and the provider harness uses the shared builder with its own id. Co-Authored-By: Claude Sonnet 5.5 --- packages/contracts/src/lease-scope.ts | 67 +++++++++++++++++++ .../daemon-client/src/daemon-client-rpc.ts | 64 ++---------------- packages/daemon-client/src/index.ts | 53 +++------------ .../lib/recorded-daemon.test.ts | 10 +-- scripts/lib/recorded-daemon.ts | 37 +++++++++- scripts/perf/forced-backend.ts | 2 +- .../ios-simulator-e2e/live-runner.ts | 2 +- .../cloud-webdriver-lease-http.test.ts | 4 +- .../provider-scenarios/harness-proxy.ts | 4 +- .../provider-scenarios/harness-rpc.ts | 13 ++++ .../integration/provider-scenarios/harness.ts | 7 +- test/wire-compat/ledger.json | 21 +++--- test/wire-compat/surface.ts | 5 +- vitest.config.ts | 2 + 14 files changed, 160 insertions(+), 131 deletions(-) rename packages/daemon-client/src/__tests__/index-recorded-daemon.test.ts => scripts/lib/recorded-daemon.test.ts (91%) diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 4ff5eaca6d..789af78351 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -3,6 +3,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { stripUndefined } from '@agent-device/kernel/record'; import type { CloudProviderProfileFields } from './remote-config-fields.ts'; import type { CommandFlags } from './command-flags.ts'; +import type { DaemonWireRequest } from './daemon-wire-request.ts'; import type { EndedLease, LeaseDevice, LeaseDeviceSpec } from './client-lease.ts'; const PROXY_LEASE_PROVIDER = 'proxy'; @@ -536,3 +537,69 @@ function readFlagString( const value = flags?.[key]; return typeof value === 'string' ? value : undefined; } + +/** + * The JSON-RPC envelope a client puts on the daemon's `/rpc` endpoint for one request: the command + * method for an ordinary request, and the lease method with its lease params for a lease command. + * The caller supplies the request id, so the envelope is the same for every client. + */ +export function buildDaemonRpcRequest( + req: DaemonWireRequest, + options: { id: string; includeTokenParam: boolean }, +): { + jsonrpc: '2.0'; + id: string; + method: string; + params: DaemonWireRequest | Record; +} { + const { id } = options; + if (!isLeaseRpcCommand(req.command)) { + return { + jsonrpc: '2.0', + id, + method: 'agent_device.command', + params: req, + }; + } + return { + jsonrpc: '2.0', + id, + method: leaseRpcMethodForCommand(req.command), + params: { + ...buildLeaseRpcParams(req, req.command, options), + ...(req.command === 'lease_allocate' ? readLeaseAllocateProviderFlags(req.flags) : {}), + ...(req.command === 'lease_allocate' && req.meta?.providerCredentialFingerprint + ? { providerCredentialFingerprint: req.meta.providerCredentialFingerprint } + : {}), + }, + }; +} + +function isLeaseRpcCommand(command: string): command is LeaseRpcCommand { + return ( + command === 'lease_allocate' || command === 'lease_heartbeat' || command === 'lease_release' + ); +} + +function leaseRpcMethodForCommand(command: LeaseRpcCommand): string { + switch (command) { + case 'lease_allocate': + return 'agent_device.lease.allocate'; + case 'lease_heartbeat': + return 'agent_device.lease.heartbeat'; + case 'lease_release': + return 'agent_device.lease.release'; + } +} + +function buildLeaseRpcParams( + req: DaemonWireRequest, + command: LeaseRpcCommand, + options: { includeTokenParam: boolean }, +): Record { + return leaseScopeToLeaseRpcParams(leaseScopeFromRequest(req), command, { + includeTokenParam: options.includeTokenParam, + token: req.token, + session: req.session, + }); +} diff --git a/packages/daemon-client/src/daemon-client-rpc.ts b/packages/daemon-client/src/daemon-client-rpc.ts index afe33b6201..6d7c74d952 100644 --- a/packages/daemon-client/src/daemon-client-rpc.ts +++ b/packages/daemon-client/src/daemon-client-rpc.ts @@ -12,12 +12,7 @@ import { createRequestId } from '@agent-device/host-kit/diagnostics'; import { materializeRemoteArtifacts } from '@agent-device/remote/daemon-artifacts'; import { localizeRemoteDaemonError } from '@agent-device/remote/remote-request-diagnostics'; import type { DaemonInfo } from './daemon-client-metadata.ts'; -import { - leaseScopeFromRequest, - leaseScopeToLeaseRpcParams, - readLeaseAllocateProviderFlags, - type LeaseRpcCommand, -} from '@agent-device/contracts/lease-scope'; +import { buildDaemonRpcRequest, leaseScopeFromRequest } from '@agent-device/contracts/lease-scope'; export function handleDaemonHttpResponseBody( body: string, @@ -174,60 +169,9 @@ async function resolveDaemonHttpResult( export function buildHttpRpcPayload( req: DaemonWireRequest, options: { includeTokenParam: boolean }, -): { - jsonrpc: '2.0'; - id: string; - method: string; - params: DaemonWireRequest | Record; -} { - const id = req.meta?.requestId ?? createRequestId(); - if (!isLeaseRpcCommand(req.command)) { - return { - jsonrpc: '2.0', - id, - method: 'agent_device.command', - params: req, - }; - } - return { - jsonrpc: '2.0', - id, - method: leaseRpcMethodForCommand(req.command), - params: { - ...buildLeaseRpcParams(req, req.command, options), - ...(req.command === 'lease_allocate' ? readLeaseAllocateProviderFlags(req.flags) : {}), - ...(req.command === 'lease_allocate' && req.meta?.providerCredentialFingerprint - ? { providerCredentialFingerprint: req.meta.providerCredentialFingerprint } - : {}), - }, - }; -} - -function isLeaseRpcCommand(command: string): command is LeaseRpcCommand { - return ( - command === 'lease_allocate' || command === 'lease_heartbeat' || command === 'lease_release' - ); -} - -function leaseRpcMethodForCommand(command: LeaseRpcCommand): string { - switch (command) { - case 'lease_allocate': - return 'agent_device.lease.allocate'; - case 'lease_heartbeat': - return 'agent_device.lease.heartbeat'; - case 'lease_release': - return 'agent_device.lease.release'; - } -} - -function buildLeaseRpcParams( - req: DaemonWireRequest, - command: LeaseRpcCommand, - options: { includeTokenParam: boolean }, -): Record { - return leaseScopeToLeaseRpcParams(leaseScopeFromRequest(req), command, { +): ReturnType { + return buildDaemonRpcRequest(req, { + id: req.meta?.requestId ?? createRequestId(), includeTokenParam: options.includeTokenParam, - token: req.token, - session: req.session, }); } diff --git a/packages/daemon-client/src/index.ts b/packages/daemon-client/src/index.ts index 958015e96c..84b5a4930b 100644 --- a/packages/daemon-client/src/index.ts +++ b/packages/daemon-client/src/index.ts @@ -1,8 +1,5 @@ import type { DaemonConnection, EnsureDaemonOptions } from '@agent-device/contracts/client'; -import { - resolveDaemonPaths, - type DaemonPaths, -} from '@agent-device/daemon-contracts/daemon-resolution'; +import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { DaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; import type { DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; import { AppError } from '@agent-device/kernel/errors'; @@ -22,8 +19,8 @@ import { } from '@agent-device/remote/daemon-artifacts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import type { EnsuringTransportOptions } from './daemon-client-ensuring.ts'; -import { createRequestGuard, sendRequest } from './daemon-client-transport.ts'; -import { isRemoteDaemon, readDaemonInfo, type DaemonInfo } from './daemon-client-metadata.ts'; +import { createRequestGuard, sendRequest as sendDaemonRequest } from './daemon-client-transport.ts'; +import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; import type { LocalDaemonCodeIdentity } from './daemon-launch-spec.ts'; import type { DaemonRequest, @@ -145,7 +142,7 @@ async function sendToDaemonOnHost( const response = await withDiagnosticTimer( 'daemon_request', async () => - await sendRequest( + await sendDaemonRequest( info, request, settings.transportPreference, @@ -401,44 +398,14 @@ async function protectArtifactUploadWithLeaseBeats( export { createRequestGuard }; -/** The JSON-RPC envelope the daemon's `/rpc` endpoint takes for a request, lease commands included. */ -export { buildHttpRpcPayload } from './daemon-client-rpc.ts'; - /** - * Sends one request to the daemon a state directory already records, for tooling that drives a - * daemon another build started. The request travels through the same transport as every other - * client request: its cancellation, request ids, typed errors, response parsing, progress and - * timeout budget. - * - * It starts nothing, replaces nothing and judges no compatibility. A timeout never reaches the - * liveness retirement (`timeoutRecovery: 'none'`), so a slow or wedged daemon is left as it is. + * The one transport primitive every request goes through: it sends a request to the daemon `info` + * names, with the caller's cancellation, timeout budget and recovery policy. `sendToDaemon` resolves + * a daemon first; a caller that already holds the registration of a daemon it must not start, + * replace or retire (tooling that drives a daemon another build started) sends through it directly, + * with `timeoutRecovery: 'none'`. */ -export async function sendToRecordedDaemon( - req: Omit, - options: { - host: HostEnvironment; - stateDir: string; - timeoutMs: number; - signal?: AbortSignal; - onProgress?: DaemonTransportOptions['onProgress']; - }, -): Promise { - const paths = resolveDaemonPaths(options.stateDir, { - env: options.host.env, - cwd: options.host.cwd, - }); - const info = readDaemonInfo(paths.infoPath); - if (!info) { - const { daemonNotRunningError } = await import('./daemon-lifecycle-errors.ts'); - throw daemonNotRunningError(paths.baseDir); - } - return await sendRequest(info, { ...req, token: info.token }, 'auto', paths, options.timeoutMs, { - host: options.host, - onProgress: options.onProgress, - signal: options.signal, - timeoutRecovery: 'none', - }); -} +export { sendDaemonRequest }; /** * Starts the local daemon on a state directory if none is usable. Loaded on first call: a client diff --git a/packages/daemon-client/src/__tests__/index-recorded-daemon.test.ts b/scripts/lib/recorded-daemon.test.ts similarity index 91% rename from packages/daemon-client/src/__tests__/index-recorded-daemon.test.ts rename to scripts/lib/recorded-daemon.test.ts index 8be1323298..f19b5bd12a 100644 --- a/packages/daemon-client/src/__tests__/index-recorded-daemon.test.ts +++ b/scripts/lib/recorded-daemon.test.ts @@ -11,11 +11,11 @@ import { mkdtempForTestSync, supportsLoopbackBind, } from '@agent-device/test-kit'; -import { sendToRecordedDaemon } from '../index.ts'; +import { sendToRecordedDaemon } from './recorded-daemon.ts'; -// `sendToRecordedDaemon` is the supported way for tooling to talk to a daemon another build -// started. It must keep every guarantee of the client transport and none of its lifecycle: nothing -// is started, replaced or retired, whatever the daemon answers. +// `sendToRecordedDaemon` resolves a recorded daemon and sends through the client transport. It must +// keep every guarantee of that transport and none of its lifecycle: nothing is started, replaced or +// retired, whatever the daemon answers. const REQUEST = { session: 'default', command: 'snapshot', positionals: [] as string[] }; const servers: net.Server[] = []; @@ -119,7 +119,7 @@ test('a state directory that records no daemon is reported, not started', async await assert.rejects( send(stateDir), - (error: unknown) => error instanceof AppError && error.details?.reason === 'daemon-not-running', + (error: unknown) => error instanceof Error && /No daemon is running/.test(error.message), ); assert.equal(fs.existsSync(resolveDaemonPaths(stateDir).infoPath), false); diff --git a/scripts/lib/recorded-daemon.ts b/scripts/lib/recorded-daemon.ts index 01b26f1185..19d4fd93a7 100644 --- a/scripts/lib/recorded-daemon.ts +++ b/scripts/lib/recorded-daemon.ts @@ -1,8 +1,15 @@ -import { readDaemonRecord } from '@agent-device/daemon-contracts/daemon-resolution'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import { sendDaemonRequest } from '@agent-device/daemon-client'; +import { + readDaemonRecord, + resolveDaemonPaths, +} from '@agent-device/daemon-contracts/daemon-resolution'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; /** * The registration a state directory records, as the measurement and live-lane tooling reads it. - * Sending to that daemon goes through `sendToRecordedDaemon` in `@agent-device/daemon-client`. + * It is the part of a daemon record that `sendDaemonRequest` needs to reach that daemon. */ export type RecordedDaemon = Readonly<{ pid: number; @@ -25,3 +32,29 @@ export function readRecordedDaemon(infoPath: string): RecordedDaemon | null { ...(typeof processStartTime === 'string' ? { processStartTime } : {}), }; } + +/** + * Sends one request to the daemon a state directory records, through the client transport. The + * tooling that calls this drives a daemon another build started, so it resolves the record and + * nothing else: no daemon is started, replaced or compatibility-checked, and a timeout never + * retires the daemon (`timeoutRecovery: 'none'`). + */ +export async function sendToRecordedDaemon( + request: Omit, + options: { host: HostEnvironment; stateDir: string; timeoutMs: number; signal?: AbortSignal }, +): Promise { + const paths = resolveDaemonPaths(options.stateDir, { + env: options.host.env, + cwd: options.host.cwd, + }); + const info = readRecordedDaemon(paths.infoPath); + if (!info) throw new Error(`No daemon is running for state dir ${paths.baseDir}.`); + return await sendDaemonRequest( + info, + { ...request, token: info.token }, + 'auto', + paths, + options.timeoutMs, + { host: options.host, signal: options.signal, timeoutRecovery: 'none' }, + ); +} diff --git a/scripts/perf/forced-backend.ts b/scripts/perf/forced-backend.ts index 8d213b841c..6e5d5c3255 100644 --- a/scripts/perf/forced-backend.ts +++ b/scripts/perf/forced-backend.ts @@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url'; import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; import { createRequestId } from '@agent-device/host-kit/diagnostics'; import type { SnapshotPreferredBackend } from '@agent-device/kernel/snapshot'; -import { sendToRecordedDaemon } from '@agent-device/daemon-client'; +import { sendToRecordedDaemon } from '../lib/recorded-daemon.ts'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { REPO_ROOT } from './config.ts'; import type { CliResult } from './types.ts'; diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index 85836df622..c5631db187 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from 'node:url'; import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; -import { sendToRecordedDaemon } from '@agent-device/daemon-client'; +import { sendToRecordedDaemon } from '../../../scripts/lib/recorded-daemon.ts'; import { assertPngFile } from '../provider-scenarios/assertions.ts'; import { assertFilesDiffer, diff --git a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts index 8526dd620e..4cf182824c 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; +import { buildScenarioRpcRequest } from './harness-rpc.ts'; import http from 'node:http'; import { test } from 'vitest'; import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-runtimes.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; -import { buildHttpRpcPayload } from '@agent-device/daemon-client'; import { skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; import { @@ -54,7 +54,7 @@ test('lease_allocate over HTTP prepares the BrowserStack session end to end', as }); try { - const envelope = buildHttpRpcPayload( + const envelope = buildScenarioRpcRequest( { token: harness.token, session: 'browserstack-e2e', diff --git a/test/integration/provider-scenarios/harness-proxy.ts b/test/integration/provider-scenarios/harness-proxy.ts index f1b812e983..92be51b389 100644 --- a/test/integration/provider-scenarios/harness-proxy.ts +++ b/test/integration/provider-scenarios/harness-proxy.ts @@ -2,9 +2,9 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; import http from 'node:http'; import { createDaemonProxyServer, type DaemonProxyAuthenticate } from '@agent-device/proxy'; import { withClientReplayScriptSources } from '../../../src/__tests__/test-utils/replay-script-source.fixtures.ts'; -import { buildHttpRpcPayload } from '@agent-device/daemon-client'; import { closeLoopbackServer, listenOnLoopback } from '@agent-device/test-kit'; import { + buildScenarioRpcRequest, commandRequest, envelopeToRpcResult, type JsonRpcResponseEnvelope, @@ -92,7 +92,7 @@ export async function createProviderScenarioProxy( const request = await withClientReplayScriptSources( commandRequest(authToken, command, positionals, flags, commandOptions), ); - const payload = buildHttpRpcPayload(request, { includeTokenParam: true }); + const payload = buildScenarioRpcRequest(request, { includeTokenParam: true }); const result = await requestRpc(authToken, payload); const envelope = result.json as JsonRpcResponseEnvelope; const id = String(payload.id); diff --git a/test/integration/provider-scenarios/harness-rpc.ts b/test/integration/provider-scenarios/harness-rpc.ts index 34d3e86505..524a703ad9 100644 --- a/test/integration/provider-scenarios/harness-rpc.ts +++ b/test/integration/provider-scenarios/harness-rpc.ts @@ -1,4 +1,6 @@ +import { randomUUID } from 'node:crypto'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import { buildDaemonRpcRequest } from '@agent-device/contracts/lease-scope'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { ProviderScenarioRpcResult } from './harness.ts'; @@ -71,3 +73,14 @@ export function envelopeToRpcResult( }, }; } + +/** The `/rpc` envelope for an ordinary scenario request: the shared builder, with the scenario's own id. */ +export function buildScenarioRpcRequest( + req: DaemonWireRequest, + options: { includeTokenParam: boolean }, +): ReturnType { + return buildDaemonRpcRequest(req, { + id: req.meta?.requestId ?? randomUUID(), + includeTokenParam: options.includeTokenParam, + }); +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 7ac94ab426..1c3efcc80d 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { createAgentDeviceClient } from '../../../src/agent-device-client.ts'; import type { AgentDeviceClient } from '../../../src/client/client-types.ts'; -import { buildHttpRpcPayload, resolveLocalDaemonCodeIdentity } from '@agent-device/daemon-client'; +import { resolveLocalDaemonCodeIdentity } from '@agent-device/daemon-client'; import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; import { startDaemonRuntime, @@ -18,6 +18,7 @@ import { import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { resolveSessionDir } from '@agent-device/daemon-contracts/session-artifact-paths'; import { + buildScenarioRpcRequest, commandRequest, envelopeToRpcResult, type JsonRpcResponseEnvelope, @@ -241,7 +242,7 @@ export async function createProviderScenarioHarness( const request = await withClientReplayScriptSources( commandRequest(runtime.token, command, positionals, flags, options), ); - const payload = buildHttpRpcPayload(request, { includeTokenParam: true }); + const payload = buildScenarioRpcRequest(request, { includeTokenParam: true }); const { statusCode, body } = await postProviderScenarioRpc({ port: httpPort, token: runtime.token, @@ -277,7 +278,7 @@ export async function createProviderScenarioHarness( async function postProviderScenarioRpc(params: { port: number; token: string; - payload: ReturnType; + payload: ReturnType; signal?: AbortSignal; }): Promise<{ statusCode: number; body: string }> { const body = JSON.stringify(params.payload); diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 3e5cb803f1..e938ffa059 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -57,11 +57,8 @@ "packages/daemon-client/src/daemon-client-progress.ts#createInvalidDaemonResponseError": "sha256:807eb96ca26de118a8e678f2ec776d17c207db4eec86ff23cfaec9cd2476b60f", "packages/daemon-client/src/daemon-client-progress.ts#shouldReadDaemonProgressStream": "sha256:27ac0a5299324aab39fa2c4fe0e0d32b1eb1515df47c7f4aa75a75d66b8b5a3e", "packages/daemon-client/src/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", - "packages/daemon-client/src/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:bbcf00e71d3975caba4add0e85537396b5831796f1af52cc8c62a4688882ebd7", - "packages/daemon-client/src/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", + "packages/daemon-client/src/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:8978ef89d3af866bd94fec52c586a0cbf3217ee9e60952fbc826e8208e5fa158", "packages/daemon-client/src/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", - "packages/daemon-client/src/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", - "packages/daemon-client/src/daemon-client-rpc.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", "packages/daemon-client/src/daemon-client-rpc.ts#parseDaemonHttpResponseBody": "sha256:0cbe39dbd09e321ec00854a1626a7d89b6344e59c6faabe2fe02504268b2d886", "packages/daemon-client/src/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:a5cf6fd4a9abea34ec051f03d6e45e5aa248f480371fdf22f93de06622a57b47", "packages/daemon-client/src/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", @@ -191,7 +188,11 @@ "packages/proxy/src/daemon-proxy.ts#buildUpstreamInstancePreconditionHeaders": "sha256:9f5d76a1f761d65fabd7244fc67295fed5934a544e86a6455451ef8520cf1615", "packages/daemon-client/src/daemon-client-transport.ts#buildRemoteInstancePreconditionHeaders": "sha256:70241561c4070a8ebbdbbdb4ebcdbdfcb9543501cbb649a6d71cf041aee55a08", "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatchResponse": "sha256:7670f48fe0fc7344f8a000cd303e49788ec7ea9d1c729bcdc2af73ff02dae04d", - "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatch": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee" + "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatch": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee", + "packages/contracts/src/lease-scope.ts#buildDaemonRpcRequest": "sha256:bead87907afdad0aa685126ef191a70d7e99495ce5d11dd1c0505d07623b9137", + "packages/contracts/src/lease-scope.ts#buildLeaseRpcParams": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", + "packages/contracts/src/lease-scope.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", + "packages/contracts/src/lease-scope.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881" }, "compatibleChanges": [ { @@ -207,7 +208,7 @@ { "declaration": "packages/remote/src/daemon-artifacts.ts#DownloadRemoteArtifactParams", "digest": "sha256:ba6d2f6ac51e05ccc5e0c940b355d62374e018b1ae6f6f744becf6e1760cbb63", - "rationale": "#2246 adds the optional isDirectory field. It is pure client-local state — never serialized, never sent to the daemon — that tells the CLIENT'S OWN download logic to extract a tar body instead of writing it verbatim; the GET /artifacts/:id request and response framing are unchanged. Every existing call site (screenshot, recording) omits it and keeps writing a single file exactly as before. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "rationale": "#2246 adds the optional isDirectory field. It is pure client-local state \u2014 never serialized, never sent to the daemon \u2014 that tells the CLIENT'S OWN download logic to extract a tar body instead of writing it verbatim; the GET /artifacts/:id request and response framing are unchanged. Every existing call site (screenshot, recording) omits it and keeps writing a single file exactly as before. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." }, { "declaration": "packages/remote/src/daemon-artifacts.ts#downloadRemoteArtifact", @@ -221,8 +222,8 @@ }, { "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#buildHttpRpcPayload", - "digest": "sha256:bbcf00e71d3975caba4add0e85537396b5831796f1af52cc8c62a4688882ebd7", - "rationale": "Adds the optional providerCredentialFingerprint parameter to agent_device.lease.allocate, forwarded only when the request meta carries one, which the client sets only for a local daemon. A released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request without a fingerprint keeps the previous payload byte-for-byte. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "digest": "sha256:8978ef89d3af866bd94fec52c586a0cbf3217ee9e60952fbc826e8208e5fa158", + "rationale": "Adds the optional providerCredentialFingerprint parameter to agent_device.lease.allocate, forwarded only when the request meta carries one, which the client sets only for a local daemon. A released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request without a fingerprint keeps the previous payload byte-for-byte. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. It now composes the envelope through buildDaemonRpcRequest in @agent-device/contracts/lease-scope, which takes the request id from its caller; the client supplies the id it supplied before (the request meta id, else a fresh one), so the method, params and id of every request are byte for byte what the client sent." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest", @@ -287,7 +288,7 @@ { "declaration": "packages/daemon/src/server/http/http-authorization.ts#authorizeAuxiliaryHttpRequest", "digest": "sha256:1c323f97c3cefec8f95633b6abd127342bf75494bfb109d1cc6eadf3ceacaeae", - "rationale": "#2198 context: adds one optional field to the value this DAEMON-INTERNAL gate hands its own route handlers — sessionNamespace, the caller's tenant plus whether the daemon partitioned that caller's session names. Nothing about it is serialized: no request header is read that was not read before, no response field is added, and a refusal keeps the same 401 {ok:false,error,code} REST body sendRestJsonError already sent. What it enables is a refusal DROPPED, never one added — an unattested (client-declared) tenant no longer has the : prefix rule applied to it, because scopeRequestSession never applied that prefix to its sessions either. A released peer that now gets 200 where it got 401 is getting the ndjson record it asked for and already parses." + "rationale": "#2198 context: adds one optional field to the value this DAEMON-INTERNAL gate hands its own route handlers \u2014 sessionNamespace, the caller's tenant plus whether the daemon partitioned that caller's session names. Nothing about it is serialized: no request header is read that was not read before, no response field is added, and a refusal keeps the same 401 {ok:false,error,code} REST body sendRestJsonError already sent. What it enables is a refusal DROPPED, never one added \u2014 an unattested (client-declared) tenant no longer has the : prefix rule applied to it, because scopeRequestSession never applied that prefix to its sessions either. A released peer that now gets 200 where it got 401 is getting the ndjson record it asked for and already parses." }, { "declaration": "packages/daemon/src/server/request-diagnostics-http.ts#RequestDiagnosticsHttpAuthorizer", @@ -470,7 +471,7 @@ "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." }, { - "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#buildLeaseRpcParams", + "declaration": "packages/contracts/src/lease-scope.ts#buildLeaseRpcParams", "digest": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." }, diff --git a/test/wire-compat/surface.ts b/test/wire-compat/surface.ts index 8526c1a301..1a5baf4c99 100644 --- a/test/wire-compat/surface.ts +++ b/test/wire-compat/surface.ts @@ -245,9 +245,10 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [ ), // Consumer side: the payload the client actually puts on the wire. A // client-only change here breaks an older daemon just as surely. + ...from(CLIENT_RPC, 'buildHttpRpcPayload'), ...from( - CLIENT_RPC, - 'buildHttpRpcPayload', + 'packages/contracts/src/lease-scope.ts', + 'buildDaemonRpcRequest', 'isLeaseRpcCommand', 'leaseRpcMethodForCommand', 'buildLeaseRpcParams', diff --git a/vitest.config.ts b/vitest.config.ts index 02cc85129d..74c2369329 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -144,6 +144,8 @@ export default defineConfig({ 'scripts/fuzz/validation-arbitraries-maestro.test.ts', 'scripts/fuzz/validation-case.test.ts', 'scripts/fuzz/envelope.test.ts', + // The tooling send over real loopback daemons: in-process, no device. + 'scripts/lib/recorded-daemon.test.ts', 'scripts/__tests__/vitest-slow-test-reporter.test.ts', // Artifact guards assert on Swift sources, package.json, workflows, docs and examples rather // than on a module under src. From 1f40ba55f678d437b4bf50ab66c8ca5d706aa83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 15:27:56 +0100 Subject: [PATCH 07/17] fix(daemon-client): verify recorded daemon ownership and host propagation --- .../__tests__/daemon-client-ensuring.test.ts | 20 ++++- .../src/__tests__/index-policy-reuse.test.ts | 24 ++++- .../plugins/src/credential-fingerprint.ts | 2 +- scripts/layering/root-module-zones.test.ts | 10 ++- scripts/lib/recorded-daemon.test.ts | 87 ++++++++++++++++++- scripts/lib/recorded-daemon.ts | 38 +++++++- .../test-utils/registered-http-daemon.ts | 21 ++++- .../command-tools-daemon-lifecycle.test.ts | 14 ++- src/provider-credential-fingerprint.test.ts | 7 ++ test/wire-compat/README.md | 2 +- 10 files changed, 209 insertions(+), 16 deletions(-) diff --git a/packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts b/packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts index 5b0f173982..8c5c7ee834 100644 --- a/packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { afterEach, test, vi } from 'vitest'; -import { closeLoopbackServer, supportsLoopbackBind } from '@agent-device/test-kit'; +import { + closeLoopbackServer, + createTestHostEnvironment, + supportsLoopbackBind, +} from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; import { isProcessAlive } from '@agent-device/host-kit/process'; import { finishRegisteredDaemonFixtures } from './registered-daemon-fixture.ts'; @@ -277,7 +281,10 @@ test('concurrent first requests share one ensure through the CLI and MCP transpo const { stateDir, paths } = stateDirAndPaths(); const fresh = await startScriptedDaemon(okResult({ via: 'fresh' })); launchesFreshDaemonAt(spawn, paths, fresh.port); - const ensuring = createEnsuredDaemonTransport(); + const firstHost = createTestHostEnvironment(); + const secondHost = createTestHostEnvironment(); + const ensuring = createEnsuredDaemonTransport({ host: firstHost }); + const secondEnsuring = createEnsuredDaemonTransport({ host: secondHost }); const flags = { stateDir, daemonTransport: 'http' }; const ensure = vi.spyOn(ensureModule, 'ensureDaemon'); @@ -292,7 +299,14 @@ test('concurrent first requests share one ensure through the CLI and MCP transpo { ok: true, data: { via: 'fresh' } }, ]); assert.equal(ensure.mock.calls.length, 1, 'one ensure for both requests'); - assert.equal(spawn.mock.calls.length, 1, 'one daemon start for both requests'); + assert.equal(ensure.mock.calls[0]?.[1], firstHost); + assert.deepEqual(await secondEnsuring(request('devices', flags)), { + ok: true, + data: { via: 'fresh' }, + }); + assert.equal(ensure.mock.calls.length, 2, 'a separate transport ensures again'); + assert.equal(ensure.mock.calls[1]?.[1], secondHost); + assert.equal(spawn.mock.calls.length, 1, 'both transports reuse one daemon'); } finally { await closeLoopbackServer(fresh.server); } diff --git a/packages/daemon-client/src/__tests__/index-policy-reuse.test.ts b/packages/daemon-client/src/__tests__/index-policy-reuse.test.ts index 4f2d5358e7..f40bb80ad4 100644 --- a/packages/daemon-client/src/__tests__/index-policy-reuse.test.ts +++ b/packages/daemon-client/src/__tests__/index-policy-reuse.test.ts @@ -21,7 +21,7 @@ import { } from '@agent-device/test-kit'; import { loadDaemonPolicy } from '@agent-device/daemon-contracts/daemon-policy-file'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { sendToDaemon } from '../index.ts'; +import { createDaemonTransport, sendToDaemon } from '../index.ts'; // ADR 0029: a caller that names a daemon policy must not use a daemon that enforces another one. @@ -31,6 +31,28 @@ afterEach(() => { mockSpawnDaemon.mockReset(); }); +test('a host-bound transport resolves policy from its host environment and working directory', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const fixture = await startReusableDaemon({ policyDigest: undefined }); + try { + vi.stubEnv('AGENT_DEVICE_DAEMON_POLICY', ''); + const host = { env: { AGENT_DEVICE_DAEMON_POLICY: 'policy.json' }, cwd: fixture.stateDir }; + await assert.rejects( + createDaemonTransport(host)({ + session: 'default', + command: 'devices', + positionals: [], + flags: { stateDir: fixture.stateDir }, + }), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'DAEMON_POLICY_MISMATCH', + ); + assert.deepEqual(fixture.seenPaths, ['GET /health']); + } finally { + await fixture.close(); + } +}); + test('a caller naming a policy refuses a running daemon without that policy', async (t) => { if (!(await supportsLoopbackBind())) { t.skip('loopback listeners are not permitted in this environment'); diff --git a/packages/plugins/src/credential-fingerprint.ts b/packages/plugins/src/credential-fingerprint.ts index 0070eca543..9901fdbd3d 100644 --- a/packages/plugins/src/credential-fingerprint.ts +++ b/packages/plugins/src/credential-fingerprint.ts @@ -19,7 +19,7 @@ export function providerCredentialFingerprint( leaseBackend?: string, inventory?: readonly PluginInventoryEntry[], ): string | undefined { - const variables = declaredCredentialVariables(provider, env, leaseBackend, inventory); + const variables = declaredCredentialVariables(provider.trim(), env, leaseBackend, inventory); // Whitespace-only counts as unset and other values are kept as is, as the provider's requireEnv does. return variables ? digest( diff --git a/scripts/layering/root-module-zones.test.ts b/scripts/layering/root-module-zones.test.ts index 6f45fb9ce1..0853d53d25 100644 --- a/scripts/layering/root-module-zones.test.ts +++ b/scripts/layering/root-module-zones.test.ts @@ -48,7 +48,7 @@ test('R5 ranks a root module by its declared zone, so no lower zone reaches the ], [ 'src/agent-device-client.ts', - "import { createRequestGuard } from './daemon-client/daemon-client-transport.ts';", + "import { createRequestGuard } from '../packages/daemon-client/src/daemon-client-transport.ts';", ], [ 'packages/daemon-client/src/daemon-client-transport.ts', @@ -57,6 +57,14 @@ test('R5 ranks a root module by its declared zone, so no lower zone reaches the ]), ); + assert.ok( + edges.some( + (edge) => + edge.file === 'src/agent-device-client.ts' && + edge.target === 'packages/daemon-client/src/daemon-client-transport.ts', + ), + ); + // The published SDK entries rank above the typed client they publish; the client zone does not. assert.deepEqual(collectBackEdges(edges), { 'client -> daemon-client': ['src/client/lease.ts -> src/agent-device-client.ts'], diff --git a/scripts/lib/recorded-daemon.test.ts b/scripts/lib/recorded-daemon.test.ts index f19b5bd12a..032618c70a 100644 --- a/scripts/lib/recorded-daemon.test.ts +++ b/scripts/lib/recorded-daemon.test.ts @@ -11,7 +11,8 @@ import { mkdtempForTestSync, supportsLoopbackBind, } from '@agent-device/test-kit'; -import { sendToRecordedDaemon } from './recorded-daemon.ts'; +import { tryAcquireProcessLock } from '@agent-device/host-kit/file'; +import { readRecordedDaemon, sendToRecordedDaemon } from './recorded-daemon.ts'; // `sendToRecordedDaemon` resolves a recorded daemon and sends through the client transport. It must // keep every guarantee of that transport and none of its lifecycle: nothing is started, replaced or @@ -82,7 +83,7 @@ test('an already-aborted signal dispatches nothing', async (t) => { await assert.rejects( send(daemon.stateDir, { signal: controller.signal }), - isRequestCanceledError, + (error: unknown) => isRequestCanceledError(error) && error.details?.dispatched === 'no', ); assert.equal(daemon.connections(), 0); @@ -124,3 +125,85 @@ test('a state directory that records no daemon is reported, not started', async assert.equal(fs.existsSync(resolveDaemonPaths(stateDir).infoPath), false); }); + +test('record admission requires a token and a usable endpoint', () => { + const dir = mkdtempForTestSync('record-validation-'); + const file = resolveDaemonPaths(dir).infoPath; + for (const [record, valid] of [ + [{ pid: 1, token: '', port: 1 }, false], + [{ pid: 1, token: 'x' }, false], + [{ pid: 1, token: 'x', port: -1 }, false], + [{ pid: 1, token: 'x', httpPort: 65536 }, false], + [{ pid: 1, token: 'x', port: 1234, transport: 'socket' }, true], + [{ pid: 1, token: 'x', httpPort: 1234, transport: 'http' }, true], + ] as const) { + fs.writeFileSync(file, JSON.stringify(record)); + assert.equal(readRecordedDaemon(file) !== null, valid, JSON.stringify(record)); + } +}); + +test('recorded daemon requires a matching held registration claim', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const daemon = await recordedSocketDaemon(() => JSON.stringify({ ok: true, data: {} })); + const owner = { pid: process.pid, startTime: 'recorded-start', acquiredAtMs: Date.now() }; + const lock = tryAcquireProcessLock({ lockDirPath: daemon.paths.lockPath, owner }); + assert.equal(lock.status, 'acquired'); + if (lock.status !== 'acquired') return; + try { + await assert.rejects( + send(daemon.stateDir), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'daemon_registration_unproven', + ); + const record = JSON.parse(fs.readFileSync(daemon.paths.infoPath, 'utf8')); + fs.writeFileSync( + daemon.paths.infoPath, + JSON.stringify({ ...record, processStartTime: 'other' }), + ); + await assert.rejects( + send(daemon.stateDir), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'daemon_registration_unproven', + ); + fs.writeFileSync( + daemon.paths.infoPath, + JSON.stringify({ ...record, processStartTime: owner.startTime }), + ); + assert.equal((await send(daemon.stateDir)).ok, true); + } finally { + await lock.acquisition.release(); + } +}); + +test('dual transport tries its recorded socket before HTTP', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const daemon = await recordedSocketDaemon(() => + JSON.stringify({ ok: true, data: { via: 'socket' } }), + ); + let httpConnections = 0; + const http = net.createServer((socket) => { + httpConnections++; + socket.destroy(); + }); + servers.push(http); + const httpPort = await listenOnLoopback(http); + const record = JSON.parse(fs.readFileSync(daemon.paths.infoPath, 'utf8')); + fs.writeFileSync( + daemon.paths.infoPath, + JSON.stringify({ ...record, httpPort, transport: 'dual' }), + ); + assert.deepEqual(await send(daemon.stateDir), { ok: true, data: { via: 'socket' } }); + assert.equal(httpConnections, 0); +}); + +test('a publishing registration cannot be used', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); + const daemon = await recordedSocketDaemon(() => JSON.stringify({ ok: true, data: {} })); + fs.writeFileSync(daemon.paths.lockPath.replace(/\.lock$/, '.reclaim.lock'), ''); + await assert.rejects( + send(daemon.stateDir), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'daemon_registration_unproven', + ); + assert.equal(daemon.connections(), 0); +}); diff --git a/scripts/lib/recorded-daemon.ts b/scripts/lib/recorded-daemon.ts index 19d4fd93a7..111de58c71 100644 --- a/scripts/lib/recorded-daemon.ts +++ b/scripts/lib/recorded-daemon.ts @@ -2,8 +2,13 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; import { sendDaemonRequest } from '@agent-device/daemon-client'; import { readDaemonRecord, + readRecordedDaemonTransport, + type DaemonServerMode, resolveDaemonPaths, } from '@agent-device/daemon-contracts/daemon-resolution'; +import { processLockHoldsDaemonIdentity } from '@agent-device/daemon-contracts/daemon-registration'; +import { inspectProcessLock } from '@agent-device/host-kit/file'; +import { AppError } from '@agent-device/kernel/errors'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; @@ -16,6 +21,7 @@ export type RecordedDaemon = Readonly<{ token: string; port?: number; httpPort?: number; + transport?: DaemonServerMode; processStartTime?: string; }>; @@ -23,12 +29,24 @@ export function readRecordedDaemon(infoPath: string): RecordedDaemon | null { const read = readDaemonRecord(infoPath); if (read.status !== 'present') return null; const { pid, token, port, httpPort, processStartTime } = read.record; - if (typeof pid !== 'number' || typeof token !== 'string') return null; + const validPort = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535; + if ( + typeof pid !== 'number' || + !Number.isInteger(pid) || + pid <= 0 || + typeof token !== 'string' || + token.length === 0 || + (!validPort(port) && !validPort(httpPort)) + ) + return null; + const transport = readRecordedDaemonTransport(read.record.transport); return { pid, token, - ...(typeof port === 'number' ? { port } : {}), - ...(typeof httpPort === 'number' ? { httpPort } : {}), + ...(validPort(port) ? { port } : {}), + ...(validPort(httpPort) ? { httpPort } : {}), + ...(transport ? { transport } : {}), ...(typeof processStartTime === 'string' ? { processStartTime } : {}), }; } @@ -49,6 +67,20 @@ export async function sendToRecordedDaemon( }); const info = readRecordedDaemon(paths.infoPath); if (!info) throw new Error(`No daemon is running for state dir ${paths.baseDir}.`); + const inspection = inspectProcessLock(paths.lockPath); + if ( + inspection.state !== 'absent' && + !processLockHoldsDaemonIdentity(inspection, { + pid: info.pid, + startTime: info.processStartTime ?? null, + }) + ) { + throw new AppError('COMMAND_FAILED', 'Daemon registration ownership could not be verified.', { + reason: 'daemon_registration_unproven', + inspection, + stateDir: paths.baseDir, + }); + } return await sendDaemonRequest( info, { ...request, token: info.token }, diff --git a/src/__tests__/test-utils/registered-http-daemon.ts b/src/__tests__/test-utils/registered-http-daemon.ts index 40af5fc78f..afb5662608 100644 --- a/src/__tests__/test-utils/registered-http-daemon.ts +++ b/src/__tests__/test-utils/registered-http-daemon.ts @@ -54,8 +54,17 @@ export function stateDirAndPaths(): { stateDir: string; paths: DaemonPaths } { export async function startRegisteredHttpDaemon(): Promise { const { stateDir, paths } = stateDirAndPaths(); const stub = await startHttpDaemonFixture({ devices: [] }); - const child = spawnRegisteredDaemonFixture(paths, await registrationFor(stub.port), undefined); - await waitForRegisteredDaemonFixture(paths, child); + try { + const child = spawnRegisteredDaemonFixture(paths, await registrationFor(stub.port), undefined); + await waitForRegisteredDaemonFixture(paths, child); + } catch (error) { + try { + await finishRegisteredDaemonFixture(stateDir); + } finally { + await closeLoopbackServer(stub.server); + } + throw error; + } return { stateDir, paths, @@ -77,7 +86,13 @@ export async function launchesFreshHttpDaemon( paths: DaemonPaths, ): Promise<{ stub: HttpDaemonFixture; commands: () => string[]; close: () => Promise }> { const stub = await startHttpDaemonFixture({ devices: [] }); - const fields = await registrationFor(stub.port); + let fields: DaemonRegistrationFields; + try { + fields = await registrationFor(stub.port); + } catch (error) { + await closeLoopbackServer(stub.server); + throw error; + } spawn.mockImplementation((_command, _args, options) => spawnRegisteredDaemonFixture(paths, fields, options), ); diff --git a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts index 70ea17c170..c8359e2276 100644 --- a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts +++ b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts @@ -11,6 +11,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ runCmdDetachedMonitored: vi.fn(), })); import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; +import * as daemonClient from '@agent-device/daemon-client'; import * as hostProcess from '@agent-device/host-kit/process'; import { createCommandToolExecutor } from '../command-tools.ts'; @@ -35,7 +36,13 @@ test('each MCP tool call ensures the daemon, then attaches to it', async (t) => const daemon = await launchesFreshHttpDaemon(spawn, paths); close = daemon.close; vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const capture = vi.spyOn(hostProcess, 'captureHostEnvironment'); + vi.stubEnv('AGENT_DEVICE_HOST_CANARY', 'process-host'); + const captureHost = hostProcess.captureHostEnvironment; + const capture = vi.spyOn(hostProcess, 'captureHostEnvironment').mockImplementation((source) => { + const host = captureHost(source); + return { ...host, env: { ...host.env, AGENT_DEVICE_HOST_CANARY: 'captured-host' } }; + }); + const factory = vi.spyOn(daemonClient, 'createEnsuredDaemonTransport'); const executor = createCommandToolExecutor(); const first = await executor.execute('devices', {}); @@ -44,6 +51,11 @@ test('each MCP tool call ensures the daemon, then attaches to it', async (t) => assert.equal(first.isError, false, JSON.stringify(first.structuredContent)); assert.equal(second.isError, false, JSON.stringify(second.structuredContent)); assert.equal(spawn.mock.calls.length, 1, 'the second call reuses the daemon the first started'); + assert.equal(spawn.mock.calls[0]?.[2]?.env?.AGENT_DEVICE_HOST_CANARY, 'captured-host'); assert.deepEqual(daemon.commands(), ['devices', 'devices']); assert.equal(capture.mock.calls.length, 2, 'one host capture per tool call'); + assert.equal(factory.mock.calls.length, 2); + for (let i = 0; i < 2; i++) { + assert.equal(factory.mock.calls[i]?.[0]?.host, capture.mock.results[i]?.value); + } }); diff --git a/src/provider-credential-fingerprint.test.ts b/src/provider-credential-fingerprint.test.ts index 9b269ae8e2..6ef316818d 100644 --- a/src/provider-credential-fingerprint.test.ts +++ b/src/provider-credential-fingerprint.test.ts @@ -34,6 +34,13 @@ test.for([ expect(providerCredentialFingerprint(provider, after)).not.toBe(fingerprint); }); +test('provider whitespace does not bypass credential fingerprinting', () => { + const first = providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: 'first' }); + expect(first).toBeDefined(); + expect(providerCredentialFingerprint(' limrun ', { LIMRUN_API_KEY: 'first' })).toBe(first); + expect(providerCredentialFingerprint(' limrun ', { LIMRUN_API_KEY: 'second' })).not.toBe(first); +}); + test('a fingerprint ignores variables the provider does not read and blank values', () => { const fingerprint = providerCredentialFingerprint('browserstack', BROWSERSTACK_ENV); expect( diff --git a/test/wire-compat/README.md b/test/wire-compat/README.md index 6ade952faa..4eb3603c8e 100644 --- a/test/wire-compat/README.md +++ b/test/wire-compat/README.md @@ -14,7 +14,7 @@ ledger honest from opposite sides. ## Why this boundary and not the local daemon -A local daemon cannot skew. `isReusableDaemonInfo` (`packages/daemon-client/src/daemon-client-lifecycle.ts`) +A local daemon cannot skew. `readReusableLocalDaemon` (`packages/daemon-client/src/daemon-client-startup.ts`) takes over any daemon whose package version differs from the client's, so the two are always the same build. From 9d60eec2970d637a08439a3e51285896db747443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 17:04:42 +0100 Subject: [PATCH 08/17] test(daemon-client): keep fixture startup errors when teardown fails; reflow package-move comments Co-Authored-By: Claude Sonnet 5.5 --- packages/daemon-client/src/daemon-client-timeout.ts | 5 +++-- scripts/layering/package-entry-surface.test.ts | 2 +- src/__tests__/test-utils/registered-http-daemon.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/daemon-client/src/daemon-client-timeout.ts b/packages/daemon-client/src/daemon-client-timeout.ts index f882aaf4d5..2db4534ef7 100644 --- a/packages/daemon-client/src/daemon-client-timeout.ts +++ b/packages/daemon-client/src/daemon-client-timeout.ts @@ -154,8 +154,9 @@ function isAffirmativelyApplePlatform(platform: PlatformSelector | undefined): b // Exported for direct hint-matrix testing: handleRequestTimeout also runs the real liveness probe // and process-kill side effects, so its wording is verified through this pure sub-function // (see also the production-seam route tests in -// packages/daemon-client/src/__tests__/daemon-client-timeout-route.test.ts, which prove the liveness-gated -// recovery this route performs — the side of the contract a pure formatter test cannot reach). +// packages/daemon-client/src/__tests__/daemon-client-timeout-route.test.ts, which prove the +// liveness-gated recovery this route performs — the side of the contract a pure formatter test +// cannot reach). export function resolveRequestTimeoutHint(params: { remote: boolean; resetDaemon: boolean; diff --git a/scripts/layering/package-entry-surface.test.ts b/scripts/layering/package-entry-surface.test.ts index 7d0b37d1d9..b7388fcbe7 100644 --- a/scripts/layering/package-entry-surface.test.ts +++ b/scripts/layering/package-entry-surface.test.ts @@ -1,4 +1,4 @@ -// The shape of the pinned package entries (R83): test-kit, daemon and daemon-client each publish a +// The shape of the pinned package entries (R83): test-kit, daemon, daemon-client and apple-runner publish a // single entry that names its exports, and test-kit also runs the shared isolation walk // (package-isolation.test.ts) under its policy. diff --git a/src/__tests__/test-utils/registered-http-daemon.ts b/src/__tests__/test-utils/registered-http-daemon.ts index afb5662608..7853c0c4a3 100644 --- a/src/__tests__/test-utils/registered-http-daemon.ts +++ b/src/__tests__/test-utils/registered-http-daemon.ts @@ -58,11 +58,11 @@ export async function startRegisteredHttpDaemon(): Promise const child = spawnRegisteredDaemonFixture(paths, await registrationFor(stub.port), undefined); await waitForRegisteredDaemonFixture(paths, child); } catch (error) { - try { - await finishRegisteredDaemonFixture(stateDir); - } finally { - await closeLoopbackServer(stub.server); - } + // Both teardown steps run, and neither failure replaces the startup error. + await Promise.allSettled([ + finishRegisteredDaemonFixture(stateDir), + closeLoopbackServer(stub.server), + ]); throw error; } return { @@ -90,7 +90,7 @@ export async function launchesFreshHttpDaemon( try { fields = await registrationFor(stub.port); } catch (error) { - await closeLoopbackServer(stub.server); + await Promise.allSettled([closeLoopbackServer(stub.server)]); throw error; } spawn.mockImplementation((_command, _args, options) => From b012cdea53ef5db0c57a4e8a366cee88ba3843b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 19:22:18 +0100 Subject: [PATCH 09/17] style(plugins): group the manifest export after imports --- packages/plugins/src/store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugins/src/store.ts b/packages/plugins/src/store.ts index 391a53d89c..db1dcd0af3 100644 --- a/packages/plugins/src/store.ts +++ b/packages/plugins/src/store.ts @@ -7,7 +7,6 @@ import { isVersionSelector, type PluginSelection, } from './store-legacy.ts'; -export { legacyStoreManifests }; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -25,6 +24,8 @@ import type { PluginPackageEntry } from './discovery.ts'; import { ROUTING_PROVIDER_IDENTITIES, type PluginManifest } from './manifest.ts'; import type { PluginCandidate } from './registry.ts'; +export { legacyStoreManifests }; + type StoreInstallation = { name: string; version: string; From c2b0a2d22cd4359fe6e7db3c3c6042435c615c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 20:28:37 +0100 Subject: [PATCH 10/17] fix(daemon-client): clarify tooling errors and client API contracts --- .../src/daemon-client-proxy-upstream.ts | 14 ++++++++++++-- packages/daemon-client/src/index.ts | 17 ++++++++++++----- scripts/lib/recorded-daemon.test.ts | 5 ++++- scripts/lib/recorded-daemon.ts | 14 ++++++++++---- test/wire-compat/ledger.json | 2 +- 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/daemon-client/src/daemon-client-proxy-upstream.ts b/packages/daemon-client/src/daemon-client-proxy-upstream.ts index d24c506af5..49dce4b81c 100644 --- a/packages/daemon-client/src/daemon-client-proxy-upstream.ts +++ b/packages/daemon-client/src/daemon-client-proxy-upstream.ts @@ -1,10 +1,20 @@ import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { ensureManagedDaemon, resolveClientSettings } from './daemon-client-lifecycle.ts'; -export async function ensureProxyUpstreamDaemon(options: { +export type ProxyUpstreamDaemonOptions = { stateDir?: string; host: HostEnvironment; -}): Promise<{ httpPort: number | undefined; token: string; stateDir: string }> { +}; + +export type ProxyUpstreamDaemon = { + httpPort: number | undefined; + token: string; + stateDir: string; +}; + +export async function ensureProxyUpstreamDaemon( + options: ProxyUpstreamDaemonOptions, +): Promise { const settings = resolveClientSettings( { session: 'default', diff --git a/packages/daemon-client/src/index.ts b/packages/daemon-client/src/index.ts index 84b5a4930b..2343de56ac 100644 --- a/packages/daemon-client/src/index.ts +++ b/packages/daemon-client/src/index.ts @@ -22,6 +22,10 @@ import type { EnsuringTransportOptions } from './daemon-client-ensuring.ts'; import { createRequestGuard, sendRequest as sendDaemonRequest } from './daemon-client-transport.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; import type { LocalDaemonCodeIdentity } from './daemon-launch-spec.ts'; +import type { + ProxyUpstreamDaemon, + ProxyUpstreamDaemonOptions, +} from './daemon-client-proxy-upstream.ts'; import type { DaemonRequest, DaemonResponse, @@ -396,6 +400,7 @@ async function protectArtifactUploadWithLeaseBeats( }); } +/** Guards a client call against cancellation before dispatch and during connection-free phases. */ export { createRequestGuard }; /** @@ -425,17 +430,19 @@ export async function stopRecordedDaemon( return await stop(record, target); } -/** What this tree would launch a daemon from, loaded on first call for the same reason. */ +/** + * What this tree would launch a daemon from. The package entry loads the transport closure; + * this helper loads the launch identity implementation on first call. + */ export async function resolveLocalDaemonCodeIdentity(): Promise { const { resolveLocalDaemonCodeIdentity: resolve } = await import('./daemon-launch-spec.ts'); return await resolve(); } /** The managed local HTTP daemon `agent-device proxy` forwards to, started if none is usable. */ -export async function ensureProxyUpstreamDaemon(options: { - stateDir?: string; - host: HostEnvironment; -}): Promise<{ httpPort: number | undefined; token: string; stateDir: string }> { +export async function ensureProxyUpstreamDaemon( + options: ProxyUpstreamDaemonOptions, +): Promise { const { ensureProxyUpstreamDaemon: ensure } = await import('./daemon-client-proxy-upstream.ts'); return await ensure(options); } diff --git a/scripts/lib/recorded-daemon.test.ts b/scripts/lib/recorded-daemon.test.ts index 032618c70a..5792822f7e 100644 --- a/scripts/lib/recorded-daemon.test.ts +++ b/scripts/lib/recorded-daemon.test.ts @@ -120,7 +120,10 @@ test('a state directory that records no daemon is reported, not started', async await assert.rejects( send(stateDir), - (error: unknown) => error instanceof Error && /No daemon is running/.test(error.message), + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'daemon_not_running' && + error.details?.stateDir === stateDir, ); assert.equal(fs.existsSync(resolveDaemonPaths(stateDir).infoPath), false); diff --git a/scripts/lib/recorded-daemon.ts b/scripts/lib/recorded-daemon.ts index 111de58c71..c842c9a158 100644 --- a/scripts/lib/recorded-daemon.ts +++ b/scripts/lib/recorded-daemon.ts @@ -53,9 +53,11 @@ export function readRecordedDaemon(infoPath: string): RecordedDaemon | null { /** * Sends one request to the daemon a state directory records, through the client transport. The - * tooling that calls this drives a daemon another build started, so it resolves the record and - * nothing else: no daemon is started, replaced or compatibility-checked, and a timeout never - * retires the daemon (`timeoutRecovery: 'none'`). + * tooling that calls this drives a daemon another build started. A held registration lock must + * match the recorded PID and process start time; a missing start time, foreign owner or publication + * in progress rejects with `daemon_registration_unproven`. With no lock, the record is sent to + * without ownership proof. No daemon is started, replaced or compatibility-checked, and a timeout + * never retires it (`timeoutRecovery: 'none'`). */ export async function sendToRecordedDaemon( request: Omit, @@ -66,7 +68,11 @@ export async function sendToRecordedDaemon( cwd: options.host.cwd, }); const info = readRecordedDaemon(paths.infoPath); - if (!info) throw new Error(`No daemon is running for state dir ${paths.baseDir}.`); + if (!info) + throw new AppError('COMMAND_FAILED', `No daemon is running for state dir ${paths.baseDir}.`, { + reason: 'daemon_not_running', + stateDir: paths.baseDir, + }); const inspection = inspectProcessLock(paths.lockPath); if ( inspection.state !== 'absent' && diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index e938ffa059..7eb63460e7 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -223,7 +223,7 @@ { "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#buildHttpRpcPayload", "digest": "sha256:8978ef89d3af866bd94fec52c586a0cbf3217ee9e60952fbc826e8208e5fa158", - "rationale": "Adds the optional providerCredentialFingerprint parameter to agent_device.lease.allocate, forwarded only when the request meta carries one, which the client sets only for a local daemon. A released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request without a fingerprint keeps the previous payload byte-for-byte. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. It now composes the envelope through buildDaemonRpcRequest in @agent-device/contracts/lease-scope, which takes the request id from its caller; the client supplies the id it supplied before (the request meta id, else a fresh one), so the method, params and id of every request are byte for byte what the client sent." + "rationale": "Adds the optional providerCredentialFingerprint parameter to agent_device.lease.allocate, forwarded only when the request meta carries one, which the client sets only for a local daemon. A released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request without a fingerprint keeps the previous payload byte-for-byte. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. It now composes the envelope through buildDaemonRpcRequest in @agent-device/contracts/lease-scope, which takes the request id from its caller; the client supplies the id it supplied before (the request meta id, else a fresh one), so envelope construction preserves method, params and id. Separately, credential fingerprint lookup now trims the provider name on both client and daemon. A whitespace-spelled provider can therefore add a fingerprint that the released client omitted; this changes fingerprint presence, not the envelope or optional parameter contract." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest", From b36fe0063f65bc6d13544fd87ae3dfd23e24245e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 20:57:51 +0100 Subject: [PATCH 11/17] test(daemon-client): retain one entry-target check per package after rebase --- scripts/layering/package-entry-surface.test.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/scripts/layering/package-entry-surface.test.ts b/scripts/layering/package-entry-surface.test.ts index b7388fcbe7..c89ef68318 100644 --- a/scripts/layering/package-entry-surface.test.ts +++ b/scripts/layering/package-entry-surface.test.ts @@ -159,20 +159,6 @@ test('the apple-runner entry names its exports: export * is refused', () => { } }); -test('every daemon condition of "." points at src/index.ts', () => { - for (const exports of [ - { '.': entry('./src/other.ts') }, - { '.': { types: './src/index.ts', default: './src/other.ts' } }, - { '.': './src/other.ts' }, - ]) { - const found = check({ daemon: { exports } }); - assert.equal(found.length, 1, JSON.stringify(exports)); - assert.match(found[0]!.file, /packages\/daemon\/package\.json/); - assert.match(found[0]!.message, /every condition of it is \.\/src\/index\.ts/); - } - assert.deepEqual(check({ daemon: { exports: { '.': './src/index.ts' } } }), []); -}); - test('command-surface exports exactly "." and nothing else', () => { for (const exports of [ { '.': entry('./src/index.ts'), './family/registry': entry('./src/family/registry.ts') }, From cf7f4f72e4be127551e40776108467852960eb69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 21:20:12 +0100 Subject: [PATCH 12/17] test(daemon-client): isolate build output and model recorder shutdown --- .../daemon-command-policy.test.ts | 26 ++++++++++++++----- .../owner-files-no-leak.test.ts | 12 +++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/test/integration/provider-scenarios/daemon-command-policy.test.ts b/test/integration/provider-scenarios/daemon-command-policy.test.ts index 91bfa8a53d..e852d59560 100644 --- a/test/integration/provider-scenarios/daemon-command-policy.test.ts +++ b/test/integration/provider-scenarios/daemon-command-policy.test.ts @@ -8,6 +8,7 @@ import { createProviderScenarioHarness, withProviderScenarioResource } from './h test('Provider-backed integration daemon command policies gate admission and provider scoping', async () => { const adbCalls: string[][] = []; let activeRecordingPath: string | undefined; + let recordingRunning = false; const inactiveLeaseMeta = { tenantId: 'tenant-a', runId: 'run-a', @@ -19,8 +20,14 @@ test('Provider-backed integration daemon command policies gate admission and pro adbCalls.push([...args]); const command = args.join(' '); const startedPath = screenRecordingPath(command); - if (startedPath) activeRecordingPath = startedPath; - return androidAdbResult(args, activeRecordingPath); + if (startedPath) { + activeRecordingPath = startedPath; + recordingRunning = true; + } + if (command === 'shell kill -2 4321' || command === 'shell kill -9 4321') { + recordingRunning = false; + } + return androidAdbResult(args, activeRecordingPath, recordingRunning); }, }; @@ -74,17 +81,20 @@ test('Provider-backed integration daemon command policies gate admission and pro ); }, ); + assert.ok(adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321')); + assert.ok(!adbCalls.some((args) => args.join(' ') === 'shell kill -9 4321')); }, 15_000); function androidAdbResult( args: readonly string[], activeRecordingPath: string | undefined, + recordingRunning: boolean, ): AdbResult { const command = args.join(' '); return ( deviceStatusResponse(command) ?? recordingManifestResponse(command) ?? - screenRecordingProcessResponse(command, activeRecordingPath) ?? + screenRecordingProcessResponse(command, activeRecordingPath, recordingRunning) ?? recordingArtifactResponse(command, activeRecordingPath) ?? adbResult() ); @@ -126,18 +136,22 @@ function recordingManifestResponse(command: string): AdbResult | undefined { function screenRecordingProcessResponse( command: string, activeRecordingPath: string | undefined, + recordingRunning: boolean, ): AdbResult | undefined { + if (command === 'shell test -d /proc/4321') { + return adbResult('', recordingRunning ? 0 : 1); + } if (isAndroidScreenrecordStartCommand(command)) { return adbResult('4321\n'); } if (command === 'shell cat /proc/4321/stat') { - return adbResult(processStat(4321, '424242')); + return recordingRunning ? adbResult(processStat(4321, '424242')) : adbResult('', 1); } - if (command === 'shell cat /proc/4321/cmdline' && activeRecordingPath) { + if (command === 'shell cat /proc/4321/cmdline' && activeRecordingPath && recordingRunning) { return adbResult(`screenrecord\u0000--bit-rate\u00008000000\u0000${activeRecordingPath}\u0000`); } if (command === 'shell ps -o pid= -p 4321') { - return adbResult('4321\n'); + return adbResult(recordingRunning ? '4321\n' : ''); } return undefined; } diff --git a/test/output-economy/owner-files-no-leak.test.ts b/test/output-economy/owner-files-no-leak.test.ts index 38824a9734..efbef621d6 100644 --- a/test/output-economy/owner-files-no-leak.test.ts +++ b/test/output-economy/owner-files-no-leak.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; import fs from 'node:fs'; import path from 'node:path'; import { runCmdSync } from '@agent-device/host-kit/command'; @@ -8,7 +9,6 @@ import { } from '@agent-device/command-registry/owner-files'; const repoRoot = path.resolve(import.meta.dirname, '../..'); -const distPath = path.join(repoRoot, 'dist/src'); function collectJsFiles(dir: string): string[] { const entries = fs.readdirSync(dir, { recursive: true, encoding: 'utf8' }); @@ -17,7 +17,9 @@ function collectJsFiles(dir: string): string[] { describe('owner-file metadata', () => { test('does not leak into production bundles after a clean build', { timeout: 30_000 }, () => { - fs.rmSync(path.join(repoRoot, 'dist'), { recursive: true, force: true }); + const sharedDistPath = path.join(repoRoot, 'dist'); + const sharedDistModified = fs.statSync(sharedDistPath, { throwIfNoEntry: false })?.mtimeMs; + const distPath = path.join(mkdtempForTestSync('owner-files-no-leak-'), 'dist'); const build = runCmdSync( 'node', @@ -26,10 +28,16 @@ describe('owner-file metadata', () => { 'node_modules/tsdown/dist/run.mjs', '--config-loader', 'native', + '--out-dir', + distPath, ], { cwd: repoRoot }, ); expect(build.exitCode, build.stderr).toBe(0); + expect( + fs.statSync(sharedDistPath, { throwIfNoEntry: false })?.mtimeMs, + 'the metadata build must preserve the shared dist used by other tests', + ).toBe(sharedDistModified); expect( fs.existsSync(path.join(distPath, 'sdk-selectors.js')), From 505d5f9041f0986a8330fe0ea66e9f699df8ceec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 31 Oct 2026 08:06:31 +0100 Subject: [PATCH 13/17] test(daemon-client): pin whitespace provider fingerprint parity --- .../plugins/src/credential-fingerprint.ts | 3 ++- ...daemon-client-provider-credentials.test.ts | 21 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/plugins/src/credential-fingerprint.ts b/packages/plugins/src/credential-fingerprint.ts index 9901fdbd3d..f2797cd046 100644 --- a/packages/plugins/src/credential-fingerprint.ts +++ b/packages/plugins/src/credential-fingerprint.ts @@ -11,7 +11,8 @@ type CredentialValues = Readonly>; /** * A versioned, non-reversible digest of the credentials a lease on `leaseBackend` reads from `env`, * or undefined when `env` holds none of them or the provider's credentials do not come from the - * environment. Variable names come from the daemon's plugin inventory, then from v1 manifests. + * environment. Provider names are trimmed before lookup. Variable names come from the daemon's + * plugin inventory, then from v1 manifests. */ export function providerCredentialFingerprint( provider: string, diff --git a/src/__tests__/daemon-client-provider-credentials.test.ts b/src/__tests__/daemon-client-provider-credentials.test.ts index f0a75a28a7..fd7263109c 100644 --- a/src/__tests__/daemon-client-provider-credentials.test.ts +++ b/src/__tests__/daemon-client-provider-credentials.test.ts @@ -16,12 +16,14 @@ import { providerCredentialFingerprint as fingerprintFor } from '@agent-device/p import { builtinPluginInventory } from './test-utils/builtin-plugin-inventory.ts'; import type { EnvMap } from '@agent-device/kernel/source-value'; import { LIMRUN_CREDENTIAL_VARIABLES } from '../provider-limrun-credentials.ts'; +import { readDaemonProviderCredentials } from '../provider-credential-fingerprint.ts'; const inventory = await builtinPluginInventory(); const providerCredentialFingerprint = (provider: string, env: EnvMap, leaseBackend?: string) => fingerprintFor(provider, env, leaseBackend, inventory); const API_KEY = 'lim-secret-key'; +const API_KEY_FINGERPRINT = 'v1:0772697674314a49'; const ANDROID_ATTACH = { LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', LIM_ANDROID_INSTANCE_TOKEN: 'android-token', @@ -47,7 +49,11 @@ test.for(['socket', 'http'] as const)( vi.stubEnv('AGENT_DEVICE_DAEMON_AUTH_TOKEN', undefined); const stateDir = mkdtempForTestSync('agent-device-provider-credentials-daemon-'); const daemon = await startLocalDaemon(stateDir, transport); - const send = (command: string, leaseBackend?: 'ios-instance' | 'android-instance') => + const send = ( + command: string, + leaseBackend?: 'ios-instance' | 'android-instance', + provider = 'limrun', + ) => sendToDaemon({ session: 'default', command, @@ -55,7 +61,7 @@ test.for(['socket', 'http'] as const)( flags: { stateDir, daemonTransport: transport }, meta: { requestId: `req-${command}`, - leaseProvider: 'limrun', + leaseProvider: provider, tenantId: 'tenant-a', ...(leaseBackend ? { leaseBackend } : {}), }, @@ -65,6 +71,7 @@ test.for(['socket', 'http'] as const)( try { vi.stubEnv('LIMRUN_API_KEY', API_KEY); await send('lease_allocate'); + await send('lease_allocate', undefined, ' limrun '); await send('devices'); vi.stubEnv('LIMRUN_API_KEY', undefined); await send('lease_allocate'); @@ -78,12 +85,20 @@ test.for(['socket', 'http'] as const)( const iosFingerprint = providerCredentialFingerprint('limrun', attachedEnv, 'ios-instance'); expect(daemon.bodies.map(readFingerprint)).toEqual([ - providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: API_KEY }), + API_KEY_FINGERPRINT, + API_KEY_FINGERPRINT, undefined, undefined, iosFingerprint, providerCredentialFingerprint('limrun', attachedEnv, 'android-instance'), ]); + const daemonFingerprint = readDaemonProviderCredentials( + { LIMRUN_API_KEY: API_KEY }, + stateDir, + inventory, + ).fingerprint(' limrun '); + expect(daemonFingerprint).toBe(API_KEY_FINGERPRINT); + expect(readFingerprint(daemon.bodies[1]!)).toBe(daemonFingerprint); expect(iosFingerprint).not.toBe(providerCredentialFingerprint('limrun', attachedEnv)); for (const body of daemon.bodies) expect(body).not.toContain(API_KEY); }, From f4d6ef2b551f4af606602cc16e7229f10acaf0a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 31 Oct 2026 08:39:21 +0100 Subject: [PATCH 14/17] fix(daemon-client): share registered daemon admission with tooling --- .../daemon-client-lifecycle-embedded.test.ts | 26 ++--- .../__tests__/daemon-client-metadata.test.ts | 42 +++++++- .../src/__tests__/index-host.test.ts | 4 +- .../daemon-client/src/daemon-client-attach.ts | 3 +- .../daemon-client/src/daemon-client-ensure.ts | 7 +- .../src/daemon-client-metadata.ts | 101 ++++++++++++++++-- .../src/daemon-client-startup.ts | 59 +++------- .../daemon-client/src/daemon-launch-spec.ts | 15 +-- .../src/daemon-lifecycle-errors.ts | 3 +- packages/daemon-client/src/index.ts | 5 +- .../ios-snapshot-benchmark/cell-admission.ts | 22 ++-- .../ios-snapshot-benchmark/local-runner.ts | 2 +- scripts/lib/recorded-daemon.test.ts | 30 ++---- scripts/lib/recorded-daemon.ts | 81 +------------- .../live-observability-scenario.ts | 4 +- 15 files changed, 206 insertions(+), 198 deletions(-) diff --git a/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts b/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts index 44ac3f174d..4c94418969 100644 --- a/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts @@ -12,7 +12,8 @@ import { skipWhenLoopbackUnavailable, listenOnLoopback, } from '@agent-device/test-kit'; -import * as startup from '../daemon-client-startup.ts'; +import * as metadata from '../daemon-client-metadata.ts'; +import { retireDaemonForTakeover } 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'; @@ -33,7 +34,6 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; import { createDaemonTransport, type sendToDaemon } from '../index.ts'; import { resolveClientSettings, resolveTimeoutRecovery } from '../daemon-client-lifecycle.ts'; -import { readDaemonInfo } from '../daemon-client-metadata.ts'; const spawn = vi.mocked(runCmdDetachedMonitored); @@ -129,7 +129,7 @@ async function crashedStaleRegistration(ownership: 'embedded' | 'unrecognized' | if (ownership === 'unrecognized') { const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')); fs.writeFileSync(paths.infoPath, JSON.stringify({ ...record, ownership: 'sideways' })); - assert.equal(readDaemonInfo(paths.infoPath)?.ownership, 'unknown'); + assert.equal(metadata.readDaemonInfo(paths.infoPath)?.ownership, 'unknown'); } process.kill(registered.child.pid, 'SIGKILL'); await registered.child.exited; @@ -157,7 +157,7 @@ for (const [ownership, reason, message, hint] of [ if (ownership !== 'embedded') { const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')); fs.writeFileSync(paths.infoPath, JSON.stringify({ ...record, ownership })); - assert.equal(readDaemonInfo(paths.infoPath)?.ownership, 'unknown'); + assert.equal(metadata.readDaemonInfo(paths.infoPath)?.ownership, 'unknown'); } const killSpy = vi.spyOn(process, 'kill'); try { @@ -171,7 +171,7 @@ for (const [ownership, reason, message, hint] of [ assert.equal(spawn.mock.calls.length, 0); assert.deepEqual(signalsSent(killSpy), []); assert.equal(isProcessAlive(child.pid), true); - assert.equal(readDaemonInfo(paths.infoPath)?.pid, child.pid); + assert.equal(metadata.readDaemonInfo(paths.infoPath)?.pid, child.pid); } finally { await closeLoopbackServer(daemon.server); } @@ -183,13 +183,13 @@ test('takeover refuses an embedded registration without a recorded host start ti const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')); delete record.processStartTime; fs.writeFileSync(paths.infoPath, JSON.stringify(record)); - const info = readDaemonInfo(paths.infoPath); + const info = metadata.readDaemonInfo(paths.infoPath); assert.ok(info); assert.equal(info.processStartTime, undefined); const before = fs.readFileSync(paths.infoPath, 'utf8'); const killSpy = vi.spyOn(process, 'kill'); - const refusal = await refusalFrom(startup.retireDaemonForTakeover(info, paths)); + const refusal = await refusalFrom(retireDaemonForTakeover(info, paths)); assert.equal(refusal.details?.reason, DAEMON_LIFECYCLE_REASONS.ownerEmbedded); assert.match(String(refusal.details?.hint), /owned by host process \d+\. Close it there/); @@ -215,7 +215,7 @@ test('the same record with no host owner is still the takeover this client perfo const killSpy = vi.spyOn(process, 'kill'); try { - assert.equal(readDaemonInfo(paths.infoPath)?.ownership, 'detached'); + assert.equal(metadata.readDaemonInfo(paths.infoPath)?.ownership, 'detached'); const response = await createDaemonTransport(createTestHostEnvironment())(request(stateDir)); assert.equal(response.ok, true); @@ -249,7 +249,7 @@ for (const ownership of ['embedded', 'unrecognized', 'detached'] as const) assert.equal(spawn.mock.calls.length, 1, 'the state directory was taken over, not wedged'); // The dead owner's record is gone: the fresh daemon's registration names a live process, and the // dead lifetime received no signal — its exit was proved from the process table, not caused. - assert.notEqual(readDaemonInfo(paths.infoPath)?.pid, child.pid); + assert.notEqual(metadata.readDaemonInfo(paths.infoPath)?.pid, child.pid); assert.deepEqual( signalsSent(killSpy).filter(([target]) => target === child.pid), [], @@ -360,7 +360,7 @@ test('a pinned client refuses an instance that is not the one it pins', async (t 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); + assert.equal(metadata.readDaemonInfo(paths.infoPath)?.pid, child.pid); } finally { await closeLoopbackServer(daemon.server); } @@ -373,7 +373,7 @@ test('a pinned client refuses a state directory that registers no daemon', async const { info } = await registerDaemon(paths, { socketPort: daemon.port }); const pin = pinTo(info); await finishRegisteredDaemonFixtures(); - assert.equal(readDaemonInfo(paths.infoPath), null); + assert.equal(metadata.readDaemonInfo(paths.infoPath), null); try { const transport = createDaemonTransport(createTestHostEnvironment()); @@ -476,8 +476,8 @@ 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 observe = startup.observeLocalDaemon; - const lookup = vi.spyOn(startup, 'observeLocalDaemon').mockImplementation(async (...args) => { + const observe = metadata.observeLocalDaemon; + const lookup = vi.spyOn(metadata, '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. diff --git a/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts b/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts index 7c664f5969..def12038d9 100644 --- a/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts @@ -1,11 +1,16 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; import path from 'node:path'; import { test } from 'vitest'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import { tryAcquireDaemonRegistration } from '@agent-device/daemon-contracts/daemon-registration-owner'; -import { readDaemonInfo, type DaemonInfo } from '../daemon-client-metadata.ts'; +import { + readDaemonInfo, + readRegisteredDaemon, + type DaemonInfo, +} from '../daemon-client-metadata.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; // The reuse decision is only as good as the identity that survives the round trip @@ -63,3 +68,38 @@ test('a daemon record without capabilities reads as a daemon with none', () => { ); assert.deepEqual(readDaemonInfo(infoPath)?.capabilities, ['quiesce']); }); + +test('registered admission requires a valid process identity, token and endpoint', async () => { + const dir = mkdtempForTestSync('record-validation-'); + const file = resolveDaemonPaths(dir).infoPath; + for (const [record, valid] of [ + [{ pid: 1, token: '', port: 1 }, false], + [{ pid: 0, token: 'x', port: 1234 }, false], + [{ pid: -1, token: 'x', port: 1234 }, false], + [{ pid: 1, token: 'x' }, false], + [{ pid: 1, token: 'x', port: -1 }, false], + [{ pid: 1, token: 'x', httpPort: 65536 }, false], + [{ pid: 1, token: 'x', port: 1234, transport: 'socket' }, true], + [{ pid: 1, token: 'x', httpPort: 1234, transport: 'http' }, true], + [{ pid: 1, token: 'x', httpPort: 65535 }, true], + ] as const) { + fs.writeFileSync(file, JSON.stringify(record)); + assert.equal( + (await readRegisteredDaemon(resolveDaemonPaths(dir))) !== null, + valid, + JSON.stringify(record), + ); + } +}); + +test('unproven ownership is refused before nullable or required record lookup', async () => { + const [stateDir] = scratchStateDir(); + const paths = resolveDaemonPaths(stateDir); + fs.writeFileSync(paths.lockPath, 'not a lock directory'); + const unproven = (error: unknown) => + error instanceof AppError && + error.details?.reason === 'daemon_registration_unproven' && + error.details?.stateDir === stateDir; + await assert.rejects(readRegisteredDaemon(paths), unproven); + await assert.rejects(readRegisteredDaemon(paths, { required: true }), unproven); +}); diff --git a/packages/daemon-client/src/__tests__/index-host.test.ts b/packages/daemon-client/src/__tests__/index-host.test.ts index a7560f3126..38a9337a6d 100644 --- a/packages/daemon-client/src/__tests__/index-host.test.ts +++ b/packages/daemon-client/src/__tests__/index-host.test.ts @@ -10,8 +10,8 @@ vi.mock('../daemon-client-lifecycle.ts', async (importOriginal) => ({ throw new Error('stop before talking to a daemon'); }, })); -vi.mock('../daemon-client-startup.ts', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('../daemon-client-metadata.ts', async (importOriginal) => ({ + ...(await importOriginal()), observeLocalDaemon: async (settings: unknown) => { ensured.settings = settings; throw new Error('stop before talking to a daemon'); diff --git a/packages/daemon-client/src/daemon-client-attach.ts b/packages/daemon-client/src/daemon-client-attach.ts index 3091c298a1..e6229c40c8 100644 --- a/packages/daemon-client/src/daemon-client-attach.ts +++ b/packages/daemon-client/src/daemon-client-attach.ts @@ -1,5 +1,6 @@ import { ensureManagedDaemon } from './daemon-client-lifecycle.ts'; -import { assertDaemonPolicyMatches, observeLocalDaemon } from './daemon-client-startup.ts'; +import { assertDaemonPolicyMatches } from './daemon-client-startup.ts'; +import { observeLocalDaemon } from './daemon-client-metadata.ts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import { daemonBindingMismatchError } from './daemon-client-binding.ts'; import { daemonIncompatibleError, daemonNotRunningError } from './daemon-lifecycle-errors.ts'; diff --git a/packages/daemon-client/src/daemon-client-ensure.ts b/packages/daemon-client/src/daemon-client-ensure.ts index da84436e1f..6f1e473149 100644 --- a/packages/daemon-client/src/daemon-client-ensure.ts +++ b/packages/daemon-client/src/daemon-client-ensure.ts @@ -12,7 +12,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { resolveLocalDaemonSettings } from './daemon-client-lifecycle.ts'; import { assertDaemonPolicyMatches, - observeLocalDaemon, retireDaemonForTakeover, startLocalDaemon, stopDaemonForTakeover, @@ -20,7 +19,11 @@ import { import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import { DAEMON_LEAVE_WAIT_MS, requestDaemonLifecycle } from './daemon-client-lifecycle-request.ts'; import { probeDaemonResponsive } from './daemon-client-liveness-probe.ts'; -import { observedDaemonIdentityOf, type DaemonInfo } from './daemon-client-metadata.ts'; +import { + observeLocalDaemon, + observedDaemonIdentityOf, + type DaemonInfo, +} from './daemon-client-metadata.ts'; import { DAEMON_CONTENDED_REASON, daemonIdentityOf, diff --git a/packages/daemon-client/src/daemon-client-metadata.ts b/packages/daemon-client/src/daemon-client-metadata.ts index d5f6a1a4e4..272887df15 100644 --- a/packages/daemon-client/src/daemon-client-metadata.ts +++ b/packages/daemon-client/src/daemon-client-metadata.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { inspectProcessLock } from '@agent-device/host-kit/file'; import { isProcessPid } from '@agent-device/host-kit/process'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; @@ -10,6 +11,7 @@ import { type DaemonCapability, type DaemonOwnership, type DaemonPaths, + type DaemonTransportPreference, type DaemonServerMode, type ObservedDaemonIdentity, } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -40,6 +42,87 @@ export type DaemonInfo = { remoteUpstreamInstanceId?: string; }; +/** + * Why a daemon is not the one this client would use. `transport` means alive but not on the + * transport this client uses: the ladder only ever answers `unreachable` for it, and a caller that + * must not kill a live daemon refines it with `observeLocalDaemon`. + */ +export type DaemonTakeoverCause = 'version' | 'code' | 'transport' | 'unreachable'; + +/** What to do with the daemon already running on this state directory. */ +export type DaemonTakeoverDecision = + | { kind: 'reuse' } + | { kind: 'replace'; reason: string; cause: DaemonTakeoverCause } + | { kind: 'refuseNewer'; daemonVersion: string; clientVersion: string }; + +export type DaemonObservationSettings = { + paths: DaemonPaths; + transportPreference: DaemonTransportPreference; +}; + +export type DaemonObservationOptions = { + deadline?: number; + waitForLiveStartup?: boolean; + refineUnreachable?: boolean; +}; + +export type LocalDaemonObservation = { + info: DaemonInfo; + decision: DaemonTakeoverDecision; +}; + +/** + * Reads the admitted registration without starting or compatibility-checking a daemon. + * Missing or foreign registrations return null; `required: true` uses the normal not-running error. + * An unproven registration always uses the normal ownership refusal. + */ +export function readRegisteredDaemon( + paths: DaemonPaths, + options: { required: true }, +): Promise; +export function readRegisteredDaemon( + paths: DaemonPaths, + options?: { required?: boolean }, +): Promise; +export async function readRegisteredDaemon( + paths: DaemonPaths, + options: { required?: boolean } = {}, +): Promise { + const { processLockHoldsDaemonIdentity } = + await import('@agent-device/daemon-contracts/daemon-registration'); + const inspection = inspectProcessLock(paths.lockPath); + if (inspection.state === 'unproven') { + const { daemonRegistrationUnprovenError } = await import('./daemon-client-startup.ts'); + throw daemonRegistrationUnprovenError(paths, inspection); + } + let info = readDaemonInfo(paths.infoPath); + if (info && inspection.state !== 'absent') { + if ( + !processLockHoldsDaemonIdentity(inspection, { + pid: info.pid, + startTime: info.processStartTime ?? null, + }) + ) + info = null; + } + if (!info && options.required) { + const { daemonNotRunningError } = await import('./daemon-lifecycle-errors.ts'); + throw daemonNotRunningError(paths.baseDir); + } + return info; +} + +/** Judges the admitted registration while keeping lifecycle machinery lazy. */ +export async function observeLocalDaemon( + settings: DaemonObservationSettings, + options: DaemonObservationOptions = {}, +): Promise { + const existing = await readRegisteredDaemon(settings.paths); + if (!existing) return null; + const { observeRegisteredDaemon } = await import('./daemon-client-startup.ts'); + return await observeRegisteredDaemon(existing, settings, options); +} + type DaemonMetadataState = { hasInfo: boolean; hasLock: boolean; @@ -51,7 +134,10 @@ export function readDaemonInfo(infoPath: string): DaemonInfo | null { const parsed = read.record; const token = readRequiredDaemonToken(parsed); const ports = readDaemonInfoPorts(parsed); - return token && ports ? readDaemonInfoFromRecord(parsed, token, ports) : null; + const pid = parsed.pid; + return token && ports && isProcessPid(pid) + ? readDaemonInfoFromRecord(parsed, token, ports, pid) + : null; } /** @@ -71,12 +157,13 @@ function readDaemonInfoFromRecord( parsed: Partial, token: string, ports: Pick, + pid: number, ): DaemonInfo { return { token, ...ports, transport: readRecordedDaemonTransport(parsed.transport), - pid: isProcessPid(parsed.pid) ? parsed.pid : 0, + pid, version: readOptionalString(parsed.version), codeOrigin: readDaemonInfoCodeOrigin(parsed.codeOrigin), codeSignature: readOptionalString(parsed.codeSignature), @@ -95,8 +182,8 @@ function readRequiredDaemonToken(parsed: Partial): string | null { function readDaemonInfoPorts( parsed: Partial, ): Pick | null { - const port = readPositiveInteger(parsed.port); - const httpPort = readPositiveInteger(parsed.httpPort); + const port = readDaemonPort(parsed.port); + const httpPort = readDaemonPort(parsed.httpPort); if (port === undefined && httpPort === undefined) return null; return { port, httpPort }; } @@ -109,8 +196,10 @@ function readOptionalString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } -function readPositiveInteger(value: unknown): number | undefined { - return Number.isInteger(value) && Number(value) > 0 ? Number(value) : undefined; +function readDaemonPort(value: unknown): number | undefined { + return Number.isInteger(value) && Number(value) > 0 && Number(value) <= 65_535 + ? Number(value) + : undefined; } export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState { diff --git a/packages/daemon-client/src/daemon-client-startup.ts b/packages/daemon-client/src/daemon-client-startup.ts index 997cca5568..6c6850fc7d 100644 --- a/packages/daemon-client/src/daemon-client-startup.ts +++ b/packages/daemon-client/src/daemon-client-startup.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; -import { processLockHoldsDaemonIdentity } from '@agent-device/daemon-contracts/daemon-registration'; import { inspectProcessLock, type ProcessLockInspection } from '@agent-device/host-kit/file'; import { classifyOwnerLiveness, isProcessAlive } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; @@ -25,13 +24,14 @@ import type { DaemonPaths, DaemonTransportPreference, } from '@agent-device/daemon-contracts/daemon-resolution'; -import { - resolveDaemonLaunchSpec, - resolveDaemonTakeover, - type DaemonTakeoverDecision, -} from './daemon-launch-spec.ts'; +import { resolveDaemonLaunchSpec, resolveDaemonTakeover } from './daemon-launch-spec.ts'; import { getDaemonMetadataState, + observeLocalDaemon, + type DaemonTakeoverDecision, + type LocalDaemonObservation, + type DaemonObservationSettings, + type DaemonObservationOptions, observedDaemonIdentityOf, readDaemonInfo, resolveDaemonStartupHint, @@ -45,11 +45,6 @@ import { } from './daemon-lifecycle-errors.ts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; -type LocalDaemonObservation = { - info: DaemonInfo; - decision: DaemonTakeoverDecision; -}; - type DaemonStartupWaitResult = | { kind: 'ready'; daemon: EnsuredDaemon } | { kind: 'early_exit'; exit: ExecDetachedExit } @@ -94,21 +89,12 @@ const DAEMON_STARTUP_ATTEMPTS = 2; const DAEMON_STARTUP_LOG_TAIL_BYTES = 64_000; const DAEMON_STARTUP_MARKER_TAIL_BYTES = 16_000; -/** Judges the registered daemon against this client, changing nothing. */ -export async function observeLocalDaemon( - settings: DaemonClientSettings, - options: { - deadline?: number; - waitForLiveStartup?: boolean; - refineUnreachable?: boolean; - } = {}, +export async function observeRegisteredDaemon( + existing: DaemonInfo, + settings: DaemonObservationSettings, + options: DaemonObservationOptions = {}, ): Promise { const { deadline } = options; - const inspection = inspectProcessLock(settings.paths.lockPath); - if (inspection.state === 'unproven') throw daemonRegistrationUnprovenError(settings, inspection); - const existing = readDaemonInfo(settings.paths.infoPath); - if (!existing) return null; - if (!registrationAllowsDaemonObservation(inspection, existing)) return null; if (options.waitForLiveStartup && (await isAwaitingDaemonStartup(existing, deadline))) return null; @@ -166,28 +152,15 @@ async function isAwaitingDaemonStartup( ); } -function registrationAllowsDaemonObservation( - inspection: ProcessLockInspection, - info: DaemonInfo, -): boolean { - return ( - inspection.state === 'absent' || - processLockHoldsDaemonIdentity(inspection, { - pid: info.pid, - startTime: info.processStartTime ?? null, - }) - ); -} - -function daemonRegistrationUnprovenError( - settings: DaemonClientSettings, +export function daemonRegistrationUnprovenError( + paths: DaemonPaths, inspection?: ProcessLockInspection, ): AppError { return new AppError('COMMAND_FAILED', 'Daemon registration ownership could not be verified.', { reason: 'daemon_registration_unproven', inspection, - stateDir: settings.paths.baseDir, - hint: resolveDaemonStartupHint(getDaemonMetadataState(settings.paths), settings.paths), + stateDir: paths.baseDir, + hint: resolveDaemonStartupHint(getDaemonMetadataState(paths), paths), }); } @@ -552,7 +525,7 @@ async function judgeExitedDaemonStartup( ): Promise { const kind = classifyDaemonStartupExit(exit); if (kind === 'unproven') - return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings) }; + return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings.paths) }; if (kind === 'failed') return { kind: 'early_exit', exit }; return await observeContendingDaemon(settings, deadline); } @@ -649,7 +622,7 @@ async function observeContendingDaemon( if (winner) return { kind: 'ready', daemon: { info: winner, startedByClient: false } }; const inspection = inspectProcessLock(settings.paths.lockPath); if (inspection.state === 'unproven') - return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings, inspection) }; + return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings.paths, inspection) }; return isRegistrationAvailable(inspection) ? { kind: 'retry' } : null; } diff --git a/packages/daemon-client/src/daemon-launch-spec.ts b/packages/daemon-client/src/daemon-launch-spec.ts index 20007ea208..3280fd7fca 100644 --- a/packages/daemon-client/src/daemon-launch-spec.ts +++ b/packages/daemon-client/src/daemon-launch-spec.ts @@ -13,7 +13,7 @@ import { computeDaemonCodeSignature, resolveDaemonCodeOrigin, } from '@agent-device/host-kit/code-signature'; -import type { DaemonInfo } from './daemon-client-metadata.ts'; +import type { DaemonInfo, DaemonTakeoverDecision } from './daemon-client-metadata.ts'; type DaemonLaunchSpec = { root: string; @@ -107,19 +107,6 @@ export async function resolveLocalDaemonCodeIdentity(): Promise { if (options.state === 'cold-cold') { prepareColdColdState(options); return; @@ -67,7 +67,7 @@ export function prepareSampleState(options: CellAdmissionOptions): void { assertAppStopped(options.udid, options.fixture.app); return; } - assertReadyState(options); + await assertReadyState(options); } export async function admitReadyCell( @@ -135,12 +135,12 @@ function prepareColdColdState(options: CellAdmissionOptions): void { assertAppStopped(options.udid, options.fixture.app); } -function admitWarmSample( +async function admitWarmSample( options: CellAdmissionOptions, result: CliResult, previousAppPid: number | undefined, -): number { - assertReadyState(options); +): Promise { + await assertReadyState(options); requireFixtureAnchor(result.payload, options.fixture, 'sample'); const appPid = assertAppRunning(options.udid, options.fixture.app); if (previousAppPid !== undefined && appPid !== previousAppPid) { @@ -158,7 +158,7 @@ async function admitOpenedFixture( options: CellAdmissionOptions, ): Promise { assertSimulatorState(options.udid, 'Booted'); - assertDaemonRunning(options.stateDir); + await assertDaemonRunning(options.stateDir); assertAppRunning(options.udid, options.fixture.app); await prepareFixture(options.fixture, { observe: () => @@ -175,9 +175,9 @@ async function admitOpenedFixture( return appPid; } -function assertReadyState(options: CellAdmissionOptions): void { +async function assertReadyState(options: CellAdmissionOptions): Promise { assertSimulatorState(options.udid, 'Booted'); - assertDaemonRunning(options.stateDir); + await assertDaemonRunning(options.stateDir); assertAppRunning(options.udid, options.fixture.app); } @@ -203,8 +203,8 @@ function assertDaemonStopped(stateDir: string): void { } } -function assertDaemonRunning(stateDir: string): void { - const info = readRecordedDaemon(resolveDaemonPaths(stateDir).infoPath); +async function assertDaemonRunning(stateDir: string): Promise { + const info = await readRegisteredDaemon(resolveDaemonPaths(stateDir)); if (!info?.processStartTime || !isAgentDeviceDaemonProcess(info.pid, info.processStartTime)) { throw new BenchmarkCellAdmissionError( 'cell-state', diff --git a/scripts/ios-snapshot-benchmark/local-runner.ts b/scripts/ios-snapshot-benchmark/local-runner.ts index c926ea62a8..8012402d15 100644 --- a/scripts/ios-snapshot-benchmark/local-runner.ts +++ b/scripts/ios-snapshot-benchmark/local-runner.ts @@ -49,7 +49,7 @@ async function runCell(options: CellAdmissionOptions): Promise { appPid = await admitReadyCell(context, options); const samples: RawSample[] = []; for (let index = 0; index < options.samples; index += 1) { - if (index > 0) prepareSampleState(options); + if (index > 0) await prepareSampleState(options); const result = runMeasuredCommand(context, options); if (result.ok) { appPid = await admitSuccessfulSample(context, options, result, appPid); diff --git a/scripts/lib/recorded-daemon.test.ts b/scripts/lib/recorded-daemon.test.ts index 5792822f7e..b6468ec1c1 100644 --- a/scripts/lib/recorded-daemon.test.ts +++ b/scripts/lib/recorded-daemon.test.ts @@ -12,7 +12,8 @@ import { supportsLoopbackBind, } from '@agent-device/test-kit'; import { tryAcquireProcessLock } from '@agent-device/host-kit/file'; -import { readRecordedDaemon, sendToRecordedDaemon } from './recorded-daemon.ts'; +import { sendToRecordedDaemon } from './recorded-daemon.ts'; +import { readRegisteredDaemon } from '@agent-device/daemon-client'; // `sendToRecordedDaemon` resolves a recorded daemon and sends through the client transport. It must // keep every guarantee of that transport and none of its lifecycle: nothing is started, replaced or @@ -122,29 +123,13 @@ test('a state directory that records no daemon is reported, not started', async send(stateDir), (error: unknown) => error instanceof AppError && - error.details?.reason === 'daemon_not_running' && + error.details?.reason === 'daemon-not-running' && error.details?.stateDir === stateDir, ); assert.equal(fs.existsSync(resolveDaemonPaths(stateDir).infoPath), false); }); -test('record admission requires a token and a usable endpoint', () => { - const dir = mkdtempForTestSync('record-validation-'); - const file = resolveDaemonPaths(dir).infoPath; - for (const [record, valid] of [ - [{ pid: 1, token: '', port: 1 }, false], - [{ pid: 1, token: 'x' }, false], - [{ pid: 1, token: 'x', port: -1 }, false], - [{ pid: 1, token: 'x', httpPort: 65536 }, false], - [{ pid: 1, token: 'x', port: 1234, transport: 'socket' }, true], - [{ pid: 1, token: 'x', httpPort: 1234, transport: 'http' }, true], - ] as const) { - fs.writeFileSync(file, JSON.stringify(record)); - assert.equal(readRecordedDaemon(file) !== null, valid, JSON.stringify(record)); - } -}); - test('recorded daemon requires a matching held registration claim', async (t) => { if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted'); const daemon = await recordedSocketDaemon(() => JSON.stringify({ ok: true, data: {} })); @@ -153,20 +138,22 @@ test('recorded daemon requires a matching held registration claim', async (t) => assert.equal(lock.status, 'acquired'); if (lock.status !== 'acquired') return; try { + assert.equal(await readRegisteredDaemon(daemon.paths), null); await assert.rejects( send(daemon.stateDir), (error: unknown) => - error instanceof AppError && error.details?.reason === 'daemon_registration_unproven', + error instanceof AppError && error.details?.reason === 'daemon-not-running', ); const record = JSON.parse(fs.readFileSync(daemon.paths.infoPath, 'utf8')); fs.writeFileSync( daemon.paths.infoPath, JSON.stringify({ ...record, processStartTime: 'other' }), ); + assert.equal(await readRegisteredDaemon(daemon.paths), null); await assert.rejects( send(daemon.stateDir), (error: unknown) => - error instanceof AppError && error.details?.reason === 'daemon_registration_unproven', + error instanceof AppError && error.details?.reason === 'daemon-not-running', ); fs.writeFileSync( daemon.paths.infoPath, @@ -205,8 +192,7 @@ test('a publishing registration cannot be used', async (t) => { fs.writeFileSync(daemon.paths.lockPath.replace(/\.lock$/, '.reclaim.lock'), ''); await assert.rejects( send(daemon.stateDir), - (error: unknown) => - error instanceof AppError && error.details?.reason === 'daemon_registration_unproven', + (error: unknown) => error instanceof AppError && error.details?.reason === 'daemon-not-running', ); assert.equal(daemon.connections(), 0); }); diff --git a/scripts/lib/recorded-daemon.ts b/scripts/lib/recorded-daemon.ts index c842c9a158..be1b45ed8c 100644 --- a/scripts/lib/recorded-daemon.ts +++ b/scripts/lib/recorded-daemon.ts @@ -1,64 +1,10 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; -import { sendDaemonRequest } from '@agent-device/daemon-client'; -import { - readDaemonRecord, - readRecordedDaemonTransport, - type DaemonServerMode, - resolveDaemonPaths, -} from '@agent-device/daemon-contracts/daemon-resolution'; -import { processLockHoldsDaemonIdentity } from '@agent-device/daemon-contracts/daemon-registration'; -import { inspectProcessLock } from '@agent-device/host-kit/file'; -import { AppError } from '@agent-device/kernel/errors'; +import { readRegisteredDaemon, sendDaemonRequest } from '@agent-device/daemon-client'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; -/** - * The registration a state directory records, as the measurement and live-lane tooling reads it. - * It is the part of a daemon record that `sendDaemonRequest` needs to reach that daemon. - */ -export type RecordedDaemon = Readonly<{ - pid: number; - token: string; - port?: number; - httpPort?: number; - transport?: DaemonServerMode; - processStartTime?: string; -}>; - -export function readRecordedDaemon(infoPath: string): RecordedDaemon | null { - const read = readDaemonRecord(infoPath); - if (read.status !== 'present') return null; - const { pid, token, port, httpPort, processStartTime } = read.record; - const validPort = (value: unknown): value is number => - typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535; - if ( - typeof pid !== 'number' || - !Number.isInteger(pid) || - pid <= 0 || - typeof token !== 'string' || - token.length === 0 || - (!validPort(port) && !validPort(httpPort)) - ) - return null; - const transport = readRecordedDaemonTransport(read.record.transport); - return { - pid, - token, - ...(validPort(port) ? { port } : {}), - ...(validPort(httpPort) ? { httpPort } : {}), - ...(transport ? { transport } : {}), - ...(typeof processStartTime === 'string' ? { processStartTime } : {}), - }; -} - -/** - * Sends one request to the daemon a state directory records, through the client transport. The - * tooling that calls this drives a daemon another build started. A held registration lock must - * match the recorded PID and process start time; a missing start time, foreign owner or publication - * in progress rejects with `daemon_registration_unproven`. With no lock, the record is sent to - * without ownership proof. No daemon is started, replaced or compatibility-checked, and a timeout - * never retires it (`timeoutRecovery: 'none'`). - */ +/** Sends to the package-admitted registration without starting, replacing or retiring a daemon. */ export async function sendToRecordedDaemon( request: Omit, options: { host: HostEnvironment; stateDir: string; timeoutMs: number; signal?: AbortSignal }, @@ -67,26 +13,7 @@ export async function sendToRecordedDaemon( env: options.host.env, cwd: options.host.cwd, }); - const info = readRecordedDaemon(paths.infoPath); - if (!info) - throw new AppError('COMMAND_FAILED', `No daemon is running for state dir ${paths.baseDir}.`, { - reason: 'daemon_not_running', - stateDir: paths.baseDir, - }); - const inspection = inspectProcessLock(paths.lockPath); - if ( - inspection.state !== 'absent' && - !processLockHoldsDaemonIdentity(inspection, { - pid: info.pid, - startTime: info.processStartTime ?? null, - }) - ) { - throw new AppError('COMMAND_FAILED', 'Daemon registration ownership could not be verified.', { - reason: 'daemon_registration_unproven', - inspection, - stateDir: paths.baseDir, - }); - } + const info = await readRegisteredDaemon(paths, { required: true }); return await sendDaemonRequest( info, { ...request, token: info.token }, diff --git a/test/integration/android-emulator-e2e/live-observability-scenario.ts b/test/integration/android-emulator-e2e/live-observability-scenario.ts index 9bf1f21bce..8fc85c1cc0 100644 --- a/test/integration/android-emulator-e2e/live-observability-scenario.ts +++ b/test/integration/android-emulator-e2e/live-observability-scenario.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; -import { readRecordedDaemon } from '../../../scripts/lib/recorded-daemon.ts'; +import { readRegisteredDaemon } from '@agent-device/daemon-client'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { collectPagedEventTimeline, @@ -203,7 +203,7 @@ async function downloadDaemonArtifact( env: context.env, projectRoot: process.cwd(), }); - const daemon = readRecordedDaemon(daemonPaths.infoPath); + const daemon = await readRegisteredDaemon(daemonPaths); assert.ok(daemon?.httpPort, `daemon HTTP metadata unavailable at ${daemonPaths.infoPath}`); const response = await fetch( `http://127.0.0.1:${daemon.httpPort}/artifacts/${encodeURIComponent(artifactId)}`, From b020cca5b52e1776d544d6ae3c833bdcf3103988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 31 Oct 2026 08:44:35 +0100 Subject: [PATCH 15/17] refactor(daemon-client): keep admission below lifecycle machinery --- .../daemon-client-lifecycle-embedded.test.ts | 8 ++-- .../src/__tests__/index-host.test.ts | 4 +- .../daemon-client/src/__tests__/index.test.ts | 2 +- .../daemon-client/src/daemon-client-attach.ts | 3 +- .../daemon-client/src/daemon-client-ensure.ts | 7 +-- .../src/daemon-client-metadata.ts | 36 +--------------- .../src/daemon-client-startup.ts | 24 +++-------- .../src/daemon-lifecycle-errors.ts | 43 ++++++++++++++++--- packages/daemon-client/src/index.ts | 2 +- 9 files changed, 56 insertions(+), 73 deletions(-) diff --git a/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts b/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts index 4c94418969..1e896dd48f 100644 --- a/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts @@ -13,7 +13,7 @@ import { listenOnLoopback, } from '@agent-device/test-kit'; import * as metadata from '../daemon-client-metadata.ts'; -import { retireDaemonForTakeover } from '../daemon-client-startup.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'; @@ -189,7 +189,7 @@ test('takeover refuses an embedded registration without a recorded host start ti const before = fs.readFileSync(paths.infoPath, 'utf8'); const killSpy = vi.spyOn(process, 'kill'); - const refusal = await refusalFrom(retireDaemonForTakeover(info, paths)); + const refusal = await refusalFrom(startup.retireDaemonForTakeover(info, paths)); assert.equal(refusal.details?.reason, DAEMON_LIFECYCLE_REASONS.ownerEmbedded); assert.match(String(refusal.details?.hint), /owned by host process \d+\. Close it there/); @@ -476,8 +476,8 @@ 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 observe = metadata.observeLocalDaemon; - const lookup = vi.spyOn(metadata, 'observeLocalDaemon').mockImplementation(async (...args) => { + 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. diff --git a/packages/daemon-client/src/__tests__/index-host.test.ts b/packages/daemon-client/src/__tests__/index-host.test.ts index 38a9337a6d..a7560f3126 100644 --- a/packages/daemon-client/src/__tests__/index-host.test.ts +++ b/packages/daemon-client/src/__tests__/index-host.test.ts @@ -10,8 +10,8 @@ vi.mock('../daemon-client-lifecycle.ts', async (importOriginal) => ({ throw new Error('stop before talking to a daemon'); }, })); -vi.mock('../daemon-client-metadata.ts', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('../daemon-client-startup.ts', async (importOriginal) => ({ + ...(await importOriginal()), observeLocalDaemon: async (settings: unknown) => { ensured.settings = settings; throw new Error('stop before talking to a daemon'); diff --git a/packages/daemon-client/src/__tests__/index.test.ts b/packages/daemon-client/src/__tests__/index.test.ts index 99bb99ed9b..81ba33b6b3 100644 --- a/packages/daemon-client/src/__tests__/index.test.ts +++ b/packages/daemon-client/src/__tests__/index.test.ts @@ -19,7 +19,7 @@ import { sendToDaemon } from '../index.ts'; import { currentDaemonCodeSignature } from './code-signature-fixture.ts'; import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; import { downloadRemoteArtifact } from '@agent-device/remote/daemon-artifacts'; -import { resolveDaemonStartupHint } from '../daemon-client-metadata.ts'; +import { resolveDaemonStartupHint } from '../daemon-lifecycle-errors.ts'; import { canConnectSocket } from '../daemon-client-transport.ts'; import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; diff --git a/packages/daemon-client/src/daemon-client-attach.ts b/packages/daemon-client/src/daemon-client-attach.ts index e6229c40c8..3091c298a1 100644 --- a/packages/daemon-client/src/daemon-client-attach.ts +++ b/packages/daemon-client/src/daemon-client-attach.ts @@ -1,6 +1,5 @@ import { ensureManagedDaemon } from './daemon-client-lifecycle.ts'; -import { assertDaemonPolicyMatches } from './daemon-client-startup.ts'; -import { observeLocalDaemon } from './daemon-client-metadata.ts'; +import { assertDaemonPolicyMatches, observeLocalDaemon } from './daemon-client-startup.ts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import { daemonBindingMismatchError } from './daemon-client-binding.ts'; import { daemonIncompatibleError, daemonNotRunningError } from './daemon-lifecycle-errors.ts'; diff --git a/packages/daemon-client/src/daemon-client-ensure.ts b/packages/daemon-client/src/daemon-client-ensure.ts index 6f1e473149..da84436e1f 100644 --- a/packages/daemon-client/src/daemon-client-ensure.ts +++ b/packages/daemon-client/src/daemon-client-ensure.ts @@ -12,6 +12,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { resolveLocalDaemonSettings } from './daemon-client-lifecycle.ts'; import { assertDaemonPolicyMatches, + observeLocalDaemon, retireDaemonForTakeover, startLocalDaemon, stopDaemonForTakeover, @@ -19,11 +20,7 @@ import { import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import { DAEMON_LEAVE_WAIT_MS, requestDaemonLifecycle } from './daemon-client-lifecycle-request.ts'; import { probeDaemonResponsive } from './daemon-client-liveness-probe.ts'; -import { - observeLocalDaemon, - observedDaemonIdentityOf, - type DaemonInfo, -} from './daemon-client-metadata.ts'; +import { observedDaemonIdentityOf, type DaemonInfo } from './daemon-client-metadata.ts'; import { DAEMON_CONTENDED_REASON, daemonIdentityOf, diff --git a/packages/daemon-client/src/daemon-client-metadata.ts b/packages/daemon-client/src/daemon-client-metadata.ts index 272887df15..1c55d52313 100644 --- a/packages/daemon-client/src/daemon-client-metadata.ts +++ b/packages/daemon-client/src/daemon-client-metadata.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import { inspectProcessLock } from '@agent-device/host-kit/file'; import { isProcessPid } from '@agent-device/host-kit/process'; @@ -92,7 +91,7 @@ export async function readRegisteredDaemon( await import('@agent-device/daemon-contracts/daemon-registration'); const inspection = inspectProcessLock(paths.lockPath); if (inspection.state === 'unproven') { - const { daemonRegistrationUnprovenError } = await import('./daemon-client-startup.ts'); + const { daemonRegistrationUnprovenError } = await import('./daemon-lifecycle-errors.ts'); throw daemonRegistrationUnprovenError(paths, inspection); } let info = readDaemonInfo(paths.infoPath); @@ -112,22 +111,6 @@ export async function readRegisteredDaemon( return info; } -/** Judges the admitted registration while keeping lifecycle machinery lazy. */ -export async function observeLocalDaemon( - settings: DaemonObservationSettings, - options: DaemonObservationOptions = {}, -): Promise { - const existing = await readRegisteredDaemon(settings.paths); - if (!existing) return null; - const { observeRegisteredDaemon } = await import('./daemon-client-startup.ts'); - return await observeRegisteredDaemon(existing, settings, options); -} - -type DaemonMetadataState = { - hasInfo: boolean; - hasLock: boolean; -}; - export function readDaemonInfo(infoPath: string): DaemonInfo | null { const read = readDaemonRecord(infoPath); if (read.status !== 'present') return null; @@ -202,23 +185,6 @@ function readDaemonPort(value: unknown): number | undefined { : undefined; } -export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState { - return { - hasInfo: fs.existsSync(paths.infoPath), - hasLock: fs.existsSync(paths.lockPath), - }; -} - export function isRemoteDaemon(info: DaemonInfo): boolean { return typeof info.baseUrl === 'string' && info.baseUrl.length > 0; } - -export function resolveDaemonStartupHint( - state: { hasInfo: boolean; hasLock: boolean }, - paths: Pick, -): string { - const artifacts = [state.hasInfo ? paths.infoPath : null, state.hasLock ? paths.lockPath : null] - .filter(Boolean) - .join(' and '); - return `Daemon startup did not establish a reachable owner. ${artifacts ? `State was retained at ${artifacts}. ` : ''}Retry with --debug and inspect daemon diagnostics. Before upgrading, stop all older clients and daemons with their original CLI and prevent them from returning to this state directory. Unverified lock state requires confirming every user stopped before manual recovery; deleting metadata alone is not a safe reset.`; -} diff --git a/packages/daemon-client/src/daemon-client-startup.ts b/packages/daemon-client/src/daemon-client-startup.ts index 6c6850fc7d..3e24356cad 100644 --- a/packages/daemon-client/src/daemon-client-startup.ts +++ b/packages/daemon-client/src/daemon-client-startup.ts @@ -26,15 +26,13 @@ import type { } from '@agent-device/daemon-contracts/daemon-resolution'; import { resolveDaemonLaunchSpec, resolveDaemonTakeover } from './daemon-launch-spec.ts'; import { - getDaemonMetadataState, - observeLocalDaemon, + readRegisteredDaemon, type DaemonTakeoverDecision, type LocalDaemonObservation, type DaemonObservationSettings, type DaemonObservationOptions, observedDaemonIdentityOf, readDaemonInfo, - resolveDaemonStartupHint, type DaemonInfo, } from './daemon-client-metadata.ts'; import { canConnect, isDaemonTransportUnavailableError } from './daemon-client-transport.ts'; @@ -42,6 +40,9 @@ import { daemonContendedError, daemonTakeoverRetirementError, daemonStopCommand, + daemonRegistrationUnprovenError, + getDaemonMetadataState, + resolveDaemonStartupHint, } from './daemon-lifecycle-errors.ts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; @@ -89,12 +90,13 @@ const DAEMON_STARTUP_ATTEMPTS = 2; const DAEMON_STARTUP_LOG_TAIL_BYTES = 64_000; const DAEMON_STARTUP_MARKER_TAIL_BYTES = 16_000; -export async function observeRegisteredDaemon( - existing: DaemonInfo, +export async function observeLocalDaemon( settings: DaemonObservationSettings, options: DaemonObservationOptions = {}, ): Promise { const { deadline } = options; + const existing = await readRegisteredDaemon(settings.paths); + if (!existing) return null; if (options.waitForLiveStartup && (await isAwaitingDaemonStartup(existing, deadline))) return null; @@ -152,18 +154,6 @@ async function isAwaitingDaemonStartup( ); } -export function daemonRegistrationUnprovenError( - paths: DaemonPaths, - inspection?: ProcessLockInspection, -): AppError { - return new AppError('COMMAND_FAILED', 'Daemon registration ownership could not be verified.', { - reason: 'daemon_registration_unproven', - inspection, - stateDir: paths.baseDir, - hint: resolveDaemonStartupHint(getDaemonMetadataState(paths), paths), - }); -} - export async function stopDaemonForTakeover( existing: DaemonInfo, paths: DaemonPaths, diff --git a/packages/daemon-client/src/daemon-lifecycle-errors.ts b/packages/daemon-client/src/daemon-lifecycle-errors.ts index 415051aafe..f8c36aff6b 100644 --- a/packages/daemon-client/src/daemon-lifecycle-errors.ts +++ b/packages/daemon-client/src/daemon-lifecycle-errors.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import type { ProcessLockInspection } from '@agent-device/host-kit/file'; import type { DaemonIdentity } from '@agent-device/contracts/client'; import { DAEMON_LIFECYCLE_REASONS, @@ -11,12 +13,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { readVersion } from '@agent-device/host-kit/version'; import type { DaemonClientSettings } from './daemon-client-lifecycle.ts'; -import { - getDaemonMetadataState, - resolveDaemonStartupHint, - type DaemonInfo, - type DaemonTakeoverCause, -} from './daemon-client-metadata.ts'; +import type { DaemonInfo, DaemonTakeoverCause } from './daemon-client-metadata.ts'; import { resolveLocalDaemonCodeIdentity } from './daemon-launch-spec.ts'; // The ways an explicit lifecycle stops short of a usable daemon, whether this client judged it or the @@ -198,3 +195,37 @@ export function daemonNotRetirableError(params: { }, ); } + +type DaemonMetadataState = { + hasInfo: boolean; + hasLock: boolean; +}; + +export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState { + return { + hasInfo: fs.existsSync(paths.infoPath), + hasLock: fs.existsSync(paths.lockPath), + }; +} + +export function resolveDaemonStartupHint( + state: { hasInfo: boolean; hasLock: boolean }, + paths: Pick, +): string { + const artifacts = [state.hasInfo ? paths.infoPath : null, state.hasLock ? paths.lockPath : null] + .filter(Boolean) + .join(' and '); + return `Daemon startup did not establish a reachable owner. ${artifacts ? `State was retained at ${artifacts}. ` : ''}Retry with --debug and inspect daemon diagnostics. Before upgrading, stop all older clients and daemons with their original CLI and prevent them from returning to this state directory. Unverified lock state requires confirming every user stopped before manual recovery; deleting metadata alone is not a safe reset.`; +} + +export function daemonRegistrationUnprovenError( + paths: DaemonPaths, + inspection?: ProcessLockInspection, +): AppError { + return new AppError('COMMAND_FAILED', 'Daemon registration ownership could not be verified.', { + reason: 'daemon_registration_unproven', + inspection, + stateDir: paths.baseDir, + hint: resolveDaemonStartupHint(getDaemonMetadataState(paths), paths), + }); +} diff --git a/packages/daemon-client/src/index.ts b/packages/daemon-client/src/index.ts index b74163c452..f43dd2c360 100644 --- a/packages/daemon-client/src/index.ts +++ b/packages/daemon-client/src/index.ts @@ -33,7 +33,7 @@ import type { } from './daemon-client-request.ts'; import { leaseScopeFromRequest } from '@agent-device/contracts/lease-scope'; -export type { DaemonRequest, DaemonResponse, DaemonInfo }; +export type { DaemonRequest, DaemonResponse }; export { readRegisteredDaemon }; /** The released default: a process-level client, which reads the process's own host. */ From 0441c18c60d902a7fbca3b3486c263f0bbcab059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 31 Oct 2026 09:10:22 +0100 Subject: [PATCH 16/17] fix(daemon-client): preserve refusals and benchmark admission diagnostics --- packages/contracts/src/lease-scope.ts | 1 + .../__tests__/daemon-client-metadata.test.ts | 40 +++++++++++++++--- .../daemon-client/src/daemon-client-attach.ts | 14 ++++++- .../src/daemon-client-metadata.ts | 13 +++++- .../src/daemon-client-startup.ts | 1 + .../daemon-client/src/daemon-client-stop.ts | 15 +------ .../src/daemon-lifecycle-errors.ts | 12 ++++++ .../cell-admission.test.ts | 42 +++++++++++++++++++ .../ios-snapshot-benchmark/cell-admission.ts | 12 +++++- .../owner-files-no-leak.test.ts | 28 +++++++++++-- 10 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 scripts/ios-snapshot-benchmark/cell-admission.test.ts diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 789af78351..3f6e2b9966 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -542,6 +542,7 @@ function readFlagString( * The JSON-RPC envelope a client puts on the daemon's `/rpc` endpoint for one request: the command * method for an ordinary request, and the lease method with its lease params for a lease command. * The caller supplies the request id, so the envelope is the same for every client. + * Provider values are forwarded verbatim; lease-provider normalization belongs to the daemon. */ export function buildDaemonRpcRequest( req: DaemonWireRequest, diff --git a/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts b/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts index def12038d9..edc3c4a9ee 100644 --- a/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts @@ -84,11 +84,17 @@ test('registered admission requires a valid process identity, token and endpoint [{ pid: 1, token: 'x', httpPort: 65535 }, true], ] as const) { fs.writeFileSync(file, JSON.stringify(record)); - assert.equal( - (await readRegisteredDaemon(resolveDaemonPaths(dir))) !== null, - valid, - JSON.stringify(record), - ); + if (valid) assert.ok(await readRegisteredDaemon(resolveDaemonPaths(dir))); + else { + for (const options of [undefined, { required: true }] as const) { + await assert.rejects( + readRegisteredDaemon(resolveDaemonPaths(dir), options), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'daemon-record-unreadable', + JSON.stringify(record), + ); + } + } } }); @@ -103,3 +109,27 @@ test('unproven ownership is refused before nullable or required record lookup', await assert.rejects(readRegisteredDaemon(paths), unproven); await assert.rejects(readRegisteredDaemon(paths, { required: true }), unproven); }); + +test('present invalid or unreadable metadata never admits a new daemon', async () => { + for (const contents of ['{not json', '[]', 'null', '{}']) { + const [stateDir] = scratchStateDir(); + const paths = resolveDaemonPaths(stateDir); + fs.writeFileSync(paths.infoPath, contents); + for (const options of [undefined, { required: true }] as const) { + await assert.rejects( + readRegisteredDaemon(paths, options), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'daemon-record-unreadable', + ); + } + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), contents); + } + const [stateDir] = scratchStateDir(); + const paths = resolveDaemonPaths(stateDir); + fs.mkdirSync(paths.infoPath); + await assert.rejects( + readRegisteredDaemon(paths), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'daemon-record-unreadable', + ); +}); diff --git a/packages/daemon-client/src/daemon-client-attach.ts b/packages/daemon-client/src/daemon-client-attach.ts index 3091c298a1..7a066918a9 100644 --- a/packages/daemon-client/src/daemon-client-attach.ts +++ b/packages/daemon-client/src/daemon-client-attach.ts @@ -1,3 +1,4 @@ +import { AppError } from '@agent-device/kernel/errors'; import { ensureManagedDaemon } from './daemon-client-lifecycle.ts'; import { assertDaemonPolicyMatches, observeLocalDaemon } from './daemon-client-startup.ts'; import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; @@ -10,7 +11,18 @@ export async function connectToDaemon(settings: DaemonClientSettings): Promise { + if ( + settings.daemonBinding && + error instanceof AppError && + error.details?.reason === 'daemon-record-unreadable' + ) { + throw daemonBindingMismatchError(settings.daemonBinding, null, stateDir); + } + throw error; + }, + ); if (!observed) { if (settings.daemonBinding) { throw daemonBindingMismatchError(settings.daemonBinding, null, stateDir); diff --git a/packages/daemon-client/src/daemon-client-metadata.ts b/packages/daemon-client/src/daemon-client-metadata.ts index 1c55d52313..35888bcbbd 100644 --- a/packages/daemon-client/src/daemon-client-metadata.ts +++ b/packages/daemon-client/src/daemon-client-metadata.ts @@ -73,6 +73,7 @@ export type LocalDaemonObservation = { /** * Reads the admitted registration without starting or compatibility-checking a daemon. * Missing or foreign registrations return null; `required: true` uses the normal not-running error. + * Unreadable or invalid records use the unreadable-record refusal. * An unproven registration always uses the normal ownership refusal. */ export function readRegisteredDaemon( @@ -94,7 +95,12 @@ export async function readRegisteredDaemon( const { daemonRegistrationUnprovenError } = await import('./daemon-lifecycle-errors.ts'); throw daemonRegistrationUnprovenError(paths, inspection); } - let info = readDaemonInfo(paths.infoPath); + const record = readDaemonRecord(paths.infoPath); + let info = record.status === 'present' ? parseDaemonInfo(record.record) : null; + if (!info && record.status !== 'absent') { + const { daemonRecordUnreadableError } = await import('./daemon-lifecycle-errors.ts'); + throw daemonRecordUnreadableError(paths); + } if (info && inspection.state !== 'absent') { if ( !processLockHoldsDaemonIdentity(inspection, { @@ -114,7 +120,10 @@ export async function readRegisteredDaemon( export function readDaemonInfo(infoPath: string): DaemonInfo | null { const read = readDaemonRecord(infoPath); if (read.status !== 'present') return null; - const parsed = read.record; + return parseDaemonInfo(read.record); +} + +function parseDaemonInfo(parsed: Readonly>): DaemonInfo | null { const token = readRequiredDaemonToken(parsed); const ports = readDaemonInfoPorts(parsed); const pid = parsed.pid; diff --git a/packages/daemon-client/src/daemon-client-startup.ts b/packages/daemon-client/src/daemon-client-startup.ts index 3e24356cad..9380655b07 100644 --- a/packages/daemon-client/src/daemon-client-startup.ts +++ b/packages/daemon-client/src/daemon-client-startup.ts @@ -90,6 +90,7 @@ const DAEMON_STARTUP_ATTEMPTS = 2; const DAEMON_STARTUP_LOG_TAIL_BYTES = 64_000; const DAEMON_STARTUP_MARKER_TAIL_BYTES = 16_000; +/** Judges the registered daemon against this client, changing nothing. */ export async function observeLocalDaemon( settings: DaemonObservationSettings, options: DaemonObservationOptions = {}, diff --git a/packages/daemon-client/src/daemon-client-stop.ts b/packages/daemon-client/src/daemon-client-stop.ts index c4ea5d4a23..bf6b1e74be 100644 --- a/packages/daemon-client/src/daemon-client-stop.ts +++ b/packages/daemon-client/src/daemon-client-stop.ts @@ -1,5 +1,4 @@ import { readDaemonShutdownReport } from '@agent-device/daemon-contracts/daemon-shutdown-report'; -import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { observedDaemonIdentityOfRegistration, type DaemonStopIdentity, @@ -9,7 +8,7 @@ import { mergeDaemonShutdownReport, type DaemonStopResult, } from '@agent-device/daemon-contracts/daemon-stop'; -import { AppError } from '@agent-device/kernel/errors'; +import { daemonRecordUnreadableError } from './daemon-lifecycle-errors.ts'; import type { DaemonLifecycleTarget } from './daemon-client-lifecycle-request.ts'; export async function stopRecordedDaemon( @@ -26,15 +25,3 @@ export async function stopRecordedDaemon( stopped.mode === 'graceful' ? readDaemonShutdownReport(target.paths.baseDir) : null, ); } - -function daemonRecordUnreadableError(paths: DaemonPaths): AppError { - return new AppError( - 'COMMAND_FAILED', - `The daemon record on ${paths.baseDir} cannot be read, so this stop cannot tell whether a daemon runs.`, - { - reason: 'daemon-record-unreadable', - stateDir: paths.baseDir, - hint: 'Nothing was signaled. Inspect or remove that record, then retry.', - }, - ); -} diff --git a/packages/daemon-client/src/daemon-lifecycle-errors.ts b/packages/daemon-client/src/daemon-lifecycle-errors.ts index f8c36aff6b..5d26ddcf6c 100644 --- a/packages/daemon-client/src/daemon-lifecycle-errors.ts +++ b/packages/daemon-client/src/daemon-lifecycle-errors.ts @@ -229,3 +229,15 @@ export function daemonRegistrationUnprovenError( hint: resolveDaemonStartupHint(getDaemonMetadataState(paths), paths), }); } + +export function daemonRecordUnreadableError(paths: DaemonPaths): AppError { + return new AppError( + 'COMMAND_FAILED', + `The daemon record on ${paths.baseDir} cannot identify a usable daemon.`, + { + reason: 'daemon-record-unreadable', + stateDir: paths.baseDir, + hint: 'No daemon was started, replaced or signaled. Inspect the retained record and its ownership before retrying.', + }, + ); +} diff --git a/scripts/ios-snapshot-benchmark/cell-admission.test.ts b/scripts/ios-snapshot-benchmark/cell-admission.test.ts new file mode 100644 index 0000000000..a90bfdd939 --- /dev/null +++ b/scripts/ios-snapshot-benchmark/cell-admission.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { prepareSampleState } from './cell-admission.ts'; +import { BenchmarkCellAdmissionError } from './lifecycle.ts'; + +vi.mock('./lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), + readSimulatorState: () => 'Booted', + readRunningAppPids: () => { + throw new Error('daemon admission must stop before the app probe'); + }, +})); + +test('an unproven registration stops a warm benchmark cell with its typed admission reason', async () => { + const stateDir = mkdtempForTestSync('benchmark-daemon-admission-'); + fs.writeFileSync(resolveDaemonPaths(stateDir).lockPath, 'not a lock directory'); + await assert.rejects( + prepareSampleState({ + repoRoot: process.cwd(), + stateDir, + derivedPath: stateDir, + udid: 'unused', + samples: 1, + state: 'warm', + fixture: { + id: 'quiet', + label: 'Unused', + app: 'unused', + anchorText: 'Unused', + }, + }), + (error: unknown) => + error instanceof BenchmarkCellAdmissionError && + error.reason === 'cell-state' && + error.cause instanceof AppError && + error.cause.details?.reason === 'daemon_registration_unproven', + ); +}); diff --git a/scripts/ios-snapshot-benchmark/cell-admission.ts b/scripts/ios-snapshot-benchmark/cell-admission.ts index 31de2c884d..a35652a1c5 100644 --- a/scripts/ios-snapshot-benchmark/cell-admission.ts +++ b/scripts/ios-snapshot-benchmark/cell-admission.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { readRegisteredDaemon } from '@agent-device/daemon-client'; import { isAgentDeviceDaemonProcess } from '@agent-device/daemon-contracts/daemon-process'; @@ -204,7 +205,16 @@ function assertDaemonStopped(stateDir: string): void { } async function assertDaemonRunning(stateDir: string): Promise { - const info = await readRegisteredDaemon(resolveDaemonPaths(stateDir)); + const info = await readRegisteredDaemon(resolveDaemonPaths(stateDir)).catch((error: unknown) => { + if (!(error instanceof AppError)) throw error; + const refused = new BenchmarkCellAdmissionError( + 'cell-state', + `Daemon admission failed for ${stateDir}: ${error.message}`, + 'agent-device daemon status', + ); + refused.cause = error; + throw refused; + }); if (!info?.processStartTime || !isAgentDeviceDaemonProcess(info.pid, info.processStartTime)) { throw new BenchmarkCellAdmissionError( 'cell-state', diff --git a/test/output-economy/owner-files-no-leak.test.ts b/test/output-economy/owner-files-no-leak.test.ts index efbef621d6..ca3a7f2435 100644 --- a/test/output-economy/owner-files-no-leak.test.ts +++ b/test/output-economy/owner-files-no-leak.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import fs from 'node:fs'; +import { createHash } from 'node:crypto'; import path from 'node:path'; import { runCmdSync } from '@agent-device/host-kit/command'; import { @@ -18,7 +19,7 @@ function collectJsFiles(dir: string): string[] { describe('owner-file metadata', () => { test('does not leak into production bundles after a clean build', { timeout: 30_000 }, () => { const sharedDistPath = path.join(repoRoot, 'dist'); - const sharedDistModified = fs.statSync(sharedDistPath, { throwIfNoEntry: false })?.mtimeMs; + const sharedDistContents = readBuildOutputs(sharedDistPath); const distPath = path.join(mkdtempForTestSync('owner-files-no-leak-'), 'dist'); const build = runCmdSync( @@ -35,9 +36,9 @@ describe('owner-file metadata', () => { ); expect(build.exitCode, build.stderr).toBe(0); expect( - fs.statSync(sharedDistPath, { throwIfNoEntry: false })?.mtimeMs, - 'the metadata build must preserve the shared dist used by other tests', - ).toBe(sharedDistModified); + readBuildOutputs(sharedDistPath), + 'the metadata build must preserve the shared dist contents used by other tests', + ).toEqual(sharedDistContents); expect( fs.existsSync(path.join(distPath, 'sdk-selectors.js')), @@ -65,3 +66,22 @@ describe('owner-file metadata', () => { } }); }); + +function readBuildOutputs(dir: string): Record | null { + if (!fs.existsSync(dir)) return null; + return Object.fromEntries( + fs + .readdirSync(dir, { recursive: true, encoding: 'utf8' }) + .sort() + .map((name) => { + const file = path.join(dir, name); + const stat = fs.lstatSync(file); + const contents = stat.isDirectory() + ? 'directory' + : stat.isSymbolicLink() + ? `symlink:${fs.readlinkSync(file)}` + : createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + return [name, contents]; + }), + ); +} From aac253f77ecb06fe10e850943fab631a1e5b5fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 31 Oct 2026 09:21:39 +0100 Subject: [PATCH 17/17] fix(benchmark): retain cell admission failures when cleanup refuses --- .../local-runner.test.ts | 58 +++++++++++++++++++ .../ios-snapshot-benchmark/local-runner.ts | 27 ++++++++- src/cli/commands/__tests__/daemon.test.ts | 8 ++- 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 scripts/ios-snapshot-benchmark/local-runner.test.ts diff --git a/scripts/ios-snapshot-benchmark/local-runner.test.ts b/scripts/ios-snapshot-benchmark/local-runner.test.ts new file mode 100644 index 0000000000..e315931bbc --- /dev/null +++ b/scripts/ios-snapshot-benchmark/local-runner.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import { BenchmarkCellAdmissionError, BenchmarkInfrastructureError } from './lifecycle.ts'; +import { runLocalMeasurements } from './local-runner.ts'; + +const failures = vi.hoisted(() => ({ + admission: undefined as unknown, + cleanup: undefined as unknown, +})); + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawnSync: () => ({ status: 0, stdout: '', stderr: '' }), +})); +vi.mock('./cell-admission.ts', async (importOriginal) => ({ + ...(await importOriginal()), + prepareCellState: () => {}, + admitReadyCell: () => { + throw failures.admission; + }, +})); +vi.mock('./lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), + stopDaemon: () => { + if (failures.cleanup) throw failures.cleanup; + }, +})); + +test.for([false, true])( + 'cell-state refusal survives cleanup (cleanup failed: %s)', + async (cleanupFails) => { + const admission = new BenchmarkCellAdmissionError('cell-state', 'Registration is unproven.'); + const cleanup = new BenchmarkInfrastructureError('Daemon cleanup refused.'); + failures.admission = admission; + failures.cleanup = cleanupFails ? cleanup : undefined; + await assert.rejects( + runLocalMeasurements({ + repoRoot: process.cwd(), + stateDir: '/unused/state', + derivedPath: '/unused/derived', + udid: 'unused', + samples: 1, + states: ['warm'], + fixtures: [{ id: 'quiet', label: 'Unused', app: 'unused', anchorText: 'Unused' }], + }), + (error: unknown) => { + if (!cleanupFails) return error === admission; + return ( + error instanceof BenchmarkCellAdmissionError && + error.reason === 'cell-state' && + error.cause instanceof AggregateError && + error.cause.errors[0] === admission && + error.cause.errors[1] === cleanup + ); + }, + ); + }, +); diff --git a/scripts/ios-snapshot-benchmark/local-runner.ts b/scripts/ios-snapshot-benchmark/local-runner.ts index 8012402d15..f403e37f69 100644 --- a/scripts/ios-snapshot-benchmark/local-runner.ts +++ b/scripts/ios-snapshot-benchmark/local-runner.ts @@ -17,7 +17,7 @@ import { type CellAdmissionOptions, } from './cell-admission.ts'; import { sampleMinimumForState } from './definitions.ts'; -import { stopDaemon } from './lifecycle.ts'; +import { BenchmarkCellAdmissionError, stopDaemon } from './lifecycle.ts'; import { buildMeasurement } from './statistics.ts'; import type { LocalState, Measurement, RawSample, ScreenFixture } from './types.ts'; @@ -44,6 +44,7 @@ export async function runLocalMeasurements(options: LocalRunnerOptions): Promise async function runCell(options: CellAdmissionOptions): Promise { const context = contextFor(options); let appPid: number | undefined; + let admissionFailure: BenchmarkCellAdmissionError | undefined; try { prepareCellState(options); appPid = await admitReadyCell(context, options); @@ -66,9 +67,33 @@ async function runCell(options: CellAdmissionOptions): Promise { operation: measuredOperation(options.state), samples, }); + } catch (error) { + if (error instanceof BenchmarkCellAdmissionError) admissionFailure = error; + throw error; } finally { closeSession(context); + stopCellDaemon(options, admissionFailure); + } +} + +function stopCellDaemon( + options: CellAdmissionOptions, + admissionFailure: BenchmarkCellAdmissionError | undefined, +): void { + try { stopDaemon(options.repoRoot, options.stateDir); + } catch (error) { + if (!admissionFailure) throw error; + const failure = new BenchmarkCellAdmissionError( + admissionFailure.reason, + `${admissionFailure.message} Cleanup also failed: ${error instanceof Error ? error.message : String(error)}`, + admissionFailure.command, + ); + failure.cause = new AggregateError( + [admissionFailure, error], + 'Cell admission and daemon cleanup failed.', + ); + throw failure; } } diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index 1f51fd282a..14ddb7f2fc 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -340,7 +340,13 @@ test.each([ /Nothing was signaled\. Check what owns that process, then repair the record: stop it by its own means and remove its daemon\.json/, ], ['unreadable', true, '{not json', 'daemon-clean-not-detached', /remove .*daemon\.json/], - ['unreadable', false, '{not json', 'daemon-record-unreadable', /Inspect or remove that record/], + [ + 'unreadable', + false, + '{not json', + 'daemon-record-unreadable', + /Inspect the retained record and its ownership before retrying/, + ], ] as const)('daemon stop refuses %s with clean=%s', async (_label, clean, record, reason, hint) => { const stateDir = mkdtempForTestSync('agent-device-daemon-command-refused-'); fs.writeFileSync(`${stateDir}/daemon.json`, record, { mode: 0o600 });