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/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-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 7d586f1894..0a9b7f587f 100644 --- a/src/daemon-client/__tests__/daemon-launch-spec.test.ts +++ b/src/daemon-client/__tests__/daemon-launch-spec.test.ts @@ -3,16 +3,40 @@ 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, + resolveLocalDaemonCodeIdentity, +} 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. +/** 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 reports +// an origin and no signature at all: its version pins its bytes (`#2458`). test('resolveDaemonLaunchSpec probes the entry candidates once per process', () => { resetAllProcessMemosForTests(); @@ -27,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 @@ -41,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 () => { @@ -63,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') @@ -77,3 +99,169 @@ 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 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; + 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 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.deepEqual(await resolveLocalDaemonCodeIdentity(), { origin: 'installed' }); + assert.equal(statSpy.mock.calls.length, 0); +}); + +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({ codeOrigin: 'installed', codeSignature: 'some-other-install' }), + true, + ), + undefined, + ); +}); + +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 ownCheckoutCodeSignature(); + assert.ok(ownCodeSignature); + + assert.equal( + await resolveDaemonTakeoverReason( + runningDaemon({ codeOrigin: 'checkout', codeSignature: ownCodeSignature }), + true, + ), + undefined, + ); +}); + +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({ codeOrigin: 'checkout', codeSignature: 'an-older-build' }), + true, + ), + 'code-signature mismatch', + ); +}); + +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( + 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', codeOrigin: 'installed', codeSignature: 'any' }), + true, + ), + expected, + ); + } +}); + +test('a reachable daemon of a matching identity survives, an unreachable one does not', async () => { + useClientTree(false); + + 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.deepEqual(await resolveLocalDaemonCodeIdentity(), { origin: 'installed' }); + + useClientTree(true); + assert.ok(await ownCheckoutCodeSignature()); +}); 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-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 74e0856eae..5fa318db1c 100644 --- a/src/daemon-client/daemon-launch-spec.ts +++ b/src/daemon-client/daemon-launch-spec.ts @@ -1,10 +1,14 @@ 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, 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 = { root: string; @@ -35,7 +39,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,28 +55,105 @@ 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. + * What this tree would launch a daemon from, and what it can prove about that code. * - * 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 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. * - * 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. + * 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 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`). + * + * 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 resolveLocalDaemonCodeIdentity(): Promise { const launchSpec = resolveDaemonLaunchSpec(); + 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), + }; +} + +/** + * 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 decides alone for the common pair of installed trees; the code + * identity (`resolveCodeIdentityMismatch`) answers next and unreachability last. + */ +export async function resolveDaemonTakeoverReason( + info: DaemonInfo, + reachable: boolean, +): Promise { + if (info.version !== readVersion()) return `version mismatch (client v${readVersion()})`; + 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/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/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 a74abc7ba2..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 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 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.