Skip to content

feat(runtime): per-profile environment overrides for Claude adapter#127

Merged
lee-to merged 2 commits into
lee-to:mainfrom
dostoevskiy-spb:feat/claude-profile-environment-overrides
May 15, 2026
Merged

feat(runtime): per-profile environment overrides for Claude adapter#127
lee-to merged 2 commits into
lee-to:mainfrom
dostoevskiy-spb:feat/claude-profile-environment-overrides

Conversation

@dostoevskiy-spb

@dostoevskiy-spb dostoevskiy-spb commented May 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds an options.environment field on Claude runtime profiles that injects profile-scoped env vars into the spawned Claude subprocess (CLI transport) and the Claude SDK execution options (SDK transport).
  • Primary motivation: run multiple Claude logins on a single host by pinning CLAUDE_CONFIG_DIR per profile, so different Handoff projects can use different ~/.claude/ home directories without mutating the host process environment, restarting Handoff, or rebuilding containers.
  • Fully additive — profiles that omit options.environment behave exactly as before.

Use case

Today, a single Handoff host shares one Claude identity across every project: subagents inherit credentials, plugins, MCP servers, memory, and chat history from whatever ~/.claude/ the Handoff process started under. Teams that want to keep personal/work or per-client Claude accounts isolated have to either (a) run separate Handoff instances on different ports, or (b) rebuild the container with a different baked-in env — both heavy.

With this PR a user can keep one Handoff instance, define two Claude CLI profiles:

{
  "name": "Claude CLI (personal)",
  "runtimeId": "claude",
  "providerId": "anthropic",
  "transport": "cli",
  "options": {
    "environment": { "CLAUDE_CONFIG_DIR": "/Users/me/.claude-personal" }
  }
}
{
  "name": "Claude CLI (work)",
  "runtimeId": "claude",
  "providerId": "anthropic",
  "transport": "cli",
  "options": {
    "environment": { "CLAUDE_CONFIG_DIR": "/Users/me/.claude-work" }
  }
}

…assign each as the default task runtime for a different project, and subagents spawned for that project read credentials/plugins/history from the matching ~/.claude-*/ directory. No host env mutation, no per-project Handoff process.

Changes

packages/runtime/src/adapters/claude/options.ts

  • New exported helper resolveProfileEnvironment(input) reads profile.options.environment and returns a Record<string, string> (non-string values dropped).
  • parseExecutionOptions() merges the helper output into execution.environment between the legacy execution.hooks.environment source and the per-call execution.environment override.

packages/runtime/src/adapters/claude/cli.ts

  • Imports resolveProfileEnvironment and merges it into the executionEnv argument passed to buildCuratedEnv() so the spawned claude subprocess receives the profile env vars. Same precedence as the SDK path.

packages/runtime/src/__tests__/claudeCli.test.ts

  • 3 new tests against the existing mocked spawn:
    • profile env injection lands in spawnOptions.env
    • per-call execution.environment overrides profile values
    • non-string values are silently dropped

docs/providers.md

  • New "Multi-account example" subsection under Claude (CLI) showing two profiles pinning different CLAUDE_CONFIG_DIR values, with a note that CLAUDE_CONFIG_DIR only meaningfully affects subprocess transports.

Behavior contract

  • Values must be strings; non-string entries are silently filtered (consistent with the existing toStringRecord helper used elsewhere in the file).
  • Precedence (later wins): execution.hooks.environmentoptions.environmentexecution.environment.
  • The whitelist in cli.ts:ALLOWED_ENV_PREFIXES already lets CLAUDE_* through from process.env, but that path is host-wide — options.environment is the per-profile equivalent.
  • API schema needs no change: runtimeOptionsSchema = z.record(z.string(), z.unknown()) already accepts arbitrary keys under options.
  • optionsJson is persisted as-is in SQLite, so no migration is needed.

Backwards compatibility

