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:
2026-07-13 17:40:48 +02:00
co-authored by Claude Fable 5
parent 1ac60d1fe7
commit e1fea83868
13 changed files with 852 additions and 17 deletions
@@ -67,6 +67,33 @@ void main() {
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: null, clideCliDir: '/opt/clide/bin');
expect(d['PATH'], '/opt/clide/bin');
});
test('exports PATH for a preset even when clide is already resolvable (D-106)', () {
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin:/bin', clideCliDir: null, prependDirs: ['/opt/go/bin']);
expect(d['PATH'], '/opt/go/bin:/usr/bin:/bin');
});
test('preset dirs come first, then the cli dir, then the current PATH (D-106)', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: '/usr/bin',
clideCliDir: '/home/dev/.local/bin',
prependDirs: ['/opt/go/bin', '/brew/bin'],
);
expect(d['PATH'], '/opt/go/bin:/brew/bin:/home/dev/.local/bin:/usr/bin');
});
test('a preset dir already on the current PATH is not duplicated (D-106)', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: '/opt/go/bin:/usr/bin',
clideCliDir: null,
prependDirs: ['/opt/go/bin'],
);
expect(d['PATH'], '/opt/go/bin:/usr/bin');
});
});
group('resolveClideCliDir (T-215)', () {
@@ -143,5 +170,16 @@ void main() {
final b = agentBootstrap('/ws', base: {'CLAUDE_CONFIG_DIR': '/override'}, boundConfigDir: (_) => '/bound');
expect(b.envDelta['CLAUDE_CONFIG_DIR'], '/override');
});
test('the workspace PATH preset lands at the head of the delta PATH (D-106)', () {
final b = agentBootstrap('/ws', pathPreset: (cwd) => cwd == '/ws' ? ['/opt/go/bin'] : const []);
expect(b.envDelta['PATH'], startsWith('/opt/go/bin:'));
});
test('no preset wired → bootstrap behaves as before (no gratuitous PATH export)', () {
final emptyPreset = agentBootstrap('/ws', pathPreset: (_) => const []);
final unwired = agentBootstrap('/ws');
expect(emptyPreset.envDelta.containsKey('PATH'), unwired.envDelta.containsKey('PATH'));
});
});
}