171 lines
7.7 KiB
TypeScript
171 lines
7.7 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { createCommanderRequestHandler, type RuntimeConfig } from "../src/components/microservices/host-codex-commander/src/index";
|
|
import { commanderHealth, summarizeCommanderTrace } from "../src/components/microservices/host-codex-commander/src/state";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
|
|
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
|
}
|
|
|
|
function asRecord(value: unknown, label: string): JsonRecord {
|
|
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
|
|
return value as JsonRecord;
|
|
}
|
|
|
|
function asRecordArray(value: unknown, label: string): JsonRecord[] {
|
|
assertCondition(Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null && !Array.isArray(item)), `${label} must be object array`, value);
|
|
return value as JsonRecord[];
|
|
}
|
|
|
|
function asStringArray(value: unknown, label: string): string[] {
|
|
assertCondition(Array.isArray(value) && value.every((item) => typeof item === "string"), `${label} must be string array`, value);
|
|
return value as string[];
|
|
}
|
|
|
|
function runCli(args: string[], expectStatus: number): JsonRecord {
|
|
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
});
|
|
assertCondition(result.status === expectStatus, `status mismatch for ${args.join(" ")}`, {
|
|
status: result.status,
|
|
stdout: result.stdout.slice(-2000),
|
|
stderr: result.stderr.slice(-2000),
|
|
});
|
|
assertCondition(result.stdout.trim().length > 0, `command produced no stdout: ${args.join(" ")}`);
|
|
return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope");
|
|
}
|
|
|
|
function dataOf(envelope: JsonRecord): JsonRecord {
|
|
return asRecord(envelope.data, "data");
|
|
}
|
|
|
|
async function readJson(response: Response): Promise<JsonRecord> {
|
|
return asRecord(await response.json() as unknown, "response body");
|
|
}
|
|
|
|
const sessionId = `no-daemon-smoke-contract-${process.pid}`;
|
|
const liveSessionPath = join(process.cwd(), ".state", "commander", "sessions", `${sessionId}.json`);
|
|
assertCondition(!existsSync(liveSessionPath), "precondition failed: smoke session path should not already exist", liveSessionPath);
|
|
|
|
const smoke = dataOf(runCli(["commander", "smoke", "--dry-run", "--session-id", sessionId], 0));
|
|
assertCondition(smoke.ok === true, "smoke must succeed", smoke);
|
|
assertCondition(smoke.phase === "source-contract", "smoke must remain source-contract phase", smoke);
|
|
assertCondition(smoke.mode === "dry-run", "smoke must report dry-run mode", smoke);
|
|
assertCondition(smoke.mutation === false, "smoke must be non-mutating", smoke);
|
|
|
|
const noDaemon = asRecord(smoke.noDaemonSmokeContract, "noDaemonSmokeContract");
|
|
for (const flag of [
|
|
"startsDaemon",
|
|
"startsPtyBridge",
|
|
"startsStdioBridge",
|
|
"opensSshBridge",
|
|
"sendsClaudeqq",
|
|
"restartsServices",
|
|
"interruptsTasks",
|
|
"cancelsTasks",
|
|
"deploys",
|
|
"runsFullCheckOrE2e",
|
|
]) {
|
|
assertCondition(noDaemon[flag] === false, `${flag} must be false`, noDaemon);
|
|
}
|
|
assertCondition(asStringArray(noDaemon.allowedCommands, "allowedCommands").includes("bun scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"), "smoke should name this lightweight contract", noDaemon);
|
|
|
|
const validationPlan = asRecordArray(smoke.validationPlan, "validationPlan");
|
|
const surfaces = validationPlan.map((item) => item.surface);
|
|
for (const expected of [
|
|
"health endpoint",
|
|
"state file",
|
|
"trace summary dry-run",
|
|
"approval draft preview",
|
|
"SSH bridge boundary",
|
|
]) {
|
|
assertCondition(surfaces.includes(expected), `missing validation surface ${expected}`, surfaces);
|
|
}
|
|
for (const item of validationPlan) {
|
|
assertCondition(asStringArray(item.expectedEvidence, "expectedEvidence").length > 0, "each validation item must define evidence", item);
|
|
assertCondition(asStringArray(item.noRuntimeSideEffects, "noRuntimeSideEffects").length > 0, "each validation item must define no-side-effect boundary", item);
|
|
}
|
|
|
|
const smokeWithoutDryRun = dataOf(runCli(["commander", "smoke", "--session-id", sessionId], 1));
|
|
assertCondition(smokeWithoutDryRun.error === "dry-run-required", "smoke must require --dry-run", smokeWithoutDryRun);
|
|
assertCondition(!existsSync(liveSessionPath), "smoke CLI must not write live commander state", liveSessionPath);
|
|
|
|
const tmp = mkdtempSync(join(tmpdir(), "host-codex-commander-smoke-"));
|
|
try {
|
|
const runtime: RuntimeConfig = {
|
|
rootDir: tmp,
|
|
host: "127.0.0.1",
|
|
port: 4261,
|
|
logFile: join(tmp, "logs", "commander.jsonl"),
|
|
serviceId: "host-codex-commander",
|
|
stateRoot: tmp,
|
|
sessionId,
|
|
};
|
|
const health = commanderHealth(runtime, "2026-05-21T00:00:00.000Z");
|
|
assertCondition(health.ok === true && health.service === "host-codex-commander", "health helper must expose service metadata", health);
|
|
assertCondition(health.stateRoot === tmp, "health helper must use temp state root", health);
|
|
|
|
const handler = createCommanderRequestHandler(runtime);
|
|
const healthBody = await readJson(await handler(new Request("http://localhost/health")));
|
|
assertCondition(healthBody.ok === true, "short-lived handler health route must succeed without Bun.serve", healthBody);
|
|
|
|
const trace = summarizeCommanderTrace({
|
|
taskId: "task-smoke",
|
|
sessionId,
|
|
traceJsonl: [
|
|
JSON.stringify({ seq: 1, kind: "message", status: "running", summary: "checking token=ghp_1234567890abcdef" }),
|
|
JSON.stringify({ seq: 2, kind: "event", status: "attention_required", text: "needs approval" }),
|
|
].join("\n"),
|
|
taskSummary: "summary password=secret",
|
|
});
|
|
assertCondition(trace.taskId === "task-smoke", "trace summary must preserve task id", trace);
|
|
assertCondition(trace.sessionId === sessionId, "trace summary must preserve session id", trace);
|
|
assertCondition(trace.lastSeq === 2, "trace summary must compute last seq", trace);
|
|
assertCondition(trace.status === "attention_required", "trace summary must derive attention_required status", trace);
|
|
assertCondition(trace.redactionsApplied >= 2, "trace summary must redact mock secrets", trace);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
|
|
const approval = dataOf(runCli([
|
|
"commander",
|
|
"approval",
|
|
"request",
|
|
"--action",
|
|
"code-queue-task-cancel",
|
|
"--reason",
|
|
"token=ghp_1234567890abcdef",
|
|
"--dry-run",
|
|
], 0));
|
|
const claudeqq = asRecord(approval.claudeqq, "claudeqq");
|
|
assertCondition(claudeqq.mutation === false, "approval preview must not mutate ClaudeQQ", claudeqq);
|
|
assertCondition(claudeqq.sendImplemented === false, "approval preview must not implement sending", claudeqq);
|
|
assertCondition(!JSON.stringify(approval).includes("ghp_1234567890abcdef"), "approval preview must redact secret-like reason", approval);
|
|
|
|
const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8");
|
|
for (const snippet of [
|
|
"commander smoke --dry-run",
|
|
"无 daemon smoke contract",
|
|
"health endpoint",
|
|
"SSH bridge boundary",
|
|
]) {
|
|
assertCondition(doc.includes(snippet), `reference doc missing snippet: ${snippet}`);
|
|
}
|
|
|
|
process.stdout.write(`${JSON.stringify({
|
|
ok: true,
|
|
checks: [
|
|
"commander smoke --dry-run is non-mutating and dry-run required",
|
|
"no-daemon smoke contract forbids daemon, SSH/PTY/stdio bridge, ClaudeQQ send, restart, interrupt, cancel, deploy, and full e2e",
|
|
"health endpoint and trace summary are validated through short-lived source-level helpers",
|
|
"approval draft preview remains sendImplemented=false and redacted",
|
|
"reference doc describes the dev validation surfaces and no-daemon boundary",
|
|
],
|
|
}, null, 2)}\n`);
|