Files
Michał Pierzchała 6e22e266d7 refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon (#2322)
* refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon

Move the pure wire vocabulary (base path, header names, URL/auth/tenant
builders, /health payload) from src/daemon into @agent-device/contracts as
the daemon-http subpath, so src/remote and src/cli stop importing daemon
server internals. buildDaemonHealthPayload takes the version its caller
advertises (R18 keeps host mechanics out of contracts); both callers pass
readVersion(). Wire-compat surface, mutation, and ledger references follow
the package path.

* chore(gates): pin the moved daemon HTTP wire surface and teach the released-baseline check file moves

Exports map + snapshot gain the daemon-http subpath. The wire ledger
re-keys the eight moved declarations (buildDaemonHealthPayload moves with
its new caller-supplied version parameter, acked additive). The
released-baseline comparison now classifies a baseline declaration that
re-appears unchanged at exactly one new path as a move instead of a
removal: a file move is not wire surface a released peer stopped sending.
A move that changes shape is a change acked at the destination path, and a
name still owned by the baseline stays a removal.
2026-09-06 08:20:39 +02:00

122 lines
4.0 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http';
import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts';
import { stopProcessForTakeover } from '../../src/daemon/daemon-process.ts';
import { formatResultDebug } from './cli-json.ts';
import { assertNoDaemonLeaks } from './support/daemon-leak-oracle.ts';
import { runCliJson } from './test-helpers.ts';
type DaemonInfo = {
token: string;
pid: number;
processStartTime?: string;
transport?: string;
httpPort?: number;
};
test('daemon HTTP transport starts from CLI and accepts a command RPC', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) {
return;
}
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-http-smoke-'));
try {
const args = [
'session',
'list',
'--json',
'--daemon-transport',
'http',
'--state-dir',
stateDir,
];
const run = () =>
runCliJson(args, {
env: {
...process.env,
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
},
});
const cli = run();
assert.equal(cli.status, 0, formatResultDebug('start HTTP daemon', ['session', 'list'], cli));
assert.equal(cli.json?.success, true, JSON.stringify(cli.json));
const info = readDaemonInfo(stateDir);
assert.equal(info.transport, 'http');
assert.equal(typeof info.httpPort, 'number');
assert.ok((info.httpPort ?? 0) > 0);
const health = await fetch(`http://127.0.0.1:${info.httpPort}/health`);
assert.equal(health.status, 200);
const healthPayload = (await health.json()) as Record<string, unknown>;
assert.equal(healthPayload.ok, true);
assert.equal(healthPayload.service, 'agent-device-daemon');
assert.equal(typeof healthPayload.version, 'string');
assert.equal(healthPayload.rpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION);
const rpc = await callCommandRpc(info, 'session_list');
assert.equal(rpc.status, 200);
assert.equal(rpc.body.result?.ok, true, JSON.stringify(rpc.body));
const unauthorized = await callCommandRpc({ ...info, token: 'wrong-token' }, 'session_list');
assert.equal(unauthorized.status, 401);
assert.equal(unauthorized.body.error?.data?.code, 'UNAUTHORIZED');
// #1781 B1: the HTTP-mode daemon must exit — leaving nothing it owns and no
// unclassified state-dir residue. Asserted on the success path so the
// oracle's settle window can never replace a primary assertion's
// diagnostic; the `finally` below stays best-effort cleanup.
await stopDaemon(info);
await assertNoDaemonLeaks({ stateDir, daemonPids: [info.pid], phase: 'after-shutdown' });
} finally {
if (fs.existsSync(path.join(stateDir, 'daemon.json'))) {
await stopDaemon(readDaemonInfo(stateDir));
}
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
function readDaemonInfo(stateDir: string): DaemonInfo {
const infoPath = path.join(stateDir, 'daemon.json');
return JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
}
async function callCommandRpc(
info: DaemonInfo,
command: string,
): Promise<{ status: number; body: any }> {
const response = await fetch(`http://127.0.0.1:${info.httpPort}/rpc`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: `rpc-${Date.now()}`,
method: 'agent_device.command',
params: {
token: info.token,
session: 'default',
command,
positionals: [],
flags: {},
},
}),
});
return {
status: response.status,
body: await response.json(),
};
}
async function stopDaemon(info: DaemonInfo): Promise<void> {
if (!Number.isInteger(info.pid) || info.pid <= 0) return;
await stopProcessForTakeover(info.pid, {
termTimeoutMs: 1500,
killTimeoutMs: 1500,
expectedStartTime: info.processStartTime,
});
}