mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
6e22e266d7
* 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.
122 lines
4.0 KiB
TypeScript
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,
|
|
});
|
|
}
|