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
+203
View File
@@ -0,0 +1,203 @@
/// Registers the `env path …` verbs — the CLI half of the per-workspace PATH
/// preset (D-106, T-511; D-6 CLI parity with the Tools settings section).
///
/// `clide env path list`
/// `clide env path set <dir> [<dir> …]`
/// `clide env path add <dir> [<dir> …]`
/// `clide env path remove <dir> [<dir> …]`
/// `clide env path clear`
/// `clide env path capture`
///
/// The argv grammar splits the first two tokens as `subsystem.verb`, so the
/// command id is `env.path` and the sub-verb arrives as the first positional;
/// the trailing `dirs` positional is variadic. Preset reads/writes go through
/// an injected [PathPresetStore] port and mutations are published on
/// [envPathChannel] — keeping this handler Flutter-free so it runs under
/// `dart test`.
///
/// Preset dirs deliberately live OUTSIDE the workspace (`~/.nvm/…`, linuxbrew),
/// so there is no workspace-confinement check here — the guards are absolute
/// paths only (after `~/` expansion) and the T-104 leading-dash rejection.
/// Nonexistent dirs warn (in the `missing` field), never error: a preset may
/// predate the dir it names.
library;
import 'dart:io' show Directory, Platform;
import '../env/path_preset.dart';
import '../env/shell_env.dart' show loginShellPathOrNull, resolvedToolPath;
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import 'dispatcher.dart';
import 'ui_command.dart' show MessagePublisher;
/// The MessageBus channel preset mutations publish on; UI surfaces subscribe
/// to reflect a CLI edit (the settings control also re-reads off the store
/// notifier, so this is the event-contract half of D-6).
const envPathChannel = 'env.path';
/// Flutter-free port over the (foundation-bound) SettingsStore, injected so
/// this command runs under `dart test`. main.dart adapts the real store to it.
abstract class PathPresetStore {
/// The preset for the workspace at [cwd], resolved to its repo identity
/// ([presetRootFor]) — a worktree reads its main repo's preset.
List<String> dirsFor(String cwd);
/// Replace the preset for [cwd]'s repo. An empty [dirs] removes the key.
Future<void> setFor(String cwd, List<String> dirs);
}
/// Register `env.path`. [store] / [publisher] / [workspaceCwd] are late-bound
/// closures (captured post-boot in main.dart); each may be null in a headless
/// context, in which case the verb degrades to a clear error. [home] /
/// [dirExists] / [loginPath] / [processPath] are test seams over the real
/// environment.
void registerEnvPathCommands(
DaemonDispatcher d,
PathPresetStore? Function() store, {
MessagePublisher? Function()? publisher,
String? Function()? workspaceCwd,
String? Function()? home,
bool Function(String dir)? dirExists,
String? Function()? loginPath,
String Function()? processPath,
}) {
d.register(
'env.path',
(req) async => _dispatch(
req,
store(),
publisher?.call(),
workspaceCwd?.call(),
home: home ?? () => Platform.environment['HOME'],
dirExists: dirExists ?? (dir) => Directory(dir).existsSync(),
loginPath: loginPath ?? loginShellPathOrNull,
processPath: processPath ?? () => Platform.environment['PATH'] ?? '',
),
schema: const CommandSchema(
positional: ['action', 'dirs'],
args: {
'action': ArgSpec(required: true, rejectLeadingDash: true),
'dirs': ArgSpec(type: ArgType.stringList, rejectLeadingDash: true, maxItems: 64),
},
),
);
}
Future<IpcResponse> _dispatch(
IpcRequest req,
PathPresetStore? store,
MessagePublisher? publish,
String? cwd, {
required String? Function() home,
required bool Function(String dir) dirExists,
required String? Function() loginPath,
required String Function() processPath,
}) async {
if (store == null) return _err(req.id, 'settings unavailable in this context');
if (cwd == null) return _err(req.id, 'no workspace open');
final action = (req.args['action'] as String?)?.trim();
final rawDirs = (req.args['dirs'] as List?)?.cast<String>() ?? const <String>[];
final root = presetRootFor(cwd);
Future<IpcResponse> mutate(List<String> dirs) async {
await store.setFor(cwd, dirs);
publish?.call('cli', envPathChannel, {'action': action, 'root': root, 'dirs': dirs});
return _ok(req.id, {'root': root, 'dirs': dirs, 'missing': _missing(dirs, dirExists)});
}
switch (action) {
case 'list':
final dirs = store.dirsFor(cwd);
return _ok(req.id, {'root': root, 'dirs': dirs, 'missing': _missing(dirs, dirExists), 'effectivePath': applyPathPreset(resolvedToolPath(), dirs)});
case 'set':
if (rawDirs.isEmpty) return _err(req.id, 'set requires at least one <dir>', hint: 'to empty the preset use: clide env path clear');
final (dirs, bad) = _expandAll(rawDirs, home());
if (bad != null) return _err(req.id, bad, hint: 'preset entries must be absolute paths (or ~/…)');
return mutate(_dedupe(dirs));
case 'add':
if (rawDirs.isEmpty) return _err(req.id, 'add requires at least one <dir>');
final (dirs, bad) = _expandAll(rawDirs, home());
if (bad != null) return _err(req.id, bad, hint: 'preset entries must be absolute paths (or ~/…)');
return mutate(_dedupe([...store.dirsFor(cwd), ...dirs]));
case 'remove':
if (rawDirs.isEmpty) return _err(req.id, 'remove requires at least one <dir>');
final (dirs, bad) = _expandAll(rawDirs, home());
if (bad != null) return _err(req.id, bad, hint: 'pass the entry exactly as `env path list` shows it');
final current = store.dirsFor(cwd);
final drop = dirs.toSet();
final kept = current.where((d) => !drop.contains(d)).toList();
if (kept.length == current.length) {
return _err(req.id, 'no preset entry matches ${dirs.join(", ")}', hint: 'clide env path list');
}
return mutate(kept);
case 'clear':
return mutate(const []);
case 'capture':
final dirs = store.dirsFor(cwd);
final login = loginPath();
final proc = processPath();
final suggested = missingLoginShellDirs(loginPath: login, processPath: proc).where((d) => !dirs.contains(d)).toList();
return _ok(req.id, {
'root': root,
'suggested': suggested,
'loginShellPath': login,
'processPath': proc,
if (login == null) 'note': 'login-shell PATH probe unavailable; nothing to diff',
});
default:
return _err(
req.id,
'unknown env path action: ${action == null || action.isEmpty ? '(none)' : action}',
hint: 'use: list | set | add | remove | clear | capture',
);
}
}
/// Expand a leading `~/` against [home] and require absolute results. Returns
/// the expanded list, or an error message on the first bad entry.
(List<String>, String?) _expandAll(List<String> dirs, String? home) {
final out = <String>[];
for (final raw in dirs) {
var d = raw.trim();
if (d.isEmpty) return (out, 'empty preset entry');
if (d == '~' || d.startsWith('~/')) {
if (home == null || home.isEmpty) return (out, 'cannot expand "~" (no HOME)');
d = d == '~' ? home : '$home${d.substring(1)}';
}
final absolute = d.startsWith('/') || RegExp(r'^[A-Za-z]:[/\\]').hasMatch(d);
if (!absolute) return (out, 'not an absolute path: $raw');
while (d.length > 1 && d.endsWith('/')) {
d = d.substring(0, d.length - 1);
}
out.add(d);
}
return (out, null);
}
List<String> _dedupe(List<String> dirs) {
final out = <String>[];
for (final d in dirs) {
if (!out.contains(d)) out.add(d);
}
return out;
}
List<String> _missing(List<String> dirs, bool Function(String) dirExists) => [
for (final d in dirs)
if (!dirExists(d)) d,
];
IpcResponse _ok(String id, Map<String, Object?> data) => IpcResponse.ok(id: id, data: data);
IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err(
id: id,
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
);
+144
View File
@@ -0,0 +1,144 @@
/// Per-workspace PATH preset (D-106, T-511) — a machine-local, user-scope list
/// of directories prepended to the PATH clide hands to every shell it spawns:
/// the hosted Claude session (so the agent's Bash tool sees it) and terminal
/// PTY panes. The T-439 login-shell probe is a global heuristic with a known
/// hole (interactive-only profile additions like brew shellenv in `~/.bashrc`);
/// the preset is the explicit per-repo layer on top of it.
///
/// Storage is the app (user) settings layer keyed by workspace hash — the
/// account-binding pattern (T-483) — so absolute machine paths never land in a
/// committed file. The hash keys off the REPO identity, not the literal
/// directory: a linked git worktree (its `.git` is a `gitdir:` pointer file,
/// e.g. under an in-repo `.worktrees/` dir) resolves to the main repo root, so
/// every worktree shares its repo's preset.
///
/// Security fence: the preset feeds spawned-shell environments only. Clide's
/// own binary resolution (D-104 pins, bundled pql/git, the toolchain probe)
/// never consults it — see D-106.
///
/// Flutter-free (consumed by the `env path` daemon verbs under `dart test`).
library;
import 'dart:io';
import '../ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex;
/// Prefix of the user-scope settings key; the suffix is the preset-root hash.
const pathPresetKeyPrefix = 'app.env.pathPrepend.';
/// The settings key holding [workspaceRoot]'s preset — app layer, suffixed
/// with the FNV-1a hash (the same one D-70 derives for the socket path) of the
/// [presetRootFor]-resolved repo root, so all worktrees of a repo share one key.
String pathPresetKey(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile}) =>
'$pathPresetKeyPrefix${fnv1a64Hex(canonicalWorkspaceKey(presetRootFor(workspaceRoot, isFile: isFile, readFile: readFile)))}';
/// The directory whose identity keys the preset: the MAIN repo root when
/// [workspaceRoot] is a linked git worktree, else [workspaceRoot] itself
/// (trailing separators stripped, so `/repo` and `/repo/` map alike).
///
/// A linked worktree's `.git` is a pointer FILE — `gitdir: <main>/.git/worktrees/<name>`
/// (git writes forward slashes on every platform; a relative
/// gitdir is resolved against the worktree root). Anything that doesn't match
/// that shape — a normal repo (`.git` directory), no `.git` at all, a
/// submodule pointer — keys off [workspaceRoot] unchanged.
///
/// [isFile]/[readFile] are injectable for tests; defaults touch the real fs.
String presetRootFor(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile}) {
final root = _stripTrailingSep(workspaceRoot);
final probe = isFile ?? _isFile;
final read = readFile ?? _readFile;
final gitPointer = '$root/.git';
if (!probe(gitPointer)) return root;
final content = read(gitPointer);
if (content == null) return root;
final match = RegExp(r'^gitdir:\s*(.+?)\s*$', multiLine: true).firstMatch(content);
if (match == null) return root;
final gitdir = match.group(1)!.replaceAll(r'\', '/');
final resolved = _normalize(_isAbsolute(gitdir) ? gitdir : '${root.replaceAll(r'\', '/')}/$gitdir');
const marker = '/.git/worktrees/';
final idx = resolved.indexOf(marker);
if (idx <= 0) return root;
return resolved.substring(0, idx);
}
/// Read [workspaceRoot]'s preset through an injected settings [read] (key →
/// stored value). Tolerant of a malformed value: anything that isn't a list of
/// non-empty strings is skipped, mirroring `AccountRegistry.accounts`.
List<String> presetDirsFrom(
Object? Function(String key) read,
String workspaceRoot, {
bool Function(String path)? isFile,
String? Function(String path)? readFile,
}) {
final raw = read(pathPresetKey(workspaceRoot, isFile: isFile, readFile: readFile));
if (raw is! List) return const [];
return [
for (final e in raw)
if (e is String && e.trim().isNotEmpty) e,
];
}
/// Pure prepend: [preset] dirs (de-duplicated, order kept) ahead of [base],
/// with base entries that repeat a preset dir dropped so the preset always
/// wins. An empty preset returns [base] unchanged.
String applyPathPreset(String base, List<String> preset, {String sep = ':'}) {
final dirs = <String>[];
for (final d in preset) {
final t = d.trim();
if (t.isNotEmpty && !dirs.contains(t)) dirs.add(t);
}
if (dirs.isEmpty) return base;
final baseParts = base.isEmpty ? const <String>[] : base.split(sep);
return [...dirs, ...baseParts.where((p) => !dirs.contains(p))].join(sep);
}
/// Capture-from-login-shell diff (D-106's `env path capture`): the entries the
/// login-shell PATH has that the process PATH lacks — the dirs a desktop
/// launch dropped, i.e. the preset candidates. Empty when the probe failed
/// ([loginPath] null/empty). Order-preserved, de-duplicated.
List<String> missingLoginShellDirs({required String? loginPath, required String processPath, String sep = ':'}) {
if (loginPath == null || loginPath.isEmpty) return const [];
final have = processPath.split(sep).toSet();
final out = <String>[];
for (final d in loginPath.split(sep)) {
if (d.isNotEmpty && !have.contains(d) && !out.contains(d)) out.add(d);
}
return out;
}
bool _isFile(String path) => FileSystemEntity.typeSync(path) == FileSystemEntityType.file;
String? _readFile(String path) {
try {
return File(path).readAsStringSync();
} catch (_) {
return null;
}
}
bool _isAbsolute(String p) => p.startsWith('/') || RegExp(r'^[A-Za-z]:/').hasMatch(p);
/// Segment-wise `.`/`..` normalization over forward-slash paths (a gitdir
/// pointer is often relative, e.g. `../../.git/worktrees/x`). No fs access.
String _normalize(String p) {
final drive = RegExp(r'^[A-Za-z]:').firstMatch(p)?.group(0) ?? '';
final rest = p.substring(drive.length);
final out = <String>[];
for (final seg in rest.split('/')) {
if (seg.isEmpty || seg == '.') continue;
if (seg == '..') {
if (out.isNotEmpty) out.removeLast();
continue;
}
out.add(seg);
}
return '$drive/${out.join('/')}';
}
String _stripTrailingSep(String p) {
var s = p;
while (s.length > 1 && (s.endsWith('/') || s.endsWith(r'\'))) {
s = s.substring(0, s.length - 1);
}
return s;
}
+5
View File
@@ -80,6 +80,11 @@ String expandToolPath(String base, {required bool isMac, required bool isLinux,
return [...missing, ...existing].join(':');
}
/// The raw login-shell PATH the probe captured, or null when it is
/// unavailable (probe failed / not yet primed / Windows). `env path capture`
/// (D-106) diffs this against the process PATH to suggest preset entries.
String? loginShellPathOrNull() => _loginShellPath;
/// Test seam: force the cached login-shell PATH (and mark primed).
void debugSetLoginShellPath(String? value) {
_loginShellPath = value;
+12 -3
View File
@@ -19,10 +19,18 @@ import 'event_sink.dart';
import 'pane.dart';
class PaneRegistry {
PaneRegistry({required this.events, this.ptyLog = PtyLog.none});
PaneRegistry({required this.events, this.ptyLog = PtyLog.none, String Function(String? cwd)? pathForSpawn})
: _pathForSpawn = pathForSpawn ?? _defaultPathForSpawn;
final DaemonEventSink events;
/// PATH for a child spawned in `cwd` — the seam the per-workspace PATH
/// preset (D-106) is applied through; the default is the plain resolved
/// login-shell PATH (T-439). main.dart wires the preset-aware closure.
final String Function(String? cwd) _pathForSpawn;
static String _defaultPathForSpawn(String? _) => resolvedToolPath();
/// Breadcrumb hook handed to every PTY this registry spawns (T-434). Default
/// no-op; production wires it to the kernel Logger + a crumb file.
final PtyLog ptyLog;
@@ -55,8 +63,9 @@ class PaneRegistry {
final fullEnv = <String, String>{
...Platform.environment,
// The login-shell-resolved PATH so PTY children find user-installed tools
// even on a desktop launch (T-439); an explicit caller PATH still wins.
'PATH': resolvedToolPath(),
// even on a desktop launch (T-439), plus the workspace's PATH preset
// (D-106) via [_pathForSpawn]; an explicit caller PATH still wins.
'PATH': _pathForSpawn(cwd),
'TERM': 'xterm-256color',
'COLORTERM': 'truecolor',
'LANG': 'en_US.UTF-8',