summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src/index.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
committerAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
commita38d5b1279db6f9de5228c173019fc2ac08daec3 (patch)
tree32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/api/src/index.ts
parent0ae805b28b5160b8d9fb43635fa172961f6550cc (diff)
downloaddispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.tar.gz
dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.zip
feat: Phase 2 — shell permissions, tree-sitter analysis, permission UI
Permission engine: - Rule-based engine: wildcard matching, last-match-wins, reject cascade - PermissionService with pending/approved state, PermissionChecker interface - dispatch.yaml config loader with per-permission pattern rules Shell tool: - run_shell tool with child_process spawn, timeout, streaming output - Tree-sitter static analysis (web-tree-sitter + tree-sitter-bash WASM) - BashArity command normalization for 'always allow' patterns - FILE_COMMANDS set: rm, cp, mv, mkdir, ls, find, grep, cat, etc. Agent loop refactored: - Removed maxSteps, manual step loop with tool execution - Permission checks on shell commands (external_directory only) - Permission checks on file tools outside workspace boundary - Symlink bypass fix (realpathSync), .. false positive fix - Shell output streaming via Promise.race + setImmediate polling API layer: - PermissionManager wraps PermissionService, broadcasts via WebSocket - WebSocket handles permission-reply messages from frontend - Config loaded from dispatch.yaml, converted to ruleset Frontend: - Permission prompt modal (native dialog, focus trap, ARIA) - Always-allow confirmation flow with pattern preview - Shell output display (live streaming + final parsed result) - Permission log panel (fixed bottom-right overlay) - Exit code badge (green 0, red non-zero) 134 tests, typecheck clean on all 3 packages
Diffstat (limited to 'packages/api/src/index.ts')
-rw-r--r--packages/api/src/index.ts48
1 files changed, 42 insertions, 6 deletions
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 02b04b6..bf0f7f9 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -1,27 +1,63 @@
import { createBunWebSocket } from "hono/bun";
-import { agentManager, app } from "./app.js";
+import { agentManager, app, permissionManager } from "./app.js";
+import type { PermissionReply } from "@dispatch/core";
const { upgradeWebSocket, websocket } = createBunWebSocket();
+let clientIdCounter = 0;
+
app.get(
"/ws",
upgradeWebSocket((_c) => {
+ const clientId = String(++clientIdCounter);
+
return {
onOpen(_event, ws) {
// Send current status immediately
ws.send(JSON.stringify({ type: "status", status: agentManager.getStatus() }));
+ // Send any pending permission prompts
+ const pending = permissionManager.getPending();
+ if (pending.length > 0) {
+ ws.send(JSON.stringify({ type: "permission-prompt", pending }));
+ }
+
const unsubscribe = agentManager.onEvent((event) => {
ws.send(JSON.stringify(event));
});
- // Store unsubscribe fn on the raw socket for cleanup
- (ws as unknown as { _unsub?: () => void })._unsub = unsubscribe;
+ permissionManager.registerClient(clientId, (data) => {
+ ws.send(JSON.stringify(data));
+ });
+
+ // Store cleanup on the raw socket
+ (ws as unknown as { _unsub?: () => void; _clientId?: string })._unsub = unsubscribe;
+ (ws as unknown as { _unsub?: () => void; _clientId?: string })._clientId = clientId;
+ },
+ onMessage(event, _ws) {
+ try {
+ const message = JSON.parse(String(event.data)) as {
+ type?: string;
+ id?: string;
+ reply?: string;
+ };
+ if (message.type === "permission-reply" && typeof message.id === "string" && typeof message.reply === "string") {
+ const validReplies: PermissionReply[] = ["once", "always", "reject"];
+ if (validReplies.includes(message.reply as PermissionReply)) {
+ permissionManager.reply(message.id, message.reply as PermissionReply);
+ }
+ }
+ } catch {
+ // ignore malformed messages
+ }
},
onClose(_event, ws) {
- const unsub = (ws as unknown as { _unsub?: () => void })._unsub;
- if (unsub) {
- unsub();
+ const raw = ws as unknown as { _unsub?: () => void; _clientId?: string };
+ if (raw._unsub) {
+ raw._unsub();
+ }
+ if (raw._clientId) {
+ permissionManager.unregisterClient(raw._clientId);
}
},
};