fix(ipc): write the /ide discovery lock into bound-account config dirs (T-479)

A `claude` started with CLAUDE_CONFIG_DIR=<dir> looks for clide's /ide
discovery lock under <dir>/ide, not ~/.claude/ide — so today a per-repo
account session (T-476) can't reach clide's IDE bridge.

McpServer now reconciles a SET of lock paths instead of one: the default
~/.claude/ide always, plus the bound account's <dir>/ide when this workspace
is bound (boundConfigDir, injected from main.dart via the AccountRegistry).
syncDiscoveryLocks() writes/removes locks to match the active set; it runs on
start, once post-boot when the registry becomes resolvable, and on every
accountActionChannel event, so binding/unbinding adds/removes the account lock
at runtime. stop() sweeps every path written — no orphans. Lock content +
0600 are identical across dirs.

Acceptance 1-4 and 6 are covered by mcp_server_test (multi-dir write, no-binding
regression, dynamic add/remove, full cleanup); #5 (a live claude resolving the
bridge end-to-end) is manual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 21:25:19 +02:00
co-authored by Claude Opus 4.8
parent 34b9690005
commit e86543d476
5 changed files with 374 additions and 19 deletions
+85
View File
@@ -6866,3 +6866,88 @@ D-6 (CLI parity).
Done: the six verbs (add/list/login/set/unset/remove) + registry persistence, plus the extension consumer set/unset respawn the workspace''s solo Claude panes onto the bound account (ClaudeSessionOrchestrator.respawnForWorkspace), remove --purge deletes the config dir behind a strict ~/.claude-* guard (isPurgeableAccountDir). The login VERB publishes its action on accountActionChannel; spawning the actual ''claude login'' terminal pane needs argv+env terminal-pane support and is split to T-485.', NULL, '2026-06-27 19:18:09', '2026-06-27 19:18:09.075', '2026-06-27 19:18:09.075', NULL, '1fa1967a8c538b0ee0622162d9730a74', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W95HJK08BCRQSWFZBF4', 'status', 'in_progress', 'done', NULL, '2026-06-27 19:18:18', '2026-06-27 19:18:18.202', '2026-06-27 19:18:18.202', NULL, 'ebe069d240f705d5da92ebcddd1b2219', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W6GP535GR8XD1XEHYWG', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 19:19:00', '2026-06-27 19:19:00.810', '2026-06-27 19:19:00.810', NULL, 'd5f6ab27c30d61574e9090ee5da94715', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W6GP535GR8XD1XEHYWG', 'description', 'Make clide''s `/ide` MCP bridge reachable from a `claude` running with a custom `CLAUDE_CONFIG_DIR`. Without this, the per-repo account work (T-476) ships a `claude` that can''t see clide''s IDE bridge.
## The trap
`lib/src/ipc/mcp_server.dart:344` writes the discovery lock at `$HOME/.claude/ide/<pid>.lock`. Claude Code with `CLAUDE_CONFIG_DIR=<X>` looks for `/ide` locks under `<X>/ide/` so a bound Claude session never finds clide today.
## Fix
Extend `_writeDiscoveryFile` (and the cleanup path) to write the lock into **every CURRENTLY-active config dir''s `ide/`**. "Active" means the union of:
- The default `$HOME/.claude/ide/` (always).
- Every account `configDir/ide/` for accounts bound to a workspace that is currently open in this clide process.
Same lock content for each; same `0600` perms per T-362. The MCP server tracks every path it wrote so shutdown cleans them all.
### React to binding changes
The set of active config dirs changes when a workspace is opened/closed in this process, or when a binding is set/unset (T-480 verbs). The MCP server subscribes to these changes and synchronizes the lock set incrementally write a new lock when a dir becomes active, remove it when no remaining workspace references that dir.
## Where
- `lib/src/ipc/mcp_server.dart` extend `_writeDiscoveryFile`, add a tracked-paths set, hook into the shutdown sweep.
- Wire the active-dir source through the existing app boot the MCP server is constructed in `lib/main.dart`; that''s where it can be handed an `AccountRegistry` (T-477) and a workspace-open observer.
## Acceptance
1. With no bindings, behaviour is identical to today (single lock at `$HOME/.claude/ide/<pid>.lock`).
2. With one or more workspaces bound to non-default accounts, a lock exists at `<each-account-dir>/ide/<pid>.lock` with the same content and `0600` perms.
3. Binding a workspace to a new (previously unused) account at runtime causes the corresponding lock to appear; unbinding the last workspace using that account causes it to disappear.
4. clide shutdown removes every lock this process wrote (defaultes plus account dirs); no orphans.
5. A live `claude` running under a bound account finds clide''s `/ide` endpoint end-to-end (manual confirmation in a real session).
6. Tests cover the multi-dir write/cleanup + the dynamic add/remove path (mock the filesystem).
## Depends on
- T-477 (storage). Needs the registry + binding API to enumerate active dirs.
## Independent of
T-478 T-479 can land in parallel; the bound `claude` just won''t have an IDE bridge until both ship.
', 'Make clide''s `/ide` MCP bridge reachable from a `claude` running with a custom `CLAUDE_CONFIG_DIR`. Without this, the per-repo account work (T-476) ships a `claude` that can''t see clide''s IDE bridge.
## The trap
`lib/src/ipc/mcp_server.dart:344` writes the discovery lock at `$HOME/.claude/ide/<pid>.lock`. Claude Code with `CLAUDE_CONFIG_DIR=<X>` looks for `/ide` locks under `<X>/ide/` so a bound Claude session never finds clide today.
## Fix
Extend `_writeDiscoveryFile` (and the cleanup path) to write the lock into **every CURRENTLY-active config dir''s `ide/`**. "Active" means the union of:
- The default `$HOME/.claude/ide/` (always).
- Every account `configDir/ide/` for accounts bound to a workspace that is currently open in this clide process.
Same lock content for each; same `0600` perms per T-362. The MCP server tracks every path it wrote so shutdown cleans them all.
### React to binding changes
The set of active config dirs changes when a workspace is opened/closed in this process, or when a binding is set/unset (T-480 verbs). The MCP server subscribes to these changes and synchronizes the lock set incrementally write a new lock when a dir becomes active, remove it when no remaining workspace references that dir.
## Where
- `lib/src/ipc/mcp_server.dart` extend `_writeDiscoveryFile`, add a tracked-paths set, hook into the shutdown sweep.
- Wire the active-dir source through the existing app boot the MCP server is constructed in `lib/main.dart`; that''s where it can be handed an `AccountRegistry` (T-477) and a workspace-open observer.
## Acceptance
1. With no bindings, behaviour is identical to today (single lock at `$HOME/.claude/ide/<pid>.lock`).
2. With one or more workspaces bound to non-default accounts, a lock exists at `<each-account-dir>/ide/<pid>.lock` with the same content and `0600` perms.
3. Binding a workspace to a new (previously unused) account at runtime causes the corresponding lock to appear; unbinding the last workspace using that account causes it to disappear.
4. clide shutdown removes every lock this process wrote (defaultes plus account dirs); no orphans.
5. A live `claude` running under a bound account finds clide''s `/ide` endpoint end-to-end (manual confirmation in a real session).
6. Tests cover the multi-dir write/cleanup + the dynamic add/remove path (mock the filesystem).
## Depends on
- T-477 (storage). Needs the registry + binding API to enumerate active dirs.
## Independent of
T-478 T-479 can land in parallel; the bound `claude` just won''t have an IDE bridge until both ship.
Done: McpServer writes the /ide discovery lock into every active config dir (default ~/.claude/ide + the bound account''s <dir>/ide), reconciled by syncDiscoveryLocks() on start + on every accountActionChannel event, all cleaned on stop. boundConfigDir injected from main.dart via AccountRegistry. Acceptance 1-4,6 covered by test/ipc/mcp_server_test.dart; #5 (live claude finds the bridge end-to-end) is manual.', NULL, '2026-06-27 19:24:52', '2026-06-27 19:24:52.424', '2026-06-27 19:24:52.424', NULL, 'fc350455bedf3c3c4e8c61a5599cef33', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W6GP535GR8XD1XEHYWG', 'status', 'in_progress', 'done', NULL, '2026-06-27 19:24:52', '2026-06-27 19:24:52.456', '2026-06-27 19:24:52.456', NULL, '9a1ec766e6abf5712e32dc32ace8c02d', 2) ON CONFLICT(hash) DO NOTHING;
+127
View File
@@ -8735,3 +8735,130 @@ D-6 (CLI parity).
Done: the six verbs (add/list/login/set/unset/remove) + registry persistence, plus the extension consumer set/unset respawn the workspace''s solo Claude panes onto the bound account (ClaudeSessionOrchestrator.respawnForWorkspace), remove --purge deletes the config dir behind a strict ~/.claude-* guard (isPurgeableAccountDir). The login VERB publishes its action on accountActionChannel; spawning the actual ''claude login'' terminal pane needs argv+env terminal-pane support and is split to T-485.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-25 09:16:42', '2026-06-27 19:18:18.202', NULL, 'b598d17e51e2349e18fa8ad0c906389a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W6GP535GR8XD1XEHYWG', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: write /ide discovery lock into every active config dir', 'Make clide''s `/ide` MCP bridge reachable from a `claude` running with a custom `CLAUDE_CONFIG_DIR`. Without this, the per-repo account work (T-476) ships a `claude` that can''t see clide''s IDE bridge.
## The trap
`lib/src/ipc/mcp_server.dart:344` writes the discovery lock at `$HOME/.claude/ide/<pid>.lock`. Claude Code with `CLAUDE_CONFIG_DIR=<X>` looks for `/ide` locks under `<X>/ide/` so a bound Claude session never finds clide today.
## Fix
Extend `_writeDiscoveryFile` (and the cleanup path) to write the lock into **every CURRENTLY-active config dir''s `ide/`**. "Active" means the union of:
- The default `$HOME/.claude/ide/` (always).
- Every account `configDir/ide/` for accounts bound to a workspace that is currently open in this clide process.
Same lock content for each; same `0600` perms per T-362. The MCP server tracks every path it wrote so shutdown cleans them all.
### React to binding changes
The set of active config dirs changes when a workspace is opened/closed in this process, or when a binding is set/unset (T-480 verbs). The MCP server subscribes to these changes and synchronizes the lock set incrementally write a new lock when a dir becomes active, remove it when no remaining workspace references that dir.
## Where
- `lib/src/ipc/mcp_server.dart` extend `_writeDiscoveryFile`, add a tracked-paths set, hook into the shutdown sweep.
- Wire the active-dir source through the existing app boot the MCP server is constructed in `lib/main.dart`; that''s where it can be handed an `AccountRegistry` (T-477) and a workspace-open observer.
## Acceptance
1. With no bindings, behaviour is identical to today (single lock at `$HOME/.claude/ide/<pid>.lock`).
2. With one or more workspaces bound to non-default accounts, a lock exists at `<each-account-dir>/ide/<pid>.lock` with the same content and `0600` perms.
3. Binding a workspace to a new (previously unused) account at runtime causes the corresponding lock to appear; unbinding the last workspace using that account causes it to disappear.
4. clide shutdown removes every lock this process wrote (defaultes plus account dirs); no orphans.
5. A live `claude` running under a bound account finds clide''s `/ide` endpoint end-to-end (manual confirmation in a real session).
6. Tests cover the multi-dir write/cleanup + the dynamic add/remove path (mock the filesystem).
## Depends on
- T-477 (storage). Needs the registry + binding API to enumerate active dirs.
## Independent of
T-478 T-479 can land in parallel; the bound `claude` just won''t have an IDE bridge until both ship.
', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 19:19:00.810', NULL, '4974062a28186003c89e90ed7f10bae8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W6GP535GR8XD1XEHYWG', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: write /ide discovery lock into every active config dir', 'Make clide''s `/ide` MCP bridge reachable from a `claude` running with a custom `CLAUDE_CONFIG_DIR`. Without this, the per-repo account work (T-476) ships a `claude` that can''t see clide''s IDE bridge.
## The trap
`lib/src/ipc/mcp_server.dart:344` writes the discovery lock at `$HOME/.claude/ide/<pid>.lock`. Claude Code with `CLAUDE_CONFIG_DIR=<X>` looks for `/ide` locks under `<X>/ide/` so a bound Claude session never finds clide today.
## Fix
Extend `_writeDiscoveryFile` (and the cleanup path) to write the lock into **every CURRENTLY-active config dir''s `ide/`**. "Active" means the union of:
- The default `$HOME/.claude/ide/` (always).
- Every account `configDir/ide/` for accounts bound to a workspace that is currently open in this clide process.
Same lock content for each; same `0600` perms per T-362. The MCP server tracks every path it wrote so shutdown cleans them all.
### React to binding changes
The set of active config dirs changes when a workspace is opened/closed in this process, or when a binding is set/unset (T-480 verbs). The MCP server subscribes to these changes and synchronizes the lock set incrementally write a new lock when a dir becomes active, remove it when no remaining workspace references that dir.
## Where
- `lib/src/ipc/mcp_server.dart` extend `_writeDiscoveryFile`, add a tracked-paths set, hook into the shutdown sweep.
- Wire the active-dir source through the existing app boot the MCP server is constructed in `lib/main.dart`; that''s where it can be handed an `AccountRegistry` (T-477) and a workspace-open observer.
## Acceptance
1. With no bindings, behaviour is identical to today (single lock at `$HOME/.claude/ide/<pid>.lock`).
2. With one or more workspaces bound to non-default accounts, a lock exists at `<each-account-dir>/ide/<pid>.lock` with the same content and `0600` perms.
3. Binding a workspace to a new (previously unused) account at runtime causes the corresponding lock to appear; unbinding the last workspace using that account causes it to disappear.
4. clide shutdown removes every lock this process wrote (defaultes plus account dirs); no orphans.
5. A live `claude` running under a bound account finds clide''s `/ide` endpoint end-to-end (manual confirmation in a real session).
6. Tests cover the multi-dir write/cleanup + the dynamic add/remove path (mock the filesystem).
## Depends on
- T-477 (storage). Needs the registry + binding API to enumerate active dirs.
## Independent of
T-478 T-479 can land in parallel; the bound `claude` just won''t have an IDE bridge until both ship.
Done: McpServer writes the /ide discovery lock into every active config dir (default ~/.claude/ide + the bound account''s <dir>/ide), reconciled by syncDiscoveryLocks() on start + on every accountActionChannel event, all cleaned on stop. boundConfigDir injected from main.dart via AccountRegistry. Acceptance 1-4,6 covered by test/ipc/mcp_server_test.dart; #5 (live claude finds the bridge end-to-end) is manual.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 19:24:52.424', NULL, 'd86b16561276261bcdf4378db9904d11', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W6GP535GR8XD1XEHYWG', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: write /ide discovery lock into every active config dir', 'Make clide''s `/ide` MCP bridge reachable from a `claude` running with a custom `CLAUDE_CONFIG_DIR`. Without this, the per-repo account work (T-476) ships a `claude` that can''t see clide''s IDE bridge.
## The trap
`lib/src/ipc/mcp_server.dart:344` writes the discovery lock at `$HOME/.claude/ide/<pid>.lock`. Claude Code with `CLAUDE_CONFIG_DIR=<X>` looks for `/ide` locks under `<X>/ide/` so a bound Claude session never finds clide today.
## Fix
Extend `_writeDiscoveryFile` (and the cleanup path) to write the lock into **every CURRENTLY-active config dir''s `ide/`**. "Active" means the union of:
- The default `$HOME/.claude/ide/` (always).
- Every account `configDir/ide/` for accounts bound to a workspace that is currently open in this clide process.
Same lock content for each; same `0600` perms per T-362. The MCP server tracks every path it wrote so shutdown cleans them all.
### React to binding changes
The set of active config dirs changes when a workspace is opened/closed in this process, or when a binding is set/unset (T-480 verbs). The MCP server subscribes to these changes and synchronizes the lock set incrementally write a new lock when a dir becomes active, remove it when no remaining workspace references that dir.
## Where
- `lib/src/ipc/mcp_server.dart` extend `_writeDiscoveryFile`, add a tracked-paths set, hook into the shutdown sweep.
- Wire the active-dir source through the existing app boot the MCP server is constructed in `lib/main.dart`; that''s where it can be handed an `AccountRegistry` (T-477) and a workspace-open observer.
## Acceptance
1. With no bindings, behaviour is identical to today (single lock at `$HOME/.claude/ide/<pid>.lock`).
2. With one or more workspaces bound to non-default accounts, a lock exists at `<each-account-dir>/ide/<pid>.lock` with the same content and `0600` perms.
3. Binding a workspace to a new (previously unused) account at runtime causes the corresponding lock to appear; unbinding the last workspace using that account causes it to disappear.
4. clide shutdown removes every lock this process wrote (defaultes plus account dirs); no orphans.
5. A live `claude` running under a bound account finds clide''s `/ide` endpoint end-to-end (manual confirmation in a real session).
6. Tests cover the multi-dir write/cleanup + the dynamic add/remove path (mock the filesystem).
## Depends on
- T-477 (storage). Needs the registry + binding API to enumerate active dirs.
## Independent of
T-478 T-479 can land in parallel; the bound `claude` just won''t have an IDE bridge until both ship.
Done: McpServer writes the /ide discovery lock into every active config dir (default ~/.claude/ide + the bound account''s <dir>/ide), reconciled by syncDiscoveryLocks() on start + on every accountActionChannel event, all cleaned on stop. boundConfigDir injected from main.dart via AccountRegistry. Acceptance 1-4,6 covered by test/ipc/mcp_server_test.dart; #5 (live claude finds the bridge end-to-end) is manual.', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 19:24:52.456', NULL, '8798b0b97587cdbac22b75004cad8f07', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+18 -1
View File
@@ -231,7 +231,19 @@ Future<void> main() async {
ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st);
return;
}
final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog, dispatcher: dispatcher);
final mcp = McpServer(
workspaceRoot: workRoot.path,
log: ipcLog,
dispatcher: dispatcher,
// T-479: when this workspace is bound to a Claude account, also write the
// /ide discovery lock into that account's config dir so a `claude` started
// with CLAUDE_CONFIG_DIR=<dir> can reach clide's bridge. Lazy — resolved
// post-boot once kernelSettings (and the registry) exist.
boundConfigDir: () {
final s = kernelSettings;
return s == null ? null : AccountRegistry(s).accountForWorkspace(workRoot.path)?.dir;
},
);
mcpServer = mcp;
try {
await mcp.start();
@@ -469,6 +481,11 @@ Future<void> main() async {
kernelMessages = services.messages;
kernelFilterStates = services.filterStates;
kernelSettings = services.settings;
// T-479: the account registry is now resolvable (kernelSettings is set), so
// re-sync the /ide discovery locks to pick up any account bound to this
// workspace at boot, and re-sync on every account binding change.
unawaited(mcpServer?.syncDiscoveryLocks() ?? Future<void>.value());
services.messages.subscribe(channel: accountActionChannel).listen((_) => unawaited(mcpServer?.syncDiscoveryLocks() ?? Future<void>.value()));
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
// shows socket-side logs alongside kernel/extension ones.
ipcLog.addSink(services.logRing.add);
+76 -18
View File
@@ -70,7 +70,15 @@ class _McpSession {
/// HTTP + SSE MCP server. Lifecycle mirrors [IpcServer]: `start()`
/// binds + writes the discovery file; `stop()` unbinds + removes it.
class McpServer {
McpServer({required this.workspaceRoot, required this.log, this.dispatcher, this.discoveryDirOverride, this.bindHost = '127.0.0.1', this.bindPort = 0});
McpServer({
required this.workspaceRoot,
required this.log,
this.dispatcher,
this.discoveryDirOverride,
this.boundConfigDir,
this.bindHost = '127.0.0.1',
this.bindPort = 0,
});
/// Workspace root reported in the discovery file. Helps Claude
/// Code show "which clide is this" when multiple are running.
@@ -86,6 +94,14 @@ class McpServer {
/// passes null; tests inject a tempdir.
final String? discoveryDirOverride;
/// Returns the Claude config dir bound to this workspace (T-479/T-480), or
/// null when unbound. When non-null, a copy of the discovery lock is also
/// written into that dir's `ide/`. A `claude` started with a custom
/// `CLAUDE_CONFIG_DIR` looks for its `/ide` lock under that dir, not under
/// `~/.claude/ide`, so without this its IDE bridge can't reach clide. Lazy:
/// main.dart passes a closure resolved against the (post-boot) AccountRegistry.
final String? Function()? boundConfigDir;
/// Bind host. localhost-only by default per D-73 (no remote
/// access; the threat model matches D-71's `0600`).
final String bindHost;
@@ -94,7 +110,11 @@ class McpServer {
final int bindPort;
HttpServer? _http;
String? _lockFile;
/// Every discovery-lock path this process has written — the default `ide/`
/// dir plus any bound-account `ide/` dirs. Reconciled by [syncDiscoveryLocks];
/// all are removed on [stop] so no orphan locks survive (T-479).
final Set<String> _lockFiles = {};
int? _port;
String? _authToken;
final Map<String, _McpSession> _sessions = {};
@@ -102,7 +122,13 @@ class McpServer {
bool get isRunning => _http != null;
int? get port => _port;
String? get lockFilePath => _lockFile;
String get _defaultIdeDir => discoveryDirOverride ?? '${Platform.environment['HOME'] ?? '/tmp'}/.claude/ide';
String get _defaultLockPath => '$_defaultIdeDir/$pid.lock';
/// The lock in the default `~/.claude/ide` dir (the one Claude finds without
/// `CLAUDE_CONFIG_DIR`). Null until [start] writes it.
String? get lockFilePath => _lockFiles.contains(_defaultLockPath) ? _defaultLockPath : null;
/// The per-start bearer token clients must present in [kMcpAuthHeader].
/// Published to legitimate clients via the 0600 lock file only.
@@ -114,7 +140,7 @@ class McpServer {
_http = server;
_port = server.port;
_authToken = _generateToken();
_lockFile = await _writeDiscoveryFile();
await syncDiscoveryLocks();
server.listen(
_route,
onError: (Object e, StackTrace st) {
@@ -134,16 +160,10 @@ class McpServer {
}
_sessions.clear();
await s.close(force: true);
final lock = _lockFile;
_lockFile = null;
if (lock != null) {
try {
final f = File(lock);
if (f.existsSync()) f.deleteSync();
} catch (e) {
log.warn('mcp', 'failed to unlink lock $lock: $e');
}
for (final lock in _lockFiles) {
_deleteLock(lock);
}
_lockFiles.clear();
}
// -- routing --------------------------------------------------------------
@@ -340,13 +360,43 @@ class McpServer {
// -- discovery file -------------------------------------------------------
Future<String> _writeDiscoveryFile() async {
final dir = discoveryDirOverride ?? '${Platform.environment['HOME'] ?? '/tmp'}/.claude/ide';
final dirHandle = Directory(dir);
/// The `ide/` dirs a discovery lock should currently live in: the default
/// `~/.claude/ide` always, plus the bound account's `<dir>/ide` when this
/// workspace is bound to a non-default account (T-479). Deduped, order-stable.
List<String> _activeIdeDirs() {
final dirs = <String>[_defaultIdeDir];
final bound = boundConfigDir?.call();
if (bound != null && bound.isNotEmpty) {
final accountIde = '$bound/ide';
if (!dirs.contains(accountIde)) dirs.add(accountIde);
}
return dirs;
}
/// Reconcile the on-disk discovery locks with the currently-active `ide/`
/// dirs (T-479): write a lock into each active dir, and remove any this
/// process wrote into a dir that is no longer active. Called on [start] and
/// whenever a per-repo account binding changes. No-op while not running.
Future<void> syncDiscoveryLocks() async {
if (!isRunning) return;
final want = {for (final d in _activeIdeDirs()) '$d/$pid.lock'};
for (final path in _lockFiles.difference(want).toList()) {
_deleteLock(path);
_lockFiles.remove(path);
}
for (final path in want.difference(_lockFiles).toList()) {
await _writeLockAt(path);
_lockFiles.add(path);
}
}
/// Write (or overwrite) the discovery lock at [path] — same content in every
/// dir — creating the `ide/` parent and 0600-scoping the file (T-362).
Future<void> _writeLockAt(String path) async {
final dirHandle = File(path).parent;
if (!dirHandle.existsSync()) {
dirHandle.createSync(recursive: true);
}
final path = '$dir/$pid.lock';
final body = jsonEncode({
'pid': pid,
'workspace': workspaceRoot,
@@ -364,7 +414,15 @@ class McpServer {
// ~/.claude which the home-dir perms usually already protect. But say so.
log.warn('mcp', 'chmod 600 on $path failed: $e — the auth token may be readable by other local users');
}
return path;
}
void _deleteLock(String path) {
try {
final f = File(path);
if (f.existsSync()) f.deleteSync();
} catch (e) {
log.warn('mcp', 'failed to unlink lock $path: $e');
}
}
/// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token.
+68
View File
@@ -102,6 +102,74 @@ void main() {
});
});
// T-479: a `claude` started with CLAUDE_CONFIG_DIR=<dir> looks for its /ide
// lock under <dir>/ide, not ~/.claude/ide — so a bound workspace must get a
// lock copy in the account dir too, kept in sync as the binding changes.
group('McpServer (T-479) per-account discovery locks', () {
late Directory disc;
late Directory accountDir;
String? bound;
setUp(() async {
disc = await Directory.systemTemp.createTemp('clide-mcp-disc2-');
accountDir = await Directory.systemTemp.createTemp('clide-mcp-acct-');
bound = accountDir.path;
});
tearDown(() {
if (disc.existsSync()) disc.deleteSync(recursive: true);
if (accountDir.existsSync()) accountDir.deleteSync(recursive: true);
});
McpServer make() => McpServer(workspaceRoot: '/x', log: _silent(), discoveryDirOverride: disc.path, boundConfigDir: () => bound);
File defaultLock() => File('${disc.path}/$pid.lock');
File accountLock() => File('${accountDir.path}/ide/$pid.lock');
test('writes the lock into the default AND the bound account ide dir, same content + 0600', () async {
final srv = make();
await srv.start();
addTearDown(srv.stop);
expect(defaultLock().existsSync(), isTrue);
expect(accountLock().existsSync(), isTrue);
expect(accountLock().readAsStringSync(), defaultLock().readAsStringSync());
expect(accountLock().statSync().mode & 0xFFF, 0x180, reason: 'account lock carries the token → 0600');
});
test('with no binding, only the default lock is written (regression)', () async {
bound = null;
final srv = make();
await srv.start();
addTearDown(srv.stop);
expect(defaultLock().existsSync(), isTrue);
expect(accountLock().existsSync(), isFalse);
});
test('syncDiscoveryLocks adds the account lock when a binding appears, removes it when it goes', () async {
bound = null;
final srv = make();
await srv.start();
addTearDown(srv.stop);
expect(accountLock().existsSync(), isFalse);
bound = accountDir.path;
await srv.syncDiscoveryLocks();
expect(accountLock().existsSync(), isTrue);
bound = null;
await srv.syncDiscoveryLocks();
expect(accountLock().existsSync(), isFalse);
expect(defaultLock().existsSync(), isTrue, reason: 'the default lock stays put');
});
test('stop removes both the default and the account lock — no orphans', () async {
final srv = make();
await srv.start();
expect(defaultLock().existsSync() && accountLock().existsSync(), isTrue);
await srv.stop();
expect(defaultLock().existsSync(), isFalse);
expect(accountLock().existsSync(), isFalse);
});
});
// T-362: D-71's "another user on this host must not drive my IDE" is
// enforced with 0600 on the unix socket — the HTTP port must not bypass it.
group('McpServer (T-362) auth token', () {