feat: wire P3 proxy harness context (unverified; GPT API pending)

This commit is contained in:
jason.woltje committed 2026-10-24 18:56:19 -05:00
1 parent 1e47b09198
commit a5330386c4
15 files changed
+216 -39

No files matched your search

+2
View File
@@ -5590,3 +5590,5 @@ Fethry at c8a9bcf94, per Sage sage-4292 and mos-1616 pi-route amendment. Before:
## 2026-10-24 — row332 crash stop, documentation-only checkpoint ## 2026-10-24 — row332 crash stop, documentation-only checkpoint
Fethry stopped under Mos mos-1630/Jason's T3 crash instruction. No production implementation, tests, probes, mutants or acceptance runs occurred; Sage CLEAR never arrived. Explicit route metadata per sage-4293 is recorded in agents/fethry/work/queue-332/BUILD.md; full state and resumption boundaries are in HANDOFF.md there. Safe crash checkpoint commits documentation/historical plain-text import manifest only; four unrun tests and the import-audit script remain uncommitted. No long jobs restarted, protected branch changes, merges, live changes or further thread messages. Fethry stopped under Mos mos-1630/Jason's T3 crash instruction. No production implementation, tests, probes, mutants or acceptance runs occurred; Sage CLEAR never arrived. Explicit route metadata per sage-4293 is recorded in agents/fethry/work/queue-332/BUILD.md; full state and resumption boundaries are in HANDOFF.md there. Safe crash checkpoint commits documentation/historical plain-text import manifest only; four unrun tests and the import-audit script remain uncommitted. No long jobs restarted, protected branch changes, merges, live changes or further thread messages.
2026-10-24 — Fethry row332 P3 UNRUN implementation checkpoint: optional proxy route per sage-4325 (default Anthropic), transient preflight/bundle/spawn context and serialization split, pi private model file/provider credential requirement. Existing initial RED evidence precedes source changes; only specifically authorized old fixtures updated. GPT wire API value PENDING until separate offline CLEAR/evidence; P2 seam remains injected context, no real proxy/auth access. Not GREEN, not accepted or landed. Details agents/fethry/work/queue-332/BUILD.md.
+36
View File
@@ -139,3 +139,39 @@ Only proxy-bundle.test.mjs edited: resolve a valid existing fixture business fir
Exactly one whole proxy-bundle run at23:44:40Z (start/end), fresh gXXX TMPDIR, nice19, timeout-k10/180s: 5tests,0pass,5fail,0skip,exit1. All five now reach real buildBundle and fail ENOENT on the required run/home/.pi/agent/models.json: missing production generation, valid feature RED rather than fixture errors. Earlier frozen-vars and unknown-model errors remain separately recorded. Log red/harness-corrected-2.txt. No production edits, further runs, mutants, probes or fetches. Lane B free. Exactly one whole proxy-bundle run at23:44:40Z (start/end), fresh gXXX TMPDIR, nice19, timeout-k10/180s: 5tests,0pass,5fail,0skip,exit1. All five now reach real buildBundle and fail ENOENT on the required run/home/.pi/agent/models.json: missing production generation, valid feature RED rather than fixture errors. Earlier frozen-vars and unknown-model errors remain separately recorded. Log red/harness-corrected-2.txt. No production edits, further runs, mutants, probes or fetches. Lane B free.
Rebase made original origin/fethry/row332 diverge from local; publish current history on fethry/row332-p1 without force, preserving original remote checkpoint. Shared stash69ad35ec79805a0303ede4454f8d6815ddfa7bb3 remains untouched. Remaining uncommitted business/seat tests, launcher addition and audit source are not included in this two-fixture checkpoint. Rebase made original origin/fethry/row332 diverge from local; publish current history on fethry/row332-p1 without force, preserving original remote checkpoint. Shared stash69ad35ec79805a0303ede4454f8d6815ddfa7bb3 remains untouched. Remaining uncommitted business/seat tests, launcher addition and audit source are not included in this two-fixture checkpoint.
## P3 implementation checkpoint — sage-4323/4325, UNRUN
Scope correction: sage-4322's P1 meant P3; no P1 code changed. Source edits authorized by sage-4323; all runs remain held. This is an implementation checkpoint, not GREEN or acceptance. Executing the established plan inline; user lane restrictions supersede skill test-run/commit defaults for this checkpoint.
Latest route ruling sage-4325 supersedes sage-4293/4298 and all earlier route-required notes: omitted proxy route means Anthropic and preserves the stored provider/model shape. Explicit anthropic/glm/codex routes remain accepted; unknown/present-invalid route refuses proxy-route-unknown. proxy-route-missing is withdrawn, not implemented. Other providers keep provider/model-only schema. Own business test updated for default; new bundle test exercises default generation without changing existing explicit-route assertions. launch-settings.test.mjs remains byte-unchanged.
Implementation in six production files:
- business launch validates optional proxy route, preserves omission, accepts account credential literal proxy.
- seat sessionEnv leaves ENV_ALLOW unchanged and filters provider variables only with proxy context.
- Claude accepts proxy credential preflight and returns proxy base/auth env; pi requires own provider key or proxy key, never Claude OAuth.
- bundle carries transient proxy/models to harness calls and propagates proxyRoute separately from primary/fallback route; no context in manifest.
- pi groups declared routes into run-local private models.json, references only $ANTHROPIC_API_KEY, refuses executable-looking model ids/key collisions, exclusive create and0700 parents/0600 file. No host auth reads. Generated file digest included in bundle manifest. Models use pi's documented default sizes/costs and reasoning support, with explicit z.ai compat for GLM; no catalog membership inference.
- launcher accepts the specified context stand-in or a per-agent factory, copies only baseUrl/key, carries it through repeated preflight checks and final bundle/spawn. Factory receives business/instance; P2 owns acquisition and may be called twice due to existing launch recheck. No P2 store/client calls invented. Provider credential variables split out at actual session.json serialization and delivered only through spawn env. Account proxy source validates against the supplied context, not host upstream variables.
### Old-test changes authorized by sage-4325 (baseline1e47b0919)
P3 item4 (rows brief pi preflight bullet, lines250-252) mandates refusal without proxy/own credential:
- plugins/tests/harness.test.mjs:102 before `assert.equal(pi.preflight({ env: {} }), null)`; after asserts `.code === no-session-credential`. Other assertions unchanged.
- cli/tests/launcher.test.mjs prepare():84 before only fake Claude OAuth; after additionally supplies ZAI_API_KEY=fx332-pi only when chief primary/fallback names pi. Other tests' assertions unchanged; Claude-only preparation does not receive it.
- cli/tests/launcher.test.mjs old no-credential pi test (sage reference697): before deletes OAuth then expects launch/pi and launch-log event; after also deletes newly supplied fake ZAI key, expects no-session-credential refusal, refused log code and no registered session. Test renamed to reflect mandated behavior. Existing no-session-credential Claude test unchanged.
No other old test was changed. Static review found these conflicts before execution and authorization preceded edits. No old-test RED occurred; baseline whole launcher had34 old passes.
### GPT wire API: PENDING
Single build-time value PROXY_CODEX_API=null in pi/index.mjs. A declared codex route refuses generation with a plain pending-evidence error until that value is settled; no guessed wire API, environment switch or runtime fallback. Consequently existing all-three-route bundle tests are not expected GREEN yet. P2 integration, full tests, pinned evidence, mutants and reviews remain pending.
Planned separate 4c evidence run (outside scripts/test-*.sh), requiring its own lane B CLEAR:
1. Use landed P1 fetchPinned only under a fresh owned temp root for the exact PIN (8.0.42). Fetch is a separately approved preparation, not part of offline networking. Verify hashes via P1; never use host installation or another seat's scratch.
2. Run the pinned proxy and local stand-in upstream inside unshare -rn with loopback enabled, empty private HOME/auth dir and a sanitized environment. No real auth file or network. All fake credentials under24 chars. Private test-only proxy config points only at the stand-in.
3. Generate two private probe-only pi model configs, one openai-responses and one openai-completions, both baseUrl=the isolated proxy/v1. Run pinned pi turns and record safe path/body shape fields, statuses, stream result and upstream observations, omitting auth/header values. These probe configs do not select the production constant.
4. Separately run the GLM completions turn under the same isolation and record whether thinkingFormat=zai/zaiToolStream-derived fields survive proxy translation.
5. If the offline stand-in cannot distinguish the two GPT shapes for the Codex-login route, report inconclusive evidence to Sage for Mos; do not select a value. A compatibility route cannot be reported as a real Codex-auth turn. First real Codex turn remains row209 phaseA step7.
No tests, probes, binary invocation or fetch ran during implementation. Import audit below is source parsing only, never repository evaluation. New run list includes the old harness.test.mjs touched under ruling; case1-accept remains EXCLUDED. Shared stash already cleared by Sage after1e47b0919 confirmation; no further stash operation used.
@@ -90,6 +90,7 @@ packages/cli/tests/lifecycle.test.mjs
packages/cli/tests/limits-tokens.test.mjs packages/cli/tests/limits-tokens.test.mjs
packages/cli/tests/limits.test.mjs packages/cli/tests/limits.test.mjs
packages/cli/tests/mark-read.test.mjs packages/cli/tests/mark-read.test.mjs
packages/cli/tests/model-proxy.test.mjs
packages/cli/tests/names.test.mjs packages/cli/tests/names.test.mjs
packages/cli/tests/notifier.test.mjs packages/cli/tests/notifier.test.mjs
packages/cli/tests/optional-tracker.test.mjs packages/cli/tests/optional-tracker.test.mjs
+2
View File
@@ -1705,3 +1705,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-24 | fethry (65673bd9-3bfa-412b-85d5-9b364187d8b6) | row332 first RED lane B | Ran four new files+whole launcher, all34 old launcher tests pass; two new fixture errors; fixes b285e581e then one rerun each: plugin valid RED1/4, bundle further fixture error0/5; reported/stopped, no mutants/probes/fetches, no test process leftovers. 2026-10-24 | fethry (65673bd9-3bfa-412b-85d5-9b364187d8b6) | row332 first RED lane B | Ran four new files+whole launcher, all34 old launcher tests pass; two new fixture errors; fixes b285e581e then one rerun each: plugin valid RED1/4, bundle further fixture error0/5; reported/stopped, no mutants/probes/fetches, no test process leftovers.
2026-10-24 | fethry (65673bd9-3bfa-412b-85d5-9b364187d8b6) | row332 corrected bundle RED sage-4320 | Fixture-only56701f715 atop b285e581e; whole bundle0pass/5fail/0skip at23:44:40Z, all missing models.json generation; lane B returned, no production edits/mutants/probes/fetches. 2026-10-24 | fethry (65673bd9-3bfa-412b-85d5-9b364187d8b6) | row332 corrected bundle RED sage-4320 | Fixture-only56701f715 atop b285e581e; whole bundle0pass/5fail/0skip at23:44:40Z, all missing models.json generation; lane B returned, no production edits/mutants/probes/fetches.
2026-10-24 | fethry (65673bd9-3bfa-412b-85d5-9b364187d8b6) | row332 P3 implementation sage-4323/4325 | Production edits and authorized old fixtures written; optional route default Anthropic; GPT wire PENDING; no runs/probes/fetches, preparing pushed unverified checkpoint and import list.
+11 -3
View File
@@ -1,6 +1,6 @@
// REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling. // REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling.
import { requireObject, requireId, requireString, rejectUnknownKeys } from "./util.mjs"; 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 name = (value, at) => requireString(value, at, { max: 200 });
const count = (value, at) => { const count = (value, at) => {
@@ -15,10 +15,18 @@ export function checkModels(value, where = "launch.models") {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => { return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
requireId(key, where); requireId(key, where);
requireObject(entry, `${where}.${key}`); 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" && Object.hasOwn(entry, "route")) {
if (!["anthropic", "glm", "codex"].includes(entry.route)) {
throw Object.assign(new BusinessError(`${where}.${key}.route must name anthropic, glm or codex`), {
code: "proxy-route-unknown",
});
}
}
return [key, { return [key, {
provider: name(entry.provider, `${where}.${key}.provider`), provider: name(entry.provider, `${where}.${key}.provider`),
model: name(entry.model, `${where}.${key}.model`), model: name(entry.model, `${where}.${key}.model`),
...(entry.provider === "proxy" && Object.hasOwn(entry, "route") ? { route: entry.route } : {}),
}]; }];
})); }));
} }
@@ -82,7 +90,7 @@ export function checkAccount(value, where = "launch.account") {
requireObject(value, where); requireObject(value, where);
rejectUnknownKeys(value, ["name", "credential"], where); rejectUnknownKeys(value, ["name", "credential"], where);
const accountName = requireId(value.name, `${where}.name`); 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 "+"`); refuse(`${where}.credential must name environment variables, joined with "+"`);
} }
return { name: accountName, credential: value.credential }; return { name: accountName, credential: value.credential };
@@ -10,9 +10,9 @@ test('P3 proxy model validation preserves each explicit wire route and the proxy
} }
}); });
test('P3 a proxy model without route refuses proxy-route-missing', () => { test('P3 a proxy model without route preserves its stored shape for the default Anthropic route', () => {
assert.throws(() => checkModels({ selected: { provider: 'proxy', model: 'proxy-only-id' } }), { assert.deepEqual(checkModels({ selected: { provider: 'proxy', model: 'proxy-only-id' } }), {
code: 'proxy-route-missing', selected: { provider: 'proxy', model: 'proxy-only-id' },
}); });
}); });
+41 -12
View File
@@ -157,6 +157,7 @@ import {
runnerOf, runnerOf,
SESSION_CREDENTIALS, SESSION_CREDENTIALS,
sessionEnv, sessionEnv,
isProviderVariable,
sessionExit, sessionExit,
sessionStore, sessionStore,
sessionWorkspace, sessionWorkspace,
@@ -216,6 +217,9 @@ export function createLauncher({
host, host,
system, system,
env = process.env, 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, tracker = false,
launches = true, launches = true,
makeLoader = createLoader, makeLoader = createLoader,
@@ -388,7 +392,18 @@ export function createLauncher({
let harness = await harnessOf(primary); let harness = await harnessOf(primary);
let route = "primary"; let route = "primary";
let routeReason = null; let routeReason = null;
const failed = call(primary.harness, "preflight", () => harness.preflight({ env })); const proxyFor = async (selected) => {
if (selected.provider !== "proxy") return null;
const context = 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 (failed) {
if (!fallback) throw new Refusal(failed.code, failed.message); 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 // The fallback only on a failed primary preflight, and only at launch (row 65). Any refusal of
@@ -397,7 +412,8 @@ export function createLauncher({
if (e instanceof Refusal) e.message += ` (primary ${primary.harness}: ${failed.message})`; if (e instanceof Refusal) e.message += ` (primary ${primary.harness}: ${failed.message})`;
throw e; 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})`); 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]; [model, harness, route, routeReason] = [fallback, second, "fallback", failed.code];
} }
@@ -408,12 +424,18 @@ export function createLauncher({
if (account !== null) { if (account !== null) {
// The usage records keep this name (bus schema 3i), so it must never be a credential's value. // 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); 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`); 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 (sessionProxy || b.launch.account.credential === "proxy") {
if (!SESSION_CREDENTIALS.some((set) => set.join("+") === named.join("+")) || !named.every((k) => Boolean(env[k]))) if (!sessionProxy || b.launch.account.credential !== "proxy")
throw new Refusal("account-credential-missing", `account ${account} names ${named.join(" with ")}, which the bus host doesn't hold as a session login`); throw new Refusal("account-credential-missing", `account ${account} needs the declared proxy credential`);
const other = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => !named.includes(k) && Boolean(env[k])); } else {
if (other.length) throw new Refusal("account-credential-ambiguous", `account ${account} names ${named.join(" with ")}, but the bus host also sets ${other.join(", ")}`); 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 }); const unavailable = sandboxProblem({ bwrap });
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable); if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
@@ -432,7 +454,7 @@ export function createLauncher({
throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`); throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`);
} }
admission(admissionGeneration); 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. // Bundle, spawn, bind, register, hand over the capability.
@@ -503,8 +525,8 @@ export function createLauncher({
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason }); 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 }); else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
} }
let harness, route, routeReason; let harness, route, routeReason, sessionProxy;
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,session,human})); ({resolved,model,account,harness,route,routeReason,sessionProxy}=await check(instance,{pm:instance===b.launch.by,thread,session,human}));
admission(admissionGeneration); admission(admissionGeneration);
const { run, runDir } = newRun(dataRoot, b.id); const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session"); const sessionDir = store ? store.session : join(runDir, "session");
@@ -525,6 +547,7 @@ export function createLauncher({
dir: join(runDir, "bundle"), dir: join(runDir, "bundle"),
resolved, resolved,
models: b.launch.models, models: b.launch.models,
proxy: sessionProxy,
contract: readFileSync(resolved.contract, "utf8"), contract: readFileSync(resolved.contract, "utf8"),
arbiter: Object.values(b.arbiters).includes(instance), arbiter: Object.values(b.arbiters).includes(instance),
tracker, tracker,
@@ -548,6 +571,12 @@ export function createLauncher({
// Row 199 (D11): or publishes it, once a reviewed commit routes git.push.remote within-role. // 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 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; 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, { writeSessionFile(runDir, {
...sessionDefaults, ...sessionDefaults,
business: b.id, business: b.id,
@@ -565,7 +594,7 @@ export function createLauncher({
sessionDir, sessionDir,
adapter, adapter,
bundle: files, bundle: files,
harnessEnv: bundle.env, harnessEnv,
turnEnd, turnEnd,
toolSocket, toolSocket,
turnMarker, turnMarker,
@@ -589,7 +618,7 @@ export function createLauncher({
try { try {
admission(admissionGeneration); admission(admissionGeneration);
spawnedAt.set(run, Date.now()); 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) { } catch (e) {
spawnedAt.delete(run); spawnedAt.delete(run);
unregisterRuntime(dataRoot, managed.id); unregisterRuntime(dataRoot, managed.id);
+7 -4
View File
@@ -82,6 +82,8 @@ function prepare(t, more = (doc) => doc) {
const f = fixture(root, "acme", (doc) => more(edit(doc))); const f = fixture(root, "acme", (doc) => more(edit(doc)));
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k]; for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
f.env.CLAUDE_CODE_OAUTH_TOKEN = "stand-in"; f.env.CLAUDE_CODE_OAUTH_TOKEN = "stand-in";
if (f.doc.roles.chief.vars.harness === "pi" || f.doc.roles.chief.vars["fallback.harness"] === "pi")
f.env.ZAI_API_KEY = "fx332-pi";
// The project root is a repository on main, so each launch can clone it. // The project root is a repository on main, so each launch can clone it.
const project = f.doc.projects.stack.root; const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main"); gitIn(project, "init", "-q", "-b", "main");
@@ -695,14 +697,15 @@ test("a Claude Code launch with no session credential refuses before it starts",
assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made"); assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
}); });
test("a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables", { skip, timeout: 30000 }, async (t) => { test("a pi launch with no own credential refuses no-session-credential", { skip, timeout: 30000 }, async (t) => {
const given = prepare(t); const given = prepare(t);
delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN; delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN;
delete given.f.env.ZAI_API_KEY;
assert.deepEqual(SESSION_CREDENTIALS.flat().filter((k) => k in given.f.env), []); assert.deepEqual(SESSION_CREDENTIALS.flat().filter((k) => k in given.f.env), []);
const { f, launcher } = await setup(t, undefined, given); const { f, launcher } = await setup(t, undefined, given);
const chief = await launcher.launchPm(); await assert.rejects(launcher.launchPm(), /PM launch refused: no-session-credential/);
assert.equal(chief.harness, "pi"); assert.deepEqual(log(f).map((e) => [e.event, e.code]), [["refused", "no-session-credential"]]);
assert.deepEqual(log(f).map((e) => [e.event, e.instance]), [["launch", "chief"]]); assert.deepEqual(readSessions(f.dataRoot), []);
}); });
// Row 65: a harness module no core file names, switched on in the organization file, runs a real // Row 65: a harness module no core file names, switched on in the organization file, runs a real
+10 -5
View File
@@ -83,7 +83,8 @@ export function sessionModel(resolved, models = null, which = "primary") {
if (models === null) return { harness, provider: null, model, thinking }; if (models === null) return { harness, provider: null, model, thinking };
const entry = Object.hasOwn(models, model) ? models[model] : null; const entry = Object.hasOwn(models, model) ? models[model] : null;
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`); 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" ? { proxyRoute: Object.hasOwn(entry, "route") ? entry.route : "anthropic" } : {}) };
} }
// Row 91 (#1585): the project clones the host made or kept in the workspace. // Row 91 (#1585): the project clones the host made or kept in the workspace.
@@ -309,10 +310,14 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
// personaDir <configDir>/personas, where a creative instance's persona file is read from // personaDir <configDir>/personas, where a creative instance's persona file is read from
// (row 213); an instance with persona.sha256 and no personaDir refuses // (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 // Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's
// adapter variables, which the host writes into session.json. // adapter variables. In proxy mode the host splits credentials into its
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 }) { // 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`); 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);
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 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 tools = typedTools({ resolved, arbiter, tracker, library, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
const untyped = untypedActions(resolved, tools); const untyped = untypedActions(resolved, tools);
@@ -346,7 +351,7 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
write("tools.json", JSON.stringify(tools, null, 2) + "\n"), 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). // 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); Object.assign(files, own.files);
digests.push(...own.digests); digests.push(...own.digests);
const sources = { gate: SOURCES.gate, tools: SOURCES.tools, ...own.sources }; const sources = { gate: SOURCES.gate, tools: SOURCES.tools, ...own.sources };
@@ -373,6 +378,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)) }])), 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" }); 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 }; return { manifest, files, policy, tools, env };
} }
@@ -42,6 +42,18 @@ test('P3 Anthropic route uses messages at proxy root and the declared model id',
assert.equal(b.env.PI_MODEL, 'claude-fixture'); assert.equal(b.env.PI_MODEL, 'claude-fixture');
}); });
test('P3 omitted route generates the Anthropic provider at proxy root', t => {
const b = build(t, 'a', { baseUrl: 'http://127.0.0.1:12345', key: 'fx332-agent-a' }, {
a: { provider: 'proxy', model: 'claude-fixture' },
});
const providers = JSON.parse(b.text).providers;
assert.deepEqual(Object.keys(providers), ['anthropic']);
assert.equal(providers.anthropic.api, 'anthropic-messages');
assert.equal(providers.anthropic.baseUrl, 'http://127.0.0.1:12345');
assert.deepEqual(providers.anthropic.models.map(model => model.id), ['claude-fixture']);
assert.equal(b.env.PI_PROVIDER, 'anthropic');
});
test('P3 GLM route uses completions at proxy /v1 with explicit z.ai compatibility', t => { test('P3 GLM route uses completions at proxy /v1 with explicit z.ai compatibility', t => {
const b = build(t, 'z', { baseUrl: 'http://127.0.0.1:12345', key: 'fx332-agent-a' }); const b = build(t, 'z', { baseUrl: 'http://127.0.0.1:12345', key: 'fx332-agent-a' });
const glm = JSON.parse(b.text).providers['mosaic-proxy-glm']; const glm = JSON.parse(b.text).providers['mosaic-proxy-glm'];
+28
View File
@@ -327,3 +327,31 @@ organization file or unavailable bus.
For the re-pin introducing this kind, Jason enables both channels through For the re-pin introducing this kind, Jason enables both channels through
plugins.set immediately after activate in Mos's window, then smokes CLI thread plugins.set immediately after activate in Mos's window, then smokes CLI thread
listing and Console chat. No development seat performs that live step. listing and Console chat. No development seat performs that live step.
## Per-session proxy context (P3)
The launcher's injected `proxy` is either a `{ baseUrl, key }` context supplied by
P2, or a factory receiving `{ business, instance }` and returning that context.
Checks may repeat before spawn; the factory must return that agent's current
credential. P3 does not acquire, issue or revoke keys. No context means existing
nonproxy launch behavior; a selected proxy model without context refuses.
The harness `preflight`, `bundle` and `env` calls receive an optional `proxy`
argument; `bundle` and `env` also receive the declared model table. In proxy
mode the launcher removes instance provider variables, then separates the
harness's credential variables from `harnessEnv` before writing `session.json`.
Only the spawned runner's environment receives those variables.
A proxy `launch.models` entry may specify `route: anthropic|glm|codex`. Omission
means Anthropic; unknown routes refuse `proxy-route-unknown`. Other providers
retain provider/model-only entries. `launch.account.credential: proxy` names the
proxy credential source. Pi needs its own provider key or a supplied proxy key;
Claude OAuth alone cannot satisfy pi preflight.
Pi writes private, fresh run-local `home/.pi/agent/models.json`, with
`$ANTHROPIC_API_KEY` references. Anthropic uses messages at the proxy root; GLM
uses completions at `/v1` with z.ai compatibility fields. GPT uses the stack-owned
`mosaic-proxy-codex` provider, never pi's Codex OAuth provider. Its build-time API
value is currently PENDING: declarations of that route refuse generation until
the separately approved offline probe supplies evidence. There is no runtime
API selection or fallback.
@@ -53,8 +53,8 @@ function binary(env) {
} }
// A session needs a credential from the host's environment and the binary it runs. // A session needs a credential from the host's environment and the binary it runs.
export function preflight({ env }) { export function preflight({ env, proxy = null }) {
if (!hasSessionCredential(env)) { if (!(typeof proxy?.key === "string" && proxy.key) && !hasSessionCredential(env)) {
const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", "); 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` }; 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 } }; 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 { return {
MOSAIC_TOOLS: claudeBuiltins(policy).join(","), MOSAIC_TOOLS: claudeBuiltins(policy).join(","),
MOSAIC_CLAUDE_SETTINGS: files.settings, MOSAIC_CLAUDE_SETTINGS: files.settings,
MOSAIC_CLAUDE_MCP_CONFIG: files.mcp, MOSAIC_CLAUDE_MCP_CONFIG: files.mcp,
MOSAIC_THINKING: session.thinking, MOSAIC_THINKING: session.thinking,
...(proxy ? { ANTHROPIC_BASE_URL: proxy.baseUrl, ANTHROPIC_AUTH_TOKEN: proxy.key } : {}),
}; };
} }
+52 -6
View File
@@ -3,8 +3,9 @@
// Pi extension, which stays in packages/harness (a module can't change that boundary). The // Pi extension, which stays in packages/harness (a module can't change that boundary). The
// launcher, the bundle and the usage reader call these on the host; the runner gets the answers // 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. // as data in session.json. packages/plugins/README.md lists the interface.
import { existsSync, readFileSync, realpathSync } from "node:fs"; import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
export const interfaceVersion = 1; export const interfaceVersion = 1;
@@ -12,6 +13,10 @@ export const interfaceVersion = 1;
const REPO = fileURLToPath(new URL("../../../../", import.meta.url)); const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
const PACKAGE = "@earendil-works/pi-coding-agent"; const PACKAGE = "@earendil-works/pi-coding-agent";
const BIN = join(REPO, "node_modules", ".bin", "pi"); const BIN = join(REPO, "node_modules", ".bin", "pi");
// PENDING: choose exactly once from the separately cleared, pinned-proxy probe.
// Neither an environment override nor a runtime fallback may pick this value.
const PROXY_CODEX_API = null;
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)); 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, // 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. // which the runner sets, and the runner tells the operator.
@@ -30,7 +35,11 @@ export const adapter = () => join(REPO, "adapters", "pi", "adapter.sh");
export const levels = () => LEVELS; export const levels = () => LEVELS;
// The session runs pi from the repository's node_modules, which the sandbox mounts. // 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` }; if (!existsSync(BIN)) return { code: "harness-binary-missing", message: `pi isn't installed at ${BIN}; run npm ci in the repository` };
return null; return null;
} }
@@ -57,16 +66,53 @@ export const builtins = ({ tools }) => [...tools];
export const typedName = (name) => name; export const typedName = (name) => name;
export const reliesOn = () => RELIES_ON; export const reliesOn = () => RELIES_ON;
export const gateTimeout = () => null; 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 ?? {})) {
if (entry.provider !== "proxy") continue;
const route = Object.hasOwn(entry, "route") ? entry.route : "anthropic";
if (!Object.hasOwn(PROXY_PROVIDERS, route)) throw new Error("proxy model has no supported route");
const provider = PROXY_PROVIDERS[route];
if (route === "codex" && PROXY_CODEX_API === null) throw new Error("proxy GPT API is PENDING build-time evidence");
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. // 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 { return {
MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","), MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","),
MOSAIC_EXTENSIONS: [EXTENSION, UNSLOP.unslopExtension].join(","), MOSAIC_EXTENSIONS: [EXTENSION, UNSLOP.unslopExtension].join(","),
PI_PROVIDER: session.provider ?? "", PI_PROVIDER: proxy ? PROXY_PROVIDERS[session.proxyRoute] : session.provider ?? "",
PI_MODEL: session.model, PI_MODEL: session.model,
MOSAIC_THINKING: session.thinking, MOSAIC_THINKING: session.thinking,
...(proxy ? { ANTHROPIC_API_KEY: proxy.key } : {}),
}; };
} }
+1 -1
View File
@@ -99,7 +99,7 @@ test("row 65 claude-code version asks MOSAIC_CLAUDE_BIN when set, else claude on
test("row 65 pi preflight passes with the repository's pi, and its version is the package pin", async () => { test("row 65 pi preflight passes with the repository's pi, and its version is the package pin", async () => {
const pi = await load("pi"); const pi = await load("pi");
assert.equal(pi.preflight({ env: {} }), null); assert.equal(pi.preflight({ env: {} }).code, "no-session-credential");
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8")); const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
assert.equal(pi.version({ env: {} }), pkg.dependencies?.["@earendil-works/pi-coding-agent"] ?? pkg.devDependencies?.["@earendil-works/pi-coding-agent"]); assert.equal(pi.version({ env: {} }), pkg.dependencies?.["@earendil-works/pi-coding-agent"] ?? pkg.devDependencies?.["@earendil-works/pi-coding-agent"]);
// Row 66: the unslop extension's files, bound read-only at their own paths. // Row 66: the unslop extension's files, bound read-only at their own paths.
+6 -2
View File
@@ -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 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 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 = {}; 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.PATH = [join(REPO, "node_modules", ".bin"), env.PATH ?? "/usr/bin:/bin"].join(":");
out.MOSAIC_RUN_ID = run; out.MOSAIC_RUN_ID = run;
return out; return out;