feat(claude): spawn hosted sessions under the bound account's CLAUDE_CONFIG_DIR (T-484)

The load-bearing piece of the multi-account epic (T-476): a bound workspace's
hosted claude now spawns with CLAUDE_CONFIG_DIR set to that account's dir, so
it runs under the bound account end-to-end. Every hosted session (primary /
secondary / fork / teammate) inherits it — the orchestrator already routes all
spawns through agentBootstrap.

- New pure resolver claudeConfigDirForWorkspace(cwd, boundConfigDir, env):
  bound account dir > parent CLAUDE_CONFIG_DIR (respect the launcher) > null
  (Claude defaults to ~/.claude). The registry is injected as a plain lookup
  so agent_bootstrap stays Flutter-free (its tests run under `dart test`).
- agentBootstrap merges CLAUDE_CONFIG_DIR BEFORE base, so an explicit
  SpawnSpec.env override still wins (override > binding > parent > unset); the
  key is omitted entirely when the resolver returns null.
- Orchestrator carries an optional AccountRegistry; the claude extension builds
  it from ctx.settings. Null in tests → no injection (unchanged behaviour).

No way to SET a binding yet (that's the CLI T-480 / settings UI T-482), so no
changelog entry — the mechanism is in place, the surface lands next. Unit tests
cover the resolver's four states and the envDelta precedence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 17:00:06 +02:00
co-authored by Claude Opus 4.8
parent a8d321f344
commit 1fa7dee863
6 changed files with 110 additions and 6 deletions
+22 -2
View File
@@ -74,6 +74,21 @@ Map<String, String> agentEnvDelta({required String workspaceRoot, required Strin
return delta;
}
/// Resolve the `CLAUDE_CONFIG_DIR` a session in [cwd] should run under (T-484,
/// epic T-476): the bound account's dir when the workspace is bound, else the
/// parent's `CLAUDE_CONFIG_DIR` when the launcher already set one, else null
/// (Claude defaults to `~/.claude`).
///
/// Pure: the AccountRegistry is injected as a plain [boundConfigDir] lookup
/// (workspace → bound config dir, or null) so this stays Flutter-free — the
/// registry itself lives behind a ChangeNotifier the orchestrator owns.
String? claudeConfigDirForWorkspace({required String cwd, required String? Function(String cwd) boundConfigDir, required Map<String, String> env}) {
final bound = boundConfigDir(cwd);
if (bound != null && bound.isNotEmpty) return bound;
final inherited = env['CLAUDE_CONFIG_DIR'];
return (inherited != null && inherited.isNotEmpty) ? inherited : null;
}
/// Locate the directory to prepend to a hosted agent's PATH so `clide`
/// resolves (T-215). Returns null when `clide` is ALREADY on [currentPath]
/// (the installed case — T-211 drops it in `~/.local/bin`, normally already
@@ -107,7 +122,7 @@ class AgentBootstrap {
/// env (usually null → inherit clide's). The returned [AgentBootstrap.extraArgs]
/// carries the context note; team callers append their own preamble and the
/// orchestrator merges both into one `--append-system-prompt`.
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base, String? Function(String cwd)? boundConfigDir}) {
final home = Platform.environment['HOME'];
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
// shells out to — find user-installed components on a desktop launch, not just
@@ -120,7 +135,12 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
];
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
// Per-repo Claude account (T-484): a bound workspace runs claude under that
// account's CLAUDE_CONFIG_DIR. Spread BEFORE base so an explicit per-call
// SpawnSpec.env override still wins (precedence: override > binding > parent
// env > unset); omitted entirely when there's nothing to set.
final configDir = claudeConfigDirForWorkspace(cwd: workspaceRoot, boundConfigDir: boundConfigDir ?? (_) => null, env: Platform.environment);
return AgentBootstrap(envDelta: {'CLAUDE_CONFIG_DIR': ?configDir, ...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
}
bool _isExecutableFile(String path) {