diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index 45878750a..bbb1e13d7 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -219,9 +219,11 @@ export function createLauncher({ host, system, env = process.env, - // P2 supplies only {baseUrl,key}; a factory may supply the current agent's - // context per check. P3 never reads a key store or calls the proxy itself. - proxy = null, + // Tests only (#774 F4): a static session proxy context {baseUrl,key}, or a + // factory giving one per check, for a provider "proxy" route while + // system.proxy is unset. It skips P2 admission, so createLauncher refuses it + // together with system.proxy. P3 never reads a key store or calls the proxy. + testProxy = null, tracker = false, launches = true, makeLoader = createLoader, @@ -236,6 +238,8 @@ export function createLauncher({ stopOptions = {}, log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`), }) { + if (testProxy !== null && system.proxy) + throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" }); const dataRoot = system.dataRoot; function admission(generation = null) { try { return assertProcessing(dataRoot, generation); } @@ -395,14 +399,81 @@ export function createLauncher({ let harness = await harnessOf(primary); let route = "primary"; let routeReason = null; - // The first check validates the request before authorization/workspace work. - // Managed credentials are acquired once in startLocked, then supplied to - // the final check. Never reject proxy-only login using upstream host env. - if (system.proxy && !proxyReady) + // A launch is checked twice. Without system.proxy both are full checks: the first before the + // authorization, workspace and checkout work, the final one in startLocked before newRun. With + // system.proxy the first check skips only what needs the key P2 admits in startLocked: the + // harness preflight and the match of the session proxy key against launch.account.name. It + // still runs the account declaration (credential "proxy" only), the sandbox, capacity, the + // limit checks and admission, so a launch that can never start costs no authorization record, + // checkout or key (#774 F1). The final check is always full, on the admitted key. Never reject + // proxy-only login using upstream host env. + const early = Boolean(system.proxy) && !proxyReady; + // The checks that follow the route's preflight. The parameters shadow the outer route on + // purpose: the first check of a proxy launch gates a route it hasn't chosen. sessionProxy is + // null there, and the account must then name credential "proxy". + const gate = async (model, harness, sessionProxy) => { + const proxied = early || Boolean(sessionProxy); + // Row 126 (#1620): only a launch on a harness whose module counts the account spends the named + // account. The bus environment must hold the named set and no other credential variable, so the + // session can't log in some other way. Messages name variables, never values. + const account = call(model.harness, "countsAccount", () => harness.countsAccount()) === true ? b.launch.account?.name ?? null : null; + if (account !== null) { + // The usage records keep this name (bus schema 3i), so it must never be a credential's value. + const holds = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => env[k] === account); + if (sessionProxy?.key === account) holds.push("session proxy key"); + if (holds.length) throw new Refusal("account-is-credential", `launch.account.name is the value of ${holds.join(", ")}; it names the account, never its credential`); + if (proxied || b.launch.account.credential === "proxy") { + if (!proxied || b.launch.account.credential !== "proxy") + throw new Refusal("account-credential-missing", `account ${account} needs the declared proxy credential`); + } else { + const named = b.launch.account.credential.split("+"); + if (!SESSION_CREDENTIALS.some((set) => set.join("+") === named.join("+")) || !named.every((k) => Boolean(env[k]))) + throw new Refusal("account-credential-missing", `account ${account} names ${named.join(" with ")}, which the bus host doesn't hold as a session login`); + const other = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => !named.includes(k) && Boolean(env[k])); + if (other.length) throw new Refusal("account-credential-ambiguous", `account ${account} names ${named.join(" with ")}, but the bus host also sets ${other.join(", ")}`); + } + } + const unavailable = sandboxProblem({ bwrap }); + if (unavailable) throw new Refusal("sandbox-unavailable", unavailable); + const hit=limitHit(registry.sessions,b.id,b.launch.limits,model); + if(hit){const error=new Refusal("capacity-full",JSON.stringify(hit));error.limit=hit;throw error;} + // Row 125 (#1619, REQ-LIMIT-4): the limit checks. An answer that isn't an allow refuses, + // and so does a bus that can't answer (fail closed, lead decision 150). + let may; + try { + may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model, account }); + } catch (e) { + throw new Refusal("limit-check-failed", `bus: ${e.code ?? "launch-op-refused"}`); + } + // Row 333: under a daily key quota the bus refuses key-quota-no-model-key for an agent P2 + // hasn't issued a key yet. Only the first check of a proxy launch lets that one answer pass: + // startLocked's modelPreflight issues the key, and the final check asks again with it. + if (early && may?.allow === false && may.check === null && may.code === "key-quota-no-model-key") return account; + if (may?.allow !== true) { + if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; } + throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`); + } + return account; + }; + if (early) { + // The final check takes the fallback only when the primary's preflight fails, which this check + // can't know yet, so it refuses only when neither route would pass, with the primary's reason. + try { + await gate(primary, harness, null); + } catch (e) { + if (!(e instanceof Refusal) || !fallback) throw e; + try { + await gate(fallback, await harnessOf(fallback), null); + } catch (also) { + throw also instanceof Refusal ? e : also; + } + } + admission(admissionGeneration); return { resolved, model, account: null, harness, route, routeReason, admissionGeneration }; + } const proxyFor = async (selected) => { if (!system.proxy && selected.provider !== "proxy") return null; - const context = system.proxy ? proxyContext : typeof proxy === "function" ? await proxy({ business: b.id, instance }) : proxy; + const context = system.proxy ? proxyContext : typeof testProxy === "function" ? await testProxy({ business: b.id, instance }) : testProxy; 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"); @@ -425,42 +496,7 @@ export function createLauncher({ 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]; } - // Row 126 (#1620): only a launch on a harness whose module counts the account spends the named - // account. The bus environment must hold the named set and no other credential variable, so the - // session can't log in some other way. Messages name variables, never values. - const account = call(model.harness, "countsAccount", () => harness.countsAccount()) === true ? b.launch.account?.name ?? null : null; - if (account !== null) { - // The usage records keep this name (bus schema 3i), so it must never be a credential's value. - const holds = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => env[k] === account); - if (sessionProxy?.key === account) holds.push("session proxy key"); - if (holds.length) throw new Refusal("account-is-credential", `launch.account.name is the value of ${holds.join(", ")}; it names the account, never its credential`); - if (sessionProxy || b.launch.account.credential === "proxy") { - if (!sessionProxy || b.launch.account.credential !== "proxy") - throw new Refusal("account-credential-missing", `account ${account} needs the declared proxy credential`); - } else { - const named = b.launch.account.credential.split("+"); - if (!SESSION_CREDENTIALS.some((set) => set.join("+") === named.join("+")) || !named.every((k) => Boolean(env[k]))) - throw new Refusal("account-credential-missing", `account ${account} names ${named.join(" with ")}, which the bus host doesn't hold as a session login`); - const other = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => !named.includes(k) && Boolean(env[k])); - if (other.length) throw new Refusal("account-credential-ambiguous", `account ${account} names ${named.join(" with ")}, but the bus host also sets ${other.join(", ")}`); - } - } - const unavailable = sandboxProblem({ bwrap }); - if (unavailable) throw new Refusal("sandbox-unavailable", unavailable); - const hit=limitHit(registry.sessions,b.id,b.launch.limits,model); - if(hit){const error=new Refusal("capacity-full",JSON.stringify(hit));error.limit=hit;throw error;} - // Row 125 (#1619, REQ-LIMIT-4): the limit checks. An answer that isn't an allow refuses, - // and so does a bus that can't answer (fail closed, lead decision 150). - let may; - try { - may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model, account }); - } catch (e) { - throw new Refusal("limit-check-failed", `bus: ${e.code ?? "launch-op-refused"}`); - } - if (may?.allow !== true) { - if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; } - throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`); - } + const account = await gate(model, harness, sessionProxy); admission(admissionGeneration); return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy }; } diff --git a/packages/cli/tests/launch-account.test.mjs b/packages/cli/tests/launch-account.test.mjs index b9070ea63..a34695b39 100644 --- a/packages/cli/tests/launch-account.test.mjs +++ b/packages/cli/tests/launch-account.test.mjs @@ -14,8 +14,8 @@ 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. 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. +// launch gets one made-up pi key, so its own preflight passes on any host. proxy goes to the launcher's +// testProxy option, a static session proxy context (row 332, #774 F4); 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; @@ -32,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:()=>{},...(proxy?{proxy}:{})}); + const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{},...(proxy?{testProxy: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}; @@ -114,7 +114,7 @@ test('REQ-LIMIT-2 with no launch.account a claude-code launch carries null and t }); // 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. +// proxy model is routed through the launcher's testProxy 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'}; diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs index ecdaef1e6..5fc181ee0 100644 --- a/packages/cli/tests/launcher.test.mjs +++ b/packages/cli/tests/launcher.test.mjs @@ -154,7 +154,7 @@ test('P3 proxy key reaches the spawned runner but never session.json or the bund doc.launch.models['claude-opus-5-5'] = { provider: 'proxy', model: 'claude-fixture', route: 'anthropic' }; return doc; }); - const { launcher, f, close } = await setup(t, doc => doc, prepared, { proxy }); + const { launcher, f, close } = await setup(t, doc => doc, prepared, { testProxy: proxy }); const launched = await launcher.launchPm(); const session = readSessions(f.dataRoot).find(s => s.run === launched.run); assert.ok(session); @@ -169,6 +169,18 @@ test('P3 proxy key reaches the spawned runner but never session.json or the bund assert.equal(await close(), 0); }); +// #774 F4: testProxy skips P2 admission, so createLauncher refuses it next to system.proxy, in both +// forms, before it reads anything else. +test('#774 F4 createLauncher refuses testProxy together with system.proxy', async t => { + const root = tmp(t); + const system = { dataRoot: join(root, 'data'), proxy: { port: 8317, binary: '/fixture-unused', firstOutputSeconds: 1 } }; + const host = { business: 'acme', op: async () => { throw new Error('no host operation expected'); } }; + for (const testProxy of [{ baseUrl: 'http://127.0.0.1:12345', key: 'fx774-static' }, async () => ({ baseUrl: 'http://127.0.0.1:12345', key: 'fx774-factory' })]) + assert.throws(() => createLauncher({ host, system, env: { MOSAIC_CONFIG: join(root, 'config', 'config.json'), MOSAIC_ROLES_DIR: join(root, 'roles') }, testProxy, log: () => {} }), + (e) => e.code === 'test-proxy-refused' && /system\.proxy/.test(e.message) && !e.message.includes('fx774')); + assert.equal(existsSync(join(root, 'data')), false, 'refused before any data-root work'); +}); + test("bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => { // Row 122: uncertain write receipts cannot prevent the window's first chief launch. const prepared = prepare(t); diff --git a/packages/cli/tests/model-managed-launch-fixture.mjs b/packages/cli/tests/model-managed-launch-fixture.mjs index 3a3bcd42b..8e602c975 100644 --- a/packages/cli/tests/model-managed-launch-fixture.mjs +++ b/packages/cli/tests/model-managed-launch-fixture.mjs @@ -15,9 +15,11 @@ import { readSessions, runsDir, sessionsFile, SESSIONS_VERSION, launchLogFile } export const waitFor = async (read, message, ms=15000) => { const end=Date.now()+ms;while(Date.now()setTimeout(r,20));}throw Error(message); }; export const pause = ms => new Promise(r=>setTimeout(r,ms)); const id = value => createHash('sha256').update(value).digest('hex').slice(0,12); -export async function managedLaunchFixture(t,{proxy=true,launches=true,refuseProbe=false,harness='pi'}={}){ +// #774: bwrap overrides the launcher's sandbox binary; admit rewrites what P2 admits, so a test can +// reach the final check's key match. trace.ops names every trusted host operation in order. +export async function managedLaunchFixture(t,{proxy=true,launches=true,refuseProbe=false,harness='pi',bwrap=null,admit=null}={}){ const root=fs.mkdtempSync(join(tmpdir(),'ml653-')),closers=[],history=new Set(['fx653-manage']),proxyReceipts=[],logs=[]; - let server,bus,launcher,stderr;const trace={preflightAttempts:0,successfulPreflights:0,enableBeforeSpawn:[],bindings:[],stalls:[]}; + let server,bus,launcher,stderr;const trace={preflightAttempts:0,successfulPreflights:0,enableBeforeSpawn:[],bindings:[],stalls:[],ops:[]}; const checkHistory=entries=>{const text=JSON.stringify(entries);for(const value of history)if(text.includes(value))throw Error('fixture credential leaked');}; t.after(async()=>{const failures=[];for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');}); const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:[harness]};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness};doc.roles.chief.vars.model='claude-opus-5-5';return doc;}); @@ -70,7 +72,8 @@ export async function managedLaunchFixture(t,{proxy=true,launches=true,refusePro }; const host={business:'acme',path:bus.path,dataRoot:f.dataRoot,beforeClose:fn=>hooks.push(fn), op:async m=>{ - if(m.op==='modelPreflight'){trace.preflightAttempts++;const value=await actualOp(m);if(value){trace.successfulPreflights++;expectedKeyId=id(value.key);}return value;} + trace.ops.push(m.op); + if(m.op==='modelPreflight'){trace.preflightAttempts++;let value=await actualOp(m);if(value&&admit)value=admit(value);if(value){trace.successfulPreflights++;expectedKeyId=id(value.key);}return value;} if(m.op==='landHolders'&&trace.successfulPreflights){ // Existing trusted host call occurs after enable publication and BEFORE bundle, // registerRuntime/spawnSession/bind in the production startLocked sequence. @@ -82,7 +85,7 @@ export async function managedLaunchFixture(t,{proxy=true,launches=true,refusePro }, bindLaunch:async record=>{const result=bus.bindLaunch(record);trace.bindings.push({run:record.run,pid:record.pid,startTime:record.startTime,modelKeyId:record.modelKeyId});return result;}}; const makeLoader=await fakeHarnesses({after:close=>closers.push(close)},{[harness]:adapter}); - launcher=createLauncher({host,system,env,launches,makeLoader,sessionDefaults:{pollInterval:50},harnessVersion:'0.85.1',log:line=>logs.push(line)}); + launcher=createLauncher({host,system,env,launches,makeLoader,...(bwrap?{bwrap}:{}),sessionDefaults:{pollInterval:50},harnessVersion:'0.85.1',log:line=>logs.push(line)}); // The existing trusted host hook owns all launcher-created children. No registry // PID is signalled by fixture teardown; normal bounded launcher close is used. closers.push(async()=>{for(const close of hooks)await close(0);});await launcher.listen(); diff --git a/packages/cli/tests/proxy-launch-order.test.mjs b/packages/cli/tests/proxy-launch-order.test.mjs new file mode 100644 index 000000000..2ad88ba19 --- /dev/null +++ b/packages/cli/tests/proxy-launch-order.test.mjs @@ -0,0 +1,124 @@ +// Vikunja #774 F1 (row 332 review): with system.proxy set, a launch that can never start refuses in +// the first check, before authorizeLaunch, the workspace and checkouts, and the P2 modelPreflight +// that may issue a key. Only the harness preflight and the session proxy key match wait for the +// admitted key, in the final check. Real launcher, bus runtime and runner (managed P2 fixture). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { sandboxProblem } from '../../seat/src/sandbox.mjs'; +import { workspaceDir } from '../../seat/src/session.mjs'; +import { managedLaunchFixture } from './model-managed-launch-fixture.mjs'; + +const skip = sandboxProblem() ?? false; +function editBusiness(f, update) { + const path = join(f.root, 'config', 'businesses', 'acme.json'); + const document = JSON.parse(fs.readFileSync(path, 'utf8')); + update(document); + fs.writeFileSync(path, JSON.stringify(document) + '\n', { mode: 0o600 }); +} +// A capability the bus issued to a claimed chief, as a launched chief holds. +function chief(f) { + const cap = f.bus.broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-774', harness: f.f.doc.roles.chief.vars.harness }); + f.bus.broker.request(cap, { verb: 'role.claim' }); + return cap; +} +// The refusal came from the first check: nothing after it ran. +function refusedFirst(f, before, preflights, bindings = 0, instance = 'coder') { + assert.equal(f.trace.preflightAttempts, preflights, 'no modelPreflight for a launch the first check refuses'); + assert.deepEqual(f.trace.ops.slice(before).filter(op => ['authorizeLaunch', 'credentialStatus', 'modelPreflight'].includes(op)), [], + 'no authorizeLaunch, credentialStatus or modelPreflight after the first check'); + assert.equal(fs.existsSync(join(workspaceDir(f.f.dataRoot, 'acme', instance), 'stack')), false, 'no checkout clone or fetch'); + assert.equal(f.trace.bindings.length, bindings, 'no runner bound for the refused launch'); +} +const refusal = (f, code) => f.launchLog().filter(e => e.event === 'refused' && e.code === code); +const claudeCoder = doc => { + doc.launch.models['claude-opus-5-5'].provider = 'anthropic'; + doc.roles.coder.vars.model = 'claude-opus-5-5'; +}; + +test('#774 F1 proxy capacity-full refuses in the first check: no modelPreflight, checkout or authorizeLaunch; the ordinary launch before it goes live', { skip, timeout: 60000 }, async t => { + const f = await managedLaunchFixture(t); + const cap = chief(f); + const ordinary = await f.launcher.fromSession(cap, 'reviewer'); + assert.equal(ordinary.ok, true, JSON.stringify(ordinary)); + assert.ok(f.sessions().find(s => s.run === ordinary.result.run)?.live, 'the ordinary proxy launch is live'); + assert.equal(f.trace.preflightAttempts, 1, 'the ordinary proxy launch makes one P2 admission'); + editBusiness(f, doc => { doc.launch.limits.total = 1; }); + const before = f.trace.ops.length; + assert.deepEqual(await f.launcher.fromSession(cap, 'coder'), { ok: false, error: 'capacity-full' }); + refusedFirst(f, before, 1, 1); + assert.deepEqual(refusal(f, 'capacity-full').map(e => e.limit), [{ scope: 'total', limit: 1, running: 1 }]); + f.valueFree(); +}); + +test('#774 F1 proxy limit-reached refuses in the first check: no modelPreflight, checkout or authorizeLaunch', { skip, timeout: 60000 }, async t => { + const f = await managedLaunchFixture(t); + editBusiness(f, doc => { + doc.plugins = { ...doc.plugins, 'limit-check': ['token-limit'] }; + doc.launch.limits.tokens = { instances: { coder: { limit: 0, since: '2026-10-18T00:00:00.000Z' } } }; + }); + const before = f.trace.ops.length; + assert.deepEqual(await f.launcher.fromSession(chief(f), 'coder'), { ok: false, error: 'limit-reached' }); + refusedFirst(f, before, 0); + assert.equal(refusal(f, 'limit-reached')[0]?.limit?.check, 'token-limit'); + assert.equal(f.runDirectories().length, 0); + f.valueFree(); +}); + +test('#774 F1 proxy sandbox-unavailable refuses in the first check: no modelPreflight, checkout or authorizeLaunch', { skip, timeout: 60000 }, async t => { + const f = await managedLaunchFixture(t, { bwrap: '/nonexistent-774/bwrap' }); + const before = f.trace.ops.length; + assert.deepEqual(await f.launcher.fromSession(chief(f), 'coder'), { ok: false, error: 'sandbox-unavailable' }); + refusedFirst(f, before, 0); + assert.equal(f.runDirectories().length, 0); + f.valueFree(); +}); + +test('#774 F1 proxy launch.account declared with an upstream credential refuses in the first check, before modelPreflight', { skip, timeout: 60000 }, async t => { + const f = await managedLaunchFixture(t, { harness: 'claude-code' }); + editBusiness(f, doc => { + claudeCoder(doc); + doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' }; + }); + const before = f.trace.ops.length; + assert.deepEqual(await f.launcher.fromSession(chief(f), 'coder'), { ok: false, error: 'account-credential-missing' }); + refusedFirst(f, before, 0); + assert.equal(refusal(f, 'account-credential-missing')[0]?.why, 'account jason-max needs the declared proxy credential'); + assert.equal(f.runDirectories().length, 0); + f.valueFree(); +}); + +// The key match needs the admitted key, so it stays in the final check: after modelPreflight, +// before newRun and spawn. The fixture makes P2 admit a key equal to the account name. +test('#774 F1 proxy session key equal to launch.account.name still refuses account-is-credential in the final check', { skip, timeout: 60000 }, async t => { + const f = await managedLaunchFixture(t, { harness: 'claude-code', admit: value => ({ ...value, key: 'jason-max' }) }); + editBusiness(f, doc => { + claudeCoder(doc); + doc.launch.account = { name: 'jason-max', credential: 'proxy' }; + }); + assert.deepEqual(await f.launcher.fromSession(chief(f), 'coder'), { ok: false, error: 'account-is-credential' }); + assert.equal(f.trace.preflightAttempts, 1, 'the first check passed and P2 admitted a key'); + assert.equal(f.trace.ops.filter(op => op === 'authorizeLaunch').length, 1); + assert.equal(refusal(f, 'account-is-credential')[0]?.why, 'launch.account.name is the value of session proxy key; it names the account, never its credential'); + assert.equal(f.trace.bindings.length, 0); + assert.equal(f.sessions().length, 0); + assert.equal(f.runDirectories().length, 0, 'refused before newRun'); + f.valueFree(); +}); + +// Row 333: under a daily key quota the bus refuses mayLaunch key-quota-no-model-key for an agent +// with no model key. In the first check that is the one answer that waits for P2 to issue a key. +test('#774 F1 proxy launch of an agent with no key yet passes the first check under a daily key quota, and both checks ask mayLaunch', { skip, timeout: 60000 }, async t => { + const f = await managedLaunchFixture(t); + editBusiness(f, doc => { + doc.plugins = { ...doc.plugins, 'limit-check': ['token-limit'] }; + doc.launch.limits.tokens = { keys: { '000000000774': { limit: 1000000, since: '2026-10-18T00:00:00.000Z', reset: '00:00', timeZone: 'UTC' } } }; + }); + assert.equal(f.bus.credentials.modelRecord('acme/chief'), null, 'chief holds no model key yet'); + const answer = await f.launcher.launchPm(); + assert.ok(f.sessions().find(s => s.run === answer.run)?.live, 'the launch goes live'); + assert.equal(f.trace.successfulPreflights, 1); + assert.equal(f.trace.ops.filter(op => op === 'mayLaunch').length, 2, 'mayLaunch in the first and the final check'); + f.valueFree(); +});