Skip to content

refactor(core): share agent child environment constructor - #880

Merged
moncher-dev merged 5 commits into
mainfrom
symphony/hojinzs-github-symphony-870
Sep 5, 2026
Merged

refactor(core): share agent child environment constructor#880
moncher-dev merged 5 commits into
mainfrom
symphony/hojinzs-github-symphony-870

Conversation

@moncher-dev

@moncher-dev moncher-dev commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Issues

Summary

  • TL;DR: Centralize built-in agent child environment assignments in core so Codex and Claude receive the same host-constructed values.
  • Apply declared-secret exclusions only to source-derived agent-visible context, while pinned child-isolation paths always win.
  • Isolate HOME, USERPROFILE, and runtime config homes consistently, preserve sanitized Symphony context, and add a CLI patch changeset.

Change-point diagram

  • packages/core/src/runtime/agent-child-env.ts → filters source context → appends pinned isolation assignments
  • packages/runtime-codex + packages/runtime-claude → collect runtime-specific removal sets → call the shared constructor
  • Core and runtime tests → prove the shared set, context exclusion, and pinned-value collision behavior

Start here

  • packages/core/src/runtime/agent-child-env.ts:5 — shared assignment constructor and precedence boundary
  • packages/runtime-codex/src/runtime.ts:657 — Codex removal set and final pinned CODEX_HOME
  • packages/runtime-claude/src/adapter.ts:654 — Claude inherit/non-inherit construction

User-Visible Behavior / Operational Impact

  • Built-in agent children consistently receive isolated HOME and USERPROFILE, plus shared GitHub CLI and Docker config paths.
  • A Symphony context name declared secret remains absent, but a collision with a pinned isolation name cannot disable child isolation.
  • Custom runtime construction remains a separate boundary and is intentionally unchanged.

Validation

  • pnpm exec vitest run packages/core/src/runtime/agent-child-env.test.ts packages/runtime-codex/src/runtime.test.ts packages/runtime-claude/src/adapter.test.ts — pass (84 tests, including after merging current main)
  • pnpm lint — pass
  • pnpm test — pass
  • pnpm typecheck — pass
  • pnpm build — pass
  • Docker E2E / blackbox — not applicable: internal built-in runtime environment construction is covered at the exact spawn-plan boundary and does not change orchestration dispatch or lifecycle behavior

Changeset

  • .changeset/fuzzy-cats-guard.md (patch)

Risks & rollback

  • Risk is limited to environment precedence at the built-in agent-child boundary; regression tests cover both runtime paths, declared-secret overlap, and pinned-name collisions.
  • Roll back commits 0bb0fd6, efa724b, and 70ef0ce together to restore per-runtime construction.

Changed files

  • packages/core/src/runtime/agent-child-env.ts and test — shared constructor, source-only exclusions, and exact assignment coverage
  • packages/core/src/runtime/index.ts — export the constructor
  • packages/runtime-codex/src/runtime.ts and test — migrate Codex and preserve removed context names plus pinned CODEX_HOME
  • packages/runtime-claude/src/adapter.ts and test — migrate both Claude environment paths and preserve pinned isolation values
  • docs/architecture.md — map the shared built-in boundary and distinct custom-runtime boundary
  • .changeset/fuzzy-cats-guard.md — patch release note

Post-merge / human validation

  • None beyond normal release review

Security

  • No real tokens, private keys, .env files, or generated installation tokens are committed

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T07:29:05.008679Z 55d4df0 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 40.97% 45037 / 109918
🔵 Statements 40.97% 45037 / 109918
🔵 Functions 72.68% 2225 / 3061
🔵 Branches 78.83% 10361 / 13142
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/runtime/agent-child-env.ts 100% 100% 100% 100%
packages/core/src/runtime/index.ts 100% 100% 100% 100%
packages/runtime-claude/src/adapter.ts 93.16% 88.27% 94.44% 93.16% 127-134, 197-200, 270-279, 313-318, 368-369, 470-471, 601-602, 752-753, 759-760, 767-768, 808-809, 858, 860-861
packages/runtime-codex/src/runtime.ts 83.99% 82.2% 93.54% 83.99% 269-285, 290, 299-300, 346-347, 353-361, 392-400, 416-424, 460-495, 521-524, 526-528, 530-536, 538-540, 557-558, 563-564, 712-713, 745-746, 749-752, 768-772, 825-828, 1020
Generated in workflow #1396 for commit 3469d2f by the Vitest Coverage Report Action

