Skip to content

refactor([issue-3194]): own the AI-CLI child-env composition in one module - #3196

Merged
atomantic merged 6 commits into
mainfrom
next/issue-3194
Jul 28, 2026
Merged

refactor([issue-3194]): own the AI-CLI child-env composition in one module#3196
atomantic merged 6 commits into
mainfrom
next/issue-3194

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

Ten AI-CLI spawn sites each rebuilt the same child-environment tuple by hand — provider.envVars over process.env, the OpenCode declared-models map, the PWD pin, the CLAUDECODE strip, and (for agents) the pm2 guard shim on PATH. Three separate fixes had already paid an N-file sweep to keep them in step: the OpenCode models map (#2190/#2243), the CLAUDECODE strip, and the PWD pin (#3193). Each time the only guard against missing a site was "a reviewer noticed" or a source-grep test — and one sweep did miss a site, which a comment at spawnViaRunner still records.

server/lib/cliChildEnv.js now owns the composition, via two entry points because the sites split into two shapes:

  • buildCliChildEnv(...) — a complete env handed straight to spawn.
  • composeProviderEnv(...) — just the ordered provider layers, for the sites that emit a delta someone else bases and spawns (the CoS runner HTTP payload, a shell-session env overlay). These two were invisible to a base-env-anchored guard, and are exactly where the OpenCode sweep was missed before.

Precedence is preserved per site, not flattened. The sites do not all layer their extras the same way, so there are two distinct slots rather than one: before sits under provider.envVars (so an explicit provider GH_TOKEN still beats the repo-owner-pinned one), extra sits over it (so a PTY always gets truecolor TERM regardless of provider config). Collapsing them would have silently flipped both.

Behavior changes

Routing askService and visionCli through the shared builder brings them the OpenCode declared-models map for the first time. Both inject --model, so an Ollama-backed OpenCode provider used for Ask or vision captioning was rejecting it as "not valid" — the #2190 fix, previously applied only at the runner/agent sites. Every other site is byte-identical to before.

The guard

A discovery-based test replaces the per-fix grep list: any file that strips CLAUDECODE, or spreads provider.envVars into a child process, must route through the shared builder or be explicitly exempt with a reason. It was tuned against the real tree — verified to flag every pre-refactor hand-rolled site (including both delta sites) while leaving the model-id lookups and capability probes alone, which legitimately need none of this.

spawnCwd.test.js's existing PWD-pin scan now counts buildCliChildEnv as a pin, but only when the call passes cwd — it is an optional key there, unlike withSpawnCwdEnv(x, cwd), so a bare call next to a cwd-bearing spawn would otherwise satisfy the #3193 guard while reintroducing the bug.

Not in scope

A spawnAiCli({command, args, cwd, env}) wrapper that would also own cwd and the Windows .cmd shim: the spawn shapes differ too much (child_process vs node-pty vs an HTTP hand-off, stdio variants, kill-tree wiring). lib/aiToolkit/runner.js keeps its inline copy — it is vendored and must not import out to other PortOS modules, and its spawn is dormant under the setCliRunner override.

Folding the three inline resolveWindowsExecutable + prepareWindowsSafeSpawn pairs into prepareCliSpawn was attempted and reverted: it is a real duplicate the helper's own docblock invites, but the #1865 tests inject a fake resolveWindowsExecutable and prepareCliSpawn calls the real one internally, so the swap costs that seam at three sites. Worth its own change with the test migration.

Closes #3194

Test plan

  • cd server && npm test23,243 passed, 210 skipped, 0 failures.
  • New server/lib/cliChildEnv.test.js: layering, PWD pin, CLAUDECODE strip, pm2 guard, composeProviderEnv delta semantics, and one case per call site pinning the precedence that site depends on.
  • The discovery guard was verified non-vacuous two ways: its exempt files must still match a marker, and the markers were run against the pre-refactor versions of every converted file to confirm each would have been flagged.
  • The PWD-pin marker carries regression tests in both directions — an unpinned composer call beside a cwd-passing spawn counts 0 pins; a nested before: { … } neither ends the argument walk early nor lets it run into the next statement.
  • Reviewed by claude (one real finding on the pin marker, fixed) and a local ollama pass (no findings).

Eight spawn sites each rebuilt the same child-env tuple by hand:
provider.envVars over process.env, the OpenCode declared-models map, the
PWD pin, the CLAUDECODE strip, and (for agents) the pm2 guard shim. Three
separate fixes had already paid an N-file sweep to keep them in step --
the OpenCode models map (#2190/#2243), the CLAUDECODE strip, and the PWD
pin (#3193) -- with a missed site failing silently every time.

buildCliChildEnv() in server/lib/cliChildEnv.js now owns the composition.
Precedence is preserved per site rather than flattened: two distinct
slots, 'before' (under provider.envVars, for forgeTokenEnv/
claudeSettingsEnv so an explicit provider GH_TOKEN still wins) and
'extra' (over it, for a PTY's TERM/COLORTERM). cliChildEnv.test.js
asserts the composed order one case per call site.

Routing askService and visionCli through the builder also brings them the
OpenCode declared-models map for the first time, fixing #2190 at two more
sites: both inject --model, so an Ollama-backed OpenCode provider used
for Ask or vision captioning was rejecting it.

A discovery-based guard replaces the per-fix grep list: any file that
strips CLAUDECODE, or spreads provider.envVars over process.env into a
cwd-bearing spawn, must route through the builder or be explicitly exempt
with a reason. spawnCwd.test.js's pin scan now counts buildCliChildEnv as
a pin so the collapsed sites stay covered.
…delta sites

The quality pass found the consolidation stopped two sites short of its
own claim. agentLifecycle's spawnViaRunner payload and agentTuiSpawning's
shell-session overlay build the same forgeTokenEnv -> provider.envVars ->
opencodeEnv layering by hand, and neither was visible to the new guard:
both compose a DELTA that someone else bases and spawns, so there is no
process.env spread and no CLAUDECODE strip to key on. The evidence they
are the recurrence site was already in the tree -- a comment at
spawnViaRunner records that the #2243/#2190 sweep missed that exact line.

composeProviderEnv() now owns the ordered layers; buildCliChildEnv calls
it and adds the base env, PWD pin, strip, and guard. Both delta sites
route through it.

Guard fixes:
- Marker B keys on a provider.envVars spread reaching a child process
  rather than on a process.env base, so it sees delta composers too. The
  base-env anchor had already been widened once for a parameter-name
  variant. It also now accepts the parenthesized spread shape, which two
  real sites use and the earlier pattern read as clean. Window sized
  empirically: at 1500 the only files flagged across server/ are the two
  exempt ones, and every pre-refactor hand-rolled site is caught.
- spawnCwd.test.js counts buildCliChildEnv as a PWD pin only when the
  call actually passes a cwd. It is an optional key there, so a bare call
  next to a cwd-bearing spawn would otherwise satisfy the #3193 guard
  while reintroducing the bug.
- The three agentLifecycle source-regex tests that pinned the old spread
  literal now assert the composer wiring; the layer ORDER is asserted
  against the real function in cliChildEnv.test.js.

Also: shared collectServerSources/readServerSource in testHelper.js (the
two overlapping guards had byte-identical tree walkers and must agree on
what a source file is), dropped the fabricated pseudo provider records at
cos-runner/tuiUsageScrape in favor of the before slot, dropped three
redundant model args (buildOpencodeEnvVars already unions defaultModel),
and corrected the stale site count in the docblock and catalog README.

Not taken: folding the inline resolveWindowsExecutable +
prepareWindowsSafeSpawn pairs into prepareCliSpawn. It is a real
duplicate the helper's own docblock invites, but the #1865 tests inject a
fake resolveWindowsExecutable and prepareCliSpawn calls the real one
internally -- the swap costs that seam at three sites. Worth its own
change with the test migration.
…ad its surroundings, not the call

countCwdBearingComposerCalls tested a fixed 400-char window from the call
site. The statement after the composer is nearly always the spawn, whose
options carry cwd -- so the window matched the SPAWN's cwd and counted an
unpinned buildCliChildEnv({ provider, model }) as a pin. That shape is the
natural way to write a new site, and it would have reopened #3193 with the
whole suite green. Replaced with a balanced-paren walk over the call's own
argument list, plus regression tests pinning both directions (unpinned call
beside a cwd-passing spawn = 0 pins; nested before: { ... } neither ends the
walk early nor lets it run into the next statement).

Also corrected the cos-runner case in cliChildEnv.test.js to mirror the real
call (before: envVars, no provider record) and assert the baked-in upstream
OPENCODE_CONFIG_CONTENT survives untouched.
@atomantic
atomantic merged commit 8964dee into main Jul 28, 2026
6 checks passed
@atomantic
atomantic deleted the next/issue-3194 branch July 28, 2026 23:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consolidate the CLI child-env composition duplicated across five spawn sites

1 participant