Skip to content

fix: harden install/init against destroying proxy/env config (#313) - #314

Open
dean0x wants to merge 12 commits into
mainfrom
fix/313-harden-proxy-env-config
Open

fix: harden install/init against destroying proxy/env config (#313)#314
dean0x wants to merge 12 commits into
mainfrom
fix/313-harden-proxy-env-config

Conversation

@dean0x

@dean0x dean0x commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • FIX 1: installSettings now merges devflow hook entries into an existing settings.json instead of replacing it wholesale. User-owned keys (env, permissions, model, apiKeyHelper, custom hooks) are never touched. Parse failure warns and skips — file left byte-identical. Idempotent by exact command string (D-SETTINGS-1). The interactive confirm prompt was removed entirely — the merge is additive/non-destructive, init's own settings pass rewrote hooks regardless of the answer anyway, and the prompt fired mid-spinner; the non-TTY "Settings exist without hooks" warning is gone too; foreign/unexpected hook shapes are left untouched (PF-023).
  • FIX 2: Hook removal in applyProxyTeardownToSettings (the new exported helper that both uninstall and disable now route through) is unconditional, while env-var stripping remains evidence-gated on ~/.devflow/proxy.json existence via proxyJsonExists(). applyDisableToSettings's both-operations invariant (removeProxyHooks + _stripProxyEnvFromObject) is untouched — only the uninstall/disable wiring changed. Previously stripProxyEnv used readProxyState()'s tolerant Ok(defaultState) on ENOENT, which caused stripping to run even when devflow had never managed the env vars (D-STRIP-1).
  • Consequence of the inherited subswitch 0.2.0→0.3.0 bump (d087565): buildRoutingConfigJson now strips sub-keys the 0.3.0 runtime hard-rejects on startup — anthropic.streamIdleTimeoutMs, limits.connectTimeoutMs, and limits.maxConcurrentRequests — collected in the new ROUTING_CONFIG_REJECTED_SUBKEYS constant. The allowed top-key set is unchanged (port, logLevel, anthropic, providers, limits). The packaging test's SUBSWITCH_VERSION pin was updated to 0.3.0.
  • FIX 3: In runProxyPreflight, the foreign-ANTHROPIC_BASE_URL refusal (check ④) now evaluates before the adopted early-return (check ③). Previously, a healthy relay on the port triggered an early-return that silently skipped the foreign-gateway guard entirely (D-EFR-5).
  • FIX 4: The ensure-proxy shell hook now attempts dependency-light re-resolution of binPath before emitting the "relay binary not found" warning: walk from command -v devflownode_modules/subswitch/ → read bin.subswitch from package.json via node; fall back to command -v subswitch. Loop bounded at 6 iterations. Always exits 0.

Changes

  • src/targets/claude-code/post-install.ts — new mergeDevflowSettingsTemplate() exported helper; installSettings() rewritten to use merge strategy; interactive confirm prompt removed
  • src/core/proxy-state.ts — new proxyJsonExists() exported helper
  • src/cli/commands/proxy.ts — new applyProxyTeardownToSettings() exported helper (unconditional hook removal + evidence-gated env strip); D-EFR-5: foreign-env check before adopted early-return in runProxyPreflight; buildRoutingConfigJson strips ROUTING_CONFIG_REJECTED_SUBKEYS for 0.3.0 runtime
  • src/cli/commands/init.ts — import + D-STRIP-1 gate for stripProxyEnv
  • src/cli/commands/uninstall.ts — import + route through applyProxyTeardownToSettings (D-STRIP-1 gate); stale comment corrected
  • src/assets/scripts/hooks/ensure-proxy — FIX 4 re-resolution block with bounded walk
  • tests/post-install-merge.test.ts — 22 unit tests for mergeDevflowSettingsTemplate + 2 tmpdir integration tests pinning installSettings parse-failure and wiring (24 total)
  • tests/proxy-state.test.ts — +8 tests for proxyJsonExists
  • tests/proxy.test.ts — +11 tests (D-EFR-5 ordering REG-EFR-1/2/3 + ROUTING_CONFIG_REJECTED_SUBKEYS stripping + applyProxyTeardownToSettings)
  • tests/shell-hooks.test.ts — +3 tests for FIX 4 re-resolution
  • tests/packaging.test.ts — bump SUBSWITCH_VERSION constant to 0.3.0

Behavior Changes

  • init no longer prompts when settings.json exists: the interactive confirm asking whether to add Devflow hooks was removed. The merge is additive and non-destructive; declining could not protect any content the subsequent hooks-settings pass would not rewrite anyway. Non-TTY silent-skip path ("Settings exist without hooks" warning) is also removed.

Breaking Changes

None.

Reviewer Focus Areas

  • applyProxyTeardownToSettings: proxy.ts — unconditional removeProxyHooks call, then port-gated _stripProxyEnvFromObject; confirm the applyDisableToSettings two-operation invariant remains intact
  • D-STRIP-1 gate: init.ts line ~1645 and uninstall.ts managedProxyPorts map — both use proxyJsonExists() before touching env vars
  • ROUTING_CONFIG_REJECTED_SUBKEYS: proxy.ts buildRoutingConfigJson — stripped sub-keys must not include any key the 0.3.0 relay still reads
  • D-EFR-5: proxy.ts runProxyPreflightreadSettingsJson() is now called inside the isOurRelayBody() branch before return Ok({ adopted: true })
  • Merge idempotency: mergeDevflowSettingsTemplate uses exact command string matching; a second merge on an already-configured file produces changed: false without mutating anything
  • Hook always exits 0: ensure-proxy re-resolution block — all branches end with exit 0

Related Issues

Closes #313

dean0x and others added 12 commits September 1, 2026 00:44
0.2.0's connect timer capped time-to-first-byte and synthesized 504s during upstream slow periods, killing Claude Code sessions machine-wide (subswitch#42). 0.3.0 (ADR-010 relay hardening) removes the TTFB cap and other proxy-side policy. Relay on this machine already restarted onto 0.3.0.
…#313)

installSettings now uses a merge strategy (D-SETTINGS-1): hook entries are added
idempotently to an existing settings.json by exact command string; statusLine and
attribution are set only when absent; user-owned keys (env, permissions, model,
apiKeyHelper) are never touched. Parse failure emits a warning and leaves the file
byte-identical. Adds exported mergeDevflowSettingsTemplate helper for testing.
Adds proxyJsonExists() to proxy-state.ts — a discriminator helper that
distinguishes "file absent" from readProxyState()'s tolerant Ok(defaultState)
on ENOENT, which was indistinguishable from a file present with DEFAULT_PROXY_PORT.

Gates ANTHROPIC_BASE_URL and CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT
stripping in init.ts and uninstall.ts on proxyJsonExists() (D-STRIP-1): strip only
when proxy.json exists, proving devflow previously wrote those env vars. Prevents
destroying a user's independently-managed ANTHROPIC_BASE_URL during devflow init.
)

In runProxyPreflight, the foreign ANTHROPIC_BASE_URL refusal (check ④) now runs
BEFORE the adopted early-return (check ③). Previously, a healthy relay on the
target port triggered an early-return that silently skipped the foreign-gateway
guard entirely — enabling devflow's proxy while a user's custom gateway URL was set.

D-EFR-5: readSettingsJson is called in the healthy-relay branch before returning
Ok(adopted:true). swallowSettingsReadError semantics preserved: when that flag is
true, readSettingsJson returns '{}' on I/O failure instead of throwing.
When the persisted binPath is missing or stale (e.g. npx cache GC cleared the
subswitch package dir), the ensure-proxy hook now attempts dependency-light
re-resolution before emitting the "relay binary not found" warning:

  Strategy a: walk up from `command -v devflow` → find node_modules/subswitch →
              read bin.subswitch from package.json via node (already present).
  Strategy b: `command -v subswitch` — globally installed CLI.

Both strategies are best-effort; the hook falls through to the existing warning
and always exits 0 when re-resolution fails (avoids PF-001, PF-009). The healed
path is used for the current session only; `devflow proxy --enable` persists it.
Loop is bounded at 6 iterations (avoids PF-017).
packaging.test.ts hardcoded SUBSWITCH_VERSION='0.2.0'; the dependency was bumped
to 0.3.0 in d087565. Align the guard constant with the current lockfile.
…313)

