diff --git a/server/lib/cliChildEnv.js b/server/lib/cliChildEnv.js index b5694cc58b..d4826f98e7 100644 --- a/server/lib/cliChildEnv.js +++ b/server/lib/cliChildEnv.js @@ -120,6 +120,18 @@ function claudeLocalEnvDefaults(provider) { * @returns {object} a fresh object holding only these layers */ export function composeProviderEnv({ before = null, provider = null, model = null, extra = null, safetyProfile = null } = {}) { + // `authOnly` marks an identity-only view of a provider — `cliProviderAuthDescriptor`'s + // `{ id, command, }`, with no `envVars`, `models`, `defaultModel`, + // or `thinking`. It exists so the CoS runner can keep that provider's ambient + // auth allowlist (`buildSafeCliBaseEnv`, which still receives it); it is NOT a + // record to generate env from. Every generator below sits ABOVE `before` in + // layer order, so running one on a partial view overwrites the complete value + // PortOS already composed and POSTed. That is how a runner-owned OpenCode + // agent lost its declared-models map: rebuilt empty from the descriptor, + // `--model ollama/` stopped resolving, and OpenCode silently fell back to + // the first model in its own catalog (a hosted OpenCode Zen model) instead of + // the local model the run was dispatched with. + if (provider?.authOnly) return { ...(before || {}), ...(extra || {}) }; return { ...(before || {}), ...claudeLocalEnvDefaults(provider), diff --git a/server/lib/cliChildEnv.test.js b/server/lib/cliChildEnv.test.js index 2720b4569b..cd26159e0c 100644 --- a/server/lib/cliChildEnv.test.js +++ b/server/lib/cliChildEnv.test.js @@ -549,7 +549,31 @@ describe('buildCliChildEnv — per-call-site composition', () => { }); }); + it('cos-runner/index.js: an auth-only descriptor never regenerates a layer over the POSTed delta', () => { + // The full two-hop shape every runner-owned spawn really takes: PortOS + // composes the delta from the FULL provider record, then POSTs it alongside + // `cliProviderAuthDescriptor`'s identity-only view. Regenerating any layer + // from that partial view lands ABOVE `before`, so it replaces the complete + // value with a worse one — the OpenCode config rebuilt with an empty models + // map, which stops `--model ollama/` resolving and drops the run onto + // OpenCode's own catalog. Asserted on the composed env, not on one key, so + // a future generative layer added to composeProviderEnv is covered too. + const envVars = composeProviderEnv({ provider: OLLAMA_OPENCODE, model: 'qwen3-coder:30b' }); + const env = buildCliChildEnv({ + baseEnv: { PATH: '/usr/bin' }, + before: envVars, + provider: cliProviderAuthDescriptor(OLLAMA_OPENCODE), + cwd: '/workspace', + }); + + expect(declaredModels(env)).toContain('qwen3-coder:30b'); + expect(env).toEqual({ ...envVars, PATH: '/usr/bin', PWD: '/workspace' }); + }); + it('retains ambient auth for the selected provider when the runner supplies its descriptor', () => { + // The descriptor's other half: inert for composition (above), but still the + // input `buildSafeCliBaseEnv` picks the ambient-auth allowlist from — which + // is the whole reason the runner is POSTed one. const provider = { id: 'codex', command: 'codex', envVars: { OPENAI_API_KEY: 'not serialized' } }; const env = buildCliChildEnv({ baseEnv: { PATH: '/usr/bin', OPENAI_API_KEY: 'ambient-key' }, diff --git a/server/lib/processEnv.js b/server/lib/processEnv.js index b3db6194ff..e87d20694c 100644 --- a/server/lib/processEnv.js +++ b/server/lib/processEnv.js @@ -72,6 +72,15 @@ const LOCAL_PROVIDER_MARKERS = ['ollamaBacked', 'mtplxBacked', 'llamaBacked', 'v * in another process. Provider envVars are deliberately excluded: those may * contain credentials and are already carried separately when explicitly * configured. + * + * The result is stamped `authOnly` because it is a PARTIAL view — an identity, + * not a provider record. Its consumer (the CoS runner) hands it to + * `buildCliChildEnv`, which feeds `provider` to two different contracts: + * `buildSafeCliBaseEnv`, which wants exactly this identity, and + * `composeProviderEnv`, which otherwise treats it as authoritative and + * regenerates every per-provider env layer from it. Regenerating from a partial + * view over an already-composed delta produces a WORSE value that still wins by + * layer order — see the `authOnly` short-circuit in `cliChildEnv.js`. */ export function cliProviderAuthDescriptor(provider) { if (!provider || typeof provider !== 'object') return null; @@ -81,7 +90,7 @@ export function cliProviderAuthDescriptor(provider) { for (const marker of LOCAL_PROVIDER_MARKERS) { if (provider[marker] === true) descriptor[marker] = true; } - return Object.keys(descriptor).length ? descriptor : null; + return Object.keys(descriptor).length ? { ...descriptor, authOnly: true } : null; } function cliProviderAuthRule(provider) {