@moncher-dev

Copy link
Copy Markdown
Collaborator Author

Smoke-tested at head 55d4df0f in a clean worktree. Ready for your review.

Bar: pnpm install --frozen-lockfilepnpm buildpnpm test (all packages, exit 0; orchestrator 362, cli 566) → pnpm lint (0) → pnpm typecheck (0). CI Test and Container Smoke both pass on this head.

Exercised for real, not asserted. I built both runtimes' child environments from the compiled dist under a deliberately hostile host env (HOME=/Users/operator, USERPROFILE=/Users/operator, a stale SYMPHONY_ISSUE_ID=STALE-999 simulating cross-issue leakage, plus GIT_ASKPASS, GIT_CONFIG_GLOBAL, AGENT_CREDENTIAL_BROKER_SECRET, GITHUB_TOKEN), passing the run-scoped values through extraEnv/env including a clone URL with an embedded ghs_ token. For Codex I then spawned a real child process with the computed plan.env and printed what the child actually saw; for Claude I drove ClaudePrintRuntimeAdapter.spawnTurn through the injected spawnImpl and captured the env handed to spawn. I ran the identical script against the main build (bca2fcbb) for a differential.

Result — the child sees HOME = USERPROFILE = child home, GH_CONFIG_DIR, DOCKER_CONFIG, CODEX_HOME under it, GIT_TERMINAL_PROMPT=0, run-scoped SYMPHONY_ISSUE_ID=CURRENT-870 winning over the stale host value, and every credential variable absent.

Two real fixes the PR body undersells, both visible only in the differential:

  1. Codex leaked an installation token to the agent child on main. Because readAgentVisibleSymphonyContext read only process.env and was spread early, the raw extraEnv value won and the child received TARGET_REPOSITORY_CLONE_URL = https://x-access-token:ghs_SECRET@github.com/.... On this branch the sanitizer reads extraEnv/agentEnv and is applied last, so the child receives https://github.com/hojinzs/github-symphony.git. The Claude path was already correct; Codex was not.
  2. Claude leaked the operator's USERPROFILE on main (/Users/operator) while HOME was correctly isolated — a hole in the inheritProcessEnv path. Now pinned to the child home on both runtimes.

Two judgment calls for you, neither blocking:

  • Precedence moved. The shared assignments are now applied after credential stripping and after orchestratorRunEnv/agentEnv, so the nine agent-visible names can no longer be overridden by those layers. I checked the overlap: orchestratorRunEnv sets only SYMPHONY_ORCHESTRATOR_URL / SYMPHONY_RUN_ID / SYMPHONY_ORCHESTRATOR_TOKEN, none of which are in AGENT_VISIBLE_SYMPHONY_CONTEXT_ENVIRONMENT_NAMES, and none of the nine are in AGENT_CHILD_CREDENTIAL_ENVIRONMENT_NAMES, so nothing stripped is reintroduced and no run-scoped value is clobbered today. It is a real ordering change to be aware of.
  • Codex's context sources widened. It now reads from config.extraEnv / config.agentEnv, where previously only process.env was consulted and resolvePreparedAgentEnvironment filtered agentEnv down to OPENAI_*. The widening is confined to the nine non-secret allowlisted names (clone URL sanitized), and it is what makes fix feat(orchestrator): add --no-status-api flag and print server address on startup #1 above work — but it is a behavior change beyond a pure refactor, and refactor(core): build the agent child's explicit environment through one shared constructor #870 carries no changeset:* label. Worth deciding whether the agent-visible env change deserves a changeset.

