feat: wire P3 proxy harness context (unverified; GPT API pending)
This commit is contained in:
1 parent
1e47b09198
commit
a5330386c4
15 files changed
+216
-39
No files matched your search
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user