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
15 changes: 2 additions & 13 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions src/daemon/code-signature.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string>();
collectImportMatches(content, STATIC_IMPORT_RE, specifiers);
collectImportMatches(content, DYNAMIC_IMPORT_RE, specifiers);
return [...specifiers];
}

function collectImportMatches(content: string, pattern: RegExp, specifiers: Set<string>): 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;
}
}
19 changes: 3 additions & 16 deletions src/daemon/server-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -129,4 +116,4 @@ export function parseIntegerEnv(raw: string | undefined): number | undefined {
return value;
}

export { readVersion, readProcessStartTime };
export { readVersion, readProcessStartTime, resolveDaemonCodeSignature };
35 changes: 30 additions & 5 deletions src/utils/__tests__/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
Loading