From 5fef822379d70e59859ae8f05260315887894682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 13 Sep 2026 20:32:49 +0200 Subject: [PATCH 1/2] fix(daemon): let an installed version stand as its whole code identity Two installs of one published version signed their identical bytes differently, because the fingerprint is size:mtime and an installer stamps a fresh mtime on every file. The second client therefore read "code-signature mismatch" and replaced a daemon that already ran exactly its code, dropping the live session on it (#2458). An installed tree's version already fixes its bytes, so it now answers with no fingerprint and the version alone decides reuse. A source checkout, where code does move under an unchanged version, keeps the check. The predicate that tells the two trees apart moves beside the project-root walk it shares, and the takeover ladder moves beside the answer it consumes so the order lives in one place. The packaging gate proves the premise against a clean install: the published tree ships no src/daemon.ts. Closes #2458 --- .../src/internal/project-root.test.ts | 58 ++++++++ .../host-kit/src/internal/project-root.ts | 21 +++ packages/host-kit/src/version.ts | 1 + scripts/check-package.ts | 25 +++- .../__tests__/daemon-launch-spec.test.ts | 129 +++++++++++++++++- src/daemon-client/daemon-client-lifecycle.ts | 23 +--- src/daemon-client/daemon-launch-spec.ts | 73 +++++++--- src/daemon/config.ts | 9 +- website/docs/docs/security-trust.md | 2 +- 9 files changed, 288 insertions(+), 53 deletions(-) create mode 100644 packages/host-kit/src/internal/project-root.test.ts diff --git a/packages/host-kit/src/internal/project-root.test.ts b/packages/host-kit/src/internal/project-root.test.ts new file mode 100644 index 0000000000..8c573a091d --- /dev/null +++ b/packages/host-kit/src/internal/project-root.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; +import { isSourceCheckoutProjectRoot, resolveAgentDeviceProjectRoot } from './project-root.ts'; + +/** + * The shape that separates a tree whose code can be rebuilt under its own version + * from an installed copy of a published one (`daemon-launch-spec.ts` stops asking a + * running daemon for its fingerprint on the installed side of that line). + */ +test('an installed package root is not a source checkout', () => { + const root = mkdtempForTestSync('agent-device-installed-root-'); + try { + fs.writeFileSync(path.join(root, 'package.json'), '{"name":"agent-device"}\n', 'utf8'); + fs.mkdirSync(path.join(root, 'dist', 'src', 'internal'), { recursive: true }); + fs.writeFileSync(path.join(root, 'dist', 'src', 'internal', 'daemon.js'), '', 'utf8'); + + assert.equal(isSourceCheckoutProjectRoot(root), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('a checkout carrying the daemon source is a source checkout', () => { + const root = mkdtempForTestSync('agent-device-source-root-'); + try { + fs.writeFileSync(path.join(root, 'package.json'), '{"name":"agent-device"}\n', 'utf8'); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync(path.join(root, 'src', 'daemon.ts'), 'export {};\n', 'utf8'); + + assert.equal(isSourceCheckoutProjectRoot(root), true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('a root with neither a manifest nor a daemon source is neither', () => { + const root = mkdtempForTestSync('agent-device-bare-root-'); + try { + assert.equal(isSourceCheckoutProjectRoot(root), false); + + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync(path.join(root, 'src', 'daemon.ts'), 'export {};\n', 'utf8'); + assert.equal( + isSourceCheckoutProjectRoot(root), + false, + 'a daemon source alone does not make a checkout', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('this checkout is the source checkout its resolver reports', () => { + assert.equal(isSourceCheckoutProjectRoot(resolveAgentDeviceProjectRoot(process.cwd())), true); +}); diff --git a/packages/host-kit/src/internal/project-root.ts b/packages/host-kit/src/internal/project-root.ts index 7a14754025..e29e7714b8 100644 --- a/packages/host-kit/src/internal/project-root.ts +++ b/packages/host-kit/src/internal/project-root.ts @@ -17,3 +17,24 @@ export function resolveAgentDeviceProjectRoot(startDirectory: string): string { } return nearest ?? startDirectory; } + +/** + * 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`). + */ +export const DAEMON_SOURCE_ENTRY = 'src/daemon.ts'; + +/** + * Whether `root` is a source checkout rather than an installed copy of a published + * version. The published package ships its `bin` and `dist` and no `src`, so the + * daemon's own source is what tells the two apart — and that is the difference between + * a tree whose code can be rebuilt under its own version and one whose version already + * fixes its bytes. + */ +export function isSourceCheckoutProjectRoot(root: string): boolean { + return ( + fs.existsSync(path.join(root, 'package.json')) && + fs.existsSync(path.join(root, DAEMON_SOURCE_ENTRY)) + ); +} diff --git a/packages/host-kit/src/version.ts b/packages/host-kit/src/version.ts index ca16de62bf..00d05cc677 100644 --- a/packages/host-kit/src/version.ts +++ b/packages/host-kit/src/version.ts @@ -1 +1,2 @@ export { findProjectRoot, readVersion } from './internal/version.ts'; +export { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from './internal/project-root.ts'; diff --git a/scripts/check-package.ts b/scripts/check-package.ts index febbf509cd..e8b5f82773 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -11,6 +11,8 @@ * 3. runtime dependency closure — every bare specifier the shipped files import is a Node builtin * or a declared `dependencies` entry, and every declared entry is actually imported. * 4. every `exports` subpath imports, and the `bin` runs, from outside the workspace. + * 5. the installed tree carries no daemon source, which is what lets an installed + * client treat its version as the whole of its code identity (#2458). * * Step 3 is the static half and step 4 the runtime half of the same question: nothing the package * imports may depend on workspace linking. Keep both — a specifier reachable only through a lazy @@ -27,6 +29,7 @@ import { type PackedManifest as PackedDependencies, } from './lib/shipped-imports.ts'; import { assertInstalledSnapshotBridge } from './size-report-install.mjs'; +import { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version'; type PackedManifest = PackedDependencies & { exports: Record; @@ -122,8 +125,25 @@ function installIntoCleanConsumer(tarball: string): string { return path.join(consumerDir, 'node_modules', 'agent-device'); } -/** Imports every documented entry point in one process so a failure names the subpath that broke. */ -function importEveryExport(manifest: PackedManifest): void { +/** + * An installed client treats its version as the whole of its code identity, because a + * published tree carries no daemon source to rebuild from (`daemon-launch-spec.ts`). + * This is the only check here that sees the tree npm would actually install, so it is + * where that premise gets proven (#2458). + */ +function assertInstalledTreeIsNotASourceCheckout(installedRoot: string): void { + if (isSourceCheckoutProjectRoot(installedRoot)) { + throw new Error( + `${installedRoot} ships ${DAEMON_SOURCE_ENTRY}, so installs of one version would ` + + "fingerprint their code again and replace each other's running daemon (#2458).", + ); + } + step('Confirmed the installed tree carries no daemon source, so its version pins its code.'); +} + +/** Imports every documented entry point in one process so a failure names the subpath that broke. */ function importEveryExport( + manifest: PackedManifest, +): void { const specifiers = Object.keys(manifest.exports).map((subpath) => path.posix.join('agent-device', subpath), ); @@ -182,6 +202,7 @@ try { lintTarball(tarball); const installedRoot = installIntoCleanConsumer(tarball); assertInstalledSnapshotBridge(installedRoot); + assertInstalledTreeIsNotASourceCheckout(installedRoot); if (verifySnapshotBridgePreparation) { if (process.platform !== 'darwin') { throw new Error('--verify-snapshot-bridge-preparation requires macOS and Xcode.'); diff --git a/src/daemon-client/__tests__/daemon-launch-spec.test.ts b/src/daemon-client/__tests__/daemon-launch-spec.test.ts index 7d586f1894..553f51b1c0 100644 --- a/src/daemon-client/__tests__/daemon-launch-spec.test.ts +++ b/src/daemon-client/__tests__/daemon-launch-spec.test.ts @@ -3,16 +3,33 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; -import { resolveDaemonLaunchSpec, resolveLocalDaemonCodeSignature } from '../daemon-launch-spec.ts'; +import { + resolveDaemonLaunchSpec, + resolveDaemonTakeoverReason, + resolveLocalDaemonCodeSignature, +} from '../daemon-launch-spec.ts'; +import { isSourceCheckoutProjectRoot, readVersion } from '@agent-device/host-kit/version'; import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; +vi.mock('@agent-device/host-kit/version', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + isSourceCheckoutProjectRoot: vi.fn(original.isSourceCheckoutProjectRoot), + }; +}); + afterEach(() => { vi.restoreAllMocks(); + // Restores the real predicate that `vi.fn` was built with, so no case hands its + // tree shape to the next one. + vi.mocked(isSourceCheckoutProjectRoot).mockReset(); }); -// The daemon-reuse check reads the launch entry and its code signature on -// every command. The entry is fixed for the process; the signature is not, so -// it stays live and leans on the stat-validated cache instead. +// A source checkout re-reads the launch entry and its code signature on every +// command. The entry is fixed for the process; the signature is not, so it stays +// live and leans on the stat-validated cache instead. An installed package has no +// signature to re-read: its version pins its bytes (`#2458`). test('resolveDaemonLaunchSpec probes the entry candidates once per process', () => { resetAllProcessMemosForTests(); @@ -77,3 +94,107 @@ test('a source client fingerprints the source entry through the stat-validated c resetAllProcessMemosForTests(); } }); + +/** + * Which daemon a command keeps. `daemon-client-lifecycle.test.ts` pins the same + * decision end to end from a source checkout, which is what this test process runs + * in; these cases flip the one input that separates a checkout from an installed + * package of a published version (#2458). + */ +function useClientTree(sourceCheckout: boolean): void { + vi.mocked(isSourceCheckoutProjectRoot).mockReturnValue(sourceCheckout); +} + +function runningDaemon(info: { version?: string; codeSignature?: string }) { + return { + token: 'local-secret', + pid: 999_999, + httpPort: 41_234, + transport: 'http' as const, + version: info.version ?? readVersion(), + codeSignature: info.codeSignature, + }; +} + +test('an installed client fingerprints nothing, because its version pins its bytes (#2458)', async () => { + // Two installs of one published version stamp identical bytes with different + // mtimes, so a signature can only ever say "different" about the same code. + resetAllProcessMemosForTests(); + useClientTree(false); + const statSpy = vi.spyOn(fs, 'statSync'); + + assert.equal(await resolveLocalDaemonCodeSignature(), undefined); + assert.equal(statSpy.mock.calls.length, 0); +}); + +test('an installed client keeps a reachable daemon whose code signature differs from its own (#2458)', async () => { + useClientTree(false); + + assert.equal( + await resolveDaemonTakeoverReason(runningDaemon({ codeSignature: 'some-other-install' }), true), + undefined, + ); +}); + +test('a source checkout keeps a reachable daemon whose code signature matches its own', async () => { + useClientTree(true); + const ownCodeSignature = await resolveLocalDaemonCodeSignature(); + assert.ok(ownCodeSignature); + + assert.equal( + await resolveDaemonTakeoverReason(runningDaemon({ codeSignature: ownCodeSignature }), true), + undefined, + ); +}); + +test('a source checkout replaces a reachable daemon whose code signature differs', async () => { + // A rebuild leaves the version alone, so the signature is the only thing that can + // notice a daemon serving code its client no longer has. + useClientTree(true); + + assert.equal( + await resolveDaemonTakeoverReason(runningDaemon({ codeSignature: 'an-older-build' }), true), + 'code-signature mismatch', + ); +}); + +test('a source checkout replaces a daemon that reported no code signature at all', async () => { + useClientTree(true); + + assert.equal( + await resolveDaemonTakeoverReason(runningDaemon({}), true), + 'code-signature mismatch', + ); +}); + +test('a mismatched version replaces the daemon whichever tree the client runs from', async () => { + const expected = `version mismatch (client v${readVersion()})`; + for (const sourceCheckout of [false, true]) { + useClientTree(sourceCheckout); + + assert.equal( + await resolveDaemonTakeoverReason( + runningDaemon({ version: '0.0.0-mismatch', codeSignature: 'any' }), + true, + ), + expected, + ); + } +}); + +test('a reachable daemon of a matching version survives, an unreachable one does not', async () => { + useClientTree(false); + + assert.equal(await resolveDaemonTakeoverReason(runningDaemon({}), true), undefined); + assert.equal(await resolveDaemonTakeoverReason(runningDaemon({}), false), 'unreachable'); +}); + +test('the tree shape is asked per call, not memoized with the signature', async () => { + // A long-lived client (the MCP server) must notice a daemon rebuilt underneath it, + // so nothing about this answer is cached — including which tree it came from. + useClientTree(false); + assert.equal(await resolveLocalDaemonCodeSignature(), undefined); + + useClientTree(true); + assert.ok(await resolveLocalDaemonCodeSignature()); +}); diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index f7bc40ce11..d336eabdfd 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -12,7 +12,6 @@ import { } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { sleep } from '@agent-device/host-kit/retry'; -import { readVersion } from '@agent-device/host-kit/version'; import { findUnrecoveredRepairCommitFailure } from '../daemon/session-repair-tombstone.ts'; import { @@ -23,7 +22,7 @@ import { type DaemonServerMode, type DaemonTransportPreference, } from '../daemon/config.ts'; -import { resolveDaemonLaunchSpec, resolveLocalDaemonCodeSignature } from './daemon-launch-spec.ts'; +import { resolveDaemonLaunchSpec, resolveDaemonTakeoverReason } from './daemon-launch-spec.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { @@ -216,26 +215,6 @@ function isDaemonTransportUnavailableError(error: unknown): boolean { ); } -/** - * Why this daemon cannot be reused, or `undefined` when it can be. - * - * One ladder answers both questions, so a daemon can never be reused and - * announced as replaced, or replaced without a reason to print. The code - * signature is asked for after the version because it is the expensive check - * (`resolveLocalDaemonCodeSignature`), and a version mismatch already decides. - */ -async function resolveDaemonTakeoverReason( - info: DaemonInfo, - reachable: boolean, -): Promise { - if (info.version !== readVersion()) return `version mismatch (client v${readVersion()})`; - if (info.codeSignature !== (await resolveLocalDaemonCodeSignature())) { - return 'code-signature mismatch'; - } - if (!reachable) return 'unreachable'; - return undefined; -} - function emitDaemonTakeoverNotice(info: DaemonInfo, reason: string, stateDir: string): void { try { const identity = info.version ? `pid ${info.pid}, v${info.version}` : `pid ${info.pid}`; diff --git a/src/daemon-client/daemon-launch-spec.ts b/src/daemon-client/daemon-launch-spec.ts index 74e0856eae..02f4cbb8ee 100644 --- a/src/daemon-client/daemon-launch-spec.ts +++ b/src/daemon-client/daemon-launch-spec.ts @@ -1,10 +1,16 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { findProjectRoot } from '@agent-device/host-kit/version'; +import { + DAEMON_SOURCE_ENTRY, + findProjectRoot, + isSourceCheckoutProjectRoot, + readVersion, +} from '@agent-device/host-kit/version'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; +import type { DaemonInfo } from './daemon-client-metadata.ts'; export type DaemonLaunchSpec = { root: string; @@ -35,7 +41,7 @@ export function resolveDaemonLaunchSpec(): DaemonLaunchSpec { throw new AppError('COMMAND_FAILED', 'Daemon dist path list is empty'); } const distPath = distPaths.find((candidate) => fs.existsSync(candidate)) ?? defaultDistPath; - const srcPath = path.join(root, 'src', 'daemon.ts'); + const srcPath = path.join(root, DAEMON_SOURCE_ENTRY); const hasDist = distPaths.some((candidate) => fs.existsSync(candidate)); const hasSrc = fs.existsSync(srcPath); @@ -51,24 +57,37 @@ export function resolveDaemonLaunchSpec(): DaemonLaunchSpec { } /** - * The signature a running daemon must report to be reusable. A dist entry is - * a bundle of ~120 chunks and walks in ~5ms, so it keeps the direct walk; a - * source checkout's graph — ~1,500 modules once the workspace packages the - * daemon imports by specifier are counted — costs tens of milliseconds and - * goes through the stat-validated cache (`code-signature-cache.ts`), which - * returns the identical signature from `statSync` alone. + * The signature a running daemon must report to be reused, or `undefined` when this + * tree's version already pins its code — which is what an installed package is. * - * The cache loads on demand, which is why this is async: an installed client - * runs the dist arm on every invocation and never reaches it, so a static - * import would put the cache and its atomic-publish dependency in the startup - * closure of a CLI that cannot use them (`eager-closure-budgets.ts`). + * An installed tree's bytes change on install and not on edit, and every install of + * one published version is the same artifact, so its version is the identity a client + * can compare. `size:mtime` cannot be: an installer stamps a fresh mtime on every + * install, so two installs of identical bytes sign differently, and demanding they + * agree replaces a daemon that already runs exactly this code — its live session with + * it (#2458). A source checkout is the tree whose code moves under a version that does + * not, which is the drift a fingerprint still has to catch. * - * Deliberately NOT memoized, unlike the launch spec above: a long-lived - * client (the MCP server) must still notice a daemon rebuilt underneath it, - * and the cache is what makes re-answering that question per request cheap. + * The two trees do not normally compete for one daemon: a checkout keeps it in a + * worktree-scoped state directory and an installed client in the shared one + * (`src/daemon/config.ts`, same test). An explicit `--state-dir` is what puts them + * together, and adopting the checkout's daemon there still adopts this version's code. + * + * A dist entry is a bundle of ~120 chunks and walks in ~5ms; a source checkout's graph + * — ~1,500 modules once the workspace packages the daemon imports by specifier are + * counted — costs tens of milliseconds and goes through the stat-validated cache + * (`code-signature-cache.ts`), which replays the identical signature from `statSync` + * alone. That cache loads on demand, which is why this is async: only the source arm + * can reach it, and a static import would put it and its atomic-publish dependency in + * the startup closure of every client that cannot use it (`eager-closure-budgets.ts`). + * + * Deliberately NOT memoized, unlike the launch spec above: a long-lived client (the + * MCP server) must still notice a daemon rebuilt underneath it, and the cache is what + * makes re-answering that question per request cheap. */ -export async function resolveLocalDaemonCodeSignature(): Promise { +export async function resolveLocalDaemonCodeSignature(): Promise { const launchSpec = resolveDaemonLaunchSpec(); + if (!isSourceCheckoutProjectRoot(launchSpec.root)) return undefined; if (!launchSpec.useSrc) { return computeDaemonCodeSignature(launchSpec.distPath, launchSpec.root); } @@ -76,3 +95,25 @@ export async function resolveLocalDaemonCodeSignature(): Promise { await import('@agent-device/host-kit/code-signature-cache'); return resolveCachedDaemonCodeSignature(launchSpec.srcPath, launchSpec.root); } + +/** + * Why the daemon already running on this state directory cannot be reused, or + * `undefined` when it can be. + * + * One ladder answers both questions, so a daemon can never be reused and announced as + * replaced, or replaced without a reason to print. The version answers first because + * it is cheap, and for an installed client it answers alone; only a source checkout + * goes on to ask `resolveLocalDaemonCodeSignature`, and unreachability decides last. + */ +export async function resolveDaemonTakeoverReason( + info: DaemonInfo, + reachable: boolean, +): Promise { + if (info.version !== readVersion()) return `version mismatch (client v${readVersion()})`; + const localCodeSignature = await resolveLocalDaemonCodeSignature(); + if (localCodeSignature !== undefined && info.codeSignature !== localCodeSignature) { + return 'code-signature mismatch'; + } + if (!reachable) return 'unreachable'; + return undefined; +} diff --git a/src/daemon/config.ts b/src/daemon/config.ts index e23b5bdf6f..06c671491b 100644 --- a/src/daemon/config.ts +++ b/src/daemon/config.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { expandUserHomePath, resolveUserPath } from '@agent-device/host-kit/file'; -import { findProjectRoot } from '@agent-device/host-kit/version'; +import { findProjectRoot, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version'; import { type EnvMap } from '@agent-device/kernel/source-value'; import type { @@ -58,13 +58,6 @@ function resolveDefaultDaemonStateDir(options: ResolveDaemonPathsOptions = {}): return path.join(globalStateDir, 'dev', buildSourceCheckoutStateDirName(projectRoot)); } -function isSourceCheckoutProjectRoot(projectRoot: string): boolean { - return ( - fs.existsSync(path.join(projectRoot, 'package.json')) && - fs.existsSync(path.join(projectRoot, 'src', 'daemon.ts')) - ); -} - function buildSourceCheckoutStateDirName(projectRoot: string): string { const resolvedRoot = resolveRealPath(projectRoot); const slug = path.basename(resolvedRoot).replaceAll(/[^a-zA-Z0-9._-]+/g, '-'); diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index a74abc7ba2..2de3c41108 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -19,7 +19,7 @@ CLI commands run through a per-user background daemon: - The daemon binds to `127.0.0.1` only, on ephemeral ports, for both its socket and HTTP transports. It is never reachable from the network unless you deliberately front it with your own proxy. - Command (RPC), upload, artifact-download, and `/admin/human-control/*` requests must present a token generated fresh on each daemon boot (24 random bytes). The only unauthenticated endpoint is `GET /health`, which intentionally returns a bare liveness response and nothing else; like the rest of the server it is reachable only via loopback. The token is stored in `daemon.json` inside the daemon state directory (`~/.agent-device` for packaged installs; source checkouts use a worktree-scoped directory under `~/.agent-device/dev/`) with `0600` permissions; whoever can read that file already has your user account. -- A client only reuses a running daemon when the daemon's version and binary code signature match its own; otherwise the daemon is restarted. This prevents a stale or tampered daemon from silently serving new clients. +- A client only reuses a running daemon when the daemon's version matches its own and, for a client running from a source checkout, when the daemon's code fingerprint matches too; otherwise the daemon is restarted. A published version is never served twice with different contents, so an installed client has nothing further to compare — its version already fixes the bytes it runs, and demanding a fingerprint instead would make two installs of one version replace each other's daemon and drop the live session on it. The residual limit: an install whose files were replaced under its own version (a hand-edited `node_modules/agent-device`, or a registry that served one version twice) is not detected. The fingerprint compares file sizes and timestamps, never contents, and two installs of identical bytes never agree on those anyway. - Artifact uploads are size-capped, filenames are sanitized, and archive extraction rejects path-traversal entries. Artifact downloads resolve through server-side IDs, never client-supplied paths. For remote or cloud deployments, the daemon supports a custom auth hook for remotely consumable HTTP routes: `AGENT_DEVICE_HTTP_AUTH_HOOK` names a module path that is dynamically imported (with `AGENT_DEVICE_HTTP_AUTH_EXPORT` selecting the export). The host-local `/admin/human-control/*` route uses the daemon token instead. The hook runs with the daemon's full privileges, so treat it as part of your trusted computing base: point it only at a read-only path you control, never at a location writable by less-trusted users or processes. Whoever controls the daemon's environment controls the hook. From edff9074afb7eb8ad5712443c4978b34a0fe466d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 14 Sep 2026 13:22:27 +0200 Subject: [PATCH 2/2] fix(daemon): scope the fingerprint bypass to two installed trees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An installed client declined the code comparison on the strength of its own tree alone, which left whatever is already running unverified: a source checkout that shares an explicit --state-dir can publish any code it likes under a published version string, and the installed client would have run it (#2458 review). A daemon now records which tree it started from beside its signature, and the bypass is pairwise — it holds only where both sides say they are installs of that version. A checkout still judges a checkout by signature; a pair whose trees cannot match at all restarts the daemon rather than run code it cannot identify, which is also the rule a daemon too old to report an origin is judged by. Closes #2458 --- packages/host-kit/src/code-signature.ts | 19 ++- .../__tests__/daemon-client-metadata.test.ts | 49 +++++++ .../__tests__/daemon-launch-spec.test.ts | 131 +++++++++++++----- src/daemon-client/daemon-client-metadata.ts | 8 ++ src/daemon-client/daemon-launch-spec.ts | 104 +++++++++----- .../__tests__/daemon-registration.test.ts | 1 + src/daemon/server/daemon-runtime.ts | 3 + src/daemon/server/server-lifecycle.ts | 9 +- .../daemon-lifecycle.test.ts | 3 + website/docs/docs/security-trust.md | 2 +- 10 files changed, 262 insertions(+), 67 deletions(-) create mode 100644 src/daemon-client/__tests__/daemon-client-metadata.test.ts diff --git a/packages/host-kit/src/code-signature.ts b/packages/host-kit/src/code-signature.ts index 1e6b553fc4..edea9084ce 100644 --- a/packages/host-kit/src/code-signature.ts +++ b/packages/host-kit/src/code-signature.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { findProjectRoot } from './version.ts'; +import { findProjectRoot, isSourceCheckoutProjectRoot } from './version.ts'; // Any quoted, relative-path-shaped string literal is treated as a module // specifier, rather than matching the `import`/`export`/`from` grammar @@ -61,6 +61,23 @@ export function resolveDaemonCodeSignature(): string { return computeDaemonCodeSignature(entryPath); } +/** + * Which tree a daemon's code came from, and so what could have changed it since the + * version it reports. + * + * A signature only means something against a tree that can be re-read, so this answers + * first: two installed trees of one version hold the same artifact and have nothing to + * compare (#2458), while a checkout's code moves under a version that does not move + * with it. A running daemon publishes its own answer beside its signature + * (`src/daemon/server/server-lifecycle.ts`) because the question is about the code that + * is RUNNING, not only about the client asking it. + */ +export type DaemonCodeOrigin = 'installed' | 'checkout'; + +export function resolveDaemonCodeOrigin(root: string = findProjectRoot()): DaemonCodeOrigin { + return isSourceCheckoutProjectRoot(root) ? 'checkout' : 'installed'; +} + export function computeDaemonCodeSignature( entryPath: string, root: string = findProjectRoot(), diff --git a/src/daemon-client/__tests__/daemon-client-metadata.test.ts b/src/daemon-client/__tests__/daemon-client-metadata.test.ts new file mode 100644 index 0000000000..ec743577cd --- /dev/null +++ b/src/daemon-client/__tests__/daemon-client-metadata.test.ts @@ -0,0 +1,49 @@ +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 '../../__tests__/test-utils/tmp-dir.ts'; +import { writeInfo } from '../../daemon/server/server-lifecycle.ts'; +import { readDaemonInfo } from '../daemon-client-metadata.ts'; + +// 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')]; +} + +function publishInfo(codeOrigin: DaemonCodeOrigin): string { + const [stateDir, infoPath] = scratchStateDir(); + writeInfo(stateDir, infoPath, path.join(stateDir, 'daemon.log'), { + httpPort: 41_234, + token: 'local-secret', + version: '0.0.0-test', + codeOrigin, + codeSignature: 'graph:1:abc', + processStartTime: 'start', + }); + return infoPath; +} + +test('a daemon publishes the code origin its client reads back', () => { + for (const codeOrigin of ['installed', 'checkout'] as const) { + assert.equal(readDaemonInfo(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); + } +}); diff --git a/src/daemon-client/__tests__/daemon-launch-spec.test.ts b/src/daemon-client/__tests__/daemon-launch-spec.test.ts index 553f51b1c0..0a9b7f587f 100644 --- a/src/daemon-client/__tests__/daemon-launch-spec.test.ts +++ b/src/daemon-client/__tests__/daemon-launch-spec.test.ts @@ -6,7 +6,7 @@ import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signatur import { resolveDaemonLaunchSpec, resolveDaemonTakeoverReason, - resolveLocalDaemonCodeSignature, + resolveLocalDaemonCodeIdentity, } from '../daemon-launch-spec.ts'; import { isSourceCheckoutProjectRoot, readVersion } from '@agent-device/host-kit/version'; import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; @@ -26,10 +26,17 @@ afterEach(() => { vi.mocked(isSourceCheckoutProjectRoot).mockReset(); }); +/** This tree's fingerprint, or `undefined` when the tree is an installed package. */ +function ownCheckoutCodeSignature(): Promise { + return resolveLocalDaemonCodeIdentity().then((identity) => + identity.origin === 'checkout' ? identity.codeSignature : undefined, + ); +} + // A source checkout re-reads the launch entry and its code signature on every // command. The entry is fixed for the process; the signature is not, so it stays -// live and leans on the stat-validated cache instead. An installed package has no -// signature to re-read: its version pins its bytes (`#2458`). +// live and leans on the stat-validated cache instead. An installed package reports +// an origin and no signature at all: its version pins its bytes (`#2458`). test('resolveDaemonLaunchSpec probes the entry candidates once per process', () => { resetAllProcessMemosForTests(); @@ -44,12 +51,13 @@ test('resolveDaemonLaunchSpec probes the entry candidates once per process', () assert.equal(existsSpy.mock.calls.length, probeCalls); }); -test('resolveLocalDaemonCodeSignature re-reads the filesystem on every call', async () => { +test('the local code identity re-reads the filesystem on every call', async () => { resetAllProcessMemosForTests(); - const first = await resolveLocalDaemonCodeSignature(); + const first = await ownCheckoutCodeSignature(); + assert.ok(first); const statSpy = vi.spyOn(fs, 'statSync'); - const second = await resolveLocalDaemonCodeSignature(); + const second = await ownCheckoutCodeSignature(); assert.equal(second, first); // Not memoized in either mode: a long-lived client (the MCP server) has to @@ -58,15 +66,12 @@ test('resolveLocalDaemonCodeSignature re-reads the filesystem on every call', as assert.ok(statSpy.mock.calls.length > 0); }); -test('resolveLocalDaemonCodeSignature agrees with the uncached walk over the launch entry', async () => { +test('the local signature agrees with the uncached walk over the launch entry', async () => { resetAllProcessMemosForTests(); const spec = resolveDaemonLaunchSpec(); const entryPath = spec.useSrc ? spec.srcPath : spec.distPath; - assert.equal( - await resolveLocalDaemonCodeSignature(), - computeDaemonCodeSignature(entryPath, spec.root), - ); + assert.equal(await ownCheckoutCodeSignature(), computeDaemonCodeSignature(entryPath, spec.root)); }); test('a source client fingerprints the source entry through the stat-validated cache', async () => { @@ -80,10 +85,10 @@ test('a source client fingerprints the source entry through the stat-validated c const spec = resolveDaemonLaunchSpec(); assert.equal(spec.useSrc, true); const expected = computeDaemonCodeSignature(spec.srcPath, spec.root); - assert.equal(await resolveLocalDaemonCodeSignature(), expected); + assert.equal(await ownCheckoutCodeSignature(), expected); const readSpy = vi.spyOn(fs, 'readFileSync'); - assert.equal(await resolveLocalDaemonCodeSignature(), expected); + assert.equal(await ownCheckoutCodeSignature(), expected); const sourceReads = readSpy.mock.calls .map(([target]) => target) .filter((target): target is string => typeof target === 'string') @@ -98,67 +103,123 @@ test('a source client fingerprints the source entry through the stat-validated c /** * Which daemon a command keeps. `daemon-client-lifecycle.test.ts` pins the same * decision end to end from a source checkout, which is what this test process runs - * in; these cases flip the one input that separates a checkout from an installed - * package of a published version (#2458). + * in; these cases flip the two inputs that separate the trees — which tree this client + * runs from, and which tree the running daemon says it was started from (#2458). */ function useClientTree(sourceCheckout: boolean): void { vi.mocked(isSourceCheckoutProjectRoot).mockReturnValue(sourceCheckout); } -function runningDaemon(info: { version?: string; codeSignature?: string }) { +function runningDaemon(info: { + version?: string; + codeOrigin?: 'installed' | 'checkout'; + codeSignature?: string; +}) { return { token: 'local-secret', pid: 999_999, httpPort: 41_234, transport: 'http' as const, version: info.version ?? readVersion(), + codeOrigin: info.codeOrigin, codeSignature: info.codeSignature, }; } -test('an installed client fingerprints nothing, because its version pins its bytes (#2458)', async () => { +test('an installed client reports an origin and no signature, because its version pins its bytes (#2458)', async () => { // Two installs of one published version stamp identical bytes with different // mtimes, so a signature can only ever say "different" about the same code. resetAllProcessMemosForTests(); useClientTree(false); const statSpy = vi.spyOn(fs, 'statSync'); - assert.equal(await resolveLocalDaemonCodeSignature(), undefined); + assert.deepEqual(await resolveLocalDaemonCodeIdentity(), { origin: 'installed' }); assert.equal(statSpy.mock.calls.length, 0); }); -test('an installed client keeps a reachable daemon whose code signature differs from its own (#2458)', async () => { +test('an installed client keeps an installed daemon whose code signature differs (#2458)', async () => { + // Both sides are installs of the version they report, so that version is the + // whole of the identity either can offer, and the session on the daemon stands. useClientTree(false); assert.equal( - await resolveDaemonTakeoverReason(runningDaemon({ codeSignature: 'some-other-install' }), true), + await resolveDaemonTakeoverReason( + runningDaemon({ codeOrigin: 'installed', codeSignature: 'some-other-install' }), + true, + ), undefined, ); }); -test('a source checkout keeps a reachable daemon whose code signature matches its own', async () => { +test('an installed client replaces a daemon that reports a source checkout (#2458)', async () => { + // The bypass is pairwise. A checkout beside a shared `--state-dir` can publish any + // code it likes under the published version string, and an installed client has no + // fingerprint of its own to notice. + useClientTree(false); + + assert.equal( + await resolveDaemonTakeoverReason( + runningDaemon({ codeOrigin: 'checkout', codeSignature: 'edited-checkout' }), + true, + ), + 'code origin mismatch (daemon checkout, client installed)', + ); +}); + +test('an installed client replaces a daemon that predates the code origin field (#2458)', async () => { + useClientTree(false); + + assert.equal( + await resolveDaemonTakeoverReason(runningDaemon({ codeSignature: 'any' }), true), + 'code origin mismatch (daemon unreported, client installed)', + ); +}); + +test('a source checkout keeps a daemon that reports the same code signature', async () => { useClientTree(true); - const ownCodeSignature = await resolveLocalDaemonCodeSignature(); + const ownCodeSignature = await ownCheckoutCodeSignature(); assert.ok(ownCodeSignature); assert.equal( - await resolveDaemonTakeoverReason(runningDaemon({ codeSignature: ownCodeSignature }), true), + await resolveDaemonTakeoverReason( + runningDaemon({ codeOrigin: 'checkout', codeSignature: ownCodeSignature }), + true, + ), undefined, ); }); -test('a source checkout replaces a reachable daemon whose code signature differs', async () => { +test('a source checkout replaces a daemon whose code signature differs', async () => { // A rebuild leaves the version alone, so the signature is the only thing that can // notice a daemon serving code its client no longer has. useClientTree(true); assert.equal( - await resolveDaemonTakeoverReason(runningDaemon({ codeSignature: 'an-older-build' }), true), + await resolveDaemonTakeoverReason( + runningDaemon({ codeOrigin: 'checkout', codeSignature: 'an-older-build' }), + true, + ), 'code-signature mismatch', ); }); -test('a source checkout replaces a daemon that reported no code signature at all', async () => { +test('a source checkout replaces a daemon that reports an installed package', async () => { + // The pair #2458 lets through is two installs; a checkout can still neither prove + // nor disprove what an install holds, and must not run it on faith. + useClientTree(true); + + assert.equal( + await resolveDaemonTakeoverReason( + runningDaemon({ codeOrigin: 'installed', codeSignature: 'the-published-artifact' }), + true, + ), + 'code origin mismatch (daemon installed, client checkout)', + ); +}); + +test('a source checkout judges a daemon that predates the code origin field by its signature', async () => { + // Every daemon published before the field answered with a signature, which is the + // comparison they were reused under until now. useClientTree(true); assert.equal( @@ -174,7 +235,7 @@ test('a mismatched version replaces the daemon whichever tree the client runs fr assert.equal( await resolveDaemonTakeoverReason( - runningDaemon({ version: '0.0.0-mismatch', codeSignature: 'any' }), + runningDaemon({ version: '0.0.0-mismatch', codeOrigin: 'installed', codeSignature: 'any' }), true, ), expected, @@ -182,19 +243,25 @@ test('a mismatched version replaces the daemon whichever tree the client runs fr } }); -test('a reachable daemon of a matching version survives, an unreachable one does not', async () => { +test('a reachable daemon of a matching identity survives, an unreachable one does not', async () => { useClientTree(false); - assert.equal(await resolveDaemonTakeoverReason(runningDaemon({}), true), undefined); - assert.equal(await resolveDaemonTakeoverReason(runningDaemon({}), false), 'unreachable'); + assert.equal( + await resolveDaemonTakeoverReason(runningDaemon({ codeOrigin: 'installed' }), true), + undefined, + ); + assert.equal( + await resolveDaemonTakeoverReason(runningDaemon({ codeOrigin: 'installed' }), false), + 'unreachable', + ); }); test('the tree shape is asked per call, not memoized with the signature', async () => { // A long-lived client (the MCP server) must notice a daemon rebuilt underneath it, // so nothing about this answer is cached — including which tree it came from. useClientTree(false); - assert.equal(await resolveLocalDaemonCodeSignature(), undefined); + assert.deepEqual(await resolveLocalDaemonCodeIdentity(), { origin: 'installed' }); useClientTree(true); - assert.ok(await resolveLocalDaemonCodeSignature()); + assert.ok(await ownCheckoutCodeSignature()); }); diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 7d9d818c35..2d3d5edebb 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -3,6 +3,8 @@ import { shellQuote } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { isAgentDeviceDaemonProcess, stopProcessForTakeover } from '../daemon/daemon-process.ts'; +import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; + import { resolveDaemonPaths, type DaemonPaths, type DaemonServerMode } from '../daemon/config.ts'; export type DaemonInfo = { @@ -12,6 +14,7 @@ export type DaemonInfo = { token: string; pid: number; version?: string; + codeOrigin?: DaemonCodeOrigin; codeSignature?: string; processStartTime?: string; baseUrl?: string; @@ -58,6 +61,7 @@ export function readDaemonInfo(infoPath: string): DaemonInfo | null { transport: readDaemonInfoTransport(parsed.transport), pid: readPositiveInteger(parsed.pid) ?? 0, version: readOptionalString(parsed.version), + codeOrigin: readDaemonInfoCodeOrigin(parsed.codeOrigin), codeSignature: readOptionalString(parsed.codeSignature), processStartTime: readOptionalString(parsed.processStartTime), }; @@ -76,6 +80,10 @@ function readDaemonInfoPorts( return { port, httpPort }; } +function readDaemonInfoCodeOrigin(value: unknown): DaemonInfo['codeOrigin'] { + return value === 'installed' || value === 'checkout' ? value : undefined; +} + function readDaemonInfoTransport(value: unknown): DaemonInfo['transport'] { return value === 'socket' || value === 'http' || value === 'dual' ? value : undefined; } diff --git a/src/daemon-client/daemon-launch-spec.ts b/src/daemon-client/daemon-launch-spec.ts index 02f4cbb8ee..5fa318db1c 100644 --- a/src/daemon-client/daemon-launch-spec.ts +++ b/src/daemon-client/daemon-launch-spec.ts @@ -1,15 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { - DAEMON_SOURCE_ENTRY, - findProjectRoot, - isSourceCheckoutProjectRoot, - readVersion, -} from '@agent-device/host-kit/version'; +import { DAEMON_SOURCE_ENTRY, findProjectRoot, readVersion } from '@agent-device/host-kit/version'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; -import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; +import { + computeDaemonCodeSignature, + resolveDaemonCodeOrigin, +} from '@agent-device/host-kit/code-signature'; import type { DaemonInfo } from './daemon-client-metadata.ts'; export type DaemonLaunchSpec = { @@ -57,27 +55,28 @@ export function resolveDaemonLaunchSpec(): DaemonLaunchSpec { } /** - * The signature a running daemon must report to be reused, or `undefined` when this - * tree's version already pins its code — which is what an installed package is. - * - * An installed tree's bytes change on install and not on edit, and every install of - * one published version is the same artifact, so its version is the identity a client - * can compare. `size:mtime` cannot be: an installer stamps a fresh mtime on every - * install, so two installs of identical bytes sign differently, and demanding they - * agree replaces a daemon that already runs exactly this code — its live session with - * it (#2458). A source checkout is the tree whose code moves under a version that does - * not, which is the drift a fingerprint still has to catch. + * What this tree would launch a daemon from, and what it can prove about that code. * - * The two trees do not normally compete for one daemon: a checkout keeps it in a - * worktree-scoped state directory and an installed client in the shared one - * (`src/daemon/config.ts`, same test). An explicit `--state-dir` is what puts them - * together, and adopting the checkout's daemon there still adopts this version's code. + * An installed tree carries no fingerprint at all, on purpose. Its bytes change on + * install and not on edit, and every install of one published version is the same + * artifact, so the version is the whole of the identity it can offer: `size:mtime` + * cannot be a fingerprint of it, because an installer stamps a fresh mtime on every + * file and two installs of identical bytes would then sign differently and replace + * 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. + */ +export type LocalDaemonCodeIdentity = + | { origin: 'installed' } + | { origin: 'checkout'; codeSignature: string }; + +/** + * This tree's answer, read afresh on every call. * * A dist entry is a bundle of ~120 chunks and walks in ~5ms; a source checkout's graph * — ~1,500 modules once the workspace packages the daemon imports by specifier are * counted — costs tens of milliseconds and goes through the stat-validated cache * (`code-signature-cache.ts`), which replays the identical signature from `statSync` - * alone. That cache loads on demand, which is why this is async: only the source arm + * alone. That cache loads on demand, which is why this is async: only the checkout arm * can reach it, and a static import would put it and its atomic-publish dependency in * the startup closure of every client that cannot use it (`eager-closure-budgets.ts`). * @@ -85,15 +84,22 @@ export function resolveDaemonLaunchSpec(): DaemonLaunchSpec { * MCP server) must still notice a daemon rebuilt underneath it, and the cache is what * makes re-answering that question per request cheap. */ -export async function resolveLocalDaemonCodeSignature(): Promise { +export async function resolveLocalDaemonCodeIdentity(): Promise { const launchSpec = resolveDaemonLaunchSpec(); - if (!isSourceCheckoutProjectRoot(launchSpec.root)) return undefined; + const origin = resolveDaemonCodeOrigin(launchSpec.root); + if (origin === 'installed') return { origin }; if (!launchSpec.useSrc) { - return computeDaemonCodeSignature(launchSpec.distPath, launchSpec.root); + return { + origin, + codeSignature: computeDaemonCodeSignature(launchSpec.distPath, launchSpec.root), + }; } const { resolveCachedDaemonCodeSignature } = await import('@agent-device/host-kit/code-signature-cache'); - return resolveCachedDaemonCodeSignature(launchSpec.srcPath, launchSpec.root); + return { + origin, + codeSignature: await resolveCachedDaemonCodeSignature(launchSpec.srcPath, launchSpec.root), + }; } /** @@ -102,18 +108,52 @@ export async function resolveLocalDaemonCodeSignature(): Promise { if (info.version !== readVersion()) return `version mismatch (client v${readVersion()})`; - const localCodeSignature = await resolveLocalDaemonCodeSignature(); - if (localCodeSignature !== undefined && info.codeSignature !== localCodeSignature) { - return 'code-signature mismatch'; - } + const localIdentity = await resolveLocalDaemonCodeIdentity(); + const codeMismatch = resolveCodeIdentityMismatch(localIdentity, info); + if (codeMismatch) return codeMismatch; if (!reachable) return 'unreachable'; return undefined; } + +/** + * Whether the running daemon holds the code this client would launch, given what each + * side can prove about itself. + * + * The bypass is pairwise, not one-sided: it holds only where BOTH sides are installed, + * because that is the only pair whose shared version already fixes the bytes both run. + * One side cannot vouch for the other — an installed client that reused an unreported + * daemon would run whatever a modified checkout beside it chose to publish under this + * version (#2458). A daemon that predates the field is therefore judged by its + * signature, the rule it was reused under until now, which costs a long-lived state + * directory one takeover after an upgrade. + * + * The two trees rarely meet: a checkout keeps its daemon in a worktree-scoped state + * directory and an installed client in the shared one (`src/daemon/config.ts`, same + * test). An explicit `--state-dir` is what puts them together, and that is where this + * pair has to hold. + */ +function resolveCodeIdentityMismatch( + local: LocalDaemonCodeIdentity, + info: DaemonInfo, +): string | undefined { + if (local.origin === 'installed') { + return info.codeOrigin === 'installed' ? undefined : describeCodeOriginMismatch(local, info); + } + if (info.codeOrigin === 'installed') return describeCodeOriginMismatch(local, info); + return info.codeSignature === local.codeSignature ? undefined : 'code-signature mismatch'; +} + +function describeCodeOriginMismatch( + local: LocalDaemonCodeIdentity, + info: DaemonInfo, +): `code origin mismatch (${string}, client ${string})` { + return `code origin mismatch (daemon ${info.codeOrigin ?? 'unreported'}, client ${local.origin})`; +} diff --git a/src/daemon/__tests__/daemon-registration.test.ts b/src/daemon/__tests__/daemon-registration.test.ts index c53b146e5e..c0668d47c0 100644 --- a/src/daemon/__tests__/daemon-registration.test.ts +++ b/src/daemon/__tests__/daemon-registration.test.ts @@ -28,6 +28,7 @@ test('reads back the identity a running daemon publishes for its state dir', () writeInfo(stateDir, infoPathOf(stateDir), path.join(stateDir, 'daemon.log'), { socketPort: 1234, token: 'token', + codeOrigin: 'checkout', version: '0.0.0-test', codeSignature: 'signature', processStartTime: 'published-start', diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 405f08adc9..b3f4f67e1c 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -61,6 +61,7 @@ import { readVersion, releaseDaemonLock, removeInfo, + resolveDaemonCodeOrigin, resolveDaemonCodeSignature, writeInfo, } from './server-lifecycle.ts'; @@ -258,6 +259,7 @@ export async function startDaemonRuntime( const version = readVersion(); const token = crypto.randomBytes(24).toString('hex'); const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; + const daemonCodeOrigin = resolveDaemonCodeOrigin(); const daemonCodeSignature = resolveDaemonCodeSignature(); const providerComposition = await createDefaultProviderRuntimeComposition(env); const providerDeviceRuntimes = [...providerComposition.runtimes]; @@ -458,6 +460,7 @@ export async function startDaemonRuntime( httpPort, token, version, + codeOrigin: daemonCodeOrigin, codeSignature: daemonCodeSignature, processStartTime: daemonProcessStartTime, }); diff --git a/src/daemon/server/server-lifecycle.ts b/src/daemon/server/server-lifecycle.ts index a976a0701a..122c836bfa 100644 --- a/src/daemon/server/server-lifecycle.ts +++ b/src/daemon/server/server-lifecycle.ts @@ -1,9 +1,14 @@ import fs from 'node:fs'; +import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import { isAgentDeviceDaemonProcess } from '../daemon-process.ts'; export { readVersion } from '@agent-device/host-kit/version'; export { readProcessStartTime } from '@agent-device/host-kit/process'; -export { resolveDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; +export { + type DaemonCodeOrigin, + resolveDaemonCodeOrigin, + resolveDaemonCodeSignature, +} from '@agent-device/host-kit/code-signature'; export type DaemonLockInfo = { pid: number; @@ -21,6 +26,7 @@ export function writeInfo( httpPort?: number; token: string; version: string; + codeOrigin: DaemonCodeOrigin; codeSignature: string; processStartTime: string | undefined; }, @@ -38,6 +44,7 @@ export function writeInfo( token: opts.token, pid: process.pid, version: opts.version, + codeOrigin: opts.codeOrigin, codeSignature: opts.codeSignature, processStartTime: opts.processStartTime, stateDir: baseDir, diff --git a/test/integration/provider-scenarios/daemon-lifecycle.test.ts b/test/integration/provider-scenarios/daemon-lifecycle.test.ts index 9de67809f6..3a04868bb5 100644 --- a/test/integration/provider-scenarios/daemon-lifecycle.test.ts +++ b/test/integration/provider-scenarios/daemon-lifecycle.test.ts @@ -23,6 +23,7 @@ test('Provider-backed integration daemon lifecycle writes metadata and protects httpPort: 4310, token: 'provider-scenario-token', version: '0.0.0-provider-scenario', + codeOrigin: 'checkout', codeSignature: 'graph:1:abc', processStartTime: 'start-time', }); @@ -40,6 +41,7 @@ test('Provider-backed integration daemon lifecycle writes metadata and protects httpPort: 4311, token: 'http-only-token', version: '0.0.0-provider-scenario', + codeOrigin: 'checkout', codeSignature: 'graph:1:http', processStartTime: undefined, }); @@ -53,6 +55,7 @@ test('Provider-backed integration daemon lifecycle writes metadata and protects socketPort: 4211, token: 'socket-only-token', version: '0.0.0-provider-scenario', + codeOrigin: 'checkout', codeSignature: 'graph:1:socket', processStartTime: undefined, }); diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index 2de3c41108..64f3ee0155 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -19,7 +19,7 @@ CLI commands run through a per-user background daemon: - The daemon binds to `127.0.0.1` only, on ephemeral ports, for both its socket and HTTP transports. It is never reachable from the network unless you deliberately front it with your own proxy. - Command (RPC), upload, artifact-download, and `/admin/human-control/*` requests must present a token generated fresh on each daemon boot (24 random bytes). The only unauthenticated endpoint is `GET /health`, which intentionally returns a bare liveness response and nothing else; like the rest of the server it is reachable only via loopback. The token is stored in `daemon.json` inside the daemon state directory (`~/.agent-device` for packaged installs; source checkouts use a worktree-scoped directory under `~/.agent-device/dev/`) with `0600` permissions; whoever can read that file already has your user account. -- A client only reuses a running daemon when the daemon's version matches its own and, for a client running from a source checkout, when the daemon's code fingerprint matches too; otherwise the daemon is restarted. A published version is never served twice with different contents, so an installed client has nothing further to compare — its version already fixes the bytes it runs, and demanding a fingerprint instead would make two installs of one version replace each other's daemon and drop the live session on it. The residual limit: an install whose files were replaced under its own version (a hand-edited `node_modules/agent-device`, or a registry that served one version twice) is not detected. The fingerprint compares file sizes and timestamps, never contents, and two installs of identical bytes never agree on those anyway. +- A client reuses a running daemon only when the version matches its own and both sides can vouch for the same code, which the daemon records in `daemon.json` as its code origin. Two installs of one published version are the same artifact, so they have nothing further to compare and share one daemon; demanding a fingerprint between them made the second install replace a daemon already running its code and drop the live session on it (#2458). Every other pair is judged by the daemon's code fingerprint: a checkout compares its own, and a pair whose trees cannot match at all — a checkout against an install, or an install against a daemon too old to report an origin — restarts the daemon rather than run code it cannot identify. The fingerprint compares file sizes and timestamps, never contents, so an install whose files were hand-replaced under its own version is still not detected. - Artifact uploads are size-capped, filenames are sanitized, and archive extraction rejects path-traversal entries. Artifact downloads resolve through server-side IDs, never client-supplied paths. For remote or cloud deployments, the daemon supports a custom auth hook for remotely consumable HTTP routes: `AGENT_DEVICE_HTTP_AUTH_HOOK` names a module path that is dynamically imported (with `AGENT_DEVICE_HTTP_AUTH_EXPORT` selecting the export). The host-local `/admin/human-control/*` route uses the daemon token instead. The hook runs with the daemon's full privileges, so treat it as part of your trusted computing base: point it only at a read-only path you control, never at a location writable by less-trusted users or processes. Whoever controls the daemon's environment controls the hook.