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 bcf480b7ab..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. @@ -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/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..f865289de2 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__/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, @@ -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/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 4ff5eaca6d..3f6e2b9966 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,70 @@ 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. + * Provider values are forwarded verbatim; lease-provider normalization belongs to the daemon. + */ +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/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-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-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-explicit-lifecycle.test.ts b/packages/daemon-client/src/__tests__/daemon-client-ensure-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-ensure-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-ensure-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-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 93% 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..8c5c7ee834 100644 --- a/src/daemon-client/__tests__/daemon-client-ensuring.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts @@ -1,9 +1,13 @@ 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 '../../__tests__/test-utils/registered-daemon-fixture.ts'; +import { finishRegisteredDaemonFixtures } from './registered-daemon-fixture.ts'; import { busy, createDaemonLeaver, @@ -22,7 +26,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 @@ -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/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-embedded-lifecycle.test.ts b/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts similarity index 96% rename from src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts index 6432a9170e..1e896dd48f 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/packages/daemon-client/src/__tests__/daemon-client-lifecycle-embedded.test.ts @@ -12,12 +12,13 @@ import { skipWhenLoopbackUnavailable, listenOnLoopback, } from '@agent-device/test-kit'; +import * as metadata from '../daemon-client-metadata.ts'; import * as startup from '../daemon-client-startup.ts'; import type { DaemonBinding } from '@agent-device/contracts/client'; import { DAEMON_LIFECYCLE_REASONS } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; 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,9 +32,8 @@ 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'; 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,7 +183,7 @@ 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'); @@ -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()); 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/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts b/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts new file mode 100644 index 0000000000..edc3c4a9ee --- /dev/null +++ b/packages/daemon-client/src/__tests__/daemon-client-metadata.test.ts @@ -0,0 +1,135 @@ +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, + 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 +// through `daemon.json`: a client cannot compare what the file lost (#2458). + +function scratchStateDir(): [stateDir: string, infoPath: string] { + const stateDir = mkdtempForTestSync('agent-device-daemon-identity-'); + return [stateDir, path.join(stateDir, 'daemon.json')]; +} + +async function publishInfo(codeOrigin: DaemonCodeOrigin): Promise { + const [stateDir, infoPath] = scratchStateDir(); + const registration = await tryAcquireDaemonRegistration(resolveDaemonPaths(stateDir)); + assert.equal(registration.status, 'acquired'); + if (registration.status !== 'acquired') throw new Error('registration refused'); + registration.owner.publish({ + httpPort: 41_234, + token: 'local-secret', + version: '0.0.0-test', + codeOrigin, + codeSignature: 'graph:1:abc', + }); + const published = readDaemonInfo(infoPath); + await registration.owner.finish(); + return published; +} + +test('a daemon publishes the code origin its client reads back', async () => { + for (const codeOrigin of ['installed', 'checkout'] as const) { + assert.equal((await publishInfo(codeOrigin))?.codeOrigin, codeOrigin); + } +}); + +test('a registration this version did not write reads back unreported', () => { + // Shaped like a daemon published before the field existed, and like a value no + // version of this writer produces. + for (const contents of [ + { httpPort: 41_234, token: 'local-secret', pid: 7 }, + { httpPort: 41_234, token: 'local-secret', pid: 7, codeOrigin: 'something-else' }, + ]) { + const [, infoPath] = scratchStateDir(); + fs.writeFileSync(infoPath, JSON.stringify(contents)); + + assert.equal(readDaemonInfo(infoPath)?.codeOrigin, undefined); + } +}); + +test('a daemon record without capabilities reads as a daemon with none', () => { + const [, infoPath] = scratchStateDir(); + fs.writeFileSync(infoPath, JSON.stringify({ httpPort: 41_234, token: 'legacy', pid: 1 })); + assert.deepEqual(readDaemonInfo(infoPath)?.capabilities, []); + fs.writeFileSync( + infoPath, + JSON.stringify({ httpPort: 41_234, token: 'new', pid: 1, capabilities: ['quiesce', 'future'] }), + ); + 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)); + 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), + ); + } + } + } +}); + +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); +}); + +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/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/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-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-abort-timeout.test.ts b/packages/daemon-client/src/__tests__/daemon-client-transport-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-transport-abort-timeout.test.ts diff --git a/src/daemon-client/__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 src/daemon-client/__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/src/daemon-client/__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/src/daemon-client/__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 src/daemon-client/__tests__/daemon-client-request-guard.test.ts rename to packages/daemon-client/src/__tests__/daemon-client-transport-request-guard.test.ts 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-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/src/daemon-client/__tests__/daemon-client-developer-dir.test.ts b/packages/daemon-client/src/__tests__/index-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__/index-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__/index-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/packages/daemon-client/src/__tests__/index-host.test.ts b/packages/daemon-client/src/__tests__/index-host.test.ts new file mode 100644 index 0000000000..a7560f3126 --- /dev/null +++ b/packages/daemon-client/src/__tests__/index-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-header.test.ts b/packages/daemon-client/src/__tests__/index-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__/index-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__/index-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-newer-daemon.test.ts b/packages/daemon-client/src/__tests__/index-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__/index-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__/index-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__/index-policy-reuse.test.ts similarity index 86% rename from src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts rename to packages/daemon-client/src/__tests__/index-policy-reuse.test.ts index 7645c0dde5..f40bb80ad4 100644 --- a/src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts +++ b/packages/daemon-client/src/__tests__/index-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 { createDaemonTransport, sendToDaemon } from '../index.ts'; // ADR 0029: a caller that names a daemon policy must not use a daemon that enforces another one. @@ -33,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/src/daemon-client/__tests__/daemon-client-stalled-probe.test.ts b/packages/daemon-client/src/__tests__/index-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__/index-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__/index-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.test.ts b/packages/daemon-client/src/__tests__/index.test.ts similarity index 99% rename from src/daemon-client/__tests__/daemon-client.test.ts rename to packages/daemon-client/src/__tests__/index.test.ts index a7be7d788f..81ba33b6b3 100644 --- a/src/daemon-client/__tests__/daemon-client.test.ts +++ b/packages/daemon-client/src/__tests__/index.test.ts @@ -11,17 +11,15 @@ 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'; +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/__tests__/registered-daemon-fixture.ts b/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts new file mode 100644 index 0000000000..e38072c789 --- /dev/null +++ b/packages/daemon-client/src/__tests__/registered-daemon-fixture.ts @@ -0,0 +1,25 @@ +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 { + 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..a07ca576c9 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 './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/src/daemon-client/daemon-client-attach.ts b/packages/daemon-client/src/daemon-client-attach.ts similarity index 82% rename from src/daemon-client/daemon-client-attach.ts rename to packages/daemon-client/src/daemon-client-attach.ts index 3091c298a1..7a066918a9 100644 --- a/src/daemon-client/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/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 97% rename from src/daemon-client/daemon-client-ensuring.ts rename to packages/daemon-client/src/daemon-client-ensuring.ts index a43275ba5a..f7cbb801d4 100644 --- a/src/daemon-client/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 './daemon-client.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/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 51% rename from src/daemon-client/daemon-client-metadata.ts rename to packages/daemon-client/src/daemon-client-metadata.ts index d5f6a1a4e4..35888bcbbd 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/packages/daemon-client/src/daemon-client-metadata.ts @@ -1,4 +1,4 @@ -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 +10,7 @@ import { type DaemonCapability, type DaemonOwnership, type DaemonPaths, + type DaemonTransportPreference, type DaemonServerMode, type ObservedDaemonIdentity, } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -40,18 +41,95 @@ export type DaemonInfo = { remoteUpstreamInstanceId?: string; }; -type DaemonMetadataState = { - hasInfo: boolean; - hasLock: boolean; +/** + * 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. + * Unreadable or invalid records use the unreadable-record refusal. + * 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-lifecycle-errors.ts'); + throw daemonRegistrationUnprovenError(paths, inspection); + } + 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, { + 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; +} + 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); - 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 +149,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 +174,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,27 +188,12 @@ 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; -} - -export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState { - return { - hasInfo: fs.existsSync(paths.infoPath), - hasLock: fs.existsSync(paths.lockPath), - }; +function readDaemonPort(value: unknown): number | undefined { + return Number.isInteger(value) && Number(value) > 0 && Number(value) <= 65_535 + ? Number(value) + : undefined; } 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/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..49dce4b81c --- /dev/null +++ b/packages/daemon-client/src/daemon-client-proxy-upstream.ts @@ -0,0 +1,39 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { ensureManagedDaemon, resolveClientSettings } from './daemon-client-lifecycle.ts'; + +export type ProxyUpstreamDaemonOptions = { + stateDir?: string; + host: HostEnvironment; +}; + +export type ProxyUpstreamDaemon = { + httpPort: number | undefined; + token: string; + stateDir: string; +}; + +export async function ensureProxyUpstreamDaemon( + options: ProxyUpstreamDaemonOptions, +): 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/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/src/daemon-client/daemon-client-rpc.ts b/packages/daemon-client/src/daemon-client-rpc.ts similarity index 76% rename from src/daemon-client/daemon-client-rpc.ts rename to packages/daemon-client/src/daemon-client-rpc.ts index afe33b6201..6d7c74d952 100644 --- a/src/daemon-client/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/src/daemon-client/daemon-client-startup.ts b/packages/daemon-client/src/daemon-client-startup.ts similarity index 94% rename from src/daemon-client/daemon-client-startup.ts rename to packages/daemon-client/src/daemon-client-startup.ts index 997cca5568..9380655b07 100644 --- a/src/daemon-client/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,16 +24,15 @@ import type { DaemonPaths, DaemonTransportPreference, } from '@agent-device/daemon-contracts/daemon-resolution'; +import { resolveDaemonLaunchSpec, resolveDaemonTakeover } from './daemon-launch-spec.ts'; import { - resolveDaemonLaunchSpec, - resolveDaemonTakeover, + readRegisteredDaemon, type DaemonTakeoverDecision, -} from './daemon-launch-spec.ts'; -import { - getDaemonMetadataState, + 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,14 +40,12 @@ import { daemonContendedError, daemonTakeoverRetirementError, daemonStopCommand, + daemonRegistrationUnprovenError, + getDaemonMetadataState, + resolveDaemonStartupHint, } 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 } @@ -96,19 +92,12 @@ 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; - } = {}, + 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); + const existing = await readRegisteredDaemon(settings.paths); if (!existing) return null; - if (!registrationAllowsDaemonObservation(inspection, existing)) return null; if (options.waitForLiveStartup && (await isAwaitingDaemonStartup(existing, deadline))) return null; @@ -166,31 +155,6 @@ 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, - 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), - }); -} - export async function stopDaemonForTakeover( existing: DaemonInfo, paths: DaemonPaths, @@ -552,7 +516,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 +613,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/src/daemon-client/daemon-client-stop.ts b/packages/daemon-client/src/daemon-client-stop.ts similarity index 63% rename from src/daemon-client/daemon-client-stop.ts rename to packages/daemon-client/src/daemon-client-stop.ts index c4ea5d4a23..bf6b1e74be 100644 --- a/src/daemon-client/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/src/daemon-client/daemon-client-timeout.ts b/packages/daemon-client/src/daemon-client-timeout.ts similarity index 98% rename from src/daemon-client/daemon-client-timeout.ts rename to packages/daemon-client/src/daemon-client-timeout.ts index 550e7d3887..2db4534ef7 100644 --- a/src/daemon-client/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 -// src/daemon-client/__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/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 91% rename from src/daemon-client/daemon-launch-spec.ts rename to packages/daemon-client/src/daemon-launch-spec.ts index 64ca84bcc8..3280fd7fca 100644 --- a/src/daemon-client/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; @@ -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 }; @@ -107,19 +107,6 @@ export async function resolveLocalDaemonCodeIdentity(): Promise, +): 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), + }); +} + +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/src/daemon-client/daemon-client.ts b/packages/daemon-client/src/index.ts similarity index 81% rename from src/daemon-client/daemon-client.ts rename to packages/daemon-client/src/index.ts index ddbe6e9611..f43dd2c360 100644 --- a/src/daemon-client/daemon-client.ts +++ b/packages/daemon-client/src/index.ts @@ -1,7 +1,7 @@ -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 { 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'; @@ -19,15 +19,22 @@ 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, type DaemonInfo } from './daemon-client-metadata.ts'; +import { createRequestGuard, sendRequest as sendDaemonRequest } from './daemon-client-transport.ts'; +import { isRemoteDaemon, readRegisteredDaemon, 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, + 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 }; +export { readRegisteredDaemon }; /** The released default: a process-level client, which reads the process's own host. */ export async function sendToDaemon( @@ -140,7 +147,7 @@ async function sendToDaemonOnHost( const response = await withDiagnosticTimer( 'daemon_request', async () => - await sendRequest( + await sendDaemonRequest( info, request, settings.transportPreference, @@ -214,7 +221,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( @@ -393,3 +400,50 @@ async function protectArtifactUploadWithLeaseBeats( callerSignal, }); } + +/** Guards a client call against cancellation before dispatch and during connection-free phases. */ +export { createRequestGuard }; + +/** + * 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 { sendDaemonRequest }; + +/** + * 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. 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: ProxyUpstreamDaemonOptions, +): 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/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..f2797cd046 --- /dev/null +++ b/packages/plugins/src/credential-fingerprint.ts @@ -0,0 +1,54 @@ +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. Provider names are trimmed before lookup. 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.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( + 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..db1dcd0af3 100644 --- a/packages/plugins/src/store.ts +++ b/packages/plugins/src/store.ts @@ -1,9 +1,18 @@ +import { + legacyStoreManifests, + readPluginConfig, + validatePluginSelection, + storeHome, + installationDirectory, + isVersionSelector, + type PluginSelection, +} from './store-legacy.ts'; 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,46 +21,10 @@ 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}`); - } -} +export { legacyStoreManifests }; type StoreInstallation = { name: string; @@ -61,14 +34,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 +132,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 +279,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/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.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 685eee23b5..a35652a1c5 100644 --- a/scripts/ios-snapshot-benchmark/cell-admission.ts +++ b/scripts/ios-snapshot-benchmark/cell-admission.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { readDaemonInfo } from '../../src/daemon-client/daemon-client-metadata.ts'; +import { readRegisteredDaemon } from '@agent-device/daemon-client'; import { isAgentDeviceDaemonProcess } from '@agent-device/daemon-contracts/daemon-process'; import { openFixture, @@ -53,7 +54,7 @@ export function prepareCellState(options: CellAdmissionOptions): void { } } -export function prepareSampleState(options: CellAdmissionOptions): void { +export async function prepareSampleState(options: CellAdmissionOptions): Promise { if (options.state === 'cold-cold') { prepareColdColdState(options); return; @@ -67,7 +68,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 +136,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 +159,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 +176,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 +204,17 @@ function assertDaemonStopped(stateDir: string): void { } } -function assertDaemonRunning(stateDir: string): void { - const info = readDaemonInfo(resolveDaemonPaths(stateDir).infoPath); +async function assertDaemonRunning(stateDir: string): Promise { + 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/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 c926ea62a8..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,12 +44,13 @@ 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); 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); @@ -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/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..c89ef68318 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, 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. 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 [ @@ -137,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') }, @@ -208,15 +216,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 +250,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..0853d53d25 100644 --- a/scripts/layering/root-module-zones.test.ts +++ b/scripts/layering/root-module-zones.test.ts @@ -48,12 +48,23 @@ 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', + 'export const createRequestGuard = 1;', ], - ['src/daemon-client/daemon-client-transport.ts', 'export const createRequestGuard = 1;'], ]), ); + 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/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.test.ts b/scripts/lib/recorded-daemon.test.ts new file mode 100644 index 0000000000..b6468ec1c1 --- /dev/null +++ b/scripts/lib/recorded-daemon.test.ts @@ -0,0 +1,198 @@ +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 { tryAcquireProcessLock } from '@agent-device/host-kit/file'; +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 +// 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 }), + (error: unknown) => isRequestCanceledError(error) && error.details?.dispatched === 'no', + ); + + 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' && + error.details?.stateDir === stateDir, + ); + + assert.equal(fs.existsSync(resolveDaemonPaths(stateDir).infoPath), false); +}); + +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 { + assert.equal(await readRegisteredDaemon(daemon.paths), null); + await assert.rejects( + send(daemon.stateDir), + (error: unknown) => + 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-not-running', + ); + 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-not-running', + ); + assert.equal(daemon.connections(), 0); +}); diff --git a/scripts/lib/recorded-daemon.ts b/scripts/lib/recorded-daemon.ts new file mode 100644 index 0000000000..be1b45ed8c --- /dev/null +++ b/scripts/lib/recorded-daemon.ts @@ -0,0 +1,25 @@ +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +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'; + +/** 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 }, +): Promise { + const paths = resolveDaemonPaths(options.stateDir, { + env: options.host.env, + cwd: options.host.cwd, + }); + const info = await readRegisteredDaemon(paths, { required: true }); + 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 b72619b3e3..6e5d5c3255 100644 --- a/scripts/perf/forced-backend.ts +++ b/scripts/perf/forced-backend.ts @@ -2,12 +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 { 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 { 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'; @@ -62,28 +60,24 @@ 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); - if (!info) throw new Error(`No daemon is running for state dir ${stateDir}.`); - return await sendRequest( - info, + return await sendToRecordedDaemon( { ...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' }, + { + host: captureHostEnvironment(process), + stateDir, + 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..9a9c72f9b7 --- /dev/null +++ b/src/__tests__/daemon-client-host.test.ts @@ -0,0 +1,75 @@ +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'; + +// 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'; + +let daemon: RegisteredHttpDaemon | undefined; + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.mocked(runCmdDetachedMonitored).mockClear(); + await daemon?.close(); + daemon = undefined; +}); + +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(daemon!.paths.baseDir); + await client.devices.list(); + + 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 () => { + 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 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); + + 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/daemon-client/__tests__/daemon-client-provider-credentials.test.ts b/src/__tests__/daemon-client-provider-credentials.test.ts similarity index 80% rename from src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts rename to src/__tests__/daemon-client-provider-credentials.test.ts index c3e2798d3e..fd7263109c 100644 --- a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts +++ b/src/__tests__/daemon-client-provider-credentials.test.ts @@ -3,28 +3,27 @@ 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 '../../provider-credential-fingerprint.ts'; -import { builtinPluginInventory } from '../../__tests__/test-utils/builtin-plugin-inventory.ts'; +import { providerCredentialFingerprint as fingerprintFor } from '@agent-device/plugins/credential-fingerprint'; +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'; +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', @@ -50,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, @@ -58,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 } : {}), }, @@ -68,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'); @@ -81,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); }, @@ -146,7 +158,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 +177,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 +193,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/__tests__/test-utils/registered-http-daemon.ts b/src/__tests__/test-utils/registered-http-daemon.ts new file mode 100644 index 0000000000..7853c0c4a3 --- /dev/null +++ b/src/__tests__/test-utils/registered-http-daemon.ts @@ -0,0 +1,107 @@ +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: [] }); + try { + const child = spawnRegisteredDaemonFixture(paths, await registrationFor(stub.port), undefined); + await waitForRegisteredDaemonFixture(paths, child); + } catch (error) { + // Both teardown steps run, and neither failure replaces the startup error. + await Promise.allSettled([ + finishRegisteredDaemonFixture(stateDir), + closeLoopbackServer(stub.server), + ]); + throw error; + } + 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: [] }); + let fields: DaemonRegistrationFields; + try { + fields = await registrationFor(stub.port); + } catch (error) { + await Promise.allSettled([closeLoopbackServer(stub.server)]); + throw error; + } + 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/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__/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 }); 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/__tests__/daemon-client-metadata.test.ts b/src/daemon-client/__tests__/daemon-client-metadata.test.ts deleted file mode 100644 index 7c664f5969..0000000000 --- a/src/daemon-client/__tests__/daemon-client-metadata.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -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 { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; - -// The reuse decision is only as good as the identity that survives the round trip -// through `daemon.json`: a client cannot compare what the file lost (#2458). - -function scratchStateDir(): [stateDir: string, infoPath: string] { - const stateDir = mkdtempForTestSync('agent-device-daemon-identity-'); - return [stateDir, path.join(stateDir, 'daemon.json')]; -} - -async function publishInfo(codeOrigin: DaemonCodeOrigin): Promise { - const [stateDir, infoPath] = scratchStateDir(); - const registration = await tryAcquireDaemonRegistration(resolveDaemonPaths(stateDir)); - assert.equal(registration.status, 'acquired'); - if (registration.status !== 'acquired') throw new Error('registration refused'); - registration.owner.publish({ - httpPort: 41_234, - token: 'local-secret', - version: '0.0.0-test', - codeOrigin, - codeSignature: 'graph:1:abc', - }); - const published = readDaemonInfo(infoPath); - await registration.owner.finish(); - return published; -} - -test('a daemon publishes the code origin its client reads back', async () => { - for (const codeOrigin of ['installed', 'checkout'] as const) { - assert.equal((await publishInfo(codeOrigin))?.codeOrigin, codeOrigin); - } -}); - -test('a registration this version did not write reads back unreported', () => { - // Shaped like a daemon published before the field existed, and like a value no - // version of this writer produces. - for (const contents of [ - { httpPort: 41_234, token: 'local-secret', pid: 7 }, - { httpPort: 41_234, token: 'local-secret', pid: 7, codeOrigin: 'something-else' }, - ]) { - const [, infoPath] = scratchStateDir(); - fs.writeFileSync(infoPath, JSON.stringify(contents)); - - assert.equal(readDaemonInfo(infoPath)?.codeOrigin, undefined); - } -}); - -test('a daemon record without capabilities reads as a daemon with none', () => { - const [, infoPath] = scratchStateDir(); - fs.writeFileSync(infoPath, JSON.stringify({ httpPort: 41_234, token: 'legacy', pid: 1 })); - assert.deepEqual(readDaemonInfo(infoPath)?.capabilities, []); - fs.writeFileSync( - infoPath, - JSON.stringify({ httpPort: 41_234, token: 'new', pid: 1, capabilities: ['quiesce', 'future'] }), - ); - assert.deepEqual(readDaemonInfo(infoPath)?.capabilities, ['quiesce']); -}); 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/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/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..c8359e2276 100644 --- a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts +++ b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts @@ -1,58 +1,61 @@ 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 { supportsLoopbackBind } from '@agent-device/test-kit'; import { - launchesFreshDaemonAt, - okResult, - startScriptedDaemon, + launchesFreshHttpDaemon, stateDirAndPaths, -} from '../../daemon-client/__tests__/explicit-lifecycle-fixture.ts'; +} 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 daemonClient from '@agent-device/daemon-client'; 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. +// 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(async () => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); spawn.mockReset(); - await finishRegisteredDaemonFixtures(); + await close?.(); + close = undefined; }); 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); + const daemon = await launchesFreshHttpDaemon(spawn, paths); + close = daemon.close; vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const ensure = vi.spyOn(ensureModule, 'ensureDaemon'); - 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(); - try { - const first = await executor.execute('devices', {}); - const second = await executor.execute('devices', {}); + 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); + 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/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/provider-credential-fingerprint.test.ts b/src/provider-credential-fingerprint.test.ts index 374d66a4d3..6ef316818d 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'; @@ -36,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/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)}`; -} 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..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 { readDaemonInfo } from '../../../src/daemon-client/daemon-client-metadata.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 = readDaemonInfo(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)}`, diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index 73874e01c1..c5631db187 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -7,9 +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 { readDaemonInfo } from '../../../src/daemon-client/daemon-client-metadata.ts'; -import { sendRequest } from '../../../src/daemon-client/daemon-client-transport.ts'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { sendToRecordedDaemon } from '../../../scripts/lib/recorded-daemon.ts'; import { assertPngFile } from '../provider-scenarios/assertions.ts'; import { assertFilesDiffer, @@ -60,20 +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 = readDaemonInfo(paths.infoPath); - assert.ok(info, 'backend conformance requires the daemon prepared by the built CLI'); - return await sendRequest( - info, - { ...request, session: request.session, token: info.token }, - 'auto', - paths, - 90_000, - { - host: createTestHostEnvironment(), - signal: context?.signal, - timeoutRecovery: 'none', - }, + return await sendToRecordedDaemon( + { ...request, session: request.session }, + { host: createTestHostEnvironment(), stateDir, 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..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 '../../../src/daemon-client/daemon-client-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 = buildScenarioRpcRequest( { token: harness.token, session: 'browserstack-e2e', 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/integration/provider-scenarios/harness-proxy.ts b/test/integration/provider-scenarios/harness-proxy.ts index 7c118eca51..92be51b389 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 { + 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 f7d5ec03e2..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 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 { + buildScenarioRpcRequest, 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 = buildScenarioRpcRequest(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/output-economy/owner-files-no-leak.test.ts b/test/output-economy/owner-files-no-leak.test.ts index 38824a9734..ca3a7f2435 100644 --- a/test/output-economy/owner-files-no-leak.test.ts +++ b/test/output-economy/owner-files-no-leak.test.ts @@ -1,5 +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 { @@ -8,7 +10,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 +18,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 sharedDistContents = readBuildOutputs(sharedDistPath); + const distPath = path.join(mkdtempForTestSync('owner-files-no-leak-'), 'dist'); const build = runCmdSync( 'node', @@ -26,10 +29,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( + 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')), @@ -57,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]; + }), + ); +} diff --git a/test/wire-compat/README.md b/test/wire-compat/README.md index a8e7d1fa1b..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` (`src/daemon-client/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. 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..7eb63460e7 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -53,25 +53,22 @@ "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:8978ef89d3af866bd94fec52c586a0cbf3217ee9e60952fbc826e8208e5fa158", + "packages/daemon-client/src/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", + "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 +186,13 @@ "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", + "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", @@ -220,9 +221,9 @@ "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", - "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." + "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 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", @@ -240,12 +241,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,24 +271,24 @@ "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." }, { "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", @@ -340,17 +341,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 +396,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 +426,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 +461,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/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." }, { - "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..1a5baf4c99 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'; @@ -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/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" }, 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.