Row 332 R2 (#1830): hermetic pi launches in cli tests, pi bundle default, review F2 and F3
The cli tests that launch pi deleted only the session login names, so pi's preflight passed or refused by whatever provider keys the host exported. A shared helper now deletes every credential pi's preflight reads and gives pi launches one made-up key. The refusal itself is unchanged. pi bundle() takes a default argument again, so a call with none returns the non-proxy bundle, as before P3. pi.bundle leaves out a launch.models entry whose provider has no proxy route instead of failing every pi launch (F2); malformed proxy entries still refuse. The invalid session proxy context BundleError gets the code invalid-proxy-context. New tests cover F2, the code, and the three proxy account branches in the launcher (F3).
This commit is contained in:
1 parent
eabeb1aa82
commit
39eadf8ba8
19 files changed
+180
-44
No files matched your search
@@ -27,11 +27,11 @@ import { Store } from "../../bus/src/store.mjs";
|
||||
import { collectCompactions, LEDGER } from "../../seat/src/compactions.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, SESSION_CREDENTIALS, sessionStore } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile, sessionStore } from "../../seat/src/session.mjs";
|
||||
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/compaction-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -73,7 +73,7 @@ function prepare(t) {
|
||||
doc.launch.threadIdleSeconds = 1;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -12,8 +12,27 @@ import { Store } from "../../bus/src/store.mjs";
|
||||
import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { REPO_SKILLS } from "../../harness/tests/helpers.mjs";
|
||||
import { tree } from "../../plugins/tests/helpers.mjs";
|
||||
import { SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { AGENT_MARKERS } from "../src/transport.mjs";
|
||||
|
||||
// Row 332 R2 (#1830): every host variable a launch reads as a login. The
|
||||
// session login names come from the seat; the other three are what pi's
|
||||
// preflight also takes as its own provider key. A test that deletes only the
|
||||
// session names passes or fails by what the host happens to export.
|
||||
export const HOST_CREDENTIALS = Object.freeze([...new Set([
|
||||
...SESSION_CREDENTIALS.flat(), "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY",
|
||||
])]);
|
||||
// A made-up pi provider key: obviously fake, never a real value.
|
||||
export const FAKE_PI_KEY = "fx332-fake-pi";
|
||||
|
||||
// Remove every host credential from `env`, then, for a test that launches pi,
|
||||
// set the one fake pi key. Returns `env`.
|
||||
export function withoutHostCredentials(env, { piKey = false } = {}) {
|
||||
for (const k of HOST_CREDENTIALS) delete env[k];
|
||||
if (piKey) env.ZAI_API_KEY = FAKE_PI_KEY;
|
||||
return env;
|
||||
}
|
||||
|
||||
export function tmp(t, prefix = "mosaic-cli-") {
|
||||
const root = mkdtempSync(join(tmpdir(), prefix));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
@@ -19,10 +19,10 @@ import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -51,7 +51,7 @@ function prepare(t) {
|
||||
doc.launch.threadIdleSeconds = 1;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -7,21 +7,23 @@ import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {fixture,tmp,withoutHostCredentials} from './helpers.mjs';
|
||||
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
|
||||
|
||||
const VALUE='stand-in-value-7f3a';
|
||||
const ACCOUNT={name:'jason-max',credential:'CLAUDE_CODE_OAUTH_TOKEN'};
|
||||
|
||||
// set is the credential variables the bus environment holds; every other one is removed.
|
||||
function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_TOKEN']}={}){
|
||||
// set is the credential variables the bus environment holds; every other one is removed. A pi
|
||||
// launch gets one made-up pi key, so its own preflight passes on any host. proxy is the launcher's
|
||||
// static session proxy context (row 332); edit changes the rest of the file.
|
||||
function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_TOKEN'],proxy=null,edit=doc=>doc}={}){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{
|
||||
doc.roles.coder.vars.model='claude-sonnet-5-5';doc.roles.coder.vars.harness=harness;
|
||||
if(account)doc.launch.account=account;
|
||||
return doc;
|
||||
return edit(doc);
|
||||
}),ops=[];
|
||||
initializeControl(f.dataRoot);
|
||||
for(const k of SESSION_CREDENTIALS.flat())delete f.env[k];
|
||||
withoutHostCredentials(f.env,{piKey:harness==='pi'});
|
||||
for(const k of set)f.env[k]=VALUE;
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
@@ -30,7 +32,7 @@ function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_
|
||||
if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});
|
||||
return {};
|
||||
}};
|
||||
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
|
||||
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{},...(proxy?{proxy}:{})});
|
||||
const last=()=>JSON.parse(readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').at(-1));
|
||||
const may=()=>ops.find(x=>x.op==='mayLaunch');
|
||||
return {launcher,ops,last,may};
|
||||
@@ -96,7 +98,8 @@ test('REQ-LIMIT-2 account-is-credential: launch.account.name is the value of a c
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-2 a pi launch carries account null and skips both account checks',async t=>{
|
||||
// No credential variable at all, and two at once: neither refuses a pi launch.
|
||||
// No session login variable at all, and two at once: neither refuses a pi launch. pi itself runs
|
||||
// on the made-up pi key setup gives it, which is no session login.
|
||||
for(const set of [[],['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']]){
|
||||
const {launcher,may}=setup(t,{harness:'pi',set});
|
||||
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy',set.join());
|
||||
@@ -109,3 +112,45 @@ test('REQ-LIMIT-2 with no launch.account a claude-code launch carries null and t
|
||||
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
|
||||
assert.equal(may().account,null);
|
||||
});
|
||||
|
||||
// Row 332 R2 (#1830, review F3): the account checks on a launch with a session proxy context. A
|
||||
// proxy model is routed through the launcher's static proxy option; no proxy runs.
|
||||
const PROXY={baseUrl:'http://127.0.0.1:9',key:'fx332-acct-key'};
|
||||
const proxyModel=doc=>{
|
||||
doc.launch.models['proxy-sonnet']={provider:'proxy',model:'claude-sonnet-5-5',route:'anthropic'};
|
||||
doc.roles.coder.vars.model='proxy-sonnet';
|
||||
return doc;
|
||||
};
|
||||
|
||||
test('row 332 R2 a proxy session refuses account-credential-missing when launch.account names a host login, not proxy',async t=>{
|
||||
const {launcher,ops,last}=setup(t,{proxy:PROXY,edit:proxyModel});
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-credential-missing'});
|
||||
const event=last();
|
||||
assert.equal(event.code,'account-credential-missing');
|
||||
assert.equal(event.why,'account jason-max needs the declared proxy credential');
|
||||
for(const value of [VALUE,PROXY.key])assert.ok(!JSON.stringify(event).includes(value),'no value in the log');
|
||||
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined);
|
||||
});
|
||||
|
||||
test('row 332 R2 launch.account credential proxy with no proxy session refuses account-credential-missing',async t=>{
|
||||
const {launcher,ops,last}=setup(t,{account:{name:'jason-max',credential:'proxy'}});
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-credential-missing'});
|
||||
const event=last();
|
||||
assert.equal(event.code,'account-credential-missing');
|
||||
assert.equal(event.why,'account jason-max needs the declared proxy credential');
|
||||
assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log');
|
||||
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined);
|
||||
});
|
||||
|
||||
test('row 332 R2 account-is-credential: launch.account.name is the session proxy key; another name passes the account checks',async t=>{
|
||||
const {launcher,ops,last}=setup(t,{account:{name:PROXY.key,credential:'proxy'},set:[],proxy:PROXY,edit:proxyModel});
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-is-credential'});
|
||||
const event=last();
|
||||
assert.equal(event.why,'launch.account.name is the value of session proxy key; it names the account, never its credential');
|
||||
assert.ok(!JSON.stringify(event).includes(PROXY.key),'no proxy key in the log');
|
||||
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined);
|
||||
// The same proxy session and credential with an account name that is no key value launches.
|
||||
const named=setup(t,{account:{name:'jason-max',credential:'proxy'},set:[],proxy:PROXY,edit:proxyModel});
|
||||
assert.equal((await named.launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
|
||||
assert.deepEqual(named.may(),{op:'mayLaunch',business:'acme',instance:'coder',provider:'proxy',model:'claude-sonnet-5-5',account:'jason-max'});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {fixture,tmp,withoutHostCredentials} from './helpers.mjs';
|
||||
import {launchLogFile} from '../../seat/src/session.mjs';
|
||||
|
||||
const HIT={path:['tokens','instances','coder'],limit:100,count:120,unknownTurns:1};
|
||||
@@ -15,6 +15,7 @@ const HIT={path:['tokens','instances','coder'],limit:100,count:120,unknownTurns:
|
||||
// answer is what the mayLaunch op returns, or an Error it throws.
|
||||
function setup(t,answer,{initialize=true}={}){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),ops=[];
|
||||
withoutHostCredentials(f.env,{piKey:true});
|
||||
if(initialize)initializeControl(f.dataRoot);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
|
||||
@@ -5,11 +5,12 @@ import {join} from 'node:path';
|
||||
import {readFileSync,writeFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {fixture,tmp,withoutHostCredentials} from './helpers.mjs';
|
||||
import {systemFor,writeJson} from '../../business/tests/helpers.mjs';
|
||||
import {launchLogFile} from '../../seat/src/session.mjs';
|
||||
test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[];
|
||||
withoutHostCredentials(f.env,{piKey:true});
|
||||
initializeControl(f.dataRoot);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='mayLaunch')return {allow:true};if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});return {};}};
|
||||
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
|
||||
|
||||
@@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fakeHarnesses,fixture,tmp} from './helpers.mjs';
|
||||
import {fakeHarnesses,fixture,tmp,withoutHostCredentials} from './helpers.mjs';
|
||||
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
|
||||
|
||||
const PI=JSON.stringify(new URL('../../plugins/harness/pi/index.mjs',import.meta.url).href);
|
||||
@@ -27,6 +27,7 @@ const ON=['pi','claude-code','down','sparse','metered','broken','throws','absent
|
||||
async function setup(t,vars,{on=ON,edit=doc=>doc,env:more={}}={}){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars=vars;doc.plugins={...doc.plugins,harness:on};return edit(doc);}),ops=[];
|
||||
initializeControl(f.dataRoot);
|
||||
withoutHostCredentials(f.env,{piKey:true});
|
||||
Object.assign(f.env,more);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, runsDir, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, runsDir, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { childrenOf } from "../../seat/src/proc.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
@@ -24,7 +24,7 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { cmdlineOf, startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, HOST_CREDENTIALS, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const HOST = fileURLToPath(new URL("./fixtures/launch-host.mjs", import.meta.url));
|
||||
@@ -76,14 +76,13 @@ function commit(dir, text) {
|
||||
|
||||
// A config and data root with the fake adapter; setup() starts a host on it.
|
||||
// The session credential is a stand-in, and the fake adapter never uses it.
|
||||
// Every credential variable of the shell running the tests is dropped.
|
||||
// Every credential variable of the shell running the tests is dropped, pi's own provider keys
|
||||
// included (row 332 R2); a pi chief gets one made-up pi key instead.
|
||||
function prepare(t, more = (doc) => doc) {
|
||||
const root = tmp(t, "mosaic-launch-");
|
||||
const f = fixture(root, "acme", (doc) => more(edit(doc)));
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: f.doc.roles.chief.vars.harness === "pi" || f.doc.roles.chief.vars["fallback.harness"] === "pi" });
|
||||
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.
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
@@ -701,7 +700,8 @@ test("a pi launch with no own credential refuses no-session-credential", { skip,
|
||||
const given = prepare(t);
|
||||
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), []);
|
||||
// No session login and none of pi's own provider keys, whatever the host exports.
|
||||
assert.deepEqual(HOST_CREDENTIALS.filter((k) => k in given.f.env), []);
|
||||
const { f, launcher } = await setup(t, undefined, given);
|
||||
await assert.rejects(launcher.launchPm(), /PM launch refused: no-session-credential/);
|
||||
assert.deepEqual(log(f).map((e) => [e.event, e.code]), [["refused", "no-session-credential"]]);
|
||||
|
||||
@@ -24,12 +24,12 @@ import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { processIdentity, registerRuntime, sweepRuntimes, unregisterRuntime, updateRuntime } from "../../processing-control/src/runtimes.mjs";
|
||||
import { controlDirectory, transition } from "../../processing-control/src/state.mjs";
|
||||
import { launchLogFile, readSessions, SESSION_CREDENTIALS, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile, readSessions, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { run } from "../src/cli.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, io, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, io, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -63,7 +63,7 @@ function prepare(t, edit = (doc) => doc) {
|
||||
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 1 });
|
||||
return edit(doc);
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -110,3 +110,23 @@ test('P3 joined unsupported provider refuses with no key acquisition, fallback,
|
||||
assert.equal(f.runDirectories().length, 0);
|
||||
f.valueFree();
|
||||
});
|
||||
|
||||
// Row 332 R2 (#1830, review F2): an entry in launch.models whose provider has no proxy route no
|
||||
// longer fails every pi launch of the business after admission. The selected entry routes, the
|
||||
// launch goes live, and the unroutable entry is left out of the run-local models file.
|
||||
test('row 332 R2 joined pi launch goes live when launch.models also holds an unroutable entry, which models.json leaves out', { skip, timeout: 30000 }, async t => {
|
||||
const f = await managedLaunchFixture(t);
|
||||
editBusiness(f, doc => {
|
||||
doc.launch.models['claude-opus-5-5'].provider = 'anthropic';
|
||||
doc.launch.models.router = { provider: 'openrouter', model: 'x' };
|
||||
});
|
||||
const answer = await f.launcher.launchPm();
|
||||
const session = f.sessions().find(value => value.run === answer.run);
|
||||
assert.ok(session?.live, 'same real confined runner is live, not launch-failed');
|
||||
assert.equal(f.trace.successfulPreflights, 1);
|
||||
assert.equal(f.runDirectories().length, 1, 'one run directory, the live one');
|
||||
const { providers } = JSON.parse(fs.readFileSync(join(session.runDir, 'home', '.pi', 'agent', 'models.json'), 'utf8'));
|
||||
assert.equal(providers.anthropic.models.some(model => model.id === 'claude-opus-5-5'), true);
|
||||
assert.equal(Object.values(providers).some(value => value.models.some(model => model.id === 'x')), false, 'no openrouter entry');
|
||||
privateArtifacts(f, session, [f.bus.credentials.modelRecord('acme/chief').key]);
|
||||
});
|
||||
@@ -21,10 +21,10 @@ import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -58,7 +58,7 @@ function prepare(t) {
|
||||
doc.launch.threadIdleSeconds = 3600;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -20,10 +20,10 @@ import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile, readSessions, Registry } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/clear-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -65,7 +65,7 @@ function prepare(t, { sonnet = 3, idle = 30 } = {}) {
|
||||
doc.launch.threadIdleSeconds = idle;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -25,10 +25,10 @@ import { transition } from "../../processing-control/src/state.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile, readSessions, Registry } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
// A thread made by hand gets the default session the 3k-3l migration gives a legacy thread, with
|
||||
// the thread's id (as in thread-launch.test.mjs).
|
||||
const DEFAULT_SESSION = [
|
||||
@@ -86,7 +86,7 @@ function prepare(t, { sonnet = 3, idle = 1 } = {}) {
|
||||
doc.launch.threadIdleSeconds = idle;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -22,11 +22,11 @@ import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, readSessions, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile, readSessions } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
import { mappingEntry } from "../../library/tests/helpers.mjs";
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
@@ -63,7 +63,7 @@ function prepare(t, { sonnet = 2, idle = 1, edit = (doc) => doc } = {}) {
|
||||
doc.launch.threadIdleSeconds = idle;
|
||||
return edit(doc);
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -20,10 +20,10 @@ import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile, readSessions, Registry } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -57,7 +57,7 @@ function prepare(t) {
|
||||
doc.launch.threadIdleSeconds = 3600;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
|
||||
@@ -329,7 +329,7 @@ export function buildBundle({ dir, resolved, contract, arbiter = false, tracker
|
||||
const model = sessionModel(resolved, models, route, { proxy: Boolean(proxy) });
|
||||
if (model.provider === "proxy" && !proxy) throw new BundleError("proxy model needs its own session credential", "no-session-credential");
|
||||
if (proxy && (typeof proxy.key !== "string" || !proxy.key || typeof proxy.baseUrl !== "string"
|
||||
|| !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(proxy.baseUrl))) throw new BundleError("invalid session proxy context");
|
||||
|| !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(proxy.baseUrl))) throw new BundleError("invalid session proxy context", "invalid-proxy-context");
|
||||
const web = webPlan(resolved, model.harness, harness.builtinWeb());
|
||||
const tools = typedTools({ resolved, arbiter, tracker, library, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const untyped = untypedActions(resolved, tools);
|
||||
|
||||
@@ -111,3 +111,36 @@ test('P3 every launch regenerates proxy port, declared models and own key withou
|
||||
assert.deepEqual(providers['mosaic-proxy-glm'].models.map(m => m.id), ['changed-glm-id']);
|
||||
assert.equal(JSON.parse(first.text).providers.anthropic.baseUrl, 'http://127.0.0.1:12345');
|
||||
});
|
||||
|
||||
// Row 332 R2 (#1830, review F2): launch.models is open table data. An entry whose provider has no
|
||||
// proxy route is one this session can't use, so pi leaves it out of models.json instead of failing
|
||||
// the launch; the selected entry was routed by sessionModel first. A malformed proxy entry still refuses.
|
||||
test('row 332 R2 an unroutable model table entry is left out of models.json and the launch goes on', t => {
|
||||
const declared = {
|
||||
opus: { provider: 'anthropic', model: 'claude-fixture' },
|
||||
glm: { provider: 'zai', model: 'glm-fixture' },
|
||||
router: { provider: 'openrouter', model: 'x' },
|
||||
};
|
||||
const b = build(t, 'opus', { baseUrl: 'http://127.0.0.1:12345', key: 'fx332-agent-a' }, declared);
|
||||
const { providers } = JSON.parse(b.text);
|
||||
assert.deepEqual(Object.keys(providers).sort(), ['anthropic', 'mosaic-proxy-glm']);
|
||||
assert.deepEqual(providers.anthropic.models.map(m => m.id), ['claude-fixture']);
|
||||
assert.deepEqual(providers['mosaic-proxy-glm'].models.map(m => m.id), ['glm-fixture']);
|
||||
assert.ok(!Object.values(providers).some(p => p.models.some(m => m.id === 'x')), 'the openrouter entry is not written');
|
||||
assert.equal(b.env.PI_MODEL, 'claude-fixture');
|
||||
assert.throws(() => build(t, 'opus', { baseUrl: 'http://127.0.0.1:12345', key: 'fx332-agent-a' }, {
|
||||
...declared, broken: { provider: 'proxy', model: 'claude-fixture' },
|
||||
}), { code: 'proxy-route-missing' });
|
||||
});
|
||||
|
||||
// Row 332 R2 (#1830, review F3): a proxy context of the wrong shape refuses with its own code.
|
||||
test('row 332 R2 a session proxy context without a key or loopback URL refuses invalid-proxy-context', t => {
|
||||
for (const proxy of [
|
||||
{ baseUrl: 'http://127.0.0.1:12345', key: '' },
|
||||
{ baseUrl: 'http://localhost:12345', key: 'fx332-agent-a' },
|
||||
{ baseUrl: 'http://127.0.0.1:12345' },
|
||||
]) {
|
||||
assert.throws(() => build(t, 'a', proxy, { a: models.a }),
|
||||
{ code: 'invalid-proxy-context', message: 'invalid session proxy context' }, JSON.stringify(proxy));
|
||||
}
|
||||
});
|
||||
@@ -67,12 +67,17 @@ export const builtins = ({ tools }) => [...tools];
|
||||
export const typedName = (name) => name;
|
||||
export const reliesOn = () => RELIES_ON;
|
||||
export const gateTimeout = () => null;
|
||||
export function bundle({ dir, models, proxy = null }) {
|
||||
export function bundle({ dir, models, proxy = null } = {}) {
|
||||
const sources = { piExtension: EXTENSION, ...UNSLOP };
|
||||
if (!proxy) return { files: {}, digests: [], sources };
|
||||
const providers = {};
|
||||
for (const entry of Object.values(models ?? {})) {
|
||||
const route = proxyRouteFor(entry);
|
||||
// Row 332 R2 (#1830): launch.models is open table data. An entry whose provider has no proxy
|
||||
// route is one this session can't use, so it is left out; sessionModel already routed the
|
||||
// session's own selection, and any other route refusal still fails closed.
|
||||
let route;
|
||||
try { route = proxyRouteFor(entry); }
|
||||
catch (e) { if (e?.code === "proxy-provider-unsupported") continue; throw e; }
|
||||
if (!Object.hasOwn(PROXY_PROVIDERS, route)) throw new Error("proxy model has no supported route");
|
||||
const provider = PROXY_PROVIDERS[route];
|
||||
if (typeof entry.model !== "string" || !entry.model || entry.model.startsWith("!")) throw new Error("unsafe proxy model id");
|
||||
|
||||
@@ -63,3 +63,14 @@ test('P3 pi accepts its own provider key or a session proxy key', async () => {
|
||||
}
|
||||
assert.equal(pi.preflight({ env: {}, proxy: { baseUrl: 'http://127.0.0.1:12345', key: 'fx332-agent-a' } }), null);
|
||||
});
|
||||
|
||||
// Row 332 R2 (#1830): callers that only want pi's bundle sources call bundle() with no argument,
|
||||
// as they could before P3 (packages/cli/tests/stack-copy.test.mjs). It returns the non-proxy bundle.
|
||||
test('row 332 R2 pi bundle() with no argument returns the non-proxy bundle it returned before P3', async () => {
|
||||
const pi = await harness('pi');
|
||||
const base = pi.bundle();
|
||||
assert.deepEqual(base.files, {});
|
||||
assert.deepEqual(base.digests, []);
|
||||
assert.deepEqual(Object.keys(base.sources).sort(), ['piExtension', 'unslopCheck', 'unslopExtension', 'unslopLists']);
|
||||
assert.deepEqual(pi.bundle({ dir: '/nonexistent/bundle', models: {} }), base, 'same as a call with no proxy context');
|
||||
});
|
||||
Reference in new issue
Block a user