server/cli-probe-env.test.ts
Rama main · réplica de lectura del repositorio original.
// A stub executable records the environment the CLI probe and the Claude update hand it. Disposable home; no network.
import { randomBytes } from "node:crypto";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, expect, it } from "vitest";
import { CONTROL_PLANE_ENV, PROVIDER_CREDENTIAL_ENV, WORKSPACE_CREDENTIAL_ENV } from "./config.ts";
import { removeTempDir, waitForExit } from "./testing/cleanup.ts";
import { freePortBlock } from "./testing/ports.ts";
const SERVER_DIR = dirname(fileURLToPath(import.meta.url));
// Every shared-list name plus one more under OMB_CLOUD_, so a credential added later is covered. Not the hosted model pair:
// a desktop holding either refuses to boot unless it is a complete portal-managed workspace.
const HOSTED_ONLY = ["OMB_HOSTED_MODELS", "OMB_HOSTED_MODEL_TOKEN"];
const secretNames = [...new Set<string>([
...WORKSPACE_CREDENTIAL_ENV, ...PROVIDER_CREDENTIAL_ENV, ...CONTROL_PLANE_ENV, "OMB_CLOUD_FIXTURE_TOKEN",
])].filter((name) => !HOSTED_ONLY.includes(name));
const secrets: Record<string, string> = Object.fromEntries(secretNames.map((name) => {
const unique = randomBytes(12).toString("hex");
return [name, name.endsWith("_URL") ? `https://fixture-${unique}.example.test/v1` : `fixture-${name.toLowerCase()}-${unique}`];
}));
const kept = { HTTPS_PROXY: "http://proxy.example.test:3128", NO_PROXY: "localhost,127.0.0.1" };
let home: string;
let base: string;
let child: ChildProcess;
let log = "";
async function post(path: string, body: unknown) {
const response = await fetch(`${base}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: response.status, body: await response.json().catch(() => null) as any };
}
/** `node <stub>` plus fixed arguments, as a wrapper string the Engines panel
* accepts: no shebang or exec bit, so it runs the same on every platform. */
const stubCli = (...fixed: string[]) =>
[process.execPath, join(home, "stub-cli.mjs"), ...fixed].map((part) => JSON.stringify(part)).join(" ");
function expectNoCredentials(file: string) {
const env = JSON.parse(readFileSync(file, "utf8")) as Record<string, string>;
// Proves the dump is the server's environment, not an empty one.
expect(env.HOME).toBe(home);
expect(env.PATH?.split(delimiter)).toEqual(expect.arrayContaining(process.env.PATH!.split(delimiter).filter(Boolean)));
expect(env).toMatchObject(kept);
const leaked = secretNames.filter((name) => name in env);
expect(leaked).toEqual([]);
const dumped = JSON.stringify(env);
expect(Object.keys(secrets).filter((name) => dumped.includes(secrets[name]))).toEqual([]);
}
beforeAll(async () => {
home = mkdtempSync(join(tmpdir(), "omb-cli-probe-env-"));
const dataDir = join(home, ".openmausbot");
mkdirSync(dataDir, { recursive: true });
// Answers like a signed-in Claude Code; `probe` (the CLI test) and
// `update` (the Claude update) record the environment they ran with.
writeFileSync(join(home, "stub-cli.mjs"), `import { writeFileSync } from "node:fs";
import { join } from "node:path";
const args = process.argv.slice(2);
if (args[0] === "probe" || args[0] === "update") writeFileSync(join(${JSON.stringify(home)}, args[0] + "-env.json"), JSON.stringify(process.env));
if (args[0] === "auth") console.log(JSON.stringify({ loggedIn: true, authMethod: "claude.ai", apiProvider: "firstParty" }));
else if (args.at(-1) === "--version") console.log("9.9.9 (Claude Code)");
`);
writeFileSync(join(dataDir, "config.json"), JSON.stringify({
instances: {
// Pin the fleet's other defaults so this never probes an installed CLI.
...Object.fromEntries(["codex", "cursor", "githubCopilot", "openaiCompat", "mistral", "qwen", "hermes", "pi"].map((id) => [id, { driver: "not-a-real-driver" }])),
claude: { driver: "claudeAgent", displayName: "Claude", config: { cli: stubCli() } },
},
}));
const port = await freePortBlock([0, 1]);
base = `http://127.0.0.1:${port}`;
const offlinePrelude = `data:text/javascript,${encodeURIComponent('globalThis.fetch = async () => new Response("offline fixture", { status: 503 });')}`;
child = spawn(process.execPath, ["--import", offlinePrelude, join(SERVER_DIR, "index.ts")], {
cwd: join(SERVER_DIR, ".."),
env: {
PATH: process.env.PATH,
...(process.env.PATHEXT ? { PATHEXT: process.env.PATHEXT } : {}),
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
HOME: home, USERPROFILE: home, OMB_DATA_DIR: dataDir, OMB_PORT: String(port), OMB_WEBHOOK_PORT: String(port + 1),
...kept,
...secrets,
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk) => { log += chunk; });
child.stderr?.on("data", (chunk) => { log += chunk; });
const deadline = Date.now() + 20_000;
for (;;) {
if (child.exitCode !== null) throw new Error(`the server exited:\n${log}`);
try {
const health = await fetch(`${base}/api/health`);
if (health.ok && (await health.json() as { pid?: number }).pid === child.pid) break;
} catch {}
if (Date.now() > deadline) throw new Error(`the server did not start:\n${log}`);
await new Promise((resolve) => setTimeout(resolve, 100));
}
}, 30_000);
afterAll(async () => {
if (child) await waitForExit(child, { signal: "SIGTERM" });
if (home) await removeTempDir(home);
});
it("probes a CLI chosen in Settings without handing it any credential the server holds", async () => {
const probe = await post("/api/cli-test", { cli: stubCli("probe") });
expect(probe.body, JSON.stringify(probe.body)).toMatchObject({ ok: true, version: "9.9.9 (Claude Code)" });
expectNoCredentials(join(home, "probe-env.json"));
});
it("updates Claude Code without handing the updater any credential the server holds", async () => {
const updated = await post("/api/instances/claude/claude-update", {});
expect(updated.body, JSON.stringify(updated.body)).toEqual({ ok: true, version: "9.9.9 (Claude Code)" });
expectNoCredentials(join(home, "update-env.json"));
});