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
+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', () {