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.
126 lines
7.2 KiB
JavaScript
126 lines
7.2 KiB
JavaScript
// The "pi" harness plug-in (row 65, REQ-PLUG-2, Q156): a managed session on the pinned
|
|
// @earendil-works/pi-coding-agent CLI, the repository's node_modules/.bin/pi. The tool gate is the
|
|
// Pi extension, which stays 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 { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const interfaceVersion = 1;
|
|
|
|
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
const PACKAGE = "@earendil-works/pi-coding-agent";
|
|
const BIN = join(REPO, "node_modules", ".bin", "pi");
|
|
const EXTENSION = fileURLToPath(new URL("../../../harness/src/pi-extension.mjs", import.meta.url));
|
|
// Row 66 (#1559): the unslop check, loaded as a second extension. It logs to MOSAIC_UNSLOP_LOG,
|
|
// which the runner sets, and the runner tells the operator.
|
|
const UNSLOP = Object.freeze({
|
|
unslopExtension: join(REPO, "tools", "unslop-hook", "extension.ts"),
|
|
unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"),
|
|
unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"),
|
|
});
|
|
// `pi --thinking <level>` on 0.85.1 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
|
|
const LEVELS = Object.freeze(["off", "minimal", "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 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start",
|
|
"S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"]);
|
|
|
|
export const adapter = () => join(REPO, "adapters", "pi", "adapter.sh");
|
|
export const levels = () => LEVELS;
|
|
export const outputPolicy = ({ proxy }) => proxy ? "non-newline-stdout" : "stdout";
|
|
|
|
// The session runs pi from the repository's node_modules, which the sandbox mounts.
|
|
export function preflight({ env = {}, proxy = null } = {}) {
|
|
if (!(typeof proxy?.key === "string" && proxy.key)
|
|
&& !["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY"].some(name => Boolean(env[name]))) {
|
|
return { code: "no-session-credential", message: "pi needs its own provider key or a session proxy key" };
|
|
}
|
|
if (!existsSync(BIN)) return { code: "harness-binary-missing", message: "pi isn't installed in the repository's node_modules; run npm ci in the repository" };
|
|
return null;
|
|
}
|
|
|
|
// The exact pin from the repository's package.json.
|
|
export function version() {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
|
|
return pkg.dependencies?.[PACKAGE] ?? pkg.devDependencies?.[PACKAGE] ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Pi sessions run on a provider key, never the Claude account limit.
|
|
export const countsAccount = () => false;
|
|
export const mounts = () => [
|
|
{ src: join(REPO, "packages/plugins/harness/pi/model-stream.mjs"), why: "the managed model event stream" },
|
|
{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" },
|
|
...Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's second extension runs" })),
|
|
];
|
|
// Pi has no web tool of its own, so a granted role under harness-builtin is refused.
|
|
export const builtinWeb = () => false;
|
|
export const builtins = ({ tools }) => [...tools];
|
|
export const typedName = (name) => name;
|
|
export const reliesOn = () => RELIES_ON;
|
|
export const gateTimeout = () => null;
|
|
// https://docs.z.ai/guides/overview/concept-param
|
|
// https://docs.z.ai/guides/vlm/glm-5.3-flash
|
|
// Pi may compact and retry for length stops below maxTokens (pi-ai
|
|
// overflow.js:163 and pi-coding-agent agent-session.js:1659): once in a row,
|
|
// re-armed by a new prompt or a successful reply, so tool rounds can repeat it.
|
|
const DOCUMENTED_MODEL_LIMITS = {
|
|
'glm-5.3-flash': { maxTokens: 65536, contextWindow: 128000 },
|
|
};
|
|
|
|
export function bundle({ dir, models, proxy = null, transports = {} } = {}) {
|
|
const sources = { piExtension: EXTENSION, ...UNSLOP };
|
|
if (!proxy) return { files: {}, digests: [], sources };
|
|
const providers = {};
|
|
for (const entry of Object.values(models ?? {})) {
|
|
const transport = transports[Object.keys(models).find(key => models[key] === entry)];
|
|
if (!transport) continue;
|
|
const { piProvider: provider, api, endpoint, compat } = transport;
|
|
if (typeof entry.model !== "string" || !entry.model || entry.model.startsWith("!")) throw new Error("unsafe proxy model id");
|
|
const baseUrl = proxy.baseUrl + (endpoint === 'v1' ? '/v1' : '');
|
|
const compatibility = compat ?? undefined;
|
|
if (providers[provider] && (providers[provider].baseUrl !== baseUrl || providers[provider].api !== api || JSON.stringify(providers[provider].compat) !== JSON.stringify(compatibility))) throw new Error('conflicting proxy transports');
|
|
providers[provider] ??= { baseUrl, api, apiKey: "$ANTHROPIC_API_KEY", ...(compatibility ? { compat: compatibility } : {}), models: [] };
|
|
if (!providers[provider].models.some(model => model.id === entry.model)) {
|
|
providers[provider].models.push({ id: entry.model, reasoning: true, ...(Object.hasOwn(DOCUMENTED_MODEL_LIMITS, entry.model) ? DOCUMENTED_MODEL_LIMITS[entry.model] : {}) });
|
|
}
|
|
}
|
|
const text = JSON.stringify({ providers }, null, 2) + "\n";
|
|
// Even an accidental collision between a configured model id and the key fails closed.
|
|
if (typeof proxy.key !== "string" || !proxy.key || text.includes(proxy.key)) throw new Error("unsafe proxy model configuration");
|
|
const runDir = dirname(dir);
|
|
for (const part of ["home", "home/.pi", "home/.pi/agent"]) {
|
|
const path = join(runDir, part);
|
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
const stat = lstatSync(path);
|
|
if (!stat.isDirectory() || (stat.mode & 0o777) !== 0o700) throw new Error("pi proxy config needs private run-local directories");
|
|
}
|
|
const file = join(runDir, "home", ".pi", "agent", "models.json");
|
|
writeFileSync(file, text, { mode: 0o600, flag: "wx" });
|
|
return { files: { proxyModels: file }, digests: [["../home/.pi/agent/models.json", createHash("sha256").update(text).digest("hex")]], sources };
|
|
}
|
|
|
|
// The tool limit is the built-ins plus the typed tools, which the extension serves.
|
|
export function env({ policy, tools, session, proxy = null }) {
|
|
return {
|
|
PI_OFFLINE: "1",
|
|
MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","),
|
|
MOSAIC_EXTENSIONS: [EXTENSION, UNSLOP.unslopExtension].join(","),
|
|
PI_PROVIDER: proxy ? session.proxyTransport.piProvider : session.provider ?? "",
|
|
PI_MODEL: session.model,
|
|
MOSAIC_THINKING: session.thinking,
|
|
...(proxy ? { ANTHROPIC_API_KEY: proxy.key } : {}),
|
|
};
|
|
}
|
|
|
|
// The extension writes the turn marker at agent_end; an exit without it is a failed turn.
|
|
export const turnEnd = () => "marker";
|
|
export const transcript = ({ runDir, store = null }) => (store ? store.session : join(runDir, "session"));
|
|
// Row 307 (#1814): the session store's compactions, read from its pi streams (compactions.mjs).
|
|
export { compactions } from "./compactions.mjs";
|