@moncher-dev
moncher-dev requested a review from hojinzs September 5, 2026 07:54

@hojinzs hojinzs left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 55d4df0f. Round 1 of at most 6.

The refactor itself is the right shape and stays inside #870's scope — no over-engineering, no drive-by decisions. One thing must change before merge.

Verdict: request changes

P1 — the reordering silently defeats the declared-secret strip. Both runtimes now apply the shared assignments after their secret-removal step, and the constructor re-reads the same sources, so any of the nine AGENT_VISIBLE_SYMPHONY_CONTEXT_ENVIRONMENT_NAMES that a tracker adapter or an MCP config designates as a secret is deleted and then put back. Inline on both call sites with the main differential. No live secret escape today — the nine are non-secret by construction and the clone URL is sanitized — but secretEnvironmentNames also carries operator- and (under trustRepoConfig) repo-supplied MCP source names, so an arbitrary name can reach that set, and the invariant main enforced no longer holds. Fix is a few lines; suggested shape is an excludeNames option on the shared constructor so both runtimes stay identical.

Also P2 (changeset) and P3 (a third construction site) below, plus two inline P3/nits.

Requirements vs #870

Acceptance criterion Status
Both runtimes build explicit child assignments through the shared helper
Per-runtime construction deleted
Adding a host-constructed value requires one edit ⚠️ see P3
A test proves both runtimes pick it up ✅ structurally (both call the helper); assertions are hand-written per runtime — nit inline

Out-of-scope items on the issue (allowlist consolidation, declarative non-secret channel, MCP composition removal) are all correctly left alone.

Smoke test

Clean checkout of 55d4df0f, pnpm install --frozen-lockfilepnpm build (success) → core 354 / runtime-codex 81 / runtime-claude 110 tests, all passing. No preview environment for this change, so I exercised the two child-environment builders directly and ran the identical probes against the main build (bca2fcb) for a differential — hostile host env (HOME/USERPROFILE=/Users/operator, stale SYMPHONY_ISSUE_ID=STALE-999, GIT_ASKPASS, GIT_CONFIG_GLOBAL, AGENT_CREDENTIAL_BROKER_SECRET, GITHUB_TOKEN), run-scoped values through extraEnv, Claude driven through spawnTurn with an injected spawnImpl.

main 55d4df0f
Codex USERPROFILE absent child home ✅
Claude USERPROFILE (non-inherit) /Users/operator child home ✅
Codex clone URL from extraEnv https://x-access-token:ghs_SECRET@… sanitized ✅
Run-scoped SYMPHONY_ISSUE_ID over stale host value wins wins ✅
Credential vars in child absent absent ✅
Declared-secret name ∩ agent-visible name stripped reintroduced

HOME / GH_CONFIG_DIR / DOCKER_CONFIG / CODEX_HOME / GIT_TERMINAL_PROMPT=0 all correct on both runtimes.

Answers to the two judgment calls you raised

Precedence. Your overlap analysis is right and I re-derived it: orchestratorRunEnv sets only SYMPHONY_ORCHESTRATOR_URL / SYMPHONY_RUN_ID / SYMPHONY_ORCHESTRATOR_TOKEN, none of the nine, and none of the nine are in AGENT_CHILD_CREDENTIAL_ENVIRONMENT_NAMES. What that analysis misses is the other strip — the secretEnvironmentNames delete loop in Codex and stripTrackerSecrets in Claude — which is where the P1 lives. Keep the new ordering; make it subtract.

Changeset — yes, add one (P2). The PR body reasons from the absence of a changeset:* label on #870, but CLAUDE.md keys the rule on behavior: "Behavior-changing PRs must add a changeset." This changes what the agent child actually receives on both runtimes (Codex gains USERPROFILE; Claude stops handing the operator's host USERPROFILE to an otherwise-isolated child). A patch changeset for @gh-symphony/cli describing the USERPROFILE pinning is the honest record.