100% additive. Profiles without options.environment produce identical execution.environment and identical spawned-subprocess env. All existing tests (786) pass unchanged.

AI Model

  • Claude Opus 4.7
  • Claude Sonnet 4.6
  • Claude Haiku 4.5
  • Other:
  • No AI used

Test Plan

  • Tests pass (npx turbo test --filter=@aif/runtime — 786/786, including 3 new CLI adapter tests)
  • Lint passes (npx turbo lint --filter=@aif/runtime)
  • Format check passes (npx prettier --check on touched files)
  • Full build green (npx turbo build — 7/7)
  • Manually verified end-to-end against compiled JS (node script that imports dist/adapters/claude/options.js and asserts: resolveProfileEnvironment filters non-string values; parseExecutionOptions merges profile env into execution.environment; per-call execution.environment wins over profile; options.environment wins over legacy execution.hooks.environment)
  • Documentation updated (docs/providers.md Claude (CLI) section)

Add a `options.environment` field on Claude runtime profiles that injects
profile-scoped env vars into both transports:

- SDK: through `parseExecutionOptions().environment` (merged after legacy
  `execution.hooks.environment`, before per-call `execution.environment`).
- CLI: through `buildCuratedEnv()` in `cli.ts` (same merge order so
  per-call overrides keep precedence).

Primary use case is multi-account Claude Code on a single host: pin
`CLAUDE_CONFIG_DIR` per profile so different Handoff projects can run
under different `~/.claude/` home directories without mutating the host
process environment or rebuilding the container.

Behavior:

- Values must be strings; non-string entries are silently dropped.
- Precedence: `execution.hooks.environment` < `options.environment` <
  `execution.environment`.
- Fully additive — existing profiles without `options.environment`
  behave identically.

Includes three CLI adapter tests (basic injection, per-call override,
non-string filtering) and a new "Multi-account example" section in
docs/providers.md.
@lee-to

lee-to commented May 14, 2026

Copy link
Copy Markdown
Owner

Code review

This PR adds profile-scoped environment injection for Claude runtime profiles. The implementation is small and additive, CI is green, and I did not find a blocker. I also checked it against PR #126: there is no direct file overlap in the current PR diffs. The only semantic overlap is that #126 pins resolved.options for same-status retries; if both PRs merge, options.environment will be part of that pinned selection, which is consistent with the retry-pinning contract.

Must fix

None.

Should fix

  1. Cover the SDK path explicitly - packages/runtime/src/adapters/claude/options.ts:152 - the change intentionally affects both CLI and SDK via parseExecutionOptions, but the new regression tests only exercise runClaudeCli. There is already an SDK env assertion in packages/runtime/src/__tests__/claudeAdapter.test.ts:835 for execution.environment; add a sibling assertion for options.environment and the precedence order hooks.environment < options.environment < execution.environment. That keeps the documented SDK behavior from regressing independently of the CLI tests.

Nits

  • packages/runtime/src/adapters/claude/options.ts:41 - toRecord treats arrays as records. For this new persisted profile option, consider using a plain-object guard in resolveProfileEnvironment so environment: ["x"] cannot become an env entry like 0=x. Not blocking, but it would match the docs more tightly.

Context gates

  • Architecture: pass - runtime adapter logic stays inside @aif/runtime; no DB boundary changes.
  • Rules: pass - no string-based error classification changes, no expensive CSS, no UI/Pencil impact.
  • Roadmap: pass - runtime-profile behavior fits the runtime adapter/provider milestone area.
  • CHECKLIST compliance (touched packages: packages/runtime): warn - docs and CLI tests are present; SDK coverage should be added as above.
  • Docs sync: pass - docs/providers.md documents the new Claude CLI profile option and multi-account use case.
  • PR size (121 changed lines / 4 files / 1 concern): pass - comfortably below the decomposition threshold.
  • Risk gating (feature flag): pass - behavior is config-driven and dormant unless a profile explicitly sets options.environment; profiles without it keep the existing path.
  • PR Pin runtime selection per task stage #126 overlap: pass - no merge conflict in the touched source/test files. The semantic interaction is acceptable: with Pin runtime selection per task stage #126 enabled, a task retry will keep using the originally pinned options.environment until the stage changes, just like it keeps the pinned model/options.

