Files
stack/packages/harness/src/bundle.mjs
T
jason.woltjeandClaude Opus 5.5 e62712e7aa seat: fetch a role clone that is on another branch or dirty (#1611)
Row 117. At a launch, update() kept a clone that was on another branch
or dirty and ran no git in it, so its refs/remotes/origin/* stayed as
old as the last clean launch. Now those two reasons get the clean
path's fetch, with the same arguments and environment, and no merge.
The result is a new outcome, fetched, with the reason, the clone's
branch and HEAD, and origin { branch, commit } after the fetch. Every
earlier check still keeps the clone with no fetch. The launcher writes
a checkout-fetched launch-log line, and the bundle prompt names a
fetched clone's branch, commit and origin. Nothing in unsafe(),
allowed(), gitEnv() or SAFE changed.

Candidate manifest 9fa839edfb2bd04771bbac1913b142cb16aef227a1265120e0d318fb64d6a502
from Darkwing's round 1 packet (17d801f48), on BASE 559ef2287. Dewey
approved in 28443. Sage reran the full gate at the landing target with
the candidate applied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:42:29 -05:00

185 lines
11 KiB
JavaScript

// The launch bundle: everything a managed session runs from, generated
// from one resolved role instance and written once into the run
// directory. prompt.md, policy.json, tools.json and manifest.json for both
// harnesses; settings.json (the gate hook) and mcp.json (the typed tools)
// for Claude Code. The Pi extension and the Claude gate stay at their
// source paths; the manifest records their digests.
//
// Each harness layer the bundle relies on is a row S0 line
// (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"); the manifest
// names them.
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { typedTools, untypedActions } from "./tools.mjs";
import { claudeBuiltins, MCP_PREFIX } from "./gate.mjs";
export const BUNDLE_VERSION = 1;
export const HARNESSES = Object.freeze(["pi", "claude-code"]);
export const SOURCES = Object.freeze({
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
mcpServer: fileURLToPath(new URL("./mcp-server.mjs", import.meta.url)),
gate: fileURLToPath(new URL("./gate.mjs", import.meta.url)),
tools: fileURLToPath(new URL("./tools.mjs", import.meta.url)),
});
// S0 lines 1-5 for both harnesses; line 6 is the Agent SDK, which S6
// doesn't use.
const RELIES_ON = Object.freeze({
pi: ["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"],
"claude-code": ["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 Claude hook: N = 10, K = 2, hook timeout 20, which leaves the
// margin the brief asks for above N + K.
const GATE_TIMEOUT = { n: 10, k: 2, hook: 20 };
export class BundleError extends Error {}
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
const quote = (s) => {
if (s.includes("'")) throw new BundleError(`path can't hold a single quote: ${s}`);
return `'${s}'`;
};
// The harness, provider and model one instance runs with. The agent-layer
// `harness` and `model` variables win; otherwise the system's execution
// adapter and model. Anything outside the two harnesses refuses.
export function sessionModel(resolved) {
const v = resolved.vars;
const harness = v.harness ?? v["execution.adapter"];
if (!HARNESSES.includes(harness)) throw new BundleError(`instance ${resolved.instance}: harness ${JSON.stringify(harness)} isn't one of ${HARNESSES.join(", ")}`);
const model = v.model ?? v["execution.model"];
if (typeof model !== "string" || !model) throw new BundleError(`instance ${resolved.instance}: no model`);
return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null };
}
// Row 91 (#1585): the project clones the host made or kept in the workspace.
// Row 104 (#1598): the commit each was at when the session started, when it
// refreshes, and why the session must not set git config in it.
// Row 117 (#1611): a fetched clone's branch, why it wasn't fast-forwarded,
// and where origin/<branch> is.
function checkoutLines(checkouts) {
if (!checkouts.length) return [];
const at = (c) => (c.commit ? `commit ${c.commit}` : "a commit the host couldn't read");
const one = (c) => {
if (c.outcome === "kept") return `${c.project} at ${c.path}, not updated at this launch (${c.reason})`;
const started = `${c.project} at ${c.path}, ${c.branch ? `on branch ${c.branch}` : "on no branch"} at ${at(c)} when this session started`;
if (c.outcome !== "fetched") return started;
return `${started}, fetched but not fast-forwarded at this launch (${c.reason}), with origin/${c.origin.branch} at ${at(c.origin)}`;
};
return [`- Project clones: ${checkouts.map(one).join("; ")}. Each is a git clone of the project. At a launch the host fetches each clone it can safely run git in from its origin, and fast-forwards only a clean clone on the root's branch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that \`git clone\` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.`];
}
function prompt({ contract, resolved, session, tools, untyped }) {
const lines = [
contract.trimEnd(),
"",
"## This session",
"",
`- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`,
`- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`,
`- Workspace: ${session.workspace}. File tools work inside it only.`,
...checkoutLines(session.checkouts ?? []),
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
`- Built-in tools: ${(session.harness === "pi" ? resolved.limits.tools : claudeBuiltins({ tools: resolved.limits.tools })).join(", ") || "none"}.`,
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
// Row 90 (#1584): PM fault 10 (#228 c91).
"- Only the human stops a run, with `mosaic stop`. If you think a run should stop, raise a role.revoke decision with blocking false and the role instance as target, naming the run and saying why.",
];
// Row 93 (#1587): the review loop, for the roles that have its tools.
const named = new Set(tools.map((t) => t.name));
if (named.has("push_working"))
lines.push("- Review loop: commit on a branch of your clone, push_working it, then request_review with the commit it returned. Work that isn't pushed can't be reviewed, and a task with pushed work closes only on an approve verdict for its latest push. After a changes verdict, commit the fixes, push again and ask again.");
if (named.has("fetch_review"))
lines.push("- Reviews: a REVIEW-REQUEST names a request id. fetch_review it, read the commit in your clone, and answer with review_verdict citing the request. One verdict per request.");
if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`);
return lines.join("\n") + "\n";
}
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion })
// dir the bundle directory, created here (0700)
// resolved resolveInstance(...) output
// contract the role contract's text
// arbiter true when the business names this instance as an arbiter
// tracker true when the broker has task verbs for the business
// credentials the broker's credentialStatus(...) for the instance (metadata only)
// session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts }
// checkouts is ensureCheckouts(...) output (packages/seat/src/checkout.mjs),
// named in the prompt; none when absent
// piVersion the pinned pi version, for the manifest
// claudeVersion the host's `claude --version`, for the manifest
// Returns { manifest, files } with absolute paths.
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null }) {
const model = sessionModel(resolved);
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project) });
const untyped = untypedActions(resolved, tools);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const write = (name, text) => {
writeFileSync(join(dir, name), text, { mode: 0o600, flag: "wx" });
return [name, sha256(text)];
};
const policy = {
harness: model.harness,
workspace: session.workspace,
tools: [...resolved.limits.tools],
typed: tools.map((t) => t.name),
network: resolved.limits.network,
needs: Object.keys(resolved.credentials ?? {}),
credentials,
};
const files = {
prompt: join(dir, "prompt.md"),
policy: join(dir, "policy.json"),
tools: join(dir, "tools.json"),
manifest: join(dir, "manifest.json"),
};
const digests = [
write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model }, tools, untyped })),
write("policy.json", JSON.stringify(policy, null, 2) + "\n"),
write("tools.json", JSON.stringify(tools, null, 2) + "\n"),
];
const sources = { gate: SOURCES.gate, tools: SOURCES.tools };
if (model.harness === "pi") {
sources.piExtension = SOURCES.piExtension;
} else {
sources.claudeGate = SOURCES.claudeGate;
sources.mcpServer = SOURCES.mcpServer;
files.settings = join(dir, "settings.json");
files.mcp = join(dir, "mcp.json");
const node = process.execPath;
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(SOURCES.claudeGate)} ${quote(files.policy)} || exit 2`;
digests.push(write("settings.json", JSON.stringify({
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
}, null, 2) + "\n"));
digests.push(write("mcp.json", JSON.stringify({
mcpServers: { mosaic: { type: "stdio", command: node, args: [SOURCES.mcpServer, files.tools, session.toolSocket] } },
}, null, 2) + "\n"));
}
const manifest = {
bundleVersion: BUNDLE_VERSION,
business: resolved.business,
instance: resolved.instance,
definition: resolved.definition,
run: session.run,
launchedBy: session.launchedBy,
resolvedDigest: resolved.digest,
...model,
piVersion: model.harness === "pi" ? piVersion : null,
claudeVersion: model.harness === "claude-code" ? claudeVersion : null,
builtins: model.harness === "pi" ? [...resolved.limits.tools] : claudeBuiltins(policy),
typed: tools.map((t) => (model.harness === "pi" ? t.name : MCP_PREFIX + t.name)),
untyped,
skills: [],
reliesOn: RELIES_ON[model.harness],
gateTimeout: model.harness === "claude-code" ? GATE_TIMEOUT : null,
files: Object.fromEntries(digests),
sources: Object.fromEntries(Object.entries(sources).map(([k, p]) => [k, { path: p, sha256: sha256(readFileSync(p)) }])),
};
writeFileSync(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600, flag: "wx" });
return { manifest, files, policy, tools };
}