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 /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;