- post-install.ts: hoist existingHooksObj and existing.hooks assignment
  out of the mergeDevflowSettingsTemplate loop — was re-evaluated on
  every iteration but always yielded the same reference after the first
- ensure-proxy: remove "(moved here from below)" tombstone from node
  re-resolution comment; describe current state, not the transition
…313)

The D-STRIP-1 gate was applied to the whole teardown instead of to the env
strip alone, and it was missing entirely from `devflow proxy --disable`.

- uninstall: with no proxy.json, `removeProxyHooks` stopped running too, so an
  uninstall could leave SessionStart/UserPromptSubmit entries pointing at a
  deleted hook script — and a re-run of an interrupted uninstall (proxy.json
  already removed by the artifact loop) could never clean them up.
- `runDisable`: `applyDisableToSettings` still ran unconditionally with the
  DEFAULT_PROXY_PORT fallback, so `devflow proxy --disable` on a machine where
  Devflow never managed the proxy deleted a user's own ANTHROPIC_BASE_URL on
  4141 plus CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT — the exact
  hazard #313 set out to fix.

Both paths now route through `applyProxyTeardownToSettings(settings, port?)`:
hooks always come out, the env strip runs only against a port the caller read
from an existing proxy.json. `applyDisableToSettings` keeps its
both-operations invariant and is still what runs on the managed path.

