Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions server/lib/cliChildEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, <local marker> }`, 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/<id>` 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),
Expand Down
24 changes: 24 additions & 0 deletions server/lib/cliChildEnv.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` 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' },
Expand Down
11 changes: 10 additions & 1 deletion server/lib/processEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down