feat(env): per-workspace PATH preset injected at spawn (T-511)
Implements D-106. The T-439 login-shell probe is a global heuristic with a known hole — login-but-non-interactive shells skip ~/.bashrc, so interactive-only additions (brew shellenv) never reach the agent's Bash tool or terminal panes on a desktop launch. The preset is the explicit per-repo layer on top: user-scope storage keyed by repo identity (a linked worktree resolves through its gitdir pointer to the main repo, so worktrees share the preset), prepended at spawn via the PaneRegistry pathForSpawn hook and agentEnvDelta prependDirs — which now exports PATH even when clide is already resolvable, closing the gap where the hosted session inherited the sparse GUI PATH untouched. CLI half: `clide env path list|set|add|remove|clear|capture` over an injected Flutter-free store port; capture diffs the login-shell PATH against the process PATH to suggest the dirs a desktop launch dropped. Binary resolution (toolchain, supporter pins, bundled pql/git) stays preset-blind per the D-92/T-98 fence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/env/path_preset.dart' show applyPathPreset;
|
||||
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
|
||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
||||
|
||||
@@ -74,12 +75,22 @@ String clideSkillsNote() =>
|
||||
///
|
||||
/// * `CLIDE_SOCK` — the per-workspace socket ([workspaceSocketPath], D-70).
|
||||
/// * `CLIDE_WORKSPACE` — the workspace root.
|
||||
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
|
||||
/// is not already resolvable), otherwise left untouched.
|
||||
Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
|
||||
/// * `PATH` — the workspace's preset dirs (D-106), then [clideCliDir] when it
|
||||
/// is non-null (i.e. `clide` is not already resolvable), then
|
||||
/// [currentPath]. Exported whenever there is anything to prepend —
|
||||
/// the preset must reach the session's Bash tool even when `clide` is
|
||||
/// already on PATH — and left untouched otherwise.
|
||||
Map<String, String> agentEnvDelta({
|
||||
required String workspaceRoot,
|
||||
required String socketPath,
|
||||
required String? currentPath,
|
||||
required String? clideCliDir,
|
||||
List<String> prependDirs = const [],
|
||||
}) {
|
||||
final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': workspaceRoot};
|
||||
if (clideCliDir != null && clideCliDir.isNotEmpty) {
|
||||
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath';
|
||||
final prepend = <String>[...prependDirs, if (clideCliDir != null && clideCliDir.isNotEmpty) clideCliDir];
|
||||
if (prepend.isNotEmpty) {
|
||||
delta['PATH'] = applyPathPreset(currentPath ?? '', prepend);
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
@@ -132,11 +143,18 @@ 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, String? Function(String cwd)? boundConfigDir}) {
|
||||
AgentBootstrap agentBootstrap(
|
||||
String workspaceRoot, {
|
||||
Map<String, String>? base,
|
||||
String? Function(String cwd)? boundConfigDir,
|
||||
List<String> Function(String cwd)? pathPreset,
|
||||
}) {
|
||||
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
|
||||
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
|
||||
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir and the
|
||||
// workspace's PATH preset (D-106), injected as a plain lookup like
|
||||
// [boundConfigDir] so this stays Flutter-free.
|
||||
final currentPath = resolvedToolPath();
|
||||
final candidates = <String>[
|
||||
if (home != null && home.isNotEmpty) '$home/.local/bin',
|
||||
@@ -144,7 +162,13 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base,
|
||||
File(Platform.resolvedExecutable).parent.path,
|
||||
];
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
|
||||
final delta = agentEnvDelta(
|
||||
workspaceRoot: workspaceRoot,
|
||||
socketPath: workspaceSocketPath(workspaceRoot),
|
||||
currentPath: currentPath,
|
||||
clideCliDir: cliDir,
|
||||
prependDirs: pathPreset?.call(workspaceRoot) ?? const [],
|
||||
);
|
||||
// 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
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/src/env/path_preset.dart' show presetDirsFrom;
|
||||
import 'package:clide/builtin/claude/src/account_login_dialog.dart';
|
||||
import 'package:clide/builtin/claude/src/account_roadblock_dialog.dart';
|
||||
import 'package:clide/builtin/claude/src/account_settings_control.dart';
|
||||
@@ -511,8 +512,13 @@ class ClaudeExtension extends ClideExtension {
|
||||
|
||||
// The clide-managed session set (T-169). Panes spawn/bind through it so a
|
||||
// session outlives its pane and is shared across surfaces. The account
|
||||
// registry (T-476) lets a bound workspace spawn under its own Claude account.
|
||||
_orchestrator = ClaudeSessionOrchestrator(accountRegistry: AccountRegistry(ctx.settings));
|
||||
// registry (T-476) lets a bound workspace spawn under its own Claude
|
||||
// account; the PATH-preset lookup (D-106) prepends the workspace's preset
|
||||
// dirs to every hosted session's PATH — read live at each spawn.
|
||||
_orchestrator = ClaudeSessionOrchestrator(
|
||||
accountRegistry: AccountRegistry(ctx.settings),
|
||||
pathPresetFor: (cwd) => presetDirsFrom((k) => ctx.settings.get<Object>(k), cwd),
|
||||
);
|
||||
activeSessionOrchestrator = _orchestrator;
|
||||
|
||||
// An in-place workspace switch (Open Project/Folder) must not leave the
|
||||
|
||||
@@ -153,7 +153,7 @@ class ManagedSession {
|
||||
ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
||||
|
||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry}) : _factory = processFactory ?? _spawnClaude {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry, this.pathPresetFor}) : _factory = processFactory ?? _spawnClaude {
|
||||
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,11 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
/// Null in tests / when no registry is wired → no injection.
|
||||
final AccountRegistry? accountRegistry;
|
||||
|
||||
/// Per-workspace PATH preset lookup (D-106): dirs prepended to a hosted
|
||||
/// session's PATH at spawn, wired by the extension over the settings store.
|
||||
/// Null in tests / when not wired → no injection.
|
||||
final List<String> Function(String cwd)? pathPresetFor;
|
||||
|
||||
final ProcessFactory _factory;
|
||||
final _sessions = <String, ManagedSession>{};
|
||||
|
||||
@@ -255,7 +260,12 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
mcpServers.add(TeamMcpServer(broker: broker, memberId: spec.id));
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env, boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir);
|
||||
final bootstrap = agentBootstrap(
|
||||
spec.cwd,
|
||||
base: spec.env,
|
||||
boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir,
|
||||
pathPreset: pathPresetFor,
|
||||
);
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
|
||||
Reference in New Issue
Block a user