One correction to the premise, since it affects how you size that changeset: claim #1 is real at the function boundary but not reachable from any production caller. runtime-codex/src/launcher.ts:44 already builds extraEnv through readAgentVisibleSymphonyContext(env), so the clone URL arrives pre-sanitized; and orchestrator/src/runtime-factory.ts:171 passes context.env, whose TARGET_REPOSITORY_CLONE_URL is deriveCloneUrl(repository.url) — a GitHub API URL with no userinfo. I reproduced your result only by injecting a tokenized URL into extraEnv directly, which nothing in the orchestrator does. So it's defense-in-depth hardening, not a leak that was occurring. Claim #2 is the one that was actually live, and it's slightly bigger than you described — see the Claude inline.

P3 — a third construction site still exists

packages/core/src/runtime/custom-child-env.ts:151-153 still hand-assigns HOME / USERPROFILE / GH_CONFIG_DIR, and omits DOCKER_CONFIG — so the custom-runtime child already diverges from the two agent runtimes on a host-constructed value, and "one edit" isn't true while it stands. #870's scope names only the two runtimes, so I'm not asking you to widen the PR. Either migrate it in a follow-up, or, if the DOCKER_CONFIG omission is deliberate, say so in the §17.5 row you touched in docs/architecture.md so the next person doesn't read the shared constructor as universal.

The docs/architecture.md update is otherwise accurate.

Fix the P1 and add the changeset and I'll approve.


Generated by Claude Code

Comment thread packages/runtime-codex/src/runtime.ts
Comment thread packages/runtime-claude/src/adapter.ts Outdated
Comment thread packages/core/src/runtime/agent-child-env.ts Outdated
Comment thread packages/runtime-codex/src/runtime.test.ts
@moncher-dev
moncher-dev requested a review from hojinzs September 5, 2026 08:26

@hojinzs hojinzs left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 — one P2 left, everything from round 1 verified fixed

Round 1 follow-up: all three confirmed closed, not just claimed

I re-ran each finding as a differential rather than reading the diff:

Round 1 Status at efa724b
P1 Codex — strip reintroduced by last-applied assignments Fixed. TARGET_REPOSITORY_URL declared secret + agent-visible → absent from plan.env.
P1 Claude — same defect, both paths Fixed. Absent on inherit=true and inherit=false.
P3 — dead ?? [] on optional sources Fixed. Required, fallback gone.
nit — hand-asserted parity Declined with reasoning on the thread. Accepted — see below.

On the declined nit: the reasoning holds, and I checked the criterion directly rather than taking it on structure. A parity assertion projecting the full shared surface out of both runtimes for identical inputs passes on efa724b and fails on main — so "adding a value once appears in both" is now genuinely true. I'd still rather see that assertion in the tree than in my scratch dir, but it is not a blocker.

Requirements vs. issue #870

  • Shared constructor in core, both runtimes migrated, per-runtime construction deleted — done.
  • One-edit property — holds; verified by the parity differential above.
  • Out-of-scope items (inheritance allowlists, declarative non-secret channel, MCP composition) all correctly left alone. No overengineering: the constructor is 20 lines and takes exactly the two inputs the call sites need.

USERPROFILE is a scope addition beyond the issue text, but it's a real isolation hole (DEFAULT_INHERITED_ENV_KEYS was copying the host value into the Claude non-inherit path while HOME was pinned), it's the natural blast radius of this refactor, and the changeset calls it out. Fine to carry here.

Smoke test

