Files
stack/packages/cli/tests/helpers.mjs
T
jason.woltje 39eadf8ba8 Row 332 R2 (#1830): hermetic pi launches in cli tests, pi bundle default, review F2 and F3
The cli tests that launch pi deleted only the session login names, so pi's
preflight passed or refused by whatever provider keys the host exported. A
shared helper now deletes every credential pi's preflight reads and gives pi
launches one made-up key. The refusal itself is unchanged.

pi bundle() takes a default argument again, so a call with none returns the
non-proxy bundle, as before P3. pi.bundle leaves out a launch.models entry
whose provider has no proxy route instead of failing every pi launch (F2);
malformed proxy entries still refuse. The invalid session proxy context
BundleError gets the code invalid-proxy-context. New tests cover F2, the code,
and the three proxy account branches in the launcher (F3).
2026-11-02 15:22:17 -06:00

171 lines
7.7 KiB
JavaScript

// Shared fixtures. Every test works in its own temporary directory and
// points MOSAIC_CONFIG there; nothing reads the real ~/.config, a real
// token, a real binding, or the real human transport.
import { chmodSync, cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { pathToFileURL } from "node:url";
import { BusError, Broker } from "../../bus/src/broker.mjs";
import { Store } from "../../bus/src/store.mjs";
import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
import { REPO_SKILLS } from "../../harness/tests/helpers.mjs";
import { tree } from "../../plugins/tests/helpers.mjs";
import { SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
import { AGENT_MARKERS } from "../src/transport.mjs";
// Row 332 R2 (#1830): every host variable a launch reads as a login. The
// session login names come from the seat; the other three are what pi's
// preflight also takes as its own provider key. A test that deletes only the
// session names passes or fails by what the host happens to export.
export const HOST_CREDENTIALS = Object.freeze([...new Set([
...SESSION_CREDENTIALS.flat(), "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY",
])]);
// A made-up pi provider key: obviously fake, never a real value.
export const FAKE_PI_KEY = "fx332-fake-pi";
// Remove every host credential from `env`, then, for a test that launches pi,
// set the one fake pi key. Returns `env`.
export function withoutHostCredentials(env, { piKey = false } = {}) {
for (const k of HOST_CREDENTIALS) delete env[k];
if (piKey) env.ZAI_API_KEY = FAKE_PI_KEY;
return env;
}
export function tmp(t, prefix = "mosaic-cli-") {
const root = mkdtempSync(join(tmpdir(), prefix));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
// The environment with every agent marker removed: the test runner itself
// may run under an agent, and the commands refuse there.
export function humanEnv(extra = {}) {
const env = { ...process.env, ...extra };
for (const k of AGENT_MARKERS) delete env[k];
delete env.NODE_TEST_CONTEXT;
return env;
}
// A config directory with the system config, the roles and business `id`. Row 65: both harnesses
// are switched on, since a launch on one that isn't refuses harness-off, and every instance names a
// harness, model and thinking level, as the live file does, since a launch missing one refuses
// no-route. The instance's own vars win, and edit can change any of it. Row 64: the data root's
// skills-enabled holds the repository's skills, since a launch whose role names one that isn't
// there refuses skill-not-enabled. The fixture makes the data root (0700) to hold them.
export function fixture(root, id = "acme", edit = (doc) => doc) {
const config = systemConfig(root);
const roles = rolesCopy(root);
const base = businessDoc(root, id);
for (const r of Object.values(base.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
const doc = edit({ ...base, plugins: { ...base.plugins, harness: ["pi", "claude-code"] } });
writeJson(join(root, "config", "businesses", `${id}.json`), doc);
mkdirSync(join(root, "data"), { recursive: true, mode: 0o700 });
cpSync(REPO_SKILLS, join(root, "data", "skills-enabled"), { recursive: true });
return { config, roles, dataRoot: join(root, "data"), doc, env: humanEnv({ MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: roles }) };
}
export function notifyConfig(dataRoot, business, binding) {
// The journal sits beside notify.json and refuses a directory looser than 0700.
mkdirSync(join(dataRoot, "notify", business), { recursive: true, mode: 0o700 });
return writeJson(join(dataRoot, "notify", business, "notify.json"), { notifyVersion: 1, binding });
}
// An io for main(): captured stdout and stderr, a stdin that is not a TTY.
export function io(env = humanEnv()) {
const out = { text: "" };
const err = { text: "" };
const stdin = new PassThrough();
stdin.isTTY = false;
return {
env,
stdin,
stdout: { write: (s) => ((out.text += s), true) },
stderr: { write: (s) => ((err.text += s), true) },
out,
err,
};
}
export const OPTIONS = [
{ key: "yes", text: "Allow" },
{ key: "no", text: "Decline" },
];
export const BUSINESSES = {
demo: {
id: "demo",
human: "jason",
plugins: { harness: ["pi"] },
arbiters: { technical: "cto", delivery: "pm" },
roles: {
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
cto: { authority: { withinRole: ["message.send"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: ["task.scope.change"] } },
},
},
};
// An in-process broker with a claimed coder and the human's capability.
// transport(business) mirrors humanTransport: errors carry the bus code.
export function broker(t, businesses = BUSINESSES) {
const root = tmp(t, "mosaic-cli-bus-");
const store = new Store(root);
t.after(() => store.close());
const b = new Broker({ store, businesses });
const coder = b.bindLaunch({ business: "demo", role: "coder", run: "coder-run", harness: "pi", address: "coder-run" });
b.request(coder, { verb: "role.claim" });
const human = b.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
const reader = b.bindReader({ business: "demo" });
const calls = [];
const transport = (business) => async (verb, args = {}) => {
calls.push({ business, verb, args });
try {
return structuredClone(b.request(human, { verb, args }));
} catch (e) {
if (!(e instanceof BusError)) throw e;
const error = new Error(e.code);
error.code = e.code;
throw error;
}
};
const raise = (action, extra = {}) =>
b.request(coder, { verb: "decision.raise", args: { action, question: "Push the release?", options: OPTIONS, recommendation: "no", blocking: false, ...extra } });
return { b, store, coder, human, reader, transport, calls, raise, read: (verb, args = {}) => structuredClone(b.request(reader, { verb, args })) };
}
// A script standing in for packages/bus/src/human-cli.mjs: it reads one
// request on stdin and answers from the table in its first argument.
export function fakeCli(root, body) {
const file = join(root, "fake-human-cli.mjs");
writeFileSync(file, body);
chmodSync(file, 0o600);
return file;
}
// Row 65: a launcher's makeLoader over a scratch plug-in tree. adapters maps a harness module to the
// adapter script a test runs in its place, or to null for the real module unchanged. A faked module
// re-exports the real one with only adapter() replaced; a faked claude-code also passes a missing
// claude binary, since the fake adapter never runs one (packages/plugins/tests/harness.test.mjs
// covers that check). more(write) adds fixture modules, write(kind, name, body).
export async function fakeHarnesses(t, adapters, more = () => {}) {
const { write, loaderUrl } = tree(t);
for (const [name, adapter] of Object.entries(adapters)) {
const real = JSON.stringify(pathToFileURL(new URL(`../../plugins/harness/${name}/index.mjs`, import.meta.url).pathname).href);
if (adapter === null) {
write("harness", name, `export * from ${real};\n`);
continue;
}
const lines = [`export * from ${real};`, `export const adapter = () => ${JSON.stringify(adapter)};`];
if (name === "claude-code") {
lines.push(`import { preflight as real } from ${real};`,
"export function preflight(input) { const p = real(input); return p?.code === \"harness-binary-missing\" ? null : p; }");
}
write("harness", name, lines.join("\n") + "\n");
}
more(write);
const { createLoader } = await import(loaderUrl);
return createLoader;
}