Review "changes 8" fix round for org-redact6. - launcher: a harness module's throw names its system code or class, never its message; preflight, loader, bundle, checkout, sandbox and route text is relayed with host paths replaced (relay()). - claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the repository's node_modules, never a path; persona and skill bundle errors and sandboxProblem name their file or the sandbox by label. - seat session: thread store, legacy store, overlap, busy lock and newRun refusals name the place under the data root. - discord: unknown keys, binding names and turn ids are described when not plain; approvals, journal and repository read errors name the file and code; git drops stderr lines that carry a host path and a spawn failure names its code only. - seat, control-board: registration fields and seen keys are described when not plain; invalid seat name and layout state the constraint. - organization: the registry names the config directory and registry file by label; --project is quoted only when it is a plain name; systemConfigProblem moves to its own module. - scripts/mosaic-config.mjs: the config by where it came from, the data root and its marker by role, keys and arguments only when plain, and error codes in place of raw messages. Exit codes unchanged. - webui sso-config: an unknown console key is named only when plain. Sentinel tests in each package's path-redaction-labels file fail on BASE; pins that quoted a path now assert the label and its absence.
154 lines
8.5 KiB
JavaScript
154 lines
8.5 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",
|
|
// Names the variable, never its value, which is a host path (#1873).
|
|
message: env.MOSAIC_CLAUDE_BIN ? "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 });
|