Files

153 lines
8.4 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));
const STREAM = join(REPO, "adapters", "claude", "stream.mjs");
// Row 66 (#1559): the unslop check's hook script, which the Stop and UserPromptSubmit hooks run. It
// logs to MOSAIC_UNSLOP_LOG, which the runner sets, and the runner tells the operator.
const UNSLOP = Object.freeze({
unslopHook: join(REPO, "tools", "unslop-hook", "claude-hook.js"),
unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"),
unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"),
});
// `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;
export const outputPolicy = () => "adapter";
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, proxy = null }) {
if (!(typeof proxy?.key === "string" && proxy.key) && !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;
}
export function orgPresets() {
return [{ name: 'claude-code', label: "Claude via this machine's sign-in",
description: 'Use Claude Code with the credentials available to this Console.',
thinking: { levels: [...LEVELS], default: 'low' },
available: ({ env }) => {
const problem = preflight({ env });
return problem ? { ok: false, reason: problem.code === 'no-session-credential' ? 'No session credential is available to the Console.' : 'Claude Code is not available to the Console.' } : { ok: true };
},
overlay: ({ thinking }) => ({ plugins: { channel: ['cli', 'webui'], harness: ['claude-code'] },
launch: { by: 'chief', instances: ['assistant'], models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } } },
assistantVars: { harness: 'claude-code', model: 'opus', thinking } }) }];
}
// `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), and the unslop
// hook's files.
export function mounts({ env }) {
const unslop = Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's Stop and UserPromptSubmit hooks run" }));
unslop.push({ src: STREAM, why: "the adapter's private model stream reader" });
if (!env.MOSAIC_CLAUDE_BIN) return unslop;
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)" }]), ...unslop];
}
// 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 the two unslop hooks (row 66), and mcp.json the typed
// tools' server. An unslop hook ends with `; exit 0`, so it never blocks a reply or a prompt.
// 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 unslop = (which) => [{ hooks: [{ type: "command", command: `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(UNSLOP.unslopHook)} ${which}; exit 0`, timeout: GATE_TIMEOUT.hook }] }];
const digests = [
write("settings.json", JSON.stringify({
hooks: {
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }],
Stop: unslop("stop"),
UserPromptSubmit: unslop("prompt"),
},
}, 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, claudeStream: STREAM, ...UNSLOP } };
}
export function env({ files, policy, session, proxy = null }) {
return {
MOSAIC_TOOLS: claudeBuiltins(policy).join(","),
MOSAIC_CLAUDE_SETTINGS: files.settings,
MOSAIC_CLAUDE_MCP_CONFIG: files.mcp,
MOSAIC_THINKING: session.thinking,
// stream.mjs marks upstream model events; final stdout may be an API error.
MOSAIC_MODEL_ACTIVITY_SOURCE: "adapter",
...(proxy ? { ANTHROPIC_BASE_URL: proxy.baseUrl, ANTHROPIC_AUTH_TOKEN: proxy.key } : {}),
};
}
// 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"));
// Row 307 (#1814): Claude Code has no pinned compaction format yet (Vikunja 466), so a collection
// reads nothing and the count is unknown with harness-unsupported, never 0.
export const compactions = () => ({ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });