Repository navigation
Description
Relocated from dezren39/darwin-nix#138 on 2026-10-28 (placement audit): mcpx's single source lives in developing-today/code (pkgs/mcpx); re-home tracked at #309. Original body below; issue references inside it are historical (dezren39/nix numbering).
Parent: #97
Summary
The e2e harness starts every test's environment from the developer's own os.Environ(), then overrides four variables. Anything else set in the caller's shell reaches the test binary. That includes MCPX_DAEMON_ENDPOINT, which the opencode mcpx plugin injects into every shell it opens. So an agent running go test ./internal/e2e inside opencode can point the suite at the user's real daemon. Separately, several code paths read $HOME/XDG_* directly, and the harness does not isolate those.
The fix: build the child environment from an allowlist, not by inheriting and overriding, and add a guard that catches new MCPX_* leaks. (The short autostart idle-exit this issue also asked for landed in #182, which fixed #52; the inheritance itself is untouched and is what remains.)
This is the precondition for "very few shouldn't work if user has own real daemon running" (#134): isolation has to be strong enough that "very few" is true.
Original notes
- testing
- a general goal is to ensure all features have one or more integration tests
- for both binary && app entrypoint (consider binary a superset)
- all tests should pass for nix flake & container
- all local also but may need impractical setup
- very few shouldn't work if user has own real daemon running
Current state (written on d0b7e01; re-checked at 05c78b2)
newEnv (internal/e2e/e2e_test.go:35-87 at 05c78b2) sets envVars: append(os.Environ(), MCPX_REGISTRY_URL=..., MCPX_STATE_DIR=..., MCPX_CACHE_DIR=..., MCPX_CONFIG=..., MCPX_AUTOSTART_IDLE_EXIT=60s) (:52-69). os/exec keeps the last value for a duplicated key, so these five win. Everything else is still inherited -- #182 added the fifth variable and changed the cleanup to stop --all with a logged error (:71-85), but did not touch the os.Environ() base. A developer's or agent's MCPX_DAEMON_ENDPOINT, MCPX_SOCKET or any other MCPX_* still reaches every test.
The table below comes from reading the code; the suite was not run for this issue. The first implementation step confirms these reads with a test.
| Inherited from the caller | Effect in a test | Where read |
|---|---|---|
MCPX_DAEMON_ENDPOINT |
every client command targets that daemon and refuses to start a local one | daemon.endpoint, internal/settings/registry.go:350. The plugin injects it into every opencode shell: plugin/opencode/mcpx-session.ts:696-701 |
MCPX_SOCKET |
replaces the socket path outright, for both bind and connect (its precedence over the info file is #88's to decide, #181 collision 8) | internal/daemon/paths.go:103-105 |
any MCPX_<SETTING> (derived from each setting's path, internal/settings/schema.go:220-224), e.g. MCPX_SCRIPT_RUNTIME, MCPX_DAEMON_INLINE |
the test runs under the developer's settings, not the documented defaults | settings.ApplyEnv, bind.go:163 |
MCPX_SESSION_ID, MCPX_PARENT_SESSION_ID, MCPX_HARNESS_* |
session scoping and lease keys come from the outer agent session | several internal/* |
HOME, XDG_CONFIG_HOME |
recipe dirs include the user's (internal/cli/scripts.go:71-72, internal/daemon/consumer.go:141-142, internal/recipes/dirs.go:28), so the user's real recipes can change TestRecipesAreMatchedDeterministically-style results. A --persist user or servers add --scope user test would write the user's real config (internal/config/write.go:33-40) |
as listed |
XDG_DATA_HOME, OPENCODE_DB |
the opencode v1 DB lookup finds the user's real opencode.db |
internal/opencode/opencode.go:35-50 |
XDG_RUNTIME_DIR |
the fallback socket dir is shared with the user's daemons. The name is hashed per state dir (paths.go:115-116), so there is no collision; leaked test daemons used to accumulate there for hours (#52), and since #182 they idle out after 60 s |
paths.go:141-148 |
XDG_STATE_HOME/XDG_CACHE_HOME |
masked, because MCPX_STATE_DIR/MCPX_CACHE_DIR win (paths.go:36-45) |
-- |
Related, from #52 (closed, fixed in #182): newEnv now sets autostart.idleExit (internal/settings/wire.go:135) to 60 s through MCPX_AUTOSTART_IDLE_EXIT (e2e_test.go:68, testIdleExit at :92), and cleanup runs mcpx stop --all through the daemon-<key>.json info files, logging rather than discarding its error (:71-85). That bounds a leak; it does not stop a test from reaching the caller's daemon, which is this issue.
The in-process runner (#133) inherits all of this through os.Getenv in the same process. The fix below has to apply to both runners.
Approaches
A -- denylist: unset known-dangerous variables (least recommended)
Strip MCPX_DAEMON_ENDPOINT and MCPX_SOCKET after os.Environ(). It is the smallest diff, and it fixes today's concrete hole. It fails open: every future MCPX_* setting, and every new XDG_*/HOME read, leaks again until someone notices. That is the pattern the history shows (#65 registry, #52 leaks). Not recommended as the end state.
B -- strip every MCPX_* and override HOME/XDG_*
Drop every inherited variable that starts with MCPX_, and set HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME and XDG_RUNTIME_DIR under e.dir (short names, for the 100-byte socket cap, paths.go:75). This is robust against new settings. It still inherits unrelated variables: DENO_DIR, BUN_INSTALL, NODE_OPTIONS, OPENCODE_*, proxies. Some of those are wanted (runtime caches), some are not (NODE_OPTIONS=--require ...).
C -- allowlist plus a guard (recommended)
- Child environment = an explicit allowlist from the parent:
PATH,TMPDIR,LANG/LC_*,TERM,SYSTEMROOT(for Windows later, Update dependency happy-dom to v21 [SECURITY] #136), and runtime cache dirs (DENO_DIR,BUN_INSTALL_CACHE_DIR, npm cache) so runs stay fast. Add B'sHOME/XDG_*undere.dir, then the five variablesnewEnvalready sets (including Phase 5: tag event bodies must travel with the event #182'sMCPX_AUTOSTART_IDLE_EXIT=60s). - Opt-in pass-through for the rare test that needs the outer world:
e.inherit("NAME"). The tests labelled "needs the user's default paths" (Update Rust crate rand to v0.9.3 [SECURITY] #134,RequireNoUserDaemon) are the ones that use it. - Guard test,
TestTheHarnessInheritsNoMCPXVariable. It setsMCPX_DAEMON_ENDPOINT=http://127.0.0.1:1,MCPX_SOCKET=/nonexistent,MCPX_SCRIPT_RUNTIME=bogusandHOME=/nonexistentin its own process (t.Setenv), builds an env, runsmcpx doctor -v,mcpx settings listandmcpx recipes, and asserts the output is local, default and home-free. This is the regression test that makes the fix stay fixed (Bump unsafe-libyaml from 0.2.8 to 0.2.10 in /sources/pavex/examples/realworld #99: a test per bug, a guard per bug class). Make its variable list derived, not typed (from Phase 2: authority judged at causal past or canonical prefix #162): enumerate the settings registry and require everyEnvName()andEnvAliasesentry (internal/settings/schema.go:220-224,284) to be either set by the harness or on an explicit safe-to-inherit list, so a new setting is covered the day it is added. UndeclaredMCPX_*reads are not in the registry; take them from Phase 2: membership, delegation, groups, and server config #172's allowlist once it lands rather than keeping a second list here. - Backstop:
TestMainininternal/e2efails fast, with a clear message, if the parent hasMCPX_DAEMON_ENDPOINTorMCPX_SOCKETset and the allowlist is somehow bypassed. That turns a silent "tests passed against the user's daemon" into a loud failure. - Machine-level check (from Phase 2: authority judged at causal past or canonical prefix #162):
TestMainrecordsmcpx daemonsfor the caller's real environment at start and end and fails on any difference. It is the one assertion of "the suite does not disturb your machine" that cannot rot, and it also catches a regression of Phase 5: tag event bodies must travel with the event #182's leak fix.
Recommended because it fails closed. A new setting, or a new read of $HOME, is isolated by default, and the guard catches the regression if someone widens the allowlist.
Implementation steps
- Write the guard test first, expecting it to fail on main. That confirms the table above.
- Replace
append(os.Environ(), ...)with the allowlist builder ininternal/testsupport(shared with the plugin harness in Update dependency @chenglou/pretext to ^0.0.9 [SECURITY] #132 and the in-process runner in Update Rust crate bytes to v1.11.1 [SECURITY] #133). AddDone in Phase 5: tag event bodies must travel with the event #182 (60 s;MCPX_AUTOSTART_IDLE_EXIT=30s, and record thestoperror rather than discarding it (chore(deps): bump build/buck/preludes/buck2-prelude fromd956689toe7457f6#52 fixes 1 and 2).stop --all, error logged).- Add the
TestMainbackstop.
Open questions
- Should a caller's
DENO_DIR/bun cache be shared with tests (fast) or isolated (hermetic)? Proposal: shared on laptops, isolated in the container entry. - Does anything need
SSH_AUTH_SOCK/git credentials? Therepo/worktreescopes use git (package.nixcomment), but only locally. (The GIT architect is replacing thegitsubprocess with native resolution ininternal/config/scope.go; if that lands,gitmay drop out of the allowlist question entirely.) - Should the plugin's
shell.envinjection skipgo testcommands? No: the harness must be robust against any caller, and special-casing a command name in the plugin is the wrong layer.
Ease · Impact · Related
Ease S-M · Impact high (a correctness hole in every e2e run started from opencode with the plugin, and the foundation for the "user's daemon" subset)
Depends on / related: #52 (closed, fixed in #182 -- step 3 is done), #139 (this is the remaining precondition for t.Parallel in e2e), #88 (owns MCPX_SOCKET precedence, #181 collision 8), #172 (the undeclared MCPX_* allowlist the guard should reuse), #134 (the labelled subset; the inline/spawn/remote run modes), #133 (the in-process runner needs the same env), #132 (the plugin harness), #99 (guard-per-bug-class), #65 (the registry precedent for "never the real thing").
Security/auth deferred; see tracker.
Acceptance criteria
- The e2e harness builds the child environment from an allowlist (no inherited MCPX_* or caller daemon endpoints), a guard test catches new MCPX_* leaks, and CI is green.
- The work described in the Summary/Scope above is delivered via a branch + PR (no direct push to main); verification runs in GitHub Actions CI, not on the sandbox.
- Evidence (CI links, timings, or probe output as applicable) is recorded on this issue before it is closed.
Activity
developing-today commentedon Oct 28, 2026
CODE-917
dezren39 commentedon Oct 28, 2026
History carried over from dezren39/darwin-nix#138 (verbatim, oldest first):
@dezren39 · 2026-09-30T06:51:51Z
Overlaps #162, written concurrently by a second worker on the same slice. Both are kept until reconciled; see the tracker (#97) for the differences and which one to keep.
@dezren39 · 2026-09-30T07:21:13Z
Integration pass. Placed in the DAG rooted at #181.
d956689toe7457f6#52 blocks sharding@dezren39 · 2026-09-30T12:03:31Z
Issues pass (2026-09-30, at 05c78b2).
d956689toe7457f6#52) addedMCPX_AUTOSTART_IDLE_EXIT=60stonewEnv(internal/e2e/e2e_test.go:68,92) and made cleanupstop --allwith a logged error (:71-85) -- implementation step 3 is done and struck. ButnewEnvstill starts fromappend(os.Environ(), ...)(:52): a caller'sMCPX_DAEMON_ENDPOINT(injected by the opencode plugin,mcpx-session.ts:696-701),MCPX_SOCKETor any otherMCPX_*still reaches every test. That is the remaining gap and the whole of this issue now.registry.go:350,paths.go:103-105,141-148,schema.go:220-224,scripts.go:71-72).TestMainmcpx daemonsbefore/after check.d956689toe7457f6#52 closed; added Update dependency hyperscript.org to v0.9.93 #139 (this is the last precondition fort.Parallel), chore(deps): bump build/buck/preludes/buck2-prelude fromd956689tofd4ce63#88 (collision 8), Phase 2: membership, delegation, groups, and server config #172. Noted that GIT's native git resolution may remove the git/SSH question.dezren39 commentedon Oct 28, 2026
Intake review
🤖 Agent note · state: filed
81 remaining items
developing-today commentedon Nov 5, 2026
🤖 Agent note · handed off
opencodebyfleet queue lane; handed off to the opencode claimant, which launches the bound session. This lane does not work the task directly.developing-today commentedon Nov 5, 2026
🤖 Agent note · handed off