Verdict: COMMENT - no blockers; please add the SDK regression coverage before relying on the documented SDK behavior.

Address PR review feedback:

- Add SDK regression test in claudeAdapter.test.ts asserting that
  profile.options.environment lands in `query()`'s `options.env` and
  that the documented precedence holds: hooks < options < execution.
  Mirrors the existing CLI coverage so the SDK contract is protected
  independently.

- Reject arrays in `resolveProfileEnvironment` with an explicit
  plain-object guard. Without this, `environment: ["x"]` would have
  leaked through as `{ "0": "x" }` because `typeof [] === "object"`.
  Adds a paired SDK test asserting array entries are dropped.
@dostoevskiy-spb

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @lee-to. Both points addressed in 6674787 (force-pushed onto the same branch).

Should fix #1 — SDK regression coverage (claudeAdapter.test.ts):
Added two SDK-side tests that exercise query().options.env directly (mirroring the existing execution.environment assertion at line 835):

  1. forwards profile.options.environment to Claude SDK env with documented precedence — sets hooks.environment, options.environment, and execution.environment together with an overlapping PRECEDENCE_KEY and HOOK_OVER key, then asserts the resolved env satisfies hooks < options < execution. Also asserts that a non-string entry in options.environment is silently dropped.
  2. ignores arrays passed as profile.options.environment (plain-object guard) — passes environment: ["x", "y"] through the SDK path and asserts env["0"] / env["1"] are not present.

Nit — array guard in resolveProfileEnvironment:
Replaced the bare toRecord(...) lookup with an explicit plain-object check (typeof === "object" && !Array.isArray). Documented the rationale inline so the next reader sees why we don't reuse toRecord here. The new SDK array-guard test is the matching regression cover.

Stats: 788/788 tests passing (+2 new SDK tests on top of the original 786), lint and prettier green, full turbo build green.

PR diff is now 197 lines / 4 files / still 1 concern.

@lee-to

lee-to commented May 15, 2026

Copy link
Copy Markdown
Owner

Code review follow-up

Response pass on the previous review comment: both items are addressed in 6674787.

Addressed

  • The SDK path now has explicit regression coverage in packages/runtime/src/__tests__/claudeAdapter.test.ts: options.environment is forwarded to query().options.env, and the documented precedence hooks.environment < options.environment < execution.environment is asserted.
  • resolveProfileEnvironment() now rejects arrays before calling toStringRecord, so environment: ["x"] cannot leak numeric env keys like 0 / 1. The new SDK array-guard test covers that path.

New findings

None.

Verification

  • Local: npm test -- --filter @aif/runtime passed: 59 files, 788 tests.
  • GitHub checks: Build, ESLint, MCP Unit, npm audit are green; the GitHub Tests check is still in progress at the time of this comment.

Context gates

  • Architecture: pass - change remains isolated to the Claude runtime adapter and docs.
  • Rules: pass - no DB boundary, UI, CSS, or string-error-classification impact.
  • CHECKLIST compliance (packages/runtime): pass - touched adapter has tests; docs are updated; no shared adapter contract/capability change requiring cross-adapter implementation.
  • Docs sync: pass - docs/providers.md covers the new profile option and multi-account example.
  • PR size: pass - 197 changed lines / 5 files / 1 concern.
  • Risk gating: pass - behavior remains opt-in through profile configuration; profiles without options.environment keep the existing behavior.

Verdict: LGTM on code. I would merge after the remaining GitHub Tests check turns green.

@lee-to lee-to merged commit e1ffa70 into lee-to:main May 15, 2026
6 checks passed
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.

2 participants