Also folds the duplicated D-EFR-5 foreign-env block into a shared
`checkSettingsEnv` helper, so the adopted-relay path and the free-port path
apply the same check ④ — the adopted path was skipping the malformed-JSON
refusal and the ANTHROPIC_API_KEY warning.
The subswitch 0.2.0 -> 0.3.0 bump on this branch retires two keys that were
valid under 0.2.0 and are now registered legacy keys — a hard startup error,
not a warning: `anthropic.streamIdleTimeoutMs` and `limits.maxConcurrentRequests`.

`buildRoutingConfigJson` preserves a user's existing `anthropic` and `limits`
blocks wholesale, so anyone who hand-tuned ~/.devflow/proxy-routing.json under
0.2.0 would have had those keys written straight back into the config the
0.3.0 relay reads — the relay refuses to boot, and the ensure-proxy hook just
reports "relay failed to start" every session.

Both keys join `limits.connectTimeoutMs` in a named
ROUTING_CONFIG_REJECTED_SUBKEYS table so the two strip sites cannot drift.
The list is scoped to keys a config that worked against the version Devflow
actually shipped could contain — keys retired before 0.2.0 are unreachable.

Also refreshes the @D-EFR-4 contract comment: in 0.3.0 anthropic.connectTimeoutMs
is a genuine DNS+TCP connect budget (armed on the socket, disarmed on 'connect'),
not the socket-inactivity timeout that motivated the 120s override under 0.2.0.

The `preserves other anthropic fields` fixture used streamIdleTimeoutMs — a
shape the pinned runtime now rejects — so it pinned behaviour that would break
the relay at startup (avoids PF-043). Retargeted at maxUpstreamSockets.
…apes (#313)

The confirm in `installSettings` was harmless while the old `hasHooks` check
made it nearly unreachable. With the template merge it fires on every re-init
for anyone with a feature turned off (HUD off -> no statusLine, memory off ->
no memory hooks), and it fires from inside init's running spinner — the same
place init deliberately calls `s.stop()` before prompting for sudo. Declining
protected nothing either: init's own settings pass rewrites the whole hook set
immediately afterwards. A prompt whose answer changes nothing is worse than no
prompt, so the merge (additive only) now runs silently in both TTY and non-TTY.
This also removes a `process.exit(0)` that would have fired under that spinner.

`mergeDevflowSettingsTemplate` also walked a hand-editable file's shape
unguarded: `hooks: []` silently swallowed the devflow entries (JSON.stringify
drops keys attached to an array), and a matcher without a `hooks` array threw,
which the outer catch turned into "could not configure settings" — hooks never
installed. Every branch now validates shape at the sink and leaves foreign
shapes untouched (applies PF-023). An empty `hooks` key is no longer
introduced into a settings.json that had none.
…vior (#313)

Add two tmpdir integration tests to tests/post-install-merge.test.ts:

(a) parse-failure bail: write invalid JSON settings.json (trailing comma),
    call installSettings, assert file bytes identical and no .tmp.* residue.
(b) merge wiring: write valid settings.json with env+permissions but no hooks,
    call installSettings, assert hooks are added and env/permissions survive
    byte-for-byte.

Also correct a stale comment in src/cli/commands/uninstall.ts:1058 that still
named applyDisableToSettings for a call that is now applyProxyTeardownToSettings.

Co-Authored-By: Claude <noreply@anthropic.com>
Reflects proxy lifecycle changes from issue #313 / PR #314: proxyJsonExists()
evidence discriminator, applyProxyTeardownToSettings unified teardown, D-STRIP-1
init.ts env-strip gating, checkSettingsEnv D-EFR-5 shared helper, ensure-proxy
binPath re-resolution, post-install mergeDevflowSettingsTemplate merge strategy,
and subswitch 0.3.0 routing config contract.
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.

fix(init): harden proxy/env config against overwrites and corruption

1 participant