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
+26
View File
@@ -99,6 +99,32 @@ void main() {
expect(pane.kind, PaneKind.claude);
expect(pane.toJson()['kind'], 'claude');
});
test('pathForSpawn hook sets the child PATH per cwd (D-106)', tags: ['pty'], () async {
String? seenCwd;
final preset = PaneRegistry(
events: sink,
pathForSpawn: (cwd) {
seenCwd = cwd;
return '/preset-marker:/usr/bin:/bin';
},
);
addTearDown(preset.shutdown);
final buf = StringBuffer();
final got = Completer<void>();
final sub = sink.stream.listen((e) {
if (e.kind != 'pane.output') return;
buf.write(utf8.decode(base64Decode(e.data['bytes_b64']! as String)));
if (buf.toString().contains('/preset-marker') && !got.isCompleted) got.complete();
});
addTearDown(sub.cancel);
await preset.spawn(kind: PaneKind.terminal, argv: const ['/bin/sh', '-c', r'printf %s "$PATH"; sleep 0.25'], cwd: '/tmp');
await got.future.timeout(ioTimeout, onTimeout: () => fail('child PATH never carried the preset marker within ${ioTimeout.inSeconds}s'));
expect(seenCwd, '/tmp');
});
});
group('RecordingEventSink filters', () {