From 64e047c73fdb3e27580c07feeeddc9664154db95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 16:52:55 +0200 Subject: [PATCH] fix: fingerprint daemon runtime graph --- src/daemon-client.ts | 15 +--- src/daemon/code-signature.ts | 98 +++++++++++++++++++++++ src/daemon/server-lifecycle.ts | 19 +---- src/utils/__tests__/daemon-client.test.ts | 35 ++++++-- 4 files changed, 133 insertions(+), 34 deletions(-) create mode 100644 src/daemon/code-signature.ts diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 857d024c..cae50512 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -22,6 +22,8 @@ import { type DaemonTransportPreference, } from './daemon/config.ts'; import { uploadArtifact } from './upload-client.ts'; +import { computeDaemonCodeSignature } from './daemon/code-signature.ts'; +export { computeDaemonCodeSignature } from './daemon/code-signature.ts'; export type DaemonRequest = SharedDaemonRequest; export type DaemonResponse = SharedDaemonResponse; @@ -734,19 +736,6 @@ function resolveLocalDaemonCodeSignature(): string { return computeDaemonCodeSignature(entryPath, launchSpec.root); } -export function computeDaemonCodeSignature( - entryPath: string, - root: string = findProjectRoot(), -): string { - try { - const stat = fs.statSync(entryPath); - const relativePath = path.relative(root, entryPath) || entryPath; - return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; - } catch { - return 'unknown'; - } -} - async function sendRequest( info: DaemonInfo, req: DaemonRequest, diff --git a/src/daemon/code-signature.ts b/src/daemon/code-signature.ts new file mode 100644 index 00000000..1b78dc5f --- /dev/null +++ b/src/daemon/code-signature.ts @@ -0,0 +1,98 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { findProjectRoot } from '../utils/version.ts'; + +const STATIC_IMPORT_RE = + /(?:^|[^\w$.])(?:import|export)\s+(?:type\s+)?(?:[^'"`]*?\s+from\s+)?['"]([^'"]+)['"]/gm; +const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/gm; +const RESOLVABLE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] as const; + +export function resolveDaemonCodeSignature(): string { + const entryPath = process.argv[1]; + if (!entryPath) return 'unknown'; + return computeDaemonCodeSignature(entryPath); +} + +export function computeDaemonCodeSignature( + entryPath: string, + root: string = findProjectRoot(), +): string { + try { + const normalizedRoot = path.resolve(root); + const normalizedEntryPath = path.resolve(entryPath); + const queue = [normalizedEntryPath]; + const visited = new Set(); + const fingerprintParts: string[] = []; + + while (queue.length > 0) { + const currentPath = queue.pop(); + if (!currentPath || visited.has(currentPath)) continue; + visited.add(currentPath); + + const stat = fs.statSync(currentPath); + if (!stat.isFile()) continue; + + const relativePath = path.relative(normalizedRoot, currentPath) || currentPath; + fingerprintParts.push(`${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`); + + const content = fs.readFileSync(currentPath, 'utf8'); + for (const specifier of collectRelativeImportSpecifiers(content)) { + const dependencyPath = resolveRelativeImportPath(currentPath, specifier); + if (dependencyPath) { + queue.push(dependencyPath); + } + } + } + + const fingerprint = fingerprintParts.sort().join('|'); + const hash = crypto.createHash('sha1').update(fingerprint).digest('hex'); + return `graph:${fingerprintParts.length}:${hash}`; + } catch { + return 'unknown'; + } +} + +function collectRelativeImportSpecifiers(content: string): string[] { + const specifiers = new Set(); + collectImportMatches(content, STATIC_IMPORT_RE, specifiers); + collectImportMatches(content, DYNAMIC_IMPORT_RE, specifiers); + return [...specifiers]; +} + +function collectImportMatches(content: string, pattern: RegExp, specifiers: Set): void { + pattern.lastIndex = 0; + let match: RegExpExecArray | null = null; + while ((match = pattern.exec(content)) !== null) { + const specifier = match[1]?.trim(); + if (specifier?.startsWith('.')) { + specifiers.add(specifier); + } + } +} + +function resolveRelativeImportPath(fromPath: string, specifier: string): string | null { + const basePath = path.resolve(path.dirname(fromPath), specifier); + const direct = resolveExistingFile(basePath); + if (direct) return direct; + + for (const extension of RESOLVABLE_EXTENSIONS) { + const withExtension = resolveExistingFile(`${basePath}${extension}`); + if (withExtension) return withExtension; + } + + for (const extension of RESOLVABLE_EXTENSIONS) { + const indexPath = resolveExistingFile(path.join(basePath, `index${extension}`)); + if (indexPath) return indexPath; + } + + return null; +} + +function resolveExistingFile(candidatePath: string): string | null { + try { + return fs.statSync(candidatePath).isFile() ? candidatePath : null; + } catch { + return null; + } +} diff --git a/src/daemon/server-lifecycle.ts b/src/daemon/server-lifecycle.ts index 9ee0dca4..7cbd3996 100644 --- a/src/daemon/server-lifecycle.ts +++ b/src/daemon/server-lifecycle.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; -import path from 'node:path'; -import { findProjectRoot, readVersion } from '../utils/version.ts'; +import { readVersion } from '../utils/version.ts'; import { isAgentDeviceDaemonProcess, readProcessStartTime } from '../utils/process-identity.ts'; +import { resolveDaemonCodeSignature } from './code-signature.ts'; export type DaemonLockInfo = { pid: number; @@ -10,19 +10,6 @@ export type DaemonLockInfo = { processStartTime?: string; }; -export function resolveDaemonCodeSignature(): string { - const entryPath = process.argv[1]; - if (!entryPath) return 'unknown'; - try { - const stat = fs.statSync(entryPath); - const root = findProjectRoot(); - const relativePath = path.relative(root, entryPath) || entryPath; - return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`; - } catch { - return 'unknown'; - } -} - export function writeInfo( baseDir: string, infoPath: string, @@ -129,4 +116,4 @@ export function parseIntegerEnv(raw: string | undefined): number | undefined { return value; } -export { readVersion, readProcessStartTime }; +export { readVersion, readProcessStartTime, resolveDaemonCodeSignature }; diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index e33d279a..cea747a3 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -1032,14 +1032,39 @@ test('downloadRemoteArtifact times out stalled artifact responses and removes pa } }); -test('computeDaemonCodeSignature includes relative path, size, and mtime', () => { +test('computeDaemonCodeSignature fingerprints the daemon runtime import graph', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-signature-')); try { - const daemonEntryPath = path.join(root, 'dist', 'src', 'daemon.js'); + const daemonEntryPath = path.join(root, 'src', 'daemon.ts'); + const helperPath = path.join(root, 'src', 'helper.ts'); + const lazyPath = path.join(root, 'src', 'lazy.ts'); + const ignoredPath = path.join(root, 'src', 'ignored.ts'); fs.mkdirSync(path.dirname(daemonEntryPath), { recursive: true }); - fs.writeFileSync(daemonEntryPath, 'console.log("daemon");\n', 'utf8'); - const signature = computeDaemonCodeSignature(daemonEntryPath, root); - assert.match(signature, /^dist\/src\/daemon\.js:\d+:\d+$/); + fs.writeFileSync( + daemonEntryPath, + [ + "import './helper.ts';", + 'export async function boot() {', + " return await import('./lazy.ts');", + '}', + '', + ].join('\n'), + 'utf8', + ); + fs.writeFileSync(helperPath, 'export const helper = 1;\n', 'utf8'); + fs.writeFileSync(lazyPath, 'export const lazy = 1;\n', 'utf8'); + fs.writeFileSync(ignoredPath, 'export const ignored = 1;\n', 'utf8'); + + const initial = computeDaemonCodeSignature(daemonEntryPath, root); + assert.match(initial, /^graph:3:[0-9a-f]{40}$/); + + fs.writeFileSync(lazyPath, 'export const lazy = 200;\n', 'utf8'); + const changedRuntime = computeDaemonCodeSignature(daemonEntryPath, root); + assert.notEqual(changedRuntime, initial); + + fs.writeFileSync(ignoredPath, 'export const ignored = 200;\n', 'utf8'); + const changedUnrelated = computeDaemonCodeSignature(daemonEntryPath, root); + assert.equal(changedUnrelated, changedRuntime); } finally { fs.rmSync(root, { recursive: true, force: true }); }