From ed1dff560e884ac0120b692943dff93532c4a02a Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 22:57:27 +0000 Subject: [PATCH] stop the CoS runner rebuilding OpenCode's config from an identity-only provider view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PortOS composes a CLI child-env delta from the full provider record and POSTs it to the CoS runner alongside cliProviderAuthDescriptor's non-secret identity ({ id, command, ollamaBacked }). The runner then re-ran composeProviderEnv with that partial view as `provider` — and every generative layer there sits ABOVE `before`, so the rebuild overwrote the complete value it was layered onto. For an OpenCode wrapper that meant a config with an EMPTY models map replacing the good one: `--model ollama/` stopped resolving, and OpenCode silently fell back to the first model in its own catalog (a hosted OpenCode Zen model) rather than failing. Every runner-owned OpenCode agent ran on a model nobody chose. Mark the descriptor `authOnly` and short-circuit composeProviderEnv on it, so an identity-only view contributes no env at all. buildSafeCliBaseEnv still receives it — selecting that provider's ambient-auth allowlist is why it is POSTed — but it can no longer generate config. Guarding the seam rather than the OpenCode builder also covers the next layer added to composeProviderEnv. --- server/lib/cliChildEnv.js | 12 ++++++++++++ server/lib/cliChildEnv.test.js | 24 ++++++++++++++++++++++++ server/lib/processEnv.js | 11 ++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) 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) {