No preview environment on this PR, so black-box locally at the spawn-plan boundary — buildCodexRuntimePlan directly, and buildClaudeSpawnEnv driven through spawnTurn with an injected spawnImpl, on efa724b vs main:

  • cross-runtime parity of the shared assignment surface — passes on head, fails on main
  • declared secret ∩ agent-visible absent — Codex ✅, Claude inherit ✅, Claude non-inherit ✅
  • clone-URL userinfo sanitized when supplied through extraEnv — ✅ (an incidental improvement: on main the raw extraEnv spread landed after the context read on Codex, so userinfo survived; now it doesn't)
  • pinned-assignment suppression — ❌ see the P2

Repo suites: packages/core/src/runtime, packages/runtime-codex, packages/runtime-claude → 227 passed. CI on efa724b: Test ✅, Container Smoke ✅.

Blocking

P2 — packages/core/src/runtime/agent-child-env.ts. excludeNames subtracts from the pinned isolation assignments as well as the agent-visible context, so an operator/repo-reachable name colliding with HOME now suppresses child-home isolation on Claude where main pinned it. Differential and a three-line fix on the inline comment. Same reachability argument that carried the round-1 P1, so I'm holding it to the same bar.

One nit on CODEX_HOME asymmetry, take it or leave it.

Fix the P2 and I'll approve — I don't have further concerns beyond it.


Generated by Claude Code

Comment thread packages/core/src/runtime/agent-child-env.ts Outdated
Comment thread packages/runtime-codex/src/runtime.ts Outdated
@moncher-dev
moncher-dev requested a review from hojinzs September 5, 2026 09:27

@hojinzs hojinzs left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3 — approved. The round-2 P2 is genuinely closed, and the main differential is clean.

Reviewed at head 3469d2f (the merge of current main). Round 3 of at most 6.

Round 2 follow-up — verified by differential, not by reading the diff

I re-ran the exact probe that produced the round-2 P2, driving buildCodexRuntimePlan directly and buildClaudeSpawnEnv through spawnTurn with an injected spawnImpl, with HOME, USERPROFILE, GH_CONFIG_DIR, DOCKER_CONFIG, CODEX_HOME and TARGET_REPOSITORY_URL all declared secret at once — i.e. a collision on every pinned name simultaneously:

Pinned name Codex Claude inherit=false Claude inherit=true
HOME child home ✅ child home ✅ child home ✅
USERPROFILE child home ✅ child home ✅ child home ✅
GH_CONFIG_DIR …/gh …/gh …/gh
DOCKER_CONFIG …/.docker …/.docker …/.docker
CODEX_HOME …/.codex n/a n/a

The pinned surface is byte-identical between the clean run and the all-names-collide run on all three paths. Splitting the subtraction so excludeNames reaches only the source-derived context is the right cut, and the CODEX_HOME nit being taken means the Codex boundary is uniform rather than one-name-special.

Critically, the round-1 P1 did not regress while fixing this: TARGET_REPOSITORY_URL, agent-visible and declared secret, is still absent from all three child environments. The two invariants — "a declared secret cannot cross" and "the strip cannot switch off isolation" — now hold at the same time, which is the whole point.

Round-1 P3 (custom-child-env.ts as a third construction site) is discharged the documented way: the §17.5 row now states the custom constructor is separate and does not share the agent-runtime assignment set. Good enough — the next reader won't mistake the shared constructor for universal.

main differential — 12 changes, all in the safe direction

Same probe against bcf80f6. Every single difference is head being stricter; there is no cell where main is safer than head:

main 3469d2f
Codex USERPROFILE absent child home
Claude USERPROFILE (both paths) /Users/operator child home
Codex TARGET_REPOSITORY_CLONE_URL https://x-access-token:ghs_SECRET@… sanitized
Codex pinned names under secret collision all dropped (HOME, GH_CONFIG_DIR, DOCKER_CONFIG, CODEX_HOME = undefined) all pinned
Claude USERPROFILE under secret collision dropped pinned

Everything else — credential vars absent, GIT_TERMINAL_PROMPT=0, run-scoped SYMPHONY_ISSUE_ID winning over a stale host value — is unchanged from main.

Two notes on how to read that table, since they affect how the release is described rather than whether it ships:

  • The clone-URL sanitization is hardening, not a leak that was occurring — I re-confirmed the round-1 reachability finding at this head. runtime-codex/src/launcher.ts:44 already builds extraEnv through readAgentVisibleSymphonyContext, and orchestrator/src/runtime-factory.ts passes context.env whose clone URL is deriveCloneUrl(repository.url) with no userinfo. buildCodexRuntimePlan has no other production caller. I reproduce the tokenized value only by injecting it into extraEnv directly, which nothing does.
  • The row that was live is the Claude USERPROFILE one, and the Codex collision row is a real robustness gain on a path main silently got wrong.

The changeset covers the USERPROFILE pinning and the secret-exclusion preservation. It doesn't mention the clone-URL hardening — given it isn't reachable, omitting it is defensible, so I'm not asking for a reword.

Requirements vs. issue #870

Acceptance criterion Status
Both runtimes build explicit child assignments through the shared helper
Per-runtime construction deleted
Adding a host-constructed value requires one edit
A test proves both runtimes pick it up

No over-engineering: the constructor is 22 lines with exactly the three inputs the call sites need. All three out-of-scope items on the issue (inheritance-allowlist consolidation, a declarative non-secret channel, MCP composition removal) are correctly left alone. The USERPROFILE scope addition remains the right call — it's the natural blast radius of the refactor and the changeset records it.

Validation

pnpm install --frozen-lockfilepnpm buildpnpm lint (0) → pnpm typecheck (0) → pnpm test (full monorepo, exit 0; orchestrator 362, cli 567). Targeted suites packages/core/src/runtime + runtime-codex + runtime-claude: 227 passed. CI on 3469d2f: Test ✅, Container Smoke ✅. No preview environment applies to an in-process spawn-plan boundary, so the smoke test is the black-box probe described above rather than a deployed surface.

The regression tests in the tree pin what matters: the core test asserts the exact assignment set with a pinned-name collision, and both runtime tests feed an overlapping agent-visible/secret name and assert absence while the pinned values survive.

One nit inline about two now-dead entries in DEFAULT_INHERITED_ENV_KEYS — I verified removing them leaves the full child environment identical across all six configurations, so it's cosmetic. Take it or leave it; it is not worth another round.

Nothing blocking remains. Approving.


Generated by Claude Code

Comment on lines +698 to +703
buildAgentChildEnvironmentAssignments({
childHome: options.childHome,
sources: [process.env, options.configEnv, options.inputEnv],
excludeNames: removedEnvironmentNames,
})
);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (non-blocking, do it in the follow-up if you prefer) — HOME and USERPROFILE in DEFAULT_INHERITED_ENV_KEYS (line 636) are now dead entries on this path.

Both are read out of process.env at line 682, survive Object.assign(env, configEnv, inputEnv) and stripTrackerSecrets, and are then unconditionally overwritten by the pinned assignments here. I checked that nothing between the two points reads either name — stripTrackerSecrets reads only SYMPHONY_TRACKER_SECRET_ENVIRONMENT_NAMES off env and repositoryDir/projectDir off arguments, and stripCredentialEnvironmentForAgentChild only deletes. So the inherit list can drop both without changing the child env.

Worth doing only because leaving them reads as though the host value still matters on the isolated path — which is exactly the misreading that produced the USERPROFILE hole this PR fixes. Zero behavioral risk either way; I confirmed the child env is byte-identical with them removed.


Generated by Claude Code

@moncher-dev
moncher-dev merged commit 19778b9 into main Sep 5, 2026
2 checks passed
@moncher-dev
moncher-dev deleted the symphony/hojinzs-github-symphony-870 branch September 5, 2026 09:50
@moncher-dev
moncher-dev restored the symphony/hojinzs-github-symphony-870 branch September 5, 2026 09:51
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.

refactor(core): build the agent child's explicit environment through one shared constructor

2 participants