Files
stack/agents/fethry/work/queue-332/join1961/source-vs-current.diff
T

469 lines
29 KiB
Diff

diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs
index 6f9b18690..d9ef01b40 100644
--- a/packages/business/src/launch.mjs
+++ b/packages/business/src/launch.mjs
@@ -1,6 +1,6 @@
// REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling.
import { requireObject, requireId, requireString, rejectUnknownKeys } from "./util.mjs";
-import { refuse } from "./errors.mjs";
+import { BusinessError, refuse } from "./errors.mjs";
const name = (value, at) => requireString(value, at, { max: 200 });
const count = (value, at) => {
@@ -15,10 +15,19 @@ export function checkModels(value, where = "launch.models") {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
requireId(key, where);
requireObject(entry, `${where}.${key}`);
- rejectUnknownKeys(entry, ["provider", "model"], `${where}.${key}`);
+ rejectUnknownKeys(entry, entry.provider === "proxy" ? ["provider", "model", "route"] : ["provider", "model"], `${where}.${key}`);
+ if (entry.provider === "proxy") {
+ const missing = !Object.hasOwn(entry, "route");
+ if (missing || !["anthropic", "glm", "codex"].includes(entry.route)) {
+ throw Object.assign(new BusinessError(`${where}.${key}.route must name anthropic, glm or codex`), {
+ code: missing ? "proxy-route-missing" : "proxy-route-unknown",
+ });
+ }
+ }
return [key, {
provider: name(entry.provider, `${where}.${key}.provider`),
model: name(entry.model, `${where}.${key}.model`),
+ ...(entry.provider === "proxy" && Object.hasOwn(entry, "route") ? { route: entry.route } : {}),
}];
}));
}
@@ -82,7 +91,7 @@ export function checkAccount(value, where = "launch.account") {
requireObject(value, where);
rejectUnknownKeys(value, ["name", "credential"], where);
const accountName = requireId(value.name, `${where}.name`);
- if (typeof value.credential !== "string" || !CREDENTIAL.test(value.credential)) {
+ if (typeof value.credential !== "string" || (value.credential !== "proxy" && !CREDENTIAL.test(value.credential))) {
refuse(`${where}.credential must name environment variables, joined with "+"`);
}
return { name: accountName, credential: value.credential };
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index 556f09c69..45878750a 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -159,6 +159,7 @@ import {
runnerOf,
SESSION_CREDENTIALS,
sessionEnv,
+ isProviderVariable,
sessionExit,
sessionStore,
sessionWorkspace,
@@ -218,6 +219,9 @@ export function createLauncher({
host,
system,
env = process.env,
+ // P2 supplies only {baseUrl,key}; a factory may supply the current agent's
+ // context per check. P3 never reads a key store or calls the proxy itself.
+ proxy = null,
tracker = false,
launches = true,
makeLoader = createLoader,
@@ -333,7 +337,7 @@ export function createLauncher({
// nothing else launches for one. Row 194 (#1692, V2): `human` is the
// human's launch request, which may name any declared instance. The listing
// limits the chief, not the human. launch.by still takes the `pm` branch.
- async function check(instance, { pm = false, thread = null, session = null, human = false } = {}) {
+ async function check(instance, { pm = false, thread = null, session = null, human = false, proxyContext = null, proxyReady = false } = {}) {
const admissionGeneration = admission().generation;
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
@@ -356,9 +360,10 @@ export function createLauncher({
// Row 65 (#1558): the route's harness, model and thinking come from the instance's vars only.
const routeOf = (which) => {
try {
- return sessionModel(resolved, b.launch.models, which);
+ return sessionModel(resolved, b.launch.models, which, { proxy: Boolean(system.proxy || proxyContext) });
} catch (e) {
- throw new Refusal(e instanceof BundleError && e.code === "no-route" ? "no-route" : "unsupported-harness", e.message);
+ const code = e instanceof BundleError && (e.code === "no-route" || e.code.startsWith("proxy-")) ? e.code : "unsupported-harness";
+ throw new Refusal(code, e.message);
}
};
const primary = routeOf("primary");
@@ -390,7 +395,23 @@ export function createLauncher({
let harness = await harnessOf(primary);
let route = "primary";
let routeReason = null;
- const failed = call(primary.harness, "preflight", () => harness.preflight({ env }));
+ // The first check validates the request before authorization/workspace work.
+ // Managed credentials are acquired once in startLocked, then supplied to
+ // the final check. Never reject proxy-only login using upstream host env.
+ if (system.proxy && !proxyReady)
+ return { resolved, model, account: null, harness, route, routeReason, admissionGeneration };
+ const proxyFor = async (selected) => {
+ if (!system.proxy && selected.provider !== "proxy") return null;
+ const context = system.proxy ? proxyContext : typeof proxy === "function" ? await proxy({ business: b.id, instance }) : proxy;
+ if (!context || typeof context.key !== "string" || !context.key
+ || typeof context.baseUrl !== "string" || !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(context.baseUrl)) {
+ throw new Refusal("no-session-credential", "proxy model needs its own session proxy context");
+ }
+ // Copy only the agreed seam; never keep a store or management handle.
+ return { baseUrl: context.baseUrl, key: context.key };
+ };
+ let sessionProxy = await proxyFor(primary);
+ const failed = call(primary.harness, "preflight", () => harness.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy }));
if (failed) {
if (!fallback) throw new Refusal(failed.code, failed.message);
// The fallback only on a failed primary preflight, and only at launch (row 65). Any refusal of
@@ -399,7 +420,8 @@ export function createLauncher({
if (e instanceof Refusal) e.message += ` (primary ${primary.harness}: ${failed.message})`;
throw e;
});
- const also = call(fallback.harness, "preflight", () => second.preflight({ env }));
+ sessionProxy = await proxyFor(fallback);
+ const also = call(fallback.harness, "preflight", () => second.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy }));
if (also) throw new Refusal(also.code, `fallback ${fallback.harness}: ${also.message} (primary ${primary.harness}: ${failed.message})`);
[model, harness, route, routeReason] = [fallback, second, "fallback", failed.code];
}
@@ -410,12 +432,18 @@ export function createLauncher({
if (account !== null) {
// The usage records keep this name (bus schema 3i), so it must never be a credential's value.
const holds = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => env[k] === account);
+ if (sessionProxy?.key === account) holds.push("session proxy key");
if (holds.length) throw new Refusal("account-is-credential", `launch.account.name is the value of ${holds.join(", ")}; it names the account, never its credential`);
- const named = b.launch.account.credential.split("+");
- if (!SESSION_CREDENTIALS.some((set) => set.join("+") === named.join("+")) || !named.every((k) => Boolean(env[k])))
- throw new Refusal("account-credential-missing", `account ${account} names ${named.join(" with ")}, which the bus host doesn't hold as a session login`);
- const other = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => !named.includes(k) && Boolean(env[k]));
- if (other.length) throw new Refusal("account-credential-ambiguous", `account ${account} names ${named.join(" with ")}, but the bus host also sets ${other.join(", ")}`);
+ if (sessionProxy || b.launch.account.credential === "proxy") {
+ if (!sessionProxy || b.launch.account.credential !== "proxy")
+ throw new Refusal("account-credential-missing", `account ${account} needs the declared proxy credential`);
+ } else {
+ const named = b.launch.account.credential.split("+");
+ if (!SESSION_CREDENTIALS.some((set) => set.join("+") === named.join("+")) || !named.every((k) => Boolean(env[k])))
+ throw new Refusal("account-credential-missing", `account ${account} names ${named.join(" with ")}, which the bus host doesn't hold as a session login`);
+ const other = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => !named.includes(k) && Boolean(env[k]));
+ if (other.length) throw new Refusal("account-credential-ambiguous", `account ${account} names ${named.join(" with ")}, but the bus host also sets ${other.join(", ")}`);
+ }
}
const unavailable = sandboxProblem({ bwrap });
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
@@ -434,7 +462,7 @@ export function createLauncher({
throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`);
}
admission(admissionGeneration);
- return { resolved, model, account, harness, route, routeReason, admissionGeneration };
+ return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy };
}
// Bundle, spawn, bind, register, hand over the capability.
@@ -505,16 +533,21 @@ export function createLauncher({
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
- let harness, route, routeReason;
- ({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,session,human}));
- admission(admissionGeneration);
- const { run, runDir } = newRun(dataRoot, b.id);
- let proxy = null, modelKeyId;
+ // Retain P2's real admission/ownership operation; do not substitute the
+ // old stand-in option or acquire again in each metadata/preflight check.
+ let admittedProxy = null;
if (system.proxy) {
- try { proxy = await host.op({ op: 'modelPreflight', business: b.id, role: instance }); }
+ try { admittedProxy = await host.op({ op: 'modelPreflight', business: b.id, role: instance }); }
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
- if (!proxy || typeof proxy.key !== 'string' || proxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
- modelKeyId = createHash('sha256').update(proxy.key).digest('hex').slice(0, 12);
+ if (!admittedProxy || typeof admittedProxy.key !== 'string' || !admittedProxy.key || admittedProxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
+ }
+ let harness, route, routeReason, sessionProxy;
+ ({resolved,model,account,harness,route,routeReason,sessionProxy}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
+ admission(admissionGeneration);
+ const { run, runDir } = newRun(dataRoot, b.id);
+ let modelKeyId;
+ if (admittedProxy) {
+ modelKeyId = createHash('sha256').update(sessionProxy.key).digest('hex').slice(0, 12);
// Internal opt-in only: no session.json, user environment or config field.
try { writeFileSync(join(runDir, 'proxy-first-turn-enabled'), 'enabled\n', { flag: 'wx', mode: 0o600 }); }
catch { throw new Refusal('launch-failed', 'first-turn protocol setup failed'); }
@@ -534,10 +567,10 @@ export function createLauncher({
const holding = holders?.filter((h) => h.instance === instance) ?? null;
const land = thread ? await host.op({ op: "landHeld", record: { business: b.id, role: instance, thread } }) : holding.length > 0;
const bundle = buildBundle({
- ...(proxy ? { proxy } : {}),
dir: join(runDir, "bundle"),
resolved,
models: b.launch.models,
+ proxy: sessionProxy,
contract: readFileSync(resolved.contract, "utf8"),
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
@@ -561,6 +594,12 @@ export function createLauncher({
// Row 199 (D11): or publishes it, once a reviewed commit routes git.push.remote within-role.
const held = [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole];
const git = land || ["git.push.working", "review.verdict", "git.push.remote"].some((a) => held.includes(a)) ? gitSocketPath(dataRoot) : null;
+ // Secrets go only to spawn, never session.json. Keep this split at the
+ // actual serialization boundary, even if a harness adds another key.
+ const harnessEnv = {}, transientEnv = {};
+ for (const [name, value] of Object.entries(bundle.env)) {
+ (sessionProxy && isProviderVariable(name) ? transientEnv : harnessEnv)[name] = value;
+ }
writeSessionFile(runDir, {
...sessionDefaults,
business: b.id,
@@ -578,7 +617,7 @@ export function createLauncher({
sessionDir,
adapter,
bundle: files,
- harnessEnv: bundle.env,
+ harnessEnv,
turnEnd,
toolSocket,
turnMarker,
@@ -602,7 +641,7 @@ export function createLauncher({
try {
admission(admissionGeneration);
spawnedAt.set(run, Date.now());
- child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
+ child = spawnSession({ runDir, env: { ...sessionEnv(env, run, sessionProxy), ...transientEnv }, paths, business: b.id, role: instance, bwrap });
} catch (e) {
spawnedAt.delete(run);
unregisterRuntime(dataRoot, managed.id);
@@ -624,13 +663,13 @@ export function createLauncher({
admission(admissionGeneration);
if (!outerStart) throw new Error("the session process exited at once");
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
- const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(proxy ? { modelKeyId } : {}) });
+ const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(admittedProxy ? { modelKeyId } : {}) });
bound = true;
admission(admissionGeneration);
entry = {
business: b.id, instance, ...(thread ? { thread, session, workspace } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
- ...(proxy ? { modelKeyId } : {}),
+ ...(admittedProxy ? { modelKeyId } : {}),
};
registry.add(entry);
// Row 156: one start per decision, whichever launch wrote it into landing.
@@ -648,7 +687,7 @@ export function createLauncher({
throw new Refusal("launch-failed", e.message);
}
children.set(run, exited);
- const outputWatch = proxy ? watchModelOutput({ runDir, armAtFirstTurn: true, keyId: modelKeyId, seconds: system.proxy.firstOutputSeconds ?? 120,
+ const outputWatch = admittedProxy ? watchModelOutput({ runDir, armAtFirstTurn: true, keyId: modelKeyId, seconds: system.proxy.firstOutputSeconds ?? 120,
stop: () => host.op({ op: 'modelStall', business: b.id, role: instance, run, keyId: modelKeyId }), receipt: record }) : null;
exited.then(async ({ code, signal }) => {
outputWatch?.cancel();
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
index ab0c72fa7..b037fe159 100644
--- a/packages/harness/src/bundle.mjs
+++ b/packages/harness/src/bundle.mjs
@@ -74,7 +74,19 @@ export const hasFallback = (resolved) => Object.hasOwn(resolved.vars, "fallback.
// "fallback". A missing var refuses no-route and names it. models is launch.models: the model var
// is one of its keys, and the entry gives the provider and model; without a table the var is the
// model and the provider is unknown.
-export function sessionModel(resolved, models = null, which = "primary") {
+export function proxyRouteFor(entry) {
+ if (entry.provider === "proxy") {
+ const missing = !Object.hasOwn(entry, "route");
+ if (missing || !["anthropic", "glm", "codex"].includes(entry.route))
+ throw new BundleError("proxy model needs an explicit anthropic, glm or codex route", missing ? "proxy-route-missing" : "proxy-route-unknown");
+ return entry.route;
+ }
+ if (entry.provider === "anthropic") return "anthropic";
+ if (entry.provider === "zai") return "glm";
+ throw new BundleError("provider is unsupported by the supplied model proxy context", "proxy-provider-unsupported");
+}
+
+export function sessionModel(resolved, models = null, which = "primary", { proxy = false } = {}) {
const v = resolved.vars;
const [harness, model, thinking] = ROUTE_VARS[which].map((name) => {
if (typeof v[name] !== "string" || !v[name]) throw new BundleError(`instance ${resolved.instance}: no ${name} var`, "no-route", name);
@@ -83,7 +95,8 @@ export function sessionModel(resolved, models = null, which = "primary") {
if (models === null) return { harness, provider: null, model, thinking };
const entry = Object.hasOwn(models, model) ? models[model] : null;
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);
- return { harness, provider: entry.provider, model: entry.model, modelKey: model, thinking };
+ return { harness, provider: entry.provider, model: entry.model, modelKey: model, thinking,
+ ...(entry.provider === "proxy" || proxy ? { proxyRoute: proxyRouteFor(entry) } : {}) };
}
// Row 91 (#1585): the project clones the host made or kept in the workspace.
@@ -309,10 +322,14 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
// personaDir <configDir>/personas, where a creative instance's persona file is read from
// (row 213); an instance with persona.sha256 and no personaDir refuses
// Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's
-// adapter variables, which the host writes into session.json.
-export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, library = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
+// adapter variables. In proxy mode the host splits credentials into its
+// transient spawn environment before writing the remaining variables to session.json.
+export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, library = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null, proxy = null }) {
if (route !== "primary" && route !== "fallback") throw new BundleError(`route ${JSON.stringify(route)} isn't primary or fallback`);
- const model = sessionModel(resolved, models, route);
+ const model = sessionModel(resolved, models, route, { proxy: Boolean(proxy) });
+ if (model.provider === "proxy" && !proxy) throw new BundleError("proxy model needs its own session credential", "no-session-credential");
+ if (proxy && (typeof proxy.key !== "string" || !proxy.key || typeof proxy.baseUrl !== "string"
+ || !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(proxy.baseUrl))) throw new BundleError("invalid session proxy context");
const web = webPlan(resolved, model.harness, harness.builtinWeb());
const tools = typedTools({ resolved, arbiter, tracker, library, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
const untyped = untypedActions(resolved, tools);
@@ -346,7 +363,7 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
write("tools.json", JSON.stringify(tools, null, 2) + "\n"),
];
// The module's own files. A name the bundle already wrote refuses at write (flag wx).
- const own = harness.bundle({ dir, files: { ...files }, session, write, quote });
+ const own = harness.bundle({ dir, files: { ...files }, session: { ...session, ...model }, write, quote, models, proxy });
Object.assign(files, own.files);
digests.push(...own.digests);
const sources = { gate: SOURCES.gate, tools: SOURCES.tools, ...own.sources };
@@ -373,6 +390,6 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
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" });
- const env = harness.env({ files, policy, tools, session: { ...session, ...model } });
+ const env = harness.env({ files, policy, tools, session: { ...session, ...model }, models, proxy });
return { manifest, files, policy, tools, env };
}
diff --git a/packages/plugins/harness/claude-code/index.mjs b/packages/plugins/harness/claude-code/index.mjs
index 3ae280c99..95f851a49 100644
--- a/packages/plugins/harness/claude-code/index.mjs
+++ b/packages/plugins/harness/claude-code/index.mjs
@@ -53,8 +53,8 @@ function binary(env) {
}
// A session needs a credential from the host's environment and the binary it runs.
-export function preflight({ env }) {
- if (!hasSessionCredential(env)) {
+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` };
}
@@ -116,12 +116,13 @@ export function bundle({ dir, files: core, session, write, quote }) {
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER, ...UNSLOP } };
}
-export function env({ files, policy, session }) {
+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,
+ ...(proxy ? { ANTHROPIC_BASE_URL: proxy.baseUrl, ANTHROPIC_AUTH_TOKEN: proxy.key } : {}),
};
}
diff --git a/packages/plugins/harness/pi/index.mjs b/packages/plugins/harness/pi/index.mjs
index 2a3b2037f..875f274dc 100644
--- a/packages/plugins/harness/pi/index.mjs
+++ b/packages/plugins/harness/pi/index.mjs
@@ -3,15 +3,21 @@
// 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, readFileSync, realpathSync } from "node:fs";
-import { join } from "node:path";
+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";
+import { proxyRouteFor } from "../../../harness/src/bundle.mjs";
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");
+// Sage sage-4331: reasoning-based tie-break; both pinned-proxy shapes passed.
+// First real Codex verification remains row209 phase A step7. No runtime switch.
+const PROXY_CODEX_API = "openai-responses";
+const PROXY_PROVIDERS = Object.freeze({ anthropic: "anthropic", glm: "mosaic-proxy-glm", codex: "mosaic-proxy-codex" });
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.
@@ -30,7 +36,11 @@ export const adapter = () => join(REPO, "adapters", "pi", "adapter.sh");
export const levels = () => LEVELS;
// The session runs pi from the repository's node_modules, which the sandbox mounts.
-export function preflight() {
+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 at ${BIN}; run npm ci in the repository` };
return null;
}
@@ -57,16 +67,51 @@ export const builtins = ({ tools }) => [...tools];
export const typedName = (name) => name;
export const reliesOn = () => RELIES_ON;
export const gateTimeout = () => null;
-export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION, ...UNSLOP } });
+export function bundle({ dir, models, proxy = null }) {
+ const sources = { piExtension: EXTENSION, ...UNSLOP };
+ if (!proxy) return { files: {}, digests: [], sources };
+ const providers = {};
+ for (const entry of Object.values(models ?? {})) {
+ const route = proxyRouteFor(entry);
+ if (!Object.hasOwn(PROXY_PROVIDERS, route)) throw new Error("proxy model has no supported route");
+ const provider = PROXY_PROVIDERS[route];
+ if (typeof entry.model !== "string" || !entry.model || entry.model.startsWith("!")) throw new Error("unsafe proxy model id");
+ const api = route === "anthropic" ? "anthropic-messages"
+ : route === "glm" ? "openai-completions" : PROXY_CODEX_API;
+ providers[provider] ??= {
+ baseUrl: proxy.baseUrl + (route === "anthropic" ? "" : "/v1"),
+ api, apiKey: "$ANTHROPIC_API_KEY",
+ ...(route === "glm" ? { compat: { thinkingFormat: "zai", zaiToolStream: true } } : {}),
+ models: [],
+ };
+ if (!providers[provider].models.some(model => model.id === entry.model)) {
+ providers[provider].models.push({ id: entry.model, reasoning: true });
+ }
+ }
+ 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 }) {
+export function env({ policy, tools, session, proxy = null }) {
return {
MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","),
MOSAIC_EXTENSIONS: [EXTENSION, UNSLOP.unslopExtension].join(","),
- PI_PROVIDER: session.provider ?? "",
+ PI_PROVIDER: proxy ? PROXY_PROVIDERS[session.proxyRoute] : session.provider ?? "",
PI_MODEL: session.model,
MOSAIC_THINKING: session.thinking,
+ ...(proxy ? { ANTHROPIC_API_KEY: proxy.key } : {}),
};
}
diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs
index c3a4db10e..52206d89b 100644
--- a/packages/seat/src/session.mjs
+++ b/packages/seat/src/session.mjs
@@ -253,9 +253,13 @@ export const ENV_ALLOW = Object.freeze([
export const SESSION_CREDENTIALS = Object.freeze([["CLAUDE_CODE_OAUTH_TOKEN"], ["ANTHROPIC_API_KEY"], ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"]].map((set) => Object.freeze(set)));
export const hasSessionCredential = (env) => SESSION_CREDENTIALS.some((set) => set.every((k) => Boolean(env[k])));
-export function sessionEnv(env, run) {
+// Used again at the serialization boundary: provider credentials are transient.
+export const isProviderVariable = (name) => name.startsWith("ANTHROPIC_")
+ || ["CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY"].includes(name);
+
+export function sessionEnv(env, run, proxy = null) {
const out = {};
- for (const k of ENV_ALLOW) if (env[k] !== undefined) out[k] = env[k];
+ for (const k of ENV_ALLOW) if (env[k] !== undefined && !(proxy && isProviderVariable(k))) out[k] = env[k];
out.PATH = [join(REPO, "node_modules", ".bin"), env.PATH ?? "/usr/bin:/bin"].join(":");
out.MOSAIC_RUN_ID = run;
return out;