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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/host-kit/src/code-signature.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(),
Expand Down
58 changes: 58 additions & 0 deletions packages/host-kit/src/internal/project-root.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
21 changes: 21 additions & 0 deletions packages/host-kit/src/internal/project-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);
}
1 change: 1 addition & 0 deletions packages/host-kit/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { findProjectRoot, readVersion } from './internal/version.ts';
export { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from './internal/project-root.ts';
25 changes: 23 additions & 2 deletions scripts/check-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, unknown>;
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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.');
Expand Down
49 changes: 49 additions & 0 deletions src/daemon-client/__tests__/daemon-client-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading
Loading