153 lines
8.4 KiB
JavaScript
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 });
|