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.
- package/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- 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
|
+
}
|