Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
116 lines
5.8 KiB
JavaScript
116 lines
5.8 KiB
JavaScript
// The "claude-code" harness plug-in (row 65, REQ-PLUG-2, Q156): a managed session on the host's
|
|
// Claude Code CLI, MOSAIC_CLAUDE_BIN when set (the bus unit pins it), else `claude` on PATH. The
|
|
// tool gate is claude-gate.mjs behind the PreToolUse hook and the typed tools are mcp-server.mjs;
|
|
// both stay in packages/harness (a module can't change that boundary). The launcher, the bundle
|
|
// and the usage reader call these on the host; the runner gets the answers as data in
|
|
// session.json. packages/plugins/README.md lists the interface.
|
|
import { spawnSync } from "node:child_process";
|
|
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
import { delimiter, isAbsolute, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { claudeBuiltins, MCP_PREFIX } from "../../../harness/src/gate.mjs";
|
|
import { hasSessionCredential, SESSION_CREDENTIALS } from "../../../seat/src/session.mjs";
|
|
|
|
export const interfaceVersion = 1;
|
|
|
|
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
const GATE = fileURLToPath(new URL("../../../harness/src/claude-gate.mjs", import.meta.url));
|
|
const MCP_SERVER = fileURLToPath(new URL("../../../harness/src/mcp-server.mjs", import.meta.url));
|
|
// `claude --effort <level>` on 2.1.302 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
|
|
// It has no off or minimal.
|
|
const LEVELS = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
|
|
// docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5.
|
|
const RELIES_ON = Object.freeze(["S0-1 command hook deny, no --bare", "S0-2 hook wrapped as <gate> || exit 2",
|
|
"S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 <gate> || exit 2 with hook timeout 20",
|
|
"S0-5 bash is limited by the tool limit only"]);
|
|
// The hook: N = 10, K = 2, hook timeout 20, which leaves the margin the brief asks for above N + K.
|
|
const GATE_TIMEOUT = Object.freeze({ n: 10, k: 2, hook: 20 });
|
|
|
|
export const adapter = () => join(REPO, "adapters", "claude", "adapter.sh");
|
|
export const levels = () => LEVELS;
|
|
|
|
const executable = (path) => {
|
|
try {
|
|
accessSync(path, constants.X_OK);
|
|
return statSync(path).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// The binary sessions run: MOSAIC_CLAUDE_BIN, else the first `claude` on PATH, or null.
|
|
function binary(env) {
|
|
if (env.MOSAIC_CLAUDE_BIN) return isAbsolute(env.MOSAIC_CLAUDE_BIN) && executable(env.MOSAIC_CLAUDE_BIN) ? env.MOSAIC_CLAUDE_BIN : null;
|
|
for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "claude"))) return join(dir, "claude");
|
|
return null;
|
|
}
|
|
|
|
// A session needs a credential from the host's environment and the binary it runs.
|
|
export function preflight({ env }) {
|
|
if (!hasSessionCredential(env)) {
|
|
const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", ");
|
|
return { code: "no-session-credential", message: `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts` };
|
|
}
|
|
if (!binary(env)) {
|
|
return {
|
|
code: "harness-binary-missing",
|
|
message: env.MOSAIC_CLAUDE_BIN ? `MOSAIC_CLAUDE_BIN ${env.MOSAIC_CLAUDE_BIN} isn't an executable file at an absolute path` : "no claude on the bus host's PATH",
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// `claude --version` from the binary sessions run, or null when it doesn't answer.
|
|
export function version({ env }) {
|
|
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
|
|
return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null;
|
|
}
|
|
|
|
// Its launches spend the business's launch.account (row 126).
|
|
export const countsAccount = () => true;
|
|
|
|
// The pinned binary, when it lives outside /usr (which the sandbox already binds).
|
|
export function mounts({ env }) {
|
|
if (!env.MOSAIC_CLAUDE_BIN) return [];
|
|
const real = realpathSync(env.MOSAIC_CLAUDE_BIN);
|
|
return real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }];
|
|
}
|
|
|
|
// WebSearch and WebFetch are its own, so it serves harness-builtin web.
|
|
export const builtinWeb = () => true;
|
|
export const builtins = ({ tools, webBuiltin = false }) => claudeBuiltins({ tools, webBuiltin });
|
|
export const typedName = (name) => MCP_PREFIX + name;
|
|
export const reliesOn = () => RELIES_ON;
|
|
export const gateTimeout = () => ({ ...GATE_TIMEOUT });
|
|
|
|
// settings.json holds the gate hook and mcp.json the typed tools' server. files: the bundle's own
|
|
// (policy, tools). write(name, text) puts a file in the bundle directory and returns
|
|
// [name, sha256]; quote(path) refuses a single quote.
|
|
export function bundle({ dir, files: core, session, write, quote }) {
|
|
const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") };
|
|
const node = process.execPath;
|
|
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(GATE)} ${quote(core.policy)} || exit 2`;
|
|
const digests = [
|
|
write("settings.json", JSON.stringify({
|
|
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
|
|
}, null, 2) + "\n"),
|
|
write("mcp.json", JSON.stringify({
|
|
mcpServers: { mosaic: { type: "stdio", command: node, args: [MCP_SERVER, core.tools, session.toolSocket] } },
|
|
}, null, 2) + "\n"),
|
|
];
|
|
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER } };
|
|
}
|
|
|
|
export function env({ files, policy, session }) {
|
|
return {
|
|
MOSAIC_TOOLS: claudeBuiltins(policy).join(","),
|
|
MOSAIC_CLAUDE_SETTINGS: files.settings,
|
|
MOSAIC_CLAUDE_MCP_CONFIG: files.mcp,
|
|
MOSAIC_THINKING: session.thinking,
|
|
};
|
|
}
|
|
|
|
// A turn ends when claude exits; there is no marker.
|
|
export const turnEnd = () => "exit";
|
|
export const transcript = ({ runDir, store = null }) => (store ? store.projects : join(runDir, "home", ".claude", "projects"));
|