469 lines
29 KiB
Diff
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;
|