From e86543d476b8ddc3a78dda9177c2ee177c261dd8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 27 Jun 2026 21:25:19 +0200 Subject: [PATCH] fix(ipc): write the /ide discovery lock into bound-account config dirs (T-479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `claude` started with CLAUDE_CONFIG_DIR= looks for clide's /ide discovery lock under /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 /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) --- .pql/changelog/ticket_history/2026-06.sql | 85 +++++++++++++++ .pql/changelog/tickets/2026-06.sql | 127 ++++++++++++++++++++++ lib/main.dart | 19 +++- lib/src/ipc/mcp_server.dart | 94 +++++++++++++--- test/ipc/mcp_server_test.dart | 68 ++++++++++++ 5 files changed, 374 insertions(+), 19 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index e952db08..6ce13626 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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/.lock`. Claude Code with `CLAUDE_CONFIG_DIR=` looks for `/ide` locks under `/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/.lock`). +2. With one or more workspaces bound to non-default accounts, a lock exists at `/ide/.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/.lock`. Claude Code with `CLAUDE_CONFIG_DIR=` looks for `/ide` locks under `/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/.lock`). +2. With one or more workspaces bound to non-default accounts, a lock exists at `/ide/.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 /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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 886971fc..3ea9ae00 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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/.lock`. Claude Code with `CLAUDE_CONFIG_DIR=` looks for `/ide` locks under `/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/.lock`). +2. With one or more workspaces bound to non-default accounts, a lock exists at `/ide/.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/.lock`. Claude Code with `CLAUDE_CONFIG_DIR=` looks for `/ide` locks under `/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/.lock`). +2. With one or more workspaces bound to non-default accounts, a lock exists at `/ide/.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 /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/.lock`. Claude Code with `CLAUDE_CONFIG_DIR=` looks for `/ide` locks under `/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/.lock`). +2. With one or more workspaces bound to non-default accounts, a lock exists at `/ide/.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 /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); diff --git a/lib/main.dart b/lib/main.dart index 7a482424..7867d971 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -231,7 +231,19 @@ Future 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= 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 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.value()); + services.messages.subscribe(channel: accountActionChannel).listen((_) => unawaited(mcpServer?.syncDiscoveryLocks() ?? Future.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); diff --git a/lib/src/ipc/mcp_server.dart b/lib/src/ipc/mcp_server.dart index 2ddb17aa..361e51ce 100644 --- a/lib/src/ipc/mcp_server.dart +++ b/lib/src/ipc/mcp_server.dart @@ -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 _lockFiles = {}; int? _port; String? _authToken; final Map _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 _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 `/ide` when this + /// workspace is bound to a non-default account (T-479). Deduped, order-stable. + List _activeIdeDirs() { + final dirs = [_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 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 _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. diff --git a/test/ipc/mcp_server_test.dart b/test/ipc/mcp_server_test.dart index 542a3dbe..7b07611e 100644 --- a/test/ipc/mcp_server_test.dart +++ b/test/ipc/mcp_server_test.dart @@ -102,6 +102,74 @@ void main() { }); }); + // T-479: a `claude` started with CLAUDE_CONFIG_DIR= looks for its /ide + // lock under /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', () {