Merge main into windows-support
Brings windows-support up to date with main (T-404/405/406, T-413–416, T-421, the T-422 workspace-lifecycle epic, and the 2.4.0 release). Conflict resolutions: - terminal_pane.dart: keep the Windows PowerShell shell selection and main's workspace-cwd fix (T-381) together. - tool_check.dart: accept main's deletion (dead, unreferenced code). - CHANGELOG.md: keep both Unreleased sections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
@@ -33,6 +34,12 @@ import 'package:clide/src/ipc/envelope.dart';
|
||||
/// separate `/ide` minimum (D-68).
|
||||
const String _clideToolPrefix = 'mcp__clide__';
|
||||
|
||||
/// Auth header Claude Code's `/ide` client sends, populated from the lock
|
||||
/// file's `authToken`. Every request must carry it (T-362): the unix socket
|
||||
/// is gated by 0600 per D-71, and an unauthenticated localhost HTTP port
|
||||
/// would bypass that gate wholesale.
|
||||
const String kMcpAuthHeader = 'x-claude-code-ide-authorization';
|
||||
|
||||
/// One connected SSE client. Each session has its own response
|
||||
/// stream; POST /messages routes back to the right one via the
|
||||
/// `sessionId` query param.
|
||||
@@ -89,6 +96,7 @@ class McpServer {
|
||||
HttpServer? _http;
|
||||
String? _lockFile;
|
||||
int? _port;
|
||||
String? _authToken;
|
||||
final Map<String, _McpSession> _sessions = {};
|
||||
int _sessionCounter = 0;
|
||||
|
||||
@@ -96,11 +104,16 @@ class McpServer {
|
||||
int? get port => _port;
|
||||
String? get lockFilePath => _lockFile;
|
||||
|
||||
/// The per-start bearer token clients must present in [kMcpAuthHeader].
|
||||
/// Published to legitimate clients via the 0600 lock file only.
|
||||
String? get authToken => _authToken;
|
||||
|
||||
Future<void> start() async {
|
||||
if (isRunning) return;
|
||||
final server = await HttpServer.bind(bindHost, bindPort);
|
||||
_http = server;
|
||||
_port = server.port;
|
||||
_authToken = _generateToken();
|
||||
_lockFile = await _writeDiscoveryFile();
|
||||
server.listen(
|
||||
_route,
|
||||
@@ -136,6 +149,13 @@ class McpServer {
|
||||
// -- routing --------------------------------------------------------------
|
||||
|
||||
Future<void> _route(HttpRequest req) async {
|
||||
// Token gate first, on every path (T-362). Without it, any local
|
||||
// process could drive the entire dispatcher D-71's 0600 socket guards.
|
||||
if (req.headers.value(kMcpAuthHeader) != _authToken) {
|
||||
req.response.statusCode = HttpStatus.unauthorized;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final path = req.uri.path;
|
||||
if (path == '/sse' && req.method == 'GET') {
|
||||
await _openSseStream(req);
|
||||
@@ -327,8 +347,39 @@ class McpServer {
|
||||
dirHandle.createSync(recursive: true);
|
||||
}
|
||||
final path = '$dir/$pid.lock';
|
||||
final body = jsonEncode({'pid': pid, 'workspace': workspaceRoot, 'transport': 'sse', 'url': 'http://$bindHost:$_port/sse'});
|
||||
final body = jsonEncode({
|
||||
'pid': pid,
|
||||
'workspace': workspaceRoot,
|
||||
'transport': 'sse',
|
||||
'url': 'http://$bindHost:$_port/sse',
|
||||
// Claude Code's /ide lock format carries the bearer token here; the
|
||||
// 0600 below is what scopes it to this user (T-362).
|
||||
'authToken': _authToken,
|
||||
});
|
||||
File(path).writeAsStringSync(body);
|
||||
try {
|
||||
await _chmod(path, '600');
|
||||
} catch (e) {
|
||||
// Not fatal like the socket's chmod (D-71): the lock lives under
|
||||
// ~/.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;
|
||||
}
|
||||
|
||||
/// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token.
|
||||
static String _generateToken() {
|
||||
final rng = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
|
||||
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// `chmod` via `chmod(1)` — dart:io doesn't expose mode bits (same
|
||||
/// approach as the unix-socket server, D-71).
|
||||
static Future<void> _chmod(String path, String octal) async {
|
||||
final r = await Process.run('chmod', [octal, path]);
|
||||
if (r.exitCode != 0) {
|
||||
throw ProcessException('chmod', [octal, path], r.stderr.toString(), r.exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-27
@@ -150,33 +150,26 @@ class IpcServer {
|
||||
|
||||
void _onClient(Socket client) {
|
||||
_clients.add(client);
|
||||
final buffer = StringBuffer();
|
||||
late StreamSubscription<List<int>> sub;
|
||||
sub = client.listen(
|
||||
(chunk) async {
|
||||
buffer.write(utf8.decode(chunk, allowMalformed: true));
|
||||
var idx = buffer.toString().indexOf('\n');
|
||||
while (idx >= 0) {
|
||||
final raw = buffer.toString().substring(0, idx);
|
||||
// Trim consumed bytes by rebuilding the buffer with the
|
||||
// tail — StringBuffer can't slice in place.
|
||||
final tail = buffer.toString().substring(idx + 1);
|
||||
buffer.clear();
|
||||
buffer.write(tail);
|
||||
await _handleLine(client, raw);
|
||||
idx = buffer.toString().indexOf('\n');
|
||||
}
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
},
|
||||
onDone: () {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
sub.cancel();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
unawaited(_serveClient(client));
|
||||
}
|
||||
|
||||
/// One read loop per connection: persistent UTF-8 decode, line framing,
|
||||
/// and true serial dispatch in a single `await for` (D-72, T-372). The
|
||||
/// old async onData handler never paused its subscription — pipelined
|
||||
/// requests interleaved mid-handler, the shared StringBuffer could
|
||||
/// re-frame while an await was in flight, and per-chunk decode corrupted
|
||||
/// runes split across reads.
|
||||
Future<void> _serveClient(Socket client) async {
|
||||
try {
|
||||
await for (final line in client.cast<List<int>>().transform(const Utf8Decoder(allowMalformed: true)).transform(const LineSplitter())) {
|
||||
await _handleLine(client, line);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
} finally {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLine(Socket client, String line) async {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/// DaemonTransport (T-331): the seam between the local app and its
|
||||
/// backend. The UI's [DaemonClient] talks JSON-lines through a
|
||||
/// [DaemonTransport] instead of a hard-coded unix-socket connect, so a
|
||||
/// remote transport (SSH-tunnelled agent socket or ssh-exec channel,
|
||||
/// T-329/Q-23) can slot in without touching the client's correlation,
|
||||
/// reconnect, or event-forwarding logic.
|
||||
///
|
||||
/// The wire protocol is unchanged either way: one JSON envelope
|
||||
/// (IpcRequest/IpcResponse/IpcEvent, see envelope.dart) per line.
|
||||
///
|
||||
/// Kept Flutter-free — this file runs under plain `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// How the app reaches its backend. Implementations own endpoint
|
||||
/// resolution + connection establishment; the caller owns retry policy
|
||||
/// (the client's backoff loop calls [open] again after a failure).
|
||||
abstract interface class DaemonTransport {
|
||||
/// Stable, human-readable endpoint description — the unix socket path
|
||||
/// locally, a `ssh://host/path` form remotely. Used for logs, status
|
||||
/// surfaces, and same-endpoint reconnect short-circuits.
|
||||
String get endpoint;
|
||||
|
||||
/// Establish one connection. Throws on failure (caller retries).
|
||||
Future<DaemonConnection> open();
|
||||
}
|
||||
|
||||
/// One live backend connection carrying JSON-lines both ways.
|
||||
abstract interface class DaemonConnection {
|
||||
/// Incoming lines, one JSON envelope each. Done/error signals the
|
||||
/// connection dropped.
|
||||
Stream<String> get lines;
|
||||
|
||||
/// Send one JSON envelope line (the newline is appended here).
|
||||
void writeLine(String line);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
/// Today's path: connect to the workspace-derived unix domain socket
|
||||
/// (D-70) the in-process IpcServer is bound to.
|
||||
class LocalSocketTransport implements DaemonTransport {
|
||||
LocalSocketTransport(this.socketPath);
|
||||
|
||||
final String socketPath;
|
||||
|
||||
@override
|
||||
String get endpoint => socketPath;
|
||||
|
||||
@override
|
||||
Future<DaemonConnection> open() async {
|
||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
||||
return _SocketConnection(await Socket.connect(addr, 0));
|
||||
}
|
||||
}
|
||||
|
||||
class _SocketConnection implements DaemonConnection {
|
||||
_SocketConnection(this._socket);
|
||||
|
||||
final Socket _socket;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
@override
|
||||
void writeLine(String line) => _socket.writeln(line);
|
||||
|
||||
@override
|
||||
Future<void> close() => _socket.close();
|
||||
}
|
||||
Reference in New Issue
Block a user