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:
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/// Tests for `env path …` (D-106, T-511; D-6 CLI parity). Verifies each verb's
|
||||
/// store effect, the published mutation payloads, the capture diff, and honest
|
||||
/// userErrors — against a fake PathPresetStore so the handler stays
|
||||
/// Flutter-free (runs under `dart test`).
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/daemon/env_path_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _FakeStore implements PathPresetStore {
|
||||
final Map<String, List<String>> byCwd = {};
|
||||
|
||||
@override
|
||||
List<String> dirsFor(String cwd) => List.of(byCwd[cwd] ?? const []);
|
||||
|
||||
@override
|
||||
Future<void> setFor(String cwd, List<String> dirs) async {
|
||||
if (dirs.isEmpty) {
|
||||
byCwd.remove(cwd);
|
||||
} else {
|
||||
byCwd[cwd] = List.of(dirs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _FakeStore store;
|
||||
late List<({String publisher, String channel, Map<String, Object?> data})> published;
|
||||
late DaemonDispatcher d;
|
||||
late Set<String> existingDirs;
|
||||
String? loginPath;
|
||||
var processPath = '/usr/bin:/bin';
|
||||
|
||||
void wire({String? cwd = '/repo', bool withStore = true}) {
|
||||
store = _FakeStore();
|
||||
published = [];
|
||||
existingDirs = {};
|
||||
loginPath = null;
|
||||
processPath = '/usr/bin:/bin';
|
||||
d = DaemonDispatcher();
|
||||
registerEnvPathCommands(
|
||||
d,
|
||||
() => withStore ? store : null,
|
||||
publisher: () =>
|
||||
(p, c, data) => published.add((publisher: p, channel: c, data: data)),
|
||||
workspaceCwd: () => cwd,
|
||||
home: () => '/home/u',
|
||||
dirExists: (dir) => existingDirs.contains(dir),
|
||||
loginPath: () => loginPath,
|
||||
processPath: () => processPath,
|
||||
);
|
||||
}
|
||||
|
||||
Future<IpcResponse> run(List<String> positional) => d.dispatch(IpcRequest(id: '1', cmd: 'env.path', args: {'positional': positional}));
|
||||
|
||||
test('registered on the dispatcher → shows in capabilities', () async {
|
||||
wire();
|
||||
final caps = await d.dispatch(IpcRequest(id: 'c', cmd: 'capabilities', args: const {}));
|
||||
expect((caps.data['commands'] as Map).containsKey('env.path'), isTrue);
|
||||
});
|
||||
|
||||
group('set', () {
|
||||
test('replaces the preset, de-duplicated, and publishes', () async {
|
||||
wire();
|
||||
existingDirs.add('/opt/go/bin');
|
||||
final r = await run(['set', '/opt/go/bin', '/x', '/opt/go/bin']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['dirs'], ['/opt/go/bin', '/x']);
|
||||
expect(r.data['missing'], ['/x'], reason: 'nonexistent dirs warn, never error');
|
||||
expect(store.byCwd['/repo'], ['/opt/go/bin', '/x']);
|
||||
expect(published.single.channel, envPathChannel);
|
||||
expect(published.single.data['action'], 'set');
|
||||
expect(published.single.data['dirs'], ['/opt/go/bin', '/x']);
|
||||
});
|
||||
|
||||
test('expands ~/ against HOME and strips trailing slashes', () async {
|
||||
wire();
|
||||
final r = await run(['set', '~/go/bin/', '~']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['dirs'], ['/home/u/go/bin', '/home/u']);
|
||||
});
|
||||
|
||||
test('relative paths error; empty set points at clear', () async {
|
||||
wire();
|
||||
final rel = await run(['set', 'go/bin']);
|
||||
expect(rel.ok, isFalse);
|
||||
expect(rel.error?.message, contains('not an absolute path'));
|
||||
final empty = await run(['set']);
|
||||
expect(empty.ok, isFalse);
|
||||
expect(empty.error?.hint, contains('clear'));
|
||||
});
|
||||
|
||||
test('a leading-dash entry is rejected by the schema (T-104 guard)', () async {
|
||||
wire();
|
||||
final r = await d.dispatch(
|
||||
IpcRequest(
|
||||
id: '1',
|
||||
cmd: 'env.path',
|
||||
args: {
|
||||
'positional': ['set'],
|
||||
'flags': {'dirs': '--evil'},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(r.ok, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('add / remove / clear', () {
|
||||
test('add appends without duplicating; remove drops; clear empties the key', () async {
|
||||
wire();
|
||||
await run(['set', '/a']);
|
||||
published.clear();
|
||||
|
||||
var r = await run(['add', '/b', '/a']);
|
||||
expect(r.data['dirs'], ['/a', '/b']);
|
||||
|
||||
r = await run(['remove', '/a']);
|
||||
expect(r.data['dirs'], ['/b']);
|
||||
|
||||
r = await run(['clear']);
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['dirs'], isEmpty);
|
||||
expect(store.byCwd.containsKey('/repo'), isFalse, reason: 'empty preset removes the key');
|
||||
expect(published.map((p) => p.data['action']), ['add', 'remove', 'clear']);
|
||||
});
|
||||
|
||||
test('add and remove require a <dir>; removing an absent entry errors', () async {
|
||||
wire();
|
||||
expect((await run(['add'])).ok, isFalse);
|
||||
expect((await run(['remove'])).ok, isFalse);
|
||||
final r = await run(['remove', '/ghost']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('no preset entry matches'));
|
||||
});
|
||||
});
|
||||
|
||||
group('list', () {
|
||||
test('returns the preset, the missing subset, and the effective PATH preview', () async {
|
||||
wire();
|
||||
existingDirs.add('/a');
|
||||
await run(['set', '/a', '/gone']);
|
||||
final r = await run(['list']);
|
||||
expect(r.data['root'], '/repo');
|
||||
expect(r.data['dirs'], ['/a', '/gone']);
|
||||
expect(r.data['missing'], ['/gone']);
|
||||
expect(r.data['effectivePath'], startsWith('/a:/gone:'), reason: 'preset prepends the resolved PATH');
|
||||
});
|
||||
});
|
||||
|
||||
group('capture', () {
|
||||
test('suggests login-shell dirs the process PATH lacks, minus the preset', () async {
|
||||
wire();
|
||||
loginPath = '/brew/bin:/usr/bin:/opt/go/bin:/bin';
|
||||
await run(['set', '/brew/bin']);
|
||||
final r = await run(['capture']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['suggested'], ['/opt/go/bin']);
|
||||
expect(r.data['loginShellPath'], loginPath);
|
||||
expect(r.data['processPath'], '/usr/bin:/bin');
|
||||
});
|
||||
|
||||
test('no login-shell probe → empty suggestions with an honest note', () async {
|
||||
wire();
|
||||
final r = await run(['capture']);
|
||||
expect(r.data['suggested'], isEmpty);
|
||||
expect(r.data['note'], contains('unavailable'));
|
||||
});
|
||||
});
|
||||
|
||||
test('no workspace / no store / unknown action error clearly', () async {
|
||||
wire(cwd: null);
|
||||
expect((await run(['list'])).error?.message, contains('no workspace'));
|
||||
wire(withStore: false);
|
||||
expect((await run(['list'])).error?.message, contains('unavailable'));
|
||||
wire();
|
||||
final r = await run(['frobnicate']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.hint, contains('list | set | add | remove | clear | capture'));
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
/// Tests for the per-workspace PATH preset (D-106, T-511): the pure prepend /
|
||||
/// capture-diff helpers, the worktree-aware preset root, and the settings-key
|
||||
/// derivation. Runs under plain `dart test` (Flutter-free).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/env/path_preset.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('applyPathPreset', () {
|
||||
test('prepends preset dirs ahead of the base', () {
|
||||
expect(applyPathPreset('/usr/bin:/bin', ['/opt/go/bin', '/x']), '/opt/go/bin:/x:/usr/bin:/bin');
|
||||
});
|
||||
|
||||
test('empty preset returns the base unchanged', () {
|
||||
expect(applyPathPreset('/usr/bin', const []), '/usr/bin');
|
||||
expect(applyPathPreset('/usr/bin', ['', ' ']), '/usr/bin');
|
||||
});
|
||||
|
||||
test('empty base returns just the preset', () {
|
||||
expect(applyPathPreset('', ['/a', '/b']), '/a:/b');
|
||||
});
|
||||
|
||||
test('de-duplicates within the preset, order kept', () {
|
||||
expect(applyPathPreset('/bin', ['/a', '/b', '/a']), '/a:/b:/bin');
|
||||
});
|
||||
|
||||
test('drops base entries the preset already names (preset wins)', () {
|
||||
expect(applyPathPreset('/usr/bin:/a:/bin', ['/a']), '/a:/usr/bin:/bin');
|
||||
});
|
||||
|
||||
test('honours a custom separator', () {
|
||||
expect(applyPathPreset(r'C:\bin', [r'C:\go'], sep: ';'), r'C:\go;C:\bin');
|
||||
});
|
||||
});
|
||||
|
||||
group('missingLoginShellDirs', () {
|
||||
test('null or empty login PATH → nothing to suggest', () {
|
||||
expect(missingLoginShellDirs(loginPath: null, processPath: '/bin'), isEmpty);
|
||||
expect(missingLoginShellDirs(loginPath: '', processPath: '/bin'), isEmpty);
|
||||
});
|
||||
|
||||
test('suggests login entries the process PATH lacks, order kept', () {
|
||||
expect(missingLoginShellDirs(loginPath: '/home/linuxbrew/.linuxbrew/bin:/usr/bin:/opt/go/bin:/bin', processPath: '/usr/bin:/bin'), [
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
'/opt/go/bin',
|
||||
]);
|
||||
});
|
||||
|
||||
test('de-duplicates and skips empty segments', () {
|
||||
expect(missingLoginShellDirs(loginPath: '/a::/a:/b', processPath: '/bin'), ['/a', '/b']);
|
||||
});
|
||||
});
|
||||
|
||||
group('presetRootFor (worktree resolution)', () {
|
||||
test('a normal repo (.git directory) keys off itself', () {
|
||||
expect(presetRootFor('/repo', isFile: (_) => false, readFile: (_) => fail('not read')), '/repo');
|
||||
});
|
||||
|
||||
test('trailing separators are stripped', () {
|
||||
expect(presetRootFor('/repo//', isFile: (_) => false, readFile: (_) => null), '/repo');
|
||||
});
|
||||
|
||||
test('an in-repo .worktrees worktree resolves to the main repo root (absolute gitdir)', () {
|
||||
expect(
|
||||
presetRootFor('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (p) => 'gitdir: /repo/.git/worktrees/fix\n'),
|
||||
'/repo',
|
||||
);
|
||||
});
|
||||
|
||||
test('a relative gitdir pointer resolves against the worktree root', () {
|
||||
expect(
|
||||
presetRootFor('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (p) => 'gitdir: ../../.git/worktrees/fix'),
|
||||
'/repo',
|
||||
);
|
||||
});
|
||||
|
||||
test('a worktree outside the repo still resolves to the main root', () {
|
||||
expect(presetRootFor('/tmp/wt', isFile: (p) => p == '/tmp/wt/.git', readFile: (_) => 'gitdir: /srv/repos/main/.git/worktrees/wt'), '/srv/repos/main');
|
||||
});
|
||||
|
||||
test('a gitdir pointer without the worktrees marker (submodule-style) keys off itself', () {
|
||||
expect(presetRootFor('/repo/sub', isFile: (p) => p == '/repo/sub/.git', readFile: (_) => 'gitdir: /repo/.git/modules/sub'), '/repo/sub');
|
||||
});
|
||||
|
||||
test('malformed or unreadable pointer files key off the workspace itself', () {
|
||||
expect(presetRootFor('/w', isFile: (_) => true, readFile: (_) => 'not a pointer'), '/w');
|
||||
expect(presetRootFor('/w', isFile: (_) => true, readFile: (_) => null), '/w');
|
||||
});
|
||||
|
||||
test('backslashed gitdir (Windows-written pointer) still matches', () {
|
||||
expect(presetRootFor('/repo/.worktrees/x', isFile: (p) => p == '/repo/.worktrees/x/.git', readFile: (_) => r'gitdir: /repo/.git\worktrees\x'), '/repo');
|
||||
});
|
||||
|
||||
test('resolves a REAL worktree layout on disk (no injected probes)', () {
|
||||
final tmp = Directory.systemTemp.createTempSync('preset-root');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final repo = Directory('${tmp.path}/repo')..createSync();
|
||||
Directory('${repo.path}/.git/worktrees/fix').createSync(recursive: true);
|
||||
final wt = Directory('${repo.path}/.worktrees/fix')..createSync(recursive: true);
|
||||
File('${wt.path}/.git').writeAsStringSync('gitdir: ${repo.path}/.git/worktrees/fix\n');
|
||||
expect(presetRootFor(wt.path), repo.path);
|
||||
expect(presetRootFor(repo.path), repo.path);
|
||||
});
|
||||
});
|
||||
|
||||
group('pathPresetKey', () {
|
||||
test('is an app-layer key under the preset prefix', () {
|
||||
final key = pathPresetKey('/repo', isFile: (_) => false, readFile: (_) => null);
|
||||
expect(key, startsWith(pathPresetKeyPrefix));
|
||||
expect(key.length, pathPresetKeyPrefix.length + 16, reason: '16-hex FNV-1a suffix');
|
||||
});
|
||||
|
||||
test('a worktree and its main repo share one key; trailing slash is unified', () {
|
||||
final main = pathPresetKey('/repo', isFile: (_) => false, readFile: (_) => null);
|
||||
final slash = pathPresetKey('/repo/', isFile: (_) => false, readFile: (_) => null);
|
||||
final wt = pathPresetKey('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (_) => 'gitdir: /repo/.git/worktrees/fix');
|
||||
expect(slash, main);
|
||||
expect(wt, main);
|
||||
expect(pathPresetKey('/other', isFile: (_) => false, readFile: (_) => null), isNot(main));
|
||||
});
|
||||
});
|
||||
|
||||
group('presetDirsFrom', () {
|
||||
List<String> read(Object? stored) => presetDirsFrom((_) => stored, '/repo', isFile: (_) => false, readFile: (_) => null);
|
||||
|
||||
test('reads a stored list of dirs', () {
|
||||
expect(read(['/a', '/b']), ['/a', '/b']);
|
||||
});
|
||||
|
||||
test('tolerates malformed values', () {
|
||||
expect(read(null), isEmpty);
|
||||
expect(read('nonsense'), isEmpty);
|
||||
expect(read([1, '', ' ', '/ok', true]), ['/ok']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user