codepiper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,1388 @@
1
+ /**
2
+ * Daemon API server using Bun with Unix socket support
3
+ */
4
+
5
+ import * as crypto from "node:crypto";
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import type { EventBus } from "@codepiper/core";
9
+ import type { Server, ServerWebSocket } from "bun";
10
+ import { ApiRateLimiter } from "../auth/apiRateLimiter";
11
+ import {
12
+ addSecurityHeaders,
13
+ extractAndHashOnboardingToken,
14
+ extractAndHashToken,
15
+ extractToken,
16
+ getClientIp,
17
+ isPublicRoute,
18
+ MAX_IMAGE_BODY_SIZE,
19
+ } from "../auth/authMiddleware";
20
+ import type { AuthService } from "../auth/authService";
21
+ import { hashToken } from "../auth/authService";
22
+ import type { RateLimiter } from "../auth/rateLimiter";
23
+ import type { Database } from "../db/db";
24
+ import { PushNotifier, type PushNotifierOptions } from "../notifications/pushNotifier";
25
+ import { AuditLogger } from "../sessions/auditLogger";
26
+ import { PolicyEngine } from "../sessions/policyEngine";
27
+ import type { SessionManager } from "../sessions/sessionManager";
28
+ import * as analyticsRoutes from "./analyticsRoutes";
29
+ import { enforceRequestBodyLimit } from "./bodyLimit";
30
+ import * as envSetRoutes from "./envSetRoutes";
31
+ import * as gitRoutes from "./gitRoutes";
32
+ import { assertInputPolicyAllowed, enforceInputPolicyPreflight } from "./inputPolicy";
33
+ import * as notificationRoutes from "./notificationRoutes";
34
+ import * as policyRoutes from "./policyRoutes";
35
+ import * as policySetRoutes from "./policySetRoutes";
36
+ import * as routes from "./routes";
37
+ import * as settingsRoutes from "./settingsRoutes";
38
+ import * as terminalRoutes from "./terminalRoutes";
39
+ import * as validationRoutes from "./validationRoutes";
40
+ import * as workflowRoutes from "./workflowRoutes";
41
+ import * as workspaceRoutes from "./workspaceRoutes";
42
+ import { parseWsMessage, WebSocketManager } from "./ws";
43
+
44
+ const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
45
+ const DEFAULT_API_RATE_LIMIT_MAX = 300;
46
+ const DEFAULT_API_RATE_LIMIT_WINDOW_MS = 10_000;
47
+ const STATE_CHANGING_HTTP_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
48
+ const WS_UPGRADE_DATA: unknown = null;
49
+
50
+ function isMfaOnboardingRoute(apiPath: string): boolean {
51
+ return apiPath === "/auth/mfa/setup" || apiPath === "/auth/mfa/verify";
52
+ }
53
+
54
+ /**
55
+ * Extract hostname from a string that may be a bare hostname or a full URL.
56
+ * Falls back to treating the input as a bare hostname if URL parsing fails.
57
+ */
58
+ function parseHostname(entry: string): string {
59
+ try {
60
+ const url = new URL(entry.includes("://") ? entry : `https://${entry}`);
61
+ return url.hostname;
62
+ } catch {
63
+ return entry;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Allowed origin hostnames parsed from CODEPIPER_ALLOWED_ORIGINS env var.
69
+ * Accepts comma-separated hostnames or origins (e.g. "myapp.example.com,other.dev"
70
+ * or "https://myapp.example.com").
71
+ */
72
+ const allowedOrigins: Set<string> = new Set(
73
+ (process.env.CODEPIPER_ALLOWED_ORIGINS ?? "")
74
+ .split(",")
75
+ .map((s) => s.trim())
76
+ .filter(Boolean)
77
+ .map(parseHostname)
78
+ );
79
+
80
+ /**
81
+ * Check whether a hostname is allowed (localhost or in CODEPIPER_ALLOWED_ORIGINS).
82
+ */
83
+ function isAllowedOriginHostname(hostname: string): boolean {
84
+ return LOCALHOST_HOSTNAMES.has(hostname) || allowedOrigins.has(hostname);
85
+ }
86
+
87
+ /**
88
+ * Validate that the Origin header (if present) is from an allowed host.
89
+ * Returns a 403 Response if the origin is rejected, null if valid.
90
+ * Prevents Cross-Site WebSocket Hijacking (CSWSH).
91
+ */
92
+ function rejectNonLocalOrigin(req: Request): Response | null {
93
+ const origin = req.headers.get("Origin");
94
+ if (!origin) return null;
95
+
96
+ try {
97
+ const originUrl = new URL(origin);
98
+ if (isAllowedOriginHostname(originUrl.hostname)) {
99
+ return null;
100
+ }
101
+ return Response.json({ error: "Origin not allowed" }, { status: 403 });
102
+ } catch {
103
+ return Response.json({ error: "Invalid Origin header" }, { status: 403 });
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Validate browser-originated state-changing API requests to mitigate CSRF.
109
+ *
110
+ * Rules:
111
+ * - Safe methods (GET/HEAD/OPTIONS) are exempt.
112
+ * - Requests without Origin/Referer are treated as non-browser clients and allowed.
113
+ * - If Origin/Referer is present, it must match the request origin OR be explicitly allowed via
114
+ * CODEPIPER_ALLOWED_ORIGINS.
115
+ */
116
+ function rejectCrossSiteApiRequest(req: Request): Response | null {
117
+ if (!STATE_CHANGING_HTTP_METHODS.has(req.method.toUpperCase())) {
118
+ return null;
119
+ }
120
+
121
+ const originHeader = req.headers.get("Origin");
122
+ const refererHeader = req.headers.get("Referer");
123
+ const source = originHeader ?? refererHeader;
124
+ if (!source) {
125
+ return null;
126
+ }
127
+
128
+ let sourceUrl: URL;
129
+ try {
130
+ sourceUrl = new URL(source);
131
+ } catch {
132
+ return Response.json({ error: "Invalid Origin/Referer header" }, { status: 403 });
133
+ }
134
+
135
+ const targetUrl = new URL(req.url);
136
+ if (sourceUrl.origin === targetUrl.origin) {
137
+ return null;
138
+ }
139
+
140
+ // Explicit allowlist for deployments serving UI from a distinct trusted origin.
141
+ if (allowedOrigins.has(sourceUrl.hostname)) {
142
+ return null;
143
+ }
144
+
145
+ return Response.json({ error: "Cross-site request blocked" }, { status: 403 });
146
+ }
147
+
148
+ function parsePositiveIntEnv(varName: string, fallback: number): number {
149
+ const raw = process.env[varName];
150
+ if (!raw) return fallback;
151
+
152
+ const parsed = Number.parseInt(raw, 10);
153
+ if (!Number.isFinite(parsed) || parsed <= 0) {
154
+ return fallback;
155
+ }
156
+
157
+ return parsed;
158
+ }
159
+
160
+ function cleanupSocketFile(socketPath: string): void {
161
+ try {
162
+ if (fs.existsSync(socketPath)) {
163
+ fs.unlinkSync(socketPath);
164
+ }
165
+ } catch (err) {
166
+ console.warn(`[server] Failed to clean up socket file ${socketPath}:`, err);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Verify that a resolved path is contained within a base directory.
172
+ * Prevents path traversal via sibling-prefix tricks (e.g. /web-malicious vs /web).
173
+ */
174
+ export function isPathWithinBaseDir(baseDir: string, targetPath: string): boolean {
175
+ const resolvedBase = path.resolve(baseDir);
176
+ const resolvedTarget = path.resolve(targetPath);
177
+ const relative = path.relative(resolvedBase, resolvedTarget);
178
+ return relative === "" || !(relative.startsWith("..") || path.isAbsolute(relative));
179
+ }
180
+
181
+ function resolveRealPath(filePath: string): string | null {
182
+ try {
183
+ return fs.realpathSync(filePath);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Resolve symlinks and verify that the canonical target still lives under baseDir.
191
+ * Prevents serving files via symlink escape from an otherwise contained lexical path.
192
+ */
193
+ export function isRealPathWithinBaseDir(baseDir: string, targetPath: string): boolean {
194
+ const realBase = resolveRealPath(baseDir);
195
+ const realTarget = resolveRealPath(targetPath);
196
+ if (!(realBase && realTarget)) {
197
+ return false;
198
+ }
199
+ return isPathWithinBaseDir(realBase, realTarget);
200
+ }
201
+
202
+ export interface DaemonServer {
203
+ stop(): Promise<void>;
204
+ wsManager: WebSocketManager;
205
+ wsPort: number;
206
+ httpPort: number;
207
+ eventBus: EventBus<Record<string, any>>;
208
+ db: Database;
209
+ }
210
+
211
+ interface Route {
212
+ method: string;
213
+ pattern: RegExp;
214
+ handler: (req: Request, ctx: routes.RouteContext, ...params: string[]) => Promise<Response>;
215
+ }
216
+
217
+ /**
218
+ * Create and start the daemon API server
219
+ */
220
+ export async function createServer(
221
+ socketPath: string,
222
+ sessionManager: SessionManager,
223
+ db: Database,
224
+ eventBus: EventBus<Record<string, any>>,
225
+ options?: {
226
+ webDir?: string;
227
+ httpPort?: number;
228
+ authService?: AuthService;
229
+ rateLimiter?: RateLimiter;
230
+ onRestartRequested?: () => Promise<void> | void;
231
+ pushNotifier?: PushNotifier;
232
+ pushNotifierOptions?: PushNotifierOptions;
233
+ }
234
+ ): Promise<DaemonServer> {
235
+ const isTestMode = process.env.NODE_ENV === "test" || process.env.BUN_TEST === "1";
236
+ const daemonSettings = db.getDaemonSettings();
237
+
238
+ // Check if socket already exists
239
+ if (fs.existsSync(socketPath)) {
240
+ throw new Error(`Socket already exists: ${socketPath}`);
241
+ }
242
+
243
+ // Create policy engine with default action from daemon settings
244
+ const policyEngine = new PolicyEngine({
245
+ defaultAction: daemonSettings.defaultPolicyAction,
246
+ });
247
+ const auditLogger = new AuditLogger(db);
248
+
249
+ // Create WebSocket manager
250
+ const wsManager = new WebSocketManager(eventBus, {
251
+ enablePtyPaste:
252
+ process.env.CODEPIPER_WS_PTY_PASTE === "0"
253
+ ? false
254
+ : daemonSettings.terminalFeatures.wsPtyPasteEnabled,
255
+ onPtyInput: async (sessionId: string, data: string) => {
256
+ const policyCheck = await enforceInputPolicyPreflight(
257
+ { sessionManager, db, eventBus, policyEngine, auditLogger },
258
+ sessionId,
259
+ {
260
+ kind: "text",
261
+ input: data,
262
+ newline: false,
263
+ }
264
+ );
265
+ assertInputPolicyAllowed(policyCheck);
266
+ await sessionManager.sendText(sessionId, data);
267
+ },
268
+ onPtyPaste: async (sessionId: string, data: string) => {
269
+ const policyCheck = await enforceInputPolicyPreflight(
270
+ { sessionManager, db, eventBus, policyEngine, auditLogger },
271
+ sessionId,
272
+ {
273
+ kind: "text",
274
+ input: data,
275
+ newline: false,
276
+ }
277
+ );
278
+ assertInputPolicyAllowed(policyCheck);
279
+ await sessionManager.sendText(sessionId, data);
280
+ },
281
+ onPtyKey: async (sessionId: string, key: string) => {
282
+ const policyCheck = await enforceInputPolicyPreflight(
283
+ { sessionManager, db, eventBus, policyEngine, auditLogger },
284
+ sessionId,
285
+ {
286
+ kind: "keys",
287
+ keys: [key],
288
+ }
289
+ );
290
+ assertInputPolicyAllowed(policyCheck);
291
+ await sessionManager.sendKeys(sessionId, [key]);
292
+ },
293
+ });
294
+ const pushNotifier =
295
+ options?.pushNotifier ?? new PushNotifier(db, eventBus, options?.pushNotifierOptions);
296
+ pushNotifier.start();
297
+ const apiRateLimiter = new ApiRateLimiter({
298
+ maxRequests: parsePositiveIntEnv("CODEPIPER_API_RATE_LIMIT_MAX", DEFAULT_API_RATE_LIMIT_MAX),
299
+ windowMs: parsePositiveIntEnv(
300
+ "CODEPIPER_API_RATE_LIMIT_WINDOW_MS",
301
+ DEFAULT_API_RATE_LIMIT_WINDOW_MS
302
+ ),
303
+ });
304
+
305
+ const authService = options?.authService;
306
+ const rateLimiter = options?.rateLimiter;
307
+
308
+ // Hook secret for authenticating hook-forward requests
309
+ let hookSecret = process.env.CODEPIPER_SECRET;
310
+ if (!hookSecret) {
311
+ hookSecret = crypto.randomBytes(32).toString("hex");
312
+ process.env.CODEPIPER_SECRET = hookSecret;
313
+ if (!isTestMode) {
314
+ console.warn(
315
+ "[security] CODEPIPER_SECRET was missing. Generated an ephemeral secret for this daemon process."
316
+ );
317
+ }
318
+ }
319
+
320
+ // Create route context
321
+ const ctx: routes.RouteContext = {
322
+ sessionManager,
323
+ db,
324
+ eventBus,
325
+ policyEngine,
326
+ auditLogger,
327
+ authService,
328
+ rateLimiter,
329
+ hookSecret,
330
+ restartDaemon: options?.onRestartRequested,
331
+ pushNotifier,
332
+ };
333
+
334
+ // Define routes
335
+ const routeHandlers: Route[] = [
336
+ // Health & version
337
+ {
338
+ method: "GET",
339
+ pattern: /^\/health$/,
340
+ handler: async (req, ctx) => routes.handleHealth(req, ctx),
341
+ },
342
+ {
343
+ method: "GET",
344
+ pattern: /^\/version$/,
345
+ handler: async (req, ctx) => routes.handleVersion(req, ctx),
346
+ },
347
+ {
348
+ method: "GET",
349
+ pattern: /^\/providers$/,
350
+ handler: async (req, ctx) => routes.handleListProviders(req, ctx),
351
+ },
352
+
353
+ // Sessions
354
+ {
355
+ method: "GET",
356
+ pattern: /^\/sessions$/,
357
+ handler: async (req, ctx) => routes.handleListSessions(req, ctx),
358
+ },
359
+ {
360
+ method: "POST",
361
+ pattern: /^\/sessions$/,
362
+ handler: async (req, ctx) => routes.handleCreateSession(req, ctx),
363
+ },
364
+ {
365
+ method: "GET",
366
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)$/,
367
+ handler: async (req, ctx, sessionId) => routes.handleGetSession(req, ctx, sessionId),
368
+ },
369
+ {
370
+ method: "PUT",
371
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/name$/,
372
+ handler: async (req, ctx, sessionId) => routes.handleUpdateSessionName(req, ctx, sessionId),
373
+ },
374
+
375
+ // Session control
376
+ {
377
+ method: "POST",
378
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/stop$/,
379
+ handler: async (req, ctx, sessionId) => routes.handleStopSession(req, ctx, sessionId),
380
+ },
381
+ {
382
+ method: "POST",
383
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/kill$/,
384
+ handler: async (req, ctx, sessionId) => routes.handleKillSession(req, ctx, sessionId),
385
+ },
386
+ {
387
+ method: "POST",
388
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/resume$/,
389
+ handler: async (req, ctx, sessionId) => routes.handleResumeSession(req, ctx, sessionId),
390
+ },
391
+ {
392
+ method: "POST",
393
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/recover$/,
394
+ handler: async (req, ctx, sessionId) => routes.handleRecoverSession(req, ctx, sessionId),
395
+ },
396
+
397
+ // Session input
398
+ {
399
+ method: "POST",
400
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/send$/,
401
+ handler: async (req, ctx, sessionId) => routes.handleSendText(req, ctx, sessionId),
402
+ },
403
+ {
404
+ method: "POST",
405
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/keys$/,
406
+ handler: async (req, ctx, sessionId) => routes.handleSendKeys(req, ctx, sessionId),
407
+ },
408
+
409
+ // Model switching (claude-code only)
410
+ {
411
+ method: "PUT",
412
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/model$/,
413
+ handler: async (req, ctx, sessionId) => routes.handleSwitchModel(req, ctx, sessionId),
414
+ },
415
+ {
416
+ method: "GET",
417
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/model$/,
418
+ handler: async (req, ctx, sessionId) => routes.handleGetModel(req, ctx, sessionId),
419
+ },
420
+
421
+ // Session-specific policies
422
+ {
423
+ method: "GET",
424
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/policy$/,
425
+ handler: async (req, ctx, sessionId) => routes.handleGetSessionPolicy(req, ctx, sessionId),
426
+ },
427
+ {
428
+ method: "PUT",
429
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/policy$/,
430
+ handler: async (req, ctx, sessionId) => routes.handleSetSessionPolicy(req, ctx, sessionId),
431
+ },
432
+
433
+ // Session output (current terminal capture)
434
+ {
435
+ method: "GET",
436
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/output$/,
437
+ handler: async (req, ctx, sessionId) => routes.handleGetSessionOutput(req, ctx, sessionId),
438
+ },
439
+
440
+ // Session resize
441
+ {
442
+ method: "POST",
443
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/resize$/,
444
+ handler: async (req, ctx, sessionId) => routes.handleResizeSession(req, ctx, sessionId),
445
+ },
446
+
447
+ // Session image upload
448
+ {
449
+ method: "POST",
450
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/upload-image$/,
451
+ handler: async (req, ctx, sessionId) => routes.handleUploadImage(req, ctx, sessionId),
452
+ },
453
+
454
+ // Session events (all sources, or filtered by ?source=hook|transcript)
455
+ {
456
+ method: "GET",
457
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/events$/,
458
+ handler: async (req, ctx, sessionId) => routes.handleGetTranscriptEvents(req, ctx, sessionId),
459
+ },
460
+
461
+ // Session transcript events (legacy endpoint - filters to transcript source)
462
+ {
463
+ method: "GET",
464
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/transcript\/events$/,
465
+ handler: async (req, ctx, sessionId) => {
466
+ // Add source=transcript to the request URL
467
+ const url = new URL(req.url);
468
+ url.searchParams.set("source", "transcript");
469
+ const modifiedReq = new Request(url.toString(), req);
470
+ return routes.handleGetTranscriptEvents(modifiedReq, ctx, sessionId);
471
+ },
472
+ },
473
+
474
+ // Session notification preferences
475
+ {
476
+ method: "GET",
477
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/notifications\/prefs$/,
478
+ handler: async (req, ctx, sessionId) =>
479
+ notificationRoutes.handleGetSessionNotificationPrefs(req, ctx, sessionId),
480
+ },
481
+ {
482
+ method: "PUT",
483
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/notifications\/prefs$/,
484
+ handler: async (req, ctx, sessionId) =>
485
+ notificationRoutes.handleUpdateSessionNotificationPrefs(req, ctx, sessionId),
486
+ },
487
+
488
+ // Notification inbox
489
+ {
490
+ method: "GET",
491
+ pattern: /^\/notifications$/,
492
+ handler: async (req, ctx) => notificationRoutes.handleListNotifications(req, ctx),
493
+ },
494
+ {
495
+ method: "GET",
496
+ pattern: /^\/notifications\/counts$/,
497
+ handler: async (req, ctx) => notificationRoutes.handleGetNotificationCounts(req, ctx),
498
+ },
499
+ {
500
+ method: "GET",
501
+ pattern: /^\/notifications\/push\/status$/,
502
+ handler: async (req, ctx) => notificationRoutes.handleGetPushStatus(req, ctx),
503
+ },
504
+ {
505
+ method: "POST",
506
+ pattern: /^\/notifications\/push\/test$/,
507
+ handler: async (req, ctx) => notificationRoutes.handleSendTestPushNotification(req, ctx),
508
+ },
509
+ {
510
+ method: "GET",
511
+ pattern: /^\/notifications\/push\/subscriptions$/,
512
+ handler: async (req, ctx) => notificationRoutes.handleListPushSubscriptions(req, ctx),
513
+ },
514
+ {
515
+ method: "PUT",
516
+ pattern: /^\/notifications\/push\/subscriptions$/,
517
+ handler: async (req, ctx) => notificationRoutes.handleUpsertPushSubscription(req, ctx),
518
+ },
519
+ {
520
+ method: "DELETE",
521
+ pattern: /^\/notifications\/push\/subscriptions$/,
522
+ handler: async (req, ctx) => notificationRoutes.handleDeletePushSubscription(req, ctx),
523
+ },
524
+ {
525
+ method: "POST",
526
+ pattern: /^\/notifications\/read$/,
527
+ handler: async (req, ctx) => notificationRoutes.handleMarkNotificationsRead(req, ctx),
528
+ },
529
+ {
530
+ method: "POST",
531
+ pattern: /^\/notifications\/([0-9]+)\/read$/,
532
+ handler: async (req, ctx, notificationId) =>
533
+ notificationRoutes.handleMarkNotificationRead(req, ctx, notificationId),
534
+ },
535
+
536
+ // Hooks
537
+ {
538
+ method: "POST",
539
+ pattern: /^\/hooks\/claude$/,
540
+ handler: async (req, ctx) => routes.handleClaudeHook(req, ctx),
541
+ },
542
+
543
+ // Auth (these are also handled specially, but included for route existence checks)
544
+ {
545
+ method: "GET",
546
+ pattern: /^\/auth\/status$/,
547
+ handler: async (req, ctx) => {
548
+ const { handleAuthStatus } = await import("./authRoutes");
549
+ return handleAuthStatus(req, ctx);
550
+ },
551
+ },
552
+ {
553
+ method: "POST",
554
+ pattern: /^\/auth\/setup$/,
555
+ handler: async (req, ctx) => {
556
+ const { handleAuthSetup } = await import("./authRoutes");
557
+ return handleAuthSetup(req, ctx);
558
+ },
559
+ },
560
+ {
561
+ method: "POST",
562
+ pattern: /^\/auth\/login$/,
563
+ handler: async (req, ctx) => {
564
+ const { handleAuthLogin } = await import("./authRoutes");
565
+ return handleAuthLogin(req, ctx);
566
+ },
567
+ },
568
+ {
569
+ method: "POST",
570
+ pattern: /^\/auth\/logout$/,
571
+ handler: async (req, ctx) => {
572
+ const { handleAuthLogout } = await import("./authRoutes");
573
+ return handleAuthLogout(req, ctx);
574
+ },
575
+ },
576
+ {
577
+ method: "POST",
578
+ pattern: /^\/auth\/password$/,
579
+ handler: async (req, ctx) => {
580
+ const { handleAuthChangePassword } = await import("./authRoutes");
581
+ return handleAuthChangePassword(req, ctx);
582
+ },
583
+ },
584
+ {
585
+ method: "POST",
586
+ pattern: /^\/auth\/mfa\/setup$/,
587
+ handler: async (req, ctx) => {
588
+ const { handleMfaSetup } = await import("./authRoutes");
589
+ return handleMfaSetup(req, ctx);
590
+ },
591
+ },
592
+ {
593
+ method: "POST",
594
+ pattern: /^\/auth\/mfa\/verify$/,
595
+ handler: async (req, ctx) => {
596
+ const { handleMfaVerify } = await import("./authRoutes");
597
+ return handleMfaVerify(req, ctx);
598
+ },
599
+ },
600
+ {
601
+ method: "GET",
602
+ pattern: /^\/auth\/sessions$/,
603
+ handler: async (req, ctx) => {
604
+ const { handleAuthSessions } = await import("./authRoutes");
605
+ return handleAuthSessions(req, ctx);
606
+ },
607
+ },
608
+ {
609
+ method: "POST",
610
+ pattern: /^\/auth\/sessions\/revoke-all$/,
611
+ handler: async (req, ctx) => {
612
+ const { handleAuthRevokeAll } = await import("./authRoutes");
613
+ return handleAuthRevokeAll(req, ctx);
614
+ },
615
+ },
616
+ // CLI-only auth routes (Unix socket access only, but registered here for routing)
617
+ {
618
+ method: "POST",
619
+ pattern: /^\/auth\/cli\/reset-password$/,
620
+ handler: async (req, ctx) => {
621
+ const { handleCliResetPassword } = await import("./authRoutes");
622
+ return handleCliResetPassword(req, ctx);
623
+ },
624
+ },
625
+ {
626
+ method: "POST",
627
+ pattern: /^\/auth\/cli\/reset-mfa$/,
628
+ handler: async (req, ctx) => {
629
+ const { handleCliResetMfa } = await import("./authRoutes");
630
+ return handleCliResetMfa(req, ctx);
631
+ },
632
+ },
633
+
634
+ // Policies
635
+ {
636
+ method: "GET",
637
+ pattern: /^\/policies$/,
638
+ handler: async (req, ctx) => policyRoutes.handleListPolicies(req, ctx),
639
+ },
640
+ {
641
+ method: "POST",
642
+ pattern: /^\/policies$/,
643
+ handler: async (req, ctx) => policyRoutes.handleCreatePolicy(req, ctx),
644
+ },
645
+ {
646
+ method: "GET",
647
+ pattern: /^\/policies\/([a-zA-Z0-9-]+)$/,
648
+ handler: async (req, ctx, policyId) => policyRoutes.handleGetPolicy(req, ctx, policyId),
649
+ },
650
+ {
651
+ method: "PUT",
652
+ pattern: /^\/policies\/([a-zA-Z0-9-]+)$/,
653
+ handler: async (req, ctx, policyId) => policyRoutes.handleUpdatePolicy(req, ctx, policyId),
654
+ },
655
+ {
656
+ method: "DELETE",
657
+ pattern: /^\/policies\/([a-zA-Z0-9-]+)$/,
658
+ handler: async (req, ctx, policyId) => policyRoutes.handleDeletePolicy(req, ctx, policyId),
659
+ },
660
+
661
+ // Policy Sets
662
+ {
663
+ method: "GET",
664
+ pattern: /^\/policy-sets$/,
665
+ handler: async (req, ctx) => policySetRoutes.handleListPolicySets(req, ctx),
666
+ },
667
+ {
668
+ method: "POST",
669
+ pattern: /^\/policy-sets$/,
670
+ handler: async (req, ctx) => policySetRoutes.handleCreatePolicySet(req, ctx),
671
+ },
672
+ {
673
+ method: "GET",
674
+ pattern: /^\/policy-sets\/([a-zA-Z0-9-]+)$/,
675
+ handler: async (req, ctx, setId) => policySetRoutes.handleGetPolicySet(req, ctx, setId),
676
+ },
677
+ {
678
+ method: "PUT",
679
+ pattern: /^\/policy-sets\/([a-zA-Z0-9-]+)$/,
680
+ handler: async (req, ctx, setId) => policySetRoutes.handleUpdatePolicySet(req, ctx, setId),
681
+ },
682
+ {
683
+ method: "DELETE",
684
+ pattern: /^\/policy-sets\/([a-zA-Z0-9-]+)$/,
685
+ handler: async (req, ctx, setId) => policySetRoutes.handleDeletePolicySet(req, ctx, setId),
686
+ },
687
+ {
688
+ method: "POST",
689
+ pattern: /^\/policy-sets\/([a-zA-Z0-9-]+)\/policies$/,
690
+ handler: async (req, ctx, setId) => policySetRoutes.handleAddPolicyToSet(req, ctx, setId),
691
+ },
692
+ {
693
+ method: "DELETE",
694
+ pattern: /^\/policy-sets\/([a-zA-Z0-9-]+)\/policies\/([a-zA-Z0-9-]+)$/,
695
+ handler: async (req, ctx, setId, policyId) =>
696
+ policySetRoutes.handleRemovePolicyFromSet(req, ctx, setId, policyId),
697
+ },
698
+ // Session policy sets
699
+ {
700
+ method: "GET",
701
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/policy-sets$/,
702
+ handler: async (req, ctx, sessionId) =>
703
+ policySetRoutes.handleGetSessionPolicySets(req, ctx, sessionId),
704
+ },
705
+ {
706
+ method: "POST",
707
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/policy-sets$/,
708
+ handler: async (req, ctx, sessionId) =>
709
+ policySetRoutes.handleApplyPolicySetToSession(req, ctx, sessionId),
710
+ },
711
+ {
712
+ method: "DELETE",
713
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/policy-sets\/([a-zA-Z0-9-]+)$/,
714
+ handler: async (req, ctx, sessionId, setId) =>
715
+ policySetRoutes.handleRemovePolicySetFromSession(req, ctx, sessionId, setId),
716
+ },
717
+ {
718
+ method: "GET",
719
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/effective-policies$/,
720
+ handler: async (req, ctx, sessionId) =>
721
+ policySetRoutes.handleGetEffectivePolicies(req, ctx, sessionId),
722
+ },
723
+ // Policy decisions (audit log)
724
+ {
725
+ method: "GET",
726
+ pattern: /^\/policy-decisions$/,
727
+ handler: async (req, ctx) => policySetRoutes.handleListPolicyDecisions(req, ctx),
728
+ },
729
+
730
+ // Workspaces
731
+ {
732
+ method: "GET",
733
+ pattern: /^\/workspaces$/,
734
+ handler: async (req, ctx) => workspaceRoutes.handleListWorkspaces(req, ctx),
735
+ },
736
+ {
737
+ method: "POST",
738
+ pattern: /^\/workspaces$/,
739
+ handler: async (req, ctx) => workspaceRoutes.handleCreateWorkspace(req, ctx),
740
+ },
741
+ {
742
+ method: "GET",
743
+ pattern: /^\/workspaces\/([a-zA-Z0-9-]+)$/,
744
+ handler: async (req, ctx, id) => workspaceRoutes.handleGetWorkspace(req, ctx, id),
745
+ },
746
+ {
747
+ method: "PUT",
748
+ pattern: /^\/workspaces\/([a-zA-Z0-9-]+)$/,
749
+ handler: async (req, ctx, id) => workspaceRoutes.handleUpdateWorkspace(req, ctx, id),
750
+ },
751
+ {
752
+ method: "DELETE",
753
+ pattern: /^\/workspaces\/([a-zA-Z0-9-]+)$/,
754
+ handler: async (req, ctx, id) => workspaceRoutes.handleDeleteWorkspace(req, ctx, id),
755
+ },
756
+
757
+ // Env Sets
758
+ {
759
+ method: "GET",
760
+ pattern: /^\/env-sets$/,
761
+ handler: async (req, ctx) => envSetRoutes.handleListEnvSets(req, ctx),
762
+ },
763
+ {
764
+ method: "POST",
765
+ pattern: /^\/env-sets$/,
766
+ handler: async (req, ctx) => envSetRoutes.handleCreateEnvSet(req, ctx),
767
+ },
768
+ {
769
+ method: "GET",
770
+ pattern: /^\/env-sets\/([a-zA-Z0-9-]+)$/,
771
+ handler: async (req, ctx, id) => envSetRoutes.handleGetEnvSet(req, ctx, id),
772
+ },
773
+ {
774
+ method: "PUT",
775
+ pattern: /^\/env-sets\/([a-zA-Z0-9-]+)$/,
776
+ handler: async (req, ctx, id) => envSetRoutes.handleUpdateEnvSet(req, ctx, id),
777
+ },
778
+ {
779
+ method: "DELETE",
780
+ pattern: /^\/env-sets\/([a-zA-Z0-9-]+)$/,
781
+ handler: async (req, ctx, id) => envSetRoutes.handleDeleteEnvSet(req, ctx, id),
782
+ },
783
+
784
+ // Daemon Settings
785
+ {
786
+ method: "GET",
787
+ pattern: /^\/settings\/daemon$/,
788
+ handler: async (req, ctx) => settingsRoutes.handleGetDaemonSettings(req, ctx),
789
+ },
790
+ {
791
+ method: "PUT",
792
+ pattern: /^\/settings\/daemon$/,
793
+ handler: async (req, ctx) => settingsRoutes.handleUpdateDaemonSettings(req, ctx),
794
+ },
795
+ {
796
+ method: "POST",
797
+ pattern: /^\/settings\/daemon\/restart$/,
798
+ handler: async (req, ctx) => settingsRoutes.handleRestartDaemon(req, ctx),
799
+ },
800
+
801
+ // Session Validation
802
+ {
803
+ method: "POST",
804
+ pattern: /^\/sessions\/validate$/,
805
+ handler: async (req, ctx) => validationRoutes.handleValidateSession(req, ctx),
806
+ },
807
+ {
808
+ method: "POST",
809
+ pattern: /^\/sessions\/validate-git$/,
810
+ handler: async (req, ctx) => validationRoutes.handleValidateGit(req, ctx),
811
+ },
812
+
813
+ // Analytics
814
+ {
815
+ method: "GET",
816
+ pattern: /^\/analytics\/overview$/,
817
+ handler: async (req, ctx) => analyticsRoutes.handleAnalyticsOverview(req, ctx),
818
+ },
819
+ {
820
+ method: "GET",
821
+ pattern: /^\/analytics\/activity-timeline$/,
822
+ handler: async (req, ctx) => analyticsRoutes.handleActivityTimeline(req, ctx),
823
+ },
824
+ {
825
+ method: "GET",
826
+ pattern: /^\/analytics\/tokens-by-model$/,
827
+ handler: async (req, ctx) => analyticsRoutes.handleTokensByModel(req, ctx),
828
+ },
829
+ {
830
+ method: "GET",
831
+ pattern: /^\/analytics\/token-usage$/,
832
+ handler: async (req, ctx) => analyticsRoutes.handleTokenUsage(req, ctx),
833
+ },
834
+ {
835
+ method: "GET",
836
+ pattern: /^\/analytics\/sessions-by-provider$/,
837
+ handler: async (req, ctx) => analyticsRoutes.handleSessionsByProvider(req, ctx),
838
+ },
839
+ {
840
+ method: "GET",
841
+ pattern: /^\/analytics\/tool-usage$/,
842
+ handler: async (req, ctx) => analyticsRoutes.handleToolUsage(req, ctx),
843
+ },
844
+ {
845
+ method: "GET",
846
+ pattern: /^\/analytics\/policy-decisions$/,
847
+ handler: async (req, ctx) => analyticsRoutes.handlePolicyDecisions(req, ctx),
848
+ },
849
+
850
+ // Git operations (per session)
851
+ {
852
+ method: "GET",
853
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/status$/,
854
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitStatus(req, ctx, sessionId),
855
+ },
856
+ {
857
+ method: "GET",
858
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/branches$/,
859
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitBranches(req, ctx, sessionId),
860
+ },
861
+ {
862
+ method: "GET",
863
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/log$/,
864
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitLog(req, ctx, sessionId),
865
+ },
866
+ {
867
+ method: "GET",
868
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/diff$/,
869
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitDiff(req, ctx, sessionId),
870
+ },
871
+ {
872
+ method: "GET",
873
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/file$/,
874
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitFile(req, ctx, sessionId),
875
+ },
876
+ {
877
+ method: "GET",
878
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/file-raw$/,
879
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitFileRaw(req, ctx, sessionId),
880
+ },
881
+ {
882
+ method: "GET",
883
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/diff-stat$/,
884
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitDiffStat(req, ctx, sessionId),
885
+ },
886
+ {
887
+ method: "POST",
888
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/stage$/,
889
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitStage(req, ctx, sessionId),
890
+ },
891
+ {
892
+ method: "POST",
893
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/git\/unstage$/,
894
+ handler: async (req, ctx, sessionId) => gitRoutes.handleGitUnstage(req, ctx, sessionId),
895
+ },
896
+
897
+ // Terminal modes (scroll, search)
898
+ {
899
+ method: "GET",
900
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/terminal\/info$/,
901
+ handler: async (req, ctx, sessionId) =>
902
+ terminalRoutes.handleGetTerminalInfo(req, ctx, sessionId),
903
+ },
904
+ {
905
+ method: "POST",
906
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/terminal\/mode$/,
907
+ handler: async (req, ctx, sessionId) =>
908
+ terminalRoutes.handleTerminalMode(req, ctx, sessionId),
909
+ },
910
+ {
911
+ method: "POST",
912
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/terminal\/scroll$/,
913
+ handler: async (req, ctx, sessionId) =>
914
+ terminalRoutes.handleTerminalScroll(req, ctx, sessionId),
915
+ },
916
+ {
917
+ method: "POST",
918
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/terminal\/search$/,
919
+ handler: async (req, ctx, sessionId) =>
920
+ terminalRoutes.handleTerminalSearch(req, ctx, sessionId),
921
+ },
922
+ {
923
+ method: "POST",
924
+ pattern: /^\/sessions\/([a-zA-Z0-9-]+)\/terminal\/transcribe$/,
925
+ handler: async (req, ctx, sessionId) =>
926
+ terminalRoutes.handleTerminalTranscribe(req, ctx, sessionId),
927
+ },
928
+
929
+ // Workflows
930
+ {
931
+ method: "GET",
932
+ pattern: /^\/workflows$/,
933
+ handler: async (req, ctx) => workflowRoutes.handleListWorkflows(req, ctx),
934
+ },
935
+ {
936
+ method: "POST",
937
+ pattern: /^\/workflows$/,
938
+ handler: async (req, ctx) => workflowRoutes.handleCreateWorkflow(req, ctx),
939
+ },
940
+ {
941
+ method: "GET",
942
+ pattern: /^\/workflows\/executions\/([a-zA-Z0-9-]+)$/,
943
+ handler: async (req, ctx, execId) => workflowRoutes.handleGetExecutionById(req, ctx, execId),
944
+ },
945
+ {
946
+ method: "POST",
947
+ pattern: /^\/workflows\/executions\/([a-zA-Z0-9-]+)\/cancel$/,
948
+ handler: async (req, ctx, execId) =>
949
+ workflowRoutes.handleCancelExecutionById(req, ctx, execId),
950
+ },
951
+ {
952
+ method: "GET",
953
+ pattern: /^\/workflows\/([a-zA-Z0-9-_]+)$/,
954
+ handler: async (req, ctx, workflowId) =>
955
+ workflowRoutes.handleGetWorkflow(req, ctx, workflowId),
956
+ },
957
+ {
958
+ method: "DELETE",
959
+ pattern: /^\/workflows\/([a-zA-Z0-9-_]+)$/,
960
+ handler: async (req, ctx, workflowId) =>
961
+ workflowRoutes.handleDeleteWorkflow(req, ctx, workflowId),
962
+ },
963
+ {
964
+ method: "POST",
965
+ pattern: /^\/workflows\/([a-zA-Z0-9-_]+)\/execute$/,
966
+ handler: async (req, ctx, workflowId) =>
967
+ workflowRoutes.handleExecuteWorkflow(req, ctx, workflowId),
968
+ },
969
+ {
970
+ method: "GET",
971
+ pattern: /^\/workflows\/([a-zA-Z0-9-_]+)\/executions$/,
972
+ handler: async (req, ctx, workflowId) =>
973
+ workflowRoutes.handleListExecutions(req, ctx, workflowId),
974
+ },
975
+ {
976
+ method: "GET",
977
+ pattern: /^\/workflows\/([a-zA-Z0-9-_]+)\/executions\/([a-zA-Z0-9-]+)$/,
978
+ handler: async (req, ctx, workflowId, execId) =>
979
+ workflowRoutes.handleGetExecution(req, ctx, workflowId, execId),
980
+ },
981
+ {
982
+ method: "POST",
983
+ pattern: /^\/workflows\/([a-zA-Z0-9-_]+)\/executions\/([a-zA-Z0-9-]+)\/cancel$/,
984
+ handler: async (req, ctx, workflowId, execId) =>
985
+ workflowRoutes.handleCancelExecution(req, ctx, workflowId, execId),
986
+ },
987
+ ];
988
+
989
+ // Server reference (will be set after Bun.serve)
990
+ let serverRef: Server<unknown>;
991
+
992
+ // Create request handler
993
+ const handleRequest = async (req: Request): Promise<Response> => {
994
+ const url = new URL(req.url);
995
+ const method = req.method;
996
+ const pathname = url.pathname;
997
+
998
+ // Handle WebSocket upgrade on /ws
999
+ if (pathname === "/ws") {
1000
+ if (serverRef?.upgrade(req, { data: WS_UPGRADE_DATA })) {
1001
+ return new Response(null); // Upgrade successful, handled by websocket handlers
1002
+ }
1003
+ return new Response("WebSocket upgrade failed", { status: 400 });
1004
+ }
1005
+
1006
+ const bodyLimitResult = await enforceRequestBodyLimit(req, pathname);
1007
+ if (bodyLimitResult instanceof Response) {
1008
+ return bodyLimitResult;
1009
+ }
1010
+ req = bodyLimitResult;
1011
+
1012
+ // Find matching route
1013
+ for (const route of routeHandlers) {
1014
+ if (route.method !== method) {
1015
+ continue;
1016
+ }
1017
+
1018
+ const match = pathname.match(route.pattern);
1019
+ if (match) {
1020
+ try {
1021
+ // Extract path parameters (exclude full match)
1022
+ const params = match.slice(1);
1023
+ return await route.handler(req, ctx, ...params);
1024
+ } catch (error) {
1025
+ // Handle JSON parsing errors
1026
+ if (error instanceof SyntaxError) {
1027
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
1028
+ }
1029
+
1030
+ // Log error and return 500 (no internal details in response)
1031
+ console.error("Error handling request:", error);
1032
+ return Response.json({ error: "Internal server error" }, { status: 500 });
1033
+ }
1034
+ }
1035
+ }
1036
+
1037
+ // Check if route exists but method is wrong
1038
+ const routeExists = routeHandlers.some((route) => pathname.match(route.pattern));
1039
+
1040
+ if (routeExists) {
1041
+ return Response.json(
1042
+ { error: `Method ${method} not allowed for ${pathname}` },
1043
+ { status: 405 }
1044
+ );
1045
+ }
1046
+
1047
+ // No route found
1048
+ return Response.json({ error: `Not found: ${pathname}` }, { status: 404 });
1049
+ };
1050
+
1051
+ // WebSocket handlers (shared between Unix and TCP servers)
1052
+ const wsHandlers = {
1053
+ open(ws: ServerWebSocket<unknown>) {
1054
+ wsManager.handleConnection(ws);
1055
+ },
1056
+ message(ws: ServerWebSocket<unknown>, message: string | Buffer) {
1057
+ try {
1058
+ const data = parseWsMessage(message);
1059
+ wsManager.handleMessage(ws, data);
1060
+ } catch (error) {
1061
+ console.error("WebSocket message error:", error);
1062
+ const errorMessage = error instanceof Error ? error.message : "Invalid message";
1063
+ const normalized = errorMessage.toLowerCase();
1064
+ if (normalized.includes("too large")) {
1065
+ ws.close(1009, "Message too large");
1066
+ return;
1067
+ }
1068
+ if (normalized.includes("rate limit")) {
1069
+ ws.close(1013, "Rate limit exceeded");
1070
+ return;
1071
+ }
1072
+ ws.send(JSON.stringify({ error: errorMessage }));
1073
+ }
1074
+ },
1075
+ close(ws: ServerWebSocket<unknown>) {
1076
+ wsManager.handleDisconnect(ws);
1077
+ },
1078
+ drain(ws: ServerWebSocket<unknown>) {
1079
+ wsManager.handleDrain(ws);
1080
+ },
1081
+ };
1082
+
1083
+ // Start main server on unix socket
1084
+ try {
1085
+ serverRef = Bun.serve({
1086
+ unix: socketPath,
1087
+ fetch: handleRequest,
1088
+ websocket: wsHandlers,
1089
+ maxRequestBodySize: MAX_IMAGE_BODY_SIZE,
1090
+ });
1091
+ // Restrict socket to owner only (prevents other local users from connecting)
1092
+ fs.chmodSync(socketPath, 0o600);
1093
+ } catch (err: any) {
1094
+ pushNotifier.stop();
1095
+ if (err?.code === "EADDRINUSE") {
1096
+ throw new Error(
1097
+ `Socket ${socketPath} is already in use. Another daemon may be running.\n` +
1098
+ ` Kill it with: pkill -f "bun.*daemon" or rm ${socketPath}`
1099
+ );
1100
+ }
1101
+ throw err;
1102
+ }
1103
+
1104
+ const server = serverRef;
1105
+
1106
+ // Also start WebSocket server on TCP for clients that don't support Unix socket WebSockets
1107
+ // In test mode, use port 0 to get random available port
1108
+ const wsPort = isTestMode ? 0 : Number.parseInt(process.env.CODEPIPER_WS_PORT || "9999", 10);
1109
+
1110
+ let wsServer: ReturnType<typeof Bun.serve>;
1111
+ try {
1112
+ wsServer = Bun.serve({
1113
+ port: wsPort,
1114
+ hostname: "127.0.0.1",
1115
+ fetch: async (req: Request) => {
1116
+ const url = new URL(req.url);
1117
+ if (url.pathname === "/ws") {
1118
+ const originReject = rejectNonLocalOrigin(req);
1119
+ if (originReject) return originReject;
1120
+
1121
+ // Auth check on WS upgrade (cookie or Authorization header only — no query params)
1122
+ if (authService) {
1123
+ const token = extractToken(req);
1124
+ if (!(token && authService.validateSession(hashToken(token)))) {
1125
+ return Response.json({ error: "Authentication required" }, { status: 401 });
1126
+ }
1127
+ }
1128
+ if (wsServer.upgrade(req, { data: WS_UPGRADE_DATA })) {
1129
+ return new Response(null);
1130
+ }
1131
+ return new Response("WebSocket upgrade failed", { status: 400 });
1132
+ }
1133
+ return new Response("WebSocket endpoint only - use Unix socket for HTTP API", {
1134
+ status: 400,
1135
+ });
1136
+ },
1137
+ websocket: wsHandlers,
1138
+ maxRequestBodySize: MAX_IMAGE_BODY_SIZE,
1139
+ });
1140
+ } catch (err: any) {
1141
+ pushNotifier.stop();
1142
+ wsManager.shutdown();
1143
+ apiRateLimiter.destroy();
1144
+ server.stop(true);
1145
+ cleanupSocketFile(socketPath);
1146
+ if (err?.code === "EADDRINUSE") {
1147
+ throw new Error(
1148
+ `WebSocket port ${wsPort} is already in use. Another daemon may be running.\n` +
1149
+ ` Kill it with: pkill -f "bun.*daemon" or lsof -ti:${wsPort} | xargs kill\n` +
1150
+ ` Or use a different port: CODEPIPER_WS_PORT=9998 codepiper daemon`
1151
+ );
1152
+ }
1153
+ throw err;
1154
+ }
1155
+
1156
+ // HTTP server: starts in development mode OR when webDir is provided
1157
+ const isDevelopment = process.env.NODE_ENV === "development";
1158
+ const webDir = options?.webDir;
1159
+ const shouldStartHttp = isDevelopment || webDir;
1160
+ const httpPort = options?.httpPort
1161
+ ? options.httpPort
1162
+ : Number.parseInt(process.env.CODEPIPER_HTTP_PORT || "3000", 10);
1163
+ let httpServer: Server<unknown> | null = null;
1164
+
1165
+ // Create HTTP request handler that serves API + static files (with auth)
1166
+ const httpFetchHandler = async (req: Request): Promise<Response> => {
1167
+ const url = new URL(req.url);
1168
+ const pathname = url.pathname;
1169
+
1170
+ // Handle WebSocket upgrade (with auth — cookie or Authorization header only)
1171
+ if (pathname === "/ws") {
1172
+ const originReject = rejectNonLocalOrigin(req);
1173
+ if (originReject) return addSecurityHeaders(originReject);
1174
+
1175
+ if (authService) {
1176
+ const token = extractToken(req);
1177
+ if (!(token && authService.validateSession(hashToken(token)))) {
1178
+ return Response.json({ error: "Authentication required" }, { status: 401 });
1179
+ }
1180
+ }
1181
+ if (httpServer?.upgrade(req, { data: WS_UPGRADE_DATA })) {
1182
+ return new Response(null);
1183
+ }
1184
+ return new Response("WebSocket upgrade failed", { status: 400 });
1185
+ }
1186
+
1187
+ // Determine the API path (strip /api prefix if present)
1188
+ const isApiRoute = pathname.startsWith("/api");
1189
+ const apiPath = isApiRoute ? pathname.replace(/^\/api/, "") || "/" : pathname;
1190
+
1191
+ if (isApiRoute) {
1192
+ const csrfReject = rejectCrossSiteApiRequest(req);
1193
+ if (csrfReject) {
1194
+ return addSecurityHeaders(csrfReject);
1195
+ }
1196
+ }
1197
+
1198
+ // Block CLI-only routes from HTTP — always, regardless of auth config
1199
+ if (isApiRoute && apiPath.startsWith("/auth/cli/")) {
1200
+ return addSecurityHeaders(Response.json({ error: "Not found" }, { status: 404 }));
1201
+ }
1202
+
1203
+ // Auth enforcement for API routes
1204
+ if (authService && isApiRoute) {
1205
+ if (!isPublicRoute(req.method, apiPath)) {
1206
+ const sessionTokenHash = extractAndHashToken(req);
1207
+ if (!sessionTokenHash) {
1208
+ const allowsOnboardingBypass =
1209
+ isMfaOnboardingRoute(apiPath) && authService.isMfaSetupPending();
1210
+ if (allowsOnboardingBypass) {
1211
+ const onboardingTokenHash = extractAndHashOnboardingToken(req);
1212
+ if (onboardingTokenHash && authService.validateOnboardingToken(onboardingTokenHash)) {
1213
+ // Allow onboarding MFA routes with a valid onboarding cookie token.
1214
+ // No auth session to touch in this branch.
1215
+ } else {
1216
+ return addSecurityHeaders(
1217
+ Response.json(
1218
+ {
1219
+ error: "Authentication required",
1220
+ setupRequired: authService.isSetupRequired(),
1221
+ mfaSetupRequired: authService.isMfaSetupPending(),
1222
+ },
1223
+ { status: 401 }
1224
+ )
1225
+ );
1226
+ }
1227
+ } else {
1228
+ return addSecurityHeaders(
1229
+ Response.json(
1230
+ {
1231
+ error: "Authentication required",
1232
+ setupRequired: authService.isSetupRequired(),
1233
+ mfaSetupRequired: authService.isMfaSetupPending(),
1234
+ },
1235
+ { status: 401 }
1236
+ )
1237
+ );
1238
+ }
1239
+ } else if (!authService.validateSession(sessionTokenHash)) {
1240
+ return addSecurityHeaders(
1241
+ Response.json(
1242
+ {
1243
+ error: "Session expired",
1244
+ mfaSetupRequired: authService.isMfaSetupPending(),
1245
+ },
1246
+ { status: 401 }
1247
+ )
1248
+ );
1249
+ } else {
1250
+ authService.touchSession(sessionTokenHash);
1251
+ }
1252
+ }
1253
+ }
1254
+
1255
+ // Route API requests
1256
+ if (isApiRoute) {
1257
+ // Basic API abuse protection for browser-facing HTTP routes.
1258
+ const clientIp = getClientIp(req, httpServer ?? undefined);
1259
+ const rateLimit = apiRateLimiter.consume(clientIp);
1260
+ if (!rateLimit.allowed) {
1261
+ const retryAfterSeconds = Math.max(1, Math.ceil((rateLimit.retryAfterMs ?? 0) / 1000));
1262
+ return addSecurityHeaders(
1263
+ new Response(
1264
+ JSON.stringify({ error: "Too many requests", retryAfter: retryAfterSeconds }),
1265
+ {
1266
+ status: 429,
1267
+ headers: {
1268
+ "Content-Type": "application/json",
1269
+ "Retry-After": String(retryAfterSeconds),
1270
+ },
1271
+ }
1272
+ )
1273
+ );
1274
+ }
1275
+
1276
+ const apiUrl = new URL(apiPath + url.search, url.origin);
1277
+ const apiReq = new Request(apiUrl.toString(), req);
1278
+ const response = await handleRequest(apiReq);
1279
+ return addSecurityHeaders(response);
1280
+ }
1281
+
1282
+ // Static file serving (only when webDir is configured)
1283
+ if (webDir) {
1284
+ const relativePath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
1285
+ const filePath = path.resolve(webDir, relativePath);
1286
+
1287
+ // Path traversal protection
1288
+ if (!isPathWithinBaseDir(webDir, filePath)) {
1289
+ return addSecurityHeaders(new Response("Forbidden", { status: 403 }));
1290
+ }
1291
+
1292
+ const file = Bun.file(filePath);
1293
+ if (await file.exists()) {
1294
+ if (!isRealPathWithinBaseDir(webDir, filePath)) {
1295
+ return addSecurityHeaders(new Response("Forbidden", { status: 403 }));
1296
+ }
1297
+ return addSecurityHeaders(new Response(file));
1298
+ }
1299
+
1300
+ // SPA fallback: serve index.html for client-side routing
1301
+ const indexPath = path.join(webDir, "index.html");
1302
+ const indexFile = Bun.file(indexPath);
1303
+ if (await indexFile.exists()) {
1304
+ if (!isRealPathWithinBaseDir(webDir, indexPath)) {
1305
+ return addSecurityHeaders(new Response("Forbidden", { status: 403 }));
1306
+ }
1307
+ return addSecurityHeaders(
1308
+ new Response(indexFile, { headers: { "Content-Type": "text/html" } })
1309
+ );
1310
+ }
1311
+ }
1312
+
1313
+ // For browser requests (Accept: text/html), return an HTML redirect to root
1314
+ // instead of raw JSON — handles SPA route refreshes when webDir is missing
1315
+ const acceptHeader = req.headers.get("Accept") || "";
1316
+ if (acceptHeader.includes("text/html")) {
1317
+ return addSecurityHeaders(
1318
+ new Response(
1319
+ `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=/"></head><body>Redirecting...</body></html>`,
1320
+ { status: 302, headers: { "Content-Type": "text/html", Location: "/" } }
1321
+ )
1322
+ );
1323
+ }
1324
+
1325
+ return addSecurityHeaders(Response.json({ error: `Not found: ${pathname}` }, { status: 404 }));
1326
+ };
1327
+
1328
+ if (shouldStartHttp && httpPort > 0) {
1329
+ try {
1330
+ httpServer = Bun.serve({
1331
+ port: httpPort,
1332
+ hostname: "127.0.0.1",
1333
+ fetch: httpFetchHandler,
1334
+ websocket: wsHandlers,
1335
+ maxRequestBodySize: MAX_IMAGE_BODY_SIZE,
1336
+ });
1337
+ } catch (err: any) {
1338
+ pushNotifier.stop();
1339
+ wsManager.shutdown();
1340
+ apiRateLimiter.destroy();
1341
+ server.stop(true);
1342
+ wsServer.stop(true);
1343
+ cleanupSocketFile(socketPath);
1344
+ if (err?.code === "EADDRINUSE") {
1345
+ throw new Error(
1346
+ `HTTP port ${httpPort} is already in use.\n` +
1347
+ ` Kill the process: lsof -ti:${httpPort} | xargs kill\n` +
1348
+ ` Or use a different port: codepiper daemon --web --port ${httpPort + 1}`
1349
+ );
1350
+ }
1351
+ throw err;
1352
+ }
1353
+ if (webDir) {
1354
+ console.log(`Web dashboard: http://127.0.0.1:${httpServer.port}`);
1355
+ } else {
1356
+ console.log(`Development HTTP server running on http://127.0.0.1:${httpServer.port}`);
1357
+ }
1358
+ }
1359
+
1360
+ // Return server interface
1361
+ return {
1362
+ wsManager,
1363
+ wsPort: wsServer.port ?? wsPort, // Use actual assigned port (important when port 0 is used)
1364
+ httpPort: httpServer?.port ?? 0,
1365
+ eventBus,
1366
+ db,
1367
+ async stop() {
1368
+ pushNotifier.stop();
1369
+
1370
+ // Shutdown WebSocket manager first
1371
+ wsManager.shutdown();
1372
+
1373
+ // Stop all servers
1374
+ server.stop(true);
1375
+ wsServer.stop(true);
1376
+ if (httpServer) {
1377
+ httpServer.stop(true);
1378
+ }
1379
+ apiRateLimiter.destroy();
1380
+
1381
+ // Close database
1382
+ db.close();
1383
+
1384
+ // Clean up socket file
1385
+ cleanupSocketFile(socketPath);
1386
+ },
1387
+ };
1388
+ }