diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index 2099b3c1..47537762 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-05T13:12:14Z", + "exported_at": "2026-05-06T07:56:04Z", "decisions": [ { "id": "D-1", @@ -2624,10 +2624,10 @@ "id": "T-77", "type": "task", "title": "IPC server: per-request timeout, broadcast logging, stale-socket race", - "status": "in_progress", + "status": "done", "priority": "high", "created_at": "2026-05-05 12:58:59", - "updated_at": "2026-05-05 13:10:57" + "updated_at": "2026-05-05 13:12:20" }, { "id": "T-78", @@ -2642,10 +2642,10 @@ "id": "T-79", "type": "task", "title": "pane.spawn / editor.open: map errno to actionable error kinds", - "status": "backlog", + "status": "in_progress", "priority": "medium", "created_at": "2026-05-05 12:58:59", - "updated_at": "2026-05-05 12:58:59" + "updated_at": "2026-05-06 07:45:02" }, { "id": "T-80", @@ -3975,6 +3975,20 @@ "old_value": "backlog", "new_value": "in_progress", "changed_at": "2026-05-05 13:10:57" + }, + { + "ticket_id": "T-77", + "field": "status", + "old_value": "in_progress", + "new_value": "done", + "changed_at": "2026-05-05 13:12:20" + }, + { + "ticket_id": "T-79", + "field": "status", + "old_value": "backlog", + "new_value": "in_progress", + "changed_at": "2026-05-06 07:45:02" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5a6072..1055aa3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. client dropped on response-write failure, and the stale-socket retry now probes for a live daemon before unlinking the socket (refusing to start if one answers). +- `pane.spawn` and `editor.open` now map POSIX errno values to + actionable IPC error kinds. ENOENT → `not_found`, EACCES/EPERM + → `user_error` with a permissions hint, EISDIR/ENOTDIR/EEXIST + → distinct user-error/conflict, EMFILE/ENFILE → `tool_error` + with a "fd limit hit" hint. Previously every spawn/open failure + was an indistinguishable `tool_error`. ### Security diff --git a/lib/src/daemon/editor_commands.dart b/lib/src/daemon/editor_commands.dart index 80b846e2..0628ef63 100644 --- a/lib/src/daemon/editor_commands.dart +++ b/lib/src/daemon/editor_commands.dart @@ -9,8 +9,11 @@ /// one-to-one onto these in `bin/clide.dart`. library; +import 'dart:io' show FileSystemException; + import '../editor/registry.dart'; import '../ipc/envelope.dart'; +import '../ipc/errno_mapping.dart'; import '../ipc/schema_v1.dart'; import 'dispatcher.dart'; @@ -64,6 +67,22 @@ Future _open(IpcRequest req, EditorRegistry r) async { try { final buf = await r.open(path); return IpcResponse.ok(id: req.id, data: buf.toJson()); + } on FileSystemException catch (e) { + final errno = e.osError?.errorCode; + if (errno != null) { + return IpcResponse.err( + id: req.id, + error: errnoToIpcError(errno: errno, op: 'editor.open', target: path), + ); + } + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: 'editor.open failed: ${e.message}', + ), + ); } catch (e) { return IpcResponse.err( id: req.id, diff --git a/lib/src/daemon/pane_commands.dart b/lib/src/daemon/pane_commands.dart index 6657183f..1f4b8d0d 100644 --- a/lib/src/daemon/pane_commands.dart +++ b/lib/src/daemon/pane_commands.dart @@ -12,9 +12,11 @@ library; import 'dart:convert'; import '../ipc/envelope.dart'; +import '../ipc/errno_mapping.dart'; import '../ipc/schema_v1.dart'; import '../panes/pane.dart'; import '../panes/registry.dart'; +import '../pty/errors.dart'; import 'dispatcher.dart'; void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) { @@ -84,6 +86,26 @@ Future _spawn(IpcRequest req, PaneRegistry registry) async { title: args['title'] as String?, ); return IpcResponse.ok(id: req.id, data: pane.toJson()); + } on PtyException catch (e) { + final errno = e.errno; + if (errno != null) { + return IpcResponse.err( + id: req.id, + error: errnoToIpcError( + errno: errno, + op: 'pane.spawn', + target: argv.isNotEmpty ? argv.first : null, + ), + ); + } + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: 'pane.spawn failed: ${e.message}', + ), + ); } catch (e) { return IpcResponse.err( id: req.id, diff --git a/lib/src/ipc/errno_mapping.dart b/lib/src/ipc/errno_mapping.dart new file mode 100644 index 00000000..f20dd504 --- /dev/null +++ b/lib/src/ipc/errno_mapping.dart @@ -0,0 +1,103 @@ +/// Map POSIX errno values to IPC error envelopes with actionable +/// messages. Used by command handlers that wrap syscall-backed work +/// (PTY spawn, file open) so the client can distinguish "binary not +/// found" from "permission denied" from "system limit hit" instead +/// of seeing the same generic `tool_error: foo failed`. +library; + +import 'envelope.dart'; +import 'schema_v1.dart'; + +/// Selected POSIX errno values we map specially. Others fall through +/// to a generic toolError. Values match Linux glibc and macOS Darwin +/// (the two platforms that share the same numbers for these entries). +abstract class PosixErrno { + static const int eperm = 1; + static const int enoent = 2; + static const int esrch = 3; + static const int eio = 5; + static const int ebadf = 9; + static const int eagain = 11; + static const int enomem = 12; + static const int eacces = 13; + static const int eexist = 17; + static const int enotdir = 20; + static const int eisdir = 21; + static const int emfile = 24; + static const int enfile = 23; + static const int epipe = 32; +} + +/// Build an [IpcError] from a POSIX [errno] for an operation [op] +/// (e.g. `pane.spawn`, `editor.open`) on optional [target] (a path, +/// command name, etc.). The returned error uses `notFound`, +/// `userError`, or `toolError` based on what's actionable. +IpcError errnoToIpcError({ + required int errno, + required String op, + String? target, + String? raw, +}) { + final what = target != null ? ' ($target)' : ''; + switch (errno) { + case PosixErrno.enoent: + return IpcError( + code: IpcExitCode.notFound, + kind: IpcErrorKind.notFound, + message: '$op: not found$what', + ); + case PosixErrno.eacces: + case PosixErrno.eperm: + return IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: '$op: permission denied$what', + hint: 'check file permissions or run with appropriate access', + ); + case PosixErrno.eisdir: + return IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: '$op: is a directory$what', + ); + case PosixErrno.enotdir: + return IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: '$op: not a directory$what', + ); + case PosixErrno.eexist: + return IpcError( + code: IpcExitCode.conflict, + kind: IpcErrorKind.conflict, + message: '$op: already exists$what', + ); + case PosixErrno.emfile: + case PosixErrno.enfile: + return IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: '$op: too many open files', + hint: 'system or per-process file descriptor limit reached', + ); + case PosixErrno.enomem: + return IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: '$op: out of memory', + ); + case PosixErrno.eagain: + return IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: '$op: resource temporarily unavailable', + hint: 'retry may succeed', + ); + default: + return IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: '$op failed${raw != null ? ': $raw' : ' (errno=$errno)'}', + ); + } +} diff --git a/test/ipc/errno_mapping_test.dart b/test/ipc/errno_mapping_test.dart new file mode 100644 index 00000000..cc90f25d --- /dev/null +++ b/test/ipc/errno_mapping_test.dart @@ -0,0 +1,55 @@ +import 'package:clide/src/ipc/errno_mapping.dart'; +import 'package:clide/src/ipc/schema_v1.dart'; +import 'package:test/test.dart'; + +void main() { + group('errnoToIpcError', () { + test('ENOENT → notFound with target', () { + final err = errnoToIpcError(errno: PosixErrno.enoent, op: 'pane.spawn', target: 'claude'); + expect(err.kind, IpcErrorKind.notFound); + expect(err.code, IpcExitCode.notFound); + expect(err.message, contains('claude')); + expect(err.message, contains('not found')); + }); + + test('EACCES → userError with hint', () { + final err = errnoToIpcError(errno: PosixErrno.eacces, op: 'editor.open', target: '/etc/shadow'); + expect(err.kind, IpcErrorKind.userError); + expect(err.code, IpcExitCode.userError); + expect(err.message, contains('permission denied')); + expect(err.hint, isNotNull); + }); + + test('EISDIR → userError', () { + final err = errnoToIpcError(errno: PosixErrno.eisdir, op: 'editor.open', target: 'src/'); + expect(err.kind, IpcErrorKind.userError); + expect(err.message, contains('is a directory')); + }); + + test('EEXIST → conflict', () { + final err = errnoToIpcError(errno: PosixErrno.eexist, op: 'files.create', target: 'README.md'); + expect(err.kind, IpcErrorKind.conflict); + expect(err.code, IpcExitCode.conflict); + }); + + test('EMFILE → toolError with hint', () { + final err = errnoToIpcError(errno: PosixErrno.emfile, op: 'pane.spawn'); + expect(err.kind, IpcErrorKind.toolError); + expect(err.message, contains('too many open files')); + expect(err.hint, isNotNull); + }); + + test('unknown errno falls through to toolError', () { + final err = errnoToIpcError(errno: 999, op: 'pane.spawn'); + expect(err.kind, IpcErrorKind.toolError); + expect(err.code, IpcExitCode.toolError); + expect(err.message, contains('errno=999')); + }); + + test('raw message overrides errno suffix in fallback', () { + final err = errnoToIpcError(errno: 999, op: 'pane.spawn', raw: 'kernel exploded'); + expect(err.message, contains('kernel exploded')); + expect(err.message, isNot(contains('errno=999'))); + }); + }); +}