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,1004 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API route handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EventBus } from "@codepiper/core";
|
|
6
|
+
import { SessionNotFoundError } from "@codepiper/core";
|
|
7
|
+
import type { Database, EventSource } from "../db/db";
|
|
8
|
+
import type { PushNotifier } from "../notifications/pushNotifier";
|
|
9
|
+
import {
|
|
10
|
+
getProviderDefinition,
|
|
11
|
+
isProviderId,
|
|
12
|
+
listProviderDefinitions,
|
|
13
|
+
listSupportedProviders,
|
|
14
|
+
} from "../providers/registry";
|
|
15
|
+
import type { AuditLogger } from "../sessions/auditLogger";
|
|
16
|
+
import type { PolicyEngine } from "../sessions/policyEngine";
|
|
17
|
+
import type { SessionManager } from "../sessions/sessionManager";
|
|
18
|
+
import { handleHookEvent } from "./hooks";
|
|
19
|
+
import { enforceInputPolicyPreflight } from "./inputPolicy";
|
|
20
|
+
import {
|
|
21
|
+
validateArgs,
|
|
22
|
+
validateCwd,
|
|
23
|
+
validateEnv,
|
|
24
|
+
validateImageUpload,
|
|
25
|
+
validateKeys,
|
|
26
|
+
validateModel,
|
|
27
|
+
validateText,
|
|
28
|
+
} from "./validation";
|
|
29
|
+
|
|
30
|
+
export interface RouteContext {
|
|
31
|
+
sessionManager: SessionManager;
|
|
32
|
+
db: Database;
|
|
33
|
+
eventBus: EventBus;
|
|
34
|
+
policyEngine: PolicyEngine;
|
|
35
|
+
auditLogger: AuditLogger;
|
|
36
|
+
authService?: import("../auth/authService").AuthService;
|
|
37
|
+
rateLimiter?: import("../auth/rateLimiter").RateLimiter;
|
|
38
|
+
/** Shared daemon secret for authenticating hook requests */
|
|
39
|
+
hookSecret?: string;
|
|
40
|
+
/** Optional callback to request daemon restart (used by settings endpoint) */
|
|
41
|
+
restartDaemon?: () => Promise<void> | void;
|
|
42
|
+
/** Optional push notifier status provider */
|
|
43
|
+
pushNotifier?: PushNotifier;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SESSION_CUSTOM_NAME_MAX_LENGTH = 80;
|
|
47
|
+
|
|
48
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasControlCharacters(value: string): boolean {
|
|
53
|
+
for (let index = 0; index < value.length; index++) {
|
|
54
|
+
const codePoint = value.charCodeAt(index);
|
|
55
|
+
if ((codePoint >= 0 && codePoint <= 31) || codePoint === 127) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function upsertSessionCustomName(
|
|
63
|
+
metadata: Record<string, unknown> | undefined,
|
|
64
|
+
customName: string | null
|
|
65
|
+
): Record<string, unknown> {
|
|
66
|
+
const current = isObjectRecord(metadata) ? metadata : {};
|
|
67
|
+
const next = { ...current };
|
|
68
|
+
const currentUi = isObjectRecord(current.ui) ? current.ui : {};
|
|
69
|
+
const nextUi = { ...currentUi };
|
|
70
|
+
|
|
71
|
+
if (customName) {
|
|
72
|
+
nextUi.customName = customName;
|
|
73
|
+
} else {
|
|
74
|
+
delete nextUi.customName;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (Object.keys(nextUi).length > 0) {
|
|
78
|
+
next.ui = nextUi;
|
|
79
|
+
} else {
|
|
80
|
+
delete next.ui;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Object.keys(next).length > 0 ? next : {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function errorResponse(error: unknown, fallbackStatus = 500): Response {
|
|
87
|
+
if (error instanceof SessionNotFoundError) {
|
|
88
|
+
return Response.json({ error: error.message }, { status: 404 });
|
|
89
|
+
}
|
|
90
|
+
return Response.json(
|
|
91
|
+
{ error: error instanceof Error ? error.message : "Unknown error" },
|
|
92
|
+
{ status: fallbackStatus }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function listActiveDbSessions(ctx: RouteContext) {
|
|
97
|
+
return [
|
|
98
|
+
...ctx.db.listSessions({ status: "RUNNING" }),
|
|
99
|
+
...ctx.db.listSessions({ status: "STARTING" }),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function listCodepiperTmuxSessionNames(): Set<string> | null {
|
|
104
|
+
try {
|
|
105
|
+
const tmuxResult = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}"], {
|
|
106
|
+
stdout: "pipe",
|
|
107
|
+
stderr: "ignore",
|
|
108
|
+
});
|
|
109
|
+
if (tmuxResult.exitCode !== 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const names = (tmuxResult.stdout?.toString() ?? "")
|
|
114
|
+
.split("\n")
|
|
115
|
+
.map((name) => name.trim())
|
|
116
|
+
.filter((name) => name.startsWith("codepiper-"));
|
|
117
|
+
return new Set(names);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function handleHealth(_req: Request, ctx: RouteContext): Promise<Response> {
|
|
124
|
+
const activeDbSessions = listActiveDbSessions(ctx);
|
|
125
|
+
const activeInMemorySessionIds = new Set(
|
|
126
|
+
ctx.sessionManager.listSessions().map((session) => session.id)
|
|
127
|
+
);
|
|
128
|
+
const tmuxSessionNames = listCodepiperTmuxSessionNames();
|
|
129
|
+
|
|
130
|
+
let zombieSessionCount = 0;
|
|
131
|
+
for (const session of activeDbSessions) {
|
|
132
|
+
if (activeInMemorySessionIds.has(session.id)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (tmuxSessionNames?.has(`codepiper-${session.id}`)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
zombieSessionCount++;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Response.json({
|
|
142
|
+
status: "ok",
|
|
143
|
+
zombieSessionCount,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function handleVersion(_req: Request, _ctx: RouteContext): Promise<Response> {
|
|
148
|
+
return Response.json({
|
|
149
|
+
version: "0.1.0",
|
|
150
|
+
bun: Bun.version,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function handleListProviders(_req: Request, _ctx: RouteContext): Promise<Response> {
|
|
155
|
+
return Response.json({
|
|
156
|
+
providers: listProviderDefinitions().map((provider) => ({
|
|
157
|
+
id: provider.id,
|
|
158
|
+
label: provider.label,
|
|
159
|
+
runtime: provider.runtime,
|
|
160
|
+
capabilities: provider.capabilities,
|
|
161
|
+
launchHints: provider.launchHints,
|
|
162
|
+
})),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function handleListSessions(_req: Request, ctx: RouteContext): Promise<Response> {
|
|
167
|
+
// Merge in-memory (active) sessions with DB (stopped/crashed) sessions
|
|
168
|
+
const activeSessions = ctx.sessionManager.listSessions();
|
|
169
|
+
const activeIds = new Set(activeSessions.map((s) => s.id));
|
|
170
|
+
const dbSessions = ctx.db.listSessions().filter((s) => !activeIds.has(s.id));
|
|
171
|
+
|
|
172
|
+
// Health check DB-only sessions that claim to be active
|
|
173
|
+
const adoptedSessions: any[] = [];
|
|
174
|
+
const remainingDbSessions: typeof dbSessions = [];
|
|
175
|
+
|
|
176
|
+
for (const session of dbSessions) {
|
|
177
|
+
if (session.status === "RUNNING" || session.status === "STARTING") {
|
|
178
|
+
const tmuxCheck = Bun.spawnSync(["tmux", "has-session", "-t", `codepiper-${session.id}`], {
|
|
179
|
+
stdout: "ignore",
|
|
180
|
+
stderr: "ignore",
|
|
181
|
+
});
|
|
182
|
+
if (tmuxCheck.exitCode !== 0) {
|
|
183
|
+
ctx.db.updateSession(session.id, { status: "STOPPED" });
|
|
184
|
+
session.status = "STOPPED";
|
|
185
|
+
remainingDbSessions.push(session);
|
|
186
|
+
} else {
|
|
187
|
+
// Tmux alive but not in memory — auto-adopt
|
|
188
|
+
try {
|
|
189
|
+
const adopted = await ctx.sessionManager.adoptSession(session.id);
|
|
190
|
+
adoptedSessions.push(adopted);
|
|
191
|
+
} catch {
|
|
192
|
+
remainingDbSessions.push(session);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
remainingDbSessions.push(session);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return Response.json({
|
|
201
|
+
sessions: [...activeSessions, ...adoptedSessions, ...remainingDbSessions],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function handleCreateSession(req: Request, ctx: RouteContext): Promise<Response> {
|
|
206
|
+
let body: any;
|
|
207
|
+
try {
|
|
208
|
+
body = await req.json();
|
|
209
|
+
} catch {
|
|
210
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate required fields
|
|
214
|
+
if (!body.provider) {
|
|
215
|
+
return Response.json({ error: "Missing required field: provider" }, { status: 400 });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!body.cwd) {
|
|
219
|
+
return Response.json({ error: "Missing required field: cwd" }, { status: 400 });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Validate provider
|
|
223
|
+
const validProviders = listSupportedProviders();
|
|
224
|
+
if (!(typeof body.provider === "string" && isProviderId(body.provider))) {
|
|
225
|
+
return Response.json(
|
|
226
|
+
{
|
|
227
|
+
error: `Invalid provider: ${body.provider}. Must be one of: ${validProviders.join(", ")}`,
|
|
228
|
+
},
|
|
229
|
+
{ status: 400 }
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const providerDefinition = getProviderDefinition(body.provider);
|
|
233
|
+
|
|
234
|
+
// Validate cwd (path length, format)
|
|
235
|
+
const cwdValidation = validateCwd(body.cwd);
|
|
236
|
+
if (!cwdValidation.valid) {
|
|
237
|
+
return Response.json({ error: cwdValidation.error }, { status: 400 });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Validate optional env (if provided)
|
|
241
|
+
if (body.env !== undefined) {
|
|
242
|
+
const envValidation = validateEnv(body.env);
|
|
243
|
+
if (!envValidation.valid) {
|
|
244
|
+
return Response.json({ error: envValidation.error }, { status: 400 });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Validate optional args (if provided)
|
|
249
|
+
if (body.args !== undefined) {
|
|
250
|
+
const argsValidation = validateArgs(body.args);
|
|
251
|
+
if (!argsValidation.valid) {
|
|
252
|
+
return Response.json({ error: argsValidation.error }, { status: 400 });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate optional billingMode (if provided)
|
|
257
|
+
if (body.billingMode !== undefined) {
|
|
258
|
+
const validModes = ["subscription", "api"];
|
|
259
|
+
if (!validModes.includes(body.billingMode)) {
|
|
260
|
+
return Response.json(
|
|
261
|
+
{
|
|
262
|
+
error: `Invalid billingMode: ${body.billingMode}. Must be one of: ${validModes.join(", ")}`,
|
|
263
|
+
},
|
|
264
|
+
{ status: 400 }
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (body.dangerousMode !== undefined && typeof body.dangerousMode !== "boolean") {
|
|
270
|
+
return Response.json({ error: "dangerousMode must be a boolean" }, { status: 400 });
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
body.dangerousMode === true &&
|
|
274
|
+
providerDefinition.capabilities.supportsDangerousMode !== true
|
|
275
|
+
) {
|
|
276
|
+
return Response.json(
|
|
277
|
+
{ error: `dangerousMode is not supported for provider ${providerDefinition.id}` },
|
|
278
|
+
{ status: 400 }
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Validate optional envSetIds (if provided)
|
|
283
|
+
if (body.envSetIds !== undefined) {
|
|
284
|
+
if (!Array.isArray(body.envSetIds)) {
|
|
285
|
+
return Response.json({ error: "envSetIds must be an array of strings" }, { status: 400 });
|
|
286
|
+
}
|
|
287
|
+
for (const id of body.envSetIds) {
|
|
288
|
+
if (typeof id !== "string") {
|
|
289
|
+
return Response.json({ error: "envSetIds must be an array of strings" }, { status: 400 });
|
|
290
|
+
}
|
|
291
|
+
const envSet = ctx.db.getEnvSet(id);
|
|
292
|
+
if (!envSet) {
|
|
293
|
+
return Response.json({ error: `Env set not found: ${id}` }, { status: 404 });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Validate optional worktree config (if provided)
|
|
299
|
+
if (body.worktree !== undefined) {
|
|
300
|
+
if (typeof body.worktree !== "object" || body.worktree === null) {
|
|
301
|
+
return Response.json({ error: "worktree must be an object" }, { status: 400 });
|
|
302
|
+
}
|
|
303
|
+
if (!body.worktree.branch || typeof body.worktree.branch !== "string") {
|
|
304
|
+
return Response.json({ error: "worktree.branch is required" }, { status: 400 });
|
|
305
|
+
}
|
|
306
|
+
if (typeof body.worktree.createBranch !== "boolean") {
|
|
307
|
+
return Response.json({ error: "worktree.createBranch must be a boolean" }, { status: 400 });
|
|
308
|
+
}
|
|
309
|
+
if (body.worktree.startPoint !== undefined && typeof body.worktree.startPoint !== "string") {
|
|
310
|
+
return Response.json({ error: "worktree.startPoint must be a string" }, { status: 400 });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (body.providerResume !== undefined) {
|
|
315
|
+
if (typeof body.providerResume !== "object" || body.providerResume === null) {
|
|
316
|
+
return Response.json({ error: "providerResume must be an object" }, { status: 400 });
|
|
317
|
+
}
|
|
318
|
+
if (typeof body.providerResume.providerSessionId !== "string") {
|
|
319
|
+
return Response.json(
|
|
320
|
+
{ error: "providerResume.providerSessionId must be a string" },
|
|
321
|
+
{ status: 400 }
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (body.providerResume.providerSessionId.trim().length === 0) {
|
|
325
|
+
return Response.json(
|
|
326
|
+
{ error: "providerResume.providerSessionId must not be empty" },
|
|
327
|
+
{ status: 400 }
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (
|
|
331
|
+
body.providerResume.mode !== undefined &&
|
|
332
|
+
body.providerResume.mode !== "resume" &&
|
|
333
|
+
body.providerResume.mode !== "fork"
|
|
334
|
+
) {
|
|
335
|
+
return Response.json(
|
|
336
|
+
{ error: 'providerResume.mode must be either "resume" or "fork"' },
|
|
337
|
+
{ status: 400 }
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const session = await ctx.sessionManager.createSession({
|
|
344
|
+
provider: body.provider,
|
|
345
|
+
cwd: body.cwd,
|
|
346
|
+
env: body.env,
|
|
347
|
+
args: body.args,
|
|
348
|
+
billingMode: body.billingMode,
|
|
349
|
+
dangerousMode: body.dangerousMode,
|
|
350
|
+
envSetIds: body.envSetIds,
|
|
351
|
+
providerResume:
|
|
352
|
+
body.providerResume !== undefined
|
|
353
|
+
? {
|
|
354
|
+
providerSessionId: body.providerResume.providerSessionId.trim(),
|
|
355
|
+
mode: body.providerResume.mode,
|
|
356
|
+
}
|
|
357
|
+
: undefined,
|
|
358
|
+
worktree: body.worktree,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
return Response.json({ session }, { status: 201 });
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return errorResponse(error);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function handleGetSession(
|
|
368
|
+
_req: Request,
|
|
369
|
+
ctx: RouteContext,
|
|
370
|
+
sessionId: string
|
|
371
|
+
): Promise<Response> {
|
|
372
|
+
// Try in-memory first (active sessions), then fall back to DB (stopped/crashed)
|
|
373
|
+
const inMemory = ctx.sessionManager.getSession(sessionId);
|
|
374
|
+
if (inMemory) {
|
|
375
|
+
return Response.json({ session: inMemory });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const session = ctx.db.getSession(sessionId);
|
|
379
|
+
if (!session) {
|
|
380
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Health check: if DB says RUNNING/STARTING but not in memory
|
|
384
|
+
if (session.status === "RUNNING" || session.status === "STARTING") {
|
|
385
|
+
const tmuxCheck = Bun.spawnSync(["tmux", "has-session", "-t", `codepiper-${sessionId}`], {
|
|
386
|
+
stdout: "ignore",
|
|
387
|
+
stderr: "ignore",
|
|
388
|
+
});
|
|
389
|
+
if (tmuxCheck.exitCode !== 0) {
|
|
390
|
+
// Tmux is gone — mark STOPPED
|
|
391
|
+
ctx.db.updateSession(sessionId, { status: "STOPPED" });
|
|
392
|
+
session.status = "STOPPED";
|
|
393
|
+
} else {
|
|
394
|
+
// Tmux is alive but not in memory — auto-adopt
|
|
395
|
+
try {
|
|
396
|
+
const adopted = await ctx.sessionManager.adoptSession(sessionId);
|
|
397
|
+
return Response.json({ session: adopted });
|
|
398
|
+
} catch {
|
|
399
|
+
// Fall through to return DB data
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return Response.json({ session });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function handleStopSession(
|
|
408
|
+
_req: Request,
|
|
409
|
+
ctx: RouteContext,
|
|
410
|
+
sessionId: string
|
|
411
|
+
): Promise<Response> {
|
|
412
|
+
try {
|
|
413
|
+
await ctx.sessionManager.stopSession(sessionId);
|
|
414
|
+
return Response.json({ success: true });
|
|
415
|
+
} catch (error) {
|
|
416
|
+
// If session not in memory, try to transition orphaned DB session to STOPPED
|
|
417
|
+
if (error instanceof SessionNotFoundError) {
|
|
418
|
+
return transitionOrphanedSession(ctx, sessionId);
|
|
419
|
+
}
|
|
420
|
+
return errorResponse(error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function handleKillSession(
|
|
425
|
+
_req: Request,
|
|
426
|
+
ctx: RouteContext,
|
|
427
|
+
sessionId: string
|
|
428
|
+
): Promise<Response> {
|
|
429
|
+
try {
|
|
430
|
+
await ctx.sessionManager.killSession(sessionId);
|
|
431
|
+
return Response.json({ success: true });
|
|
432
|
+
} catch (error) {
|
|
433
|
+
// If session not in memory, try to transition orphaned DB session to STOPPED
|
|
434
|
+
if (error instanceof SessionNotFoundError) {
|
|
435
|
+
return transitionOrphanedSession(ctx, sessionId);
|
|
436
|
+
}
|
|
437
|
+
return errorResponse(error);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export async function handleResumeSession(
|
|
442
|
+
_req: Request,
|
|
443
|
+
ctx: RouteContext,
|
|
444
|
+
sessionId: string
|
|
445
|
+
): Promise<Response> {
|
|
446
|
+
try {
|
|
447
|
+
const session = await ctx.sessionManager.resumeSession(sessionId);
|
|
448
|
+
return Response.json({ session });
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return errorResponse(error, 400);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function handleRecoverSession(
|
|
455
|
+
_req: Request,
|
|
456
|
+
ctx: RouteContext,
|
|
457
|
+
sessionId: string
|
|
458
|
+
): Promise<Response> {
|
|
459
|
+
// Already in memory — already active
|
|
460
|
+
const inMemory = ctx.sessionManager.getSession(sessionId);
|
|
461
|
+
if (inMemory) {
|
|
462
|
+
return Response.json({ session: inMemory });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Try to adopt the orphaned session
|
|
466
|
+
try {
|
|
467
|
+
const session = await ctx.sessionManager.recoverSession(sessionId);
|
|
468
|
+
return Response.json({ session });
|
|
469
|
+
} catch (error) {
|
|
470
|
+
const msg = error instanceof Error ? error.message : "Failed to recover session";
|
|
471
|
+
return Response.json({ error: msg }, { status: 400 });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export async function handleUpdateSessionName(
|
|
476
|
+
req: Request,
|
|
477
|
+
ctx: RouteContext,
|
|
478
|
+
sessionId: string
|
|
479
|
+
): Promise<Response> {
|
|
480
|
+
let body: any;
|
|
481
|
+
try {
|
|
482
|
+
body = await req.json();
|
|
483
|
+
} catch {
|
|
484
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!(isObjectRecord(body) && "name" in body)) {
|
|
488
|
+
return Response.json({ error: "Missing required field: name" }, { status: 400 });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!(typeof body.name === "string" || body.name === null)) {
|
|
492
|
+
return Response.json({ error: "name must be a string or null" }, { status: 400 });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let normalizedName: string | null = null;
|
|
496
|
+
if (typeof body.name === "string") {
|
|
497
|
+
const trimmed = body.name.trim();
|
|
498
|
+
if (trimmed.length > 0) {
|
|
499
|
+
if (trimmed.length > SESSION_CUSTOM_NAME_MAX_LENGTH) {
|
|
500
|
+
return Response.json(
|
|
501
|
+
{ error: `name must be ${SESSION_CUSTOM_NAME_MAX_LENGTH} characters or fewer` },
|
|
502
|
+
{ status: 400 }
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (hasControlCharacters(trimmed)) {
|
|
506
|
+
return Response.json(
|
|
507
|
+
{ error: "name must not contain control characters" },
|
|
508
|
+
{ status: 400 }
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
normalizedName = trimmed;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const activeSession = ctx.sessionManager.getSession(sessionId);
|
|
516
|
+
if (activeSession) {
|
|
517
|
+
const session = ctx.sessionManager.setSessionCustomName(sessionId, normalizedName);
|
|
518
|
+
return Response.json({ session });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const dbSession = ctx.db.getSession(sessionId);
|
|
522
|
+
if (!dbSession) {
|
|
523
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const nextMetadata = upsertSessionCustomName(dbSession.metadata, normalizedName);
|
|
527
|
+
ctx.db.updateSession(sessionId, { metadata: nextMetadata });
|
|
528
|
+
|
|
529
|
+
const updatedSession = ctx.db.getSession(sessionId);
|
|
530
|
+
return Response.json({ session: updatedSession ?? { ...dbSession, metadata: nextMetadata } });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Transition an orphaned session (exists in DB but not in memory) to STOPPED.
|
|
535
|
+
* Also attempts to clean up any lingering tmux session.
|
|
536
|
+
*/
|
|
537
|
+
function transitionOrphanedSession(ctx: RouteContext, sessionId: string): Response {
|
|
538
|
+
const dbSession = ctx.db.getSession(sessionId);
|
|
539
|
+
if (!dbSession) {
|
|
540
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Already in terminal state — nothing to do
|
|
544
|
+
if (dbSession.status === "STOPPED" || dbSession.status === "CRASHED") {
|
|
545
|
+
return Response.json({ success: true });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Update DB to STOPPED
|
|
549
|
+
ctx.db.updateSession(sessionId, { status: "STOPPED" });
|
|
550
|
+
|
|
551
|
+
// Best-effort: kill any orphaned tmux session that might still exist
|
|
552
|
+
try {
|
|
553
|
+
Bun.spawnSync(["tmux", "kill-session", "-t", `codepiper-${sessionId}`], {
|
|
554
|
+
stdout: "ignore",
|
|
555
|
+
stderr: "ignore",
|
|
556
|
+
});
|
|
557
|
+
} catch {
|
|
558
|
+
// tmux session may not exist — that's fine
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return Response.json({ success: true });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function handleSendText(
|
|
565
|
+
req: Request,
|
|
566
|
+
ctx: RouteContext,
|
|
567
|
+
sessionId: string
|
|
568
|
+
): Promise<Response> {
|
|
569
|
+
let body: any;
|
|
570
|
+
try {
|
|
571
|
+
body = await req.json();
|
|
572
|
+
} catch {
|
|
573
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Validate text field
|
|
577
|
+
if (typeof body.text !== "string") {
|
|
578
|
+
return Response.json({ error: "Missing required field: text" }, { status: 400 });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const textValidation = validateText(body.text);
|
|
582
|
+
if (!textValidation.valid) {
|
|
583
|
+
return Response.json({ error: textValidation.error }, { status: 400 });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
const policyCheck = await enforceInputPolicyPreflight(ctx, sessionId, {
|
|
588
|
+
kind: "text",
|
|
589
|
+
input: body.text,
|
|
590
|
+
newline: body.newline === true,
|
|
591
|
+
});
|
|
592
|
+
if (!policyCheck.allowed) {
|
|
593
|
+
return Response.json(
|
|
594
|
+
{
|
|
595
|
+
error: policyCheck.error || "Input blocked by policy",
|
|
596
|
+
policyAction: policyCheck.policyAction,
|
|
597
|
+
provider: policyCheck.provider,
|
|
598
|
+
},
|
|
599
|
+
{ status: policyCheck.status ?? 403 }
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Send the text
|
|
604
|
+
await ctx.sessionManager.sendText(sessionId, body.text);
|
|
605
|
+
|
|
606
|
+
// If newline requested, flush writes and send Enter key
|
|
607
|
+
if (body.newline) {
|
|
608
|
+
// Flush any batched writes before sending Enter
|
|
609
|
+
ctx.sessionManager.flushWrites(sessionId);
|
|
610
|
+
// Small delay to ensure flush completed
|
|
611
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
612
|
+
// Send Enter key
|
|
613
|
+
await ctx.sessionManager.sendKeys(sessionId, ["enter"]);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return Response.json({ success: true });
|
|
617
|
+
} catch (error) {
|
|
618
|
+
return errorResponse(error);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export async function handleSendKeys(
|
|
623
|
+
req: Request,
|
|
624
|
+
ctx: RouteContext,
|
|
625
|
+
sessionId: string
|
|
626
|
+
): Promise<Response> {
|
|
627
|
+
let body: any;
|
|
628
|
+
try {
|
|
629
|
+
body = await req.json();
|
|
630
|
+
} catch {
|
|
631
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Validate keys array
|
|
635
|
+
const keysValidation = validateKeys(body.keys);
|
|
636
|
+
if (!keysValidation.valid) {
|
|
637
|
+
return Response.json({ error: keysValidation.error }, { status: 400 });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
const policyCheck = await enforceInputPolicyPreflight(ctx, sessionId, {
|
|
642
|
+
kind: "keys",
|
|
643
|
+
keys: body.keys,
|
|
644
|
+
});
|
|
645
|
+
if (!policyCheck.allowed) {
|
|
646
|
+
return Response.json(
|
|
647
|
+
{
|
|
648
|
+
error: policyCheck.error || "Input blocked by policy",
|
|
649
|
+
policyAction: policyCheck.policyAction,
|
|
650
|
+
provider: policyCheck.provider,
|
|
651
|
+
},
|
|
652
|
+
{ status: policyCheck.status ?? 403 }
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
await ctx.sessionManager.sendKeys(sessionId, body.keys);
|
|
657
|
+
|
|
658
|
+
return Response.json({ success: true });
|
|
659
|
+
} catch (error) {
|
|
660
|
+
return errorResponse(error);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export async function handleClaudeHook(req: Request, ctx: RouteContext): Promise<Response> {
|
|
665
|
+
return handleHookEvent(req, {
|
|
666
|
+
db: ctx.db,
|
|
667
|
+
eventBus: ctx.eventBus,
|
|
668
|
+
sessionManager: ctx.sessionManager,
|
|
669
|
+
policyEngine: ctx.policyEngine,
|
|
670
|
+
auditLogger: ctx.auditLogger,
|
|
671
|
+
hookSecret: ctx.hookSecret,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export async function handleGetTranscriptEvents(
|
|
676
|
+
req: Request,
|
|
677
|
+
ctx: RouteContext,
|
|
678
|
+
sessionId: string
|
|
679
|
+
): Promise<Response> {
|
|
680
|
+
const url = new URL(req.url);
|
|
681
|
+
const since = url.searchParams.get("since");
|
|
682
|
+
const before = url.searchParams.get("before");
|
|
683
|
+
const limit = url.searchParams.get("limit");
|
|
684
|
+
const source = url.searchParams.get("source");
|
|
685
|
+
const type = url.searchParams.get("type");
|
|
686
|
+
const order = url.searchParams.get("order");
|
|
687
|
+
|
|
688
|
+
try {
|
|
689
|
+
const parsedLimit = limit ? parseInt(limit, 10) : undefined;
|
|
690
|
+
const parsedSource: EventSource | undefined =
|
|
691
|
+
source === "pty" || source === "hook" || source === "transcript" || source === "statusline"
|
|
692
|
+
? source
|
|
693
|
+
: undefined;
|
|
694
|
+
|
|
695
|
+
const events = ctx.db.getEventsBySessionId(sessionId, {
|
|
696
|
+
source: parsedSource,
|
|
697
|
+
type: type || undefined,
|
|
698
|
+
since: since ? parseInt(since, 10) : undefined,
|
|
699
|
+
before: before ? parseInt(before, 10) : undefined,
|
|
700
|
+
limit: parsedLimit ? parsedLimit + 1 : undefined,
|
|
701
|
+
order: order === "asc" || order === "desc" ? order : undefined,
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// Determine hasMore by checking if we got more than the requested limit
|
|
705
|
+
let hasMore = false;
|
|
706
|
+
if (parsedLimit && events.length > parsedLimit) {
|
|
707
|
+
hasMore = true;
|
|
708
|
+
events.pop();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return Response.json({ events, hasMore });
|
|
712
|
+
} catch (error) {
|
|
713
|
+
return errorResponse(error);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export async function handleSwitchModel(
|
|
718
|
+
req: Request,
|
|
719
|
+
ctx: RouteContext,
|
|
720
|
+
sessionId: string
|
|
721
|
+
): Promise<Response> {
|
|
722
|
+
const session = ctx.sessionManager.getSession(sessionId);
|
|
723
|
+
if (!session) {
|
|
724
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const capabilities = getProviderDefinition(session.provider).capabilities;
|
|
728
|
+
if (!capabilities.supportsModelSwitch) {
|
|
729
|
+
return Response.json(
|
|
730
|
+
{
|
|
731
|
+
error: `Model switching is not supported for provider ${session.provider}`,
|
|
732
|
+
provider: session.provider,
|
|
733
|
+
supportsModelSwitch: false,
|
|
734
|
+
},
|
|
735
|
+
{ status: 409 }
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
let body: any;
|
|
740
|
+
try {
|
|
741
|
+
body = await req.json();
|
|
742
|
+
} catch {
|
|
743
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Validate model field
|
|
747
|
+
if (typeof body.model !== "string") {
|
|
748
|
+
return Response.json({ error: "Missing required field: model" }, { status: 400 });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const modelValidation = validateModel(body.model);
|
|
752
|
+
if (!modelValidation.valid) {
|
|
753
|
+
return Response.json({ error: modelValidation.error }, { status: 400 });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
await ctx.sessionManager.switchModel(sessionId, body.model);
|
|
758
|
+
|
|
759
|
+
return Response.json({ success: true });
|
|
760
|
+
} catch (error) {
|
|
761
|
+
return errorResponse(error);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export async function handleGetModel(
|
|
766
|
+
_req: Request,
|
|
767
|
+
ctx: RouteContext,
|
|
768
|
+
sessionId: string
|
|
769
|
+
): Promise<Response> {
|
|
770
|
+
try {
|
|
771
|
+
const session = ctx.sessionManager.getSession(sessionId);
|
|
772
|
+
if (!session) {
|
|
773
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
774
|
+
}
|
|
775
|
+
const capabilities = getProviderDefinition(session.provider).capabilities;
|
|
776
|
+
const model = ctx.sessionManager.getCurrentModel(sessionId);
|
|
777
|
+
|
|
778
|
+
return Response.json({
|
|
779
|
+
model,
|
|
780
|
+
provider: session.provider,
|
|
781
|
+
supportsModelSwitch: capabilities.supportsModelSwitch,
|
|
782
|
+
});
|
|
783
|
+
} catch (error) {
|
|
784
|
+
return errorResponse(error);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export async function handleGetSessionPolicy(
|
|
789
|
+
_req: Request,
|
|
790
|
+
ctx: RouteContext,
|
|
791
|
+
sessionId: string
|
|
792
|
+
): Promise<Response> {
|
|
793
|
+
try {
|
|
794
|
+
// Verify session exists
|
|
795
|
+
const session = ctx.sessionManager.getSession(sessionId);
|
|
796
|
+
if (!session) {
|
|
797
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Get policies for this session (sessionId filter)
|
|
801
|
+
const policies = ctx.db.listPolicies({ sessionId });
|
|
802
|
+
|
|
803
|
+
return Response.json({ policies });
|
|
804
|
+
} catch (error) {
|
|
805
|
+
return errorResponse(error);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export async function handleGetSessionOutput(
|
|
810
|
+
_req: Request,
|
|
811
|
+
ctx: RouteContext,
|
|
812
|
+
sessionId: string
|
|
813
|
+
): Promise<Response> {
|
|
814
|
+
try {
|
|
815
|
+
const output = await ctx.sessionManager.getSessionOutput(sessionId);
|
|
816
|
+
return Response.json({ output });
|
|
817
|
+
} catch (error) {
|
|
818
|
+
if (error instanceof SessionNotFoundError) {
|
|
819
|
+
const dbSession = ctx.db.getSession(sessionId);
|
|
820
|
+
if (!dbSession) {
|
|
821
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return Response.json(
|
|
825
|
+
{
|
|
826
|
+
error: `Session is not actively managed: ${sessionId}`,
|
|
827
|
+
status: dbSession.status,
|
|
828
|
+
},
|
|
829
|
+
{ status: 409 }
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return errorResponse(error);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export async function handleResizeSession(
|
|
838
|
+
req: Request,
|
|
839
|
+
ctx: RouteContext,
|
|
840
|
+
sessionId: string
|
|
841
|
+
): Promise<Response> {
|
|
842
|
+
let body: any;
|
|
843
|
+
try {
|
|
844
|
+
body = await req.json();
|
|
845
|
+
} catch {
|
|
846
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const cols = body.cols;
|
|
850
|
+
const rows = body.rows;
|
|
851
|
+
if (typeof cols !== "number" || typeof rows !== "number" || cols < 1 || rows < 1) {
|
|
852
|
+
return Response.json({ error: "cols and rows must be positive numbers" }, { status: 400 });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
try {
|
|
856
|
+
await ctx.sessionManager.resizeSession(sessionId, cols, rows);
|
|
857
|
+
return Response.json({ success: true });
|
|
858
|
+
} catch {
|
|
859
|
+
// Session may be stopped — silently ignore resize for dead sessions
|
|
860
|
+
return Response.json({ success: false });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export async function handleSetSessionPolicy(
|
|
865
|
+
req: Request,
|
|
866
|
+
ctx: RouteContext,
|
|
867
|
+
sessionId: string
|
|
868
|
+
): Promise<Response> {
|
|
869
|
+
try {
|
|
870
|
+
// Verify session exists
|
|
871
|
+
const session = ctx.sessionManager.getSession(sessionId);
|
|
872
|
+
if (!session) {
|
|
873
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
let body: any;
|
|
877
|
+
try {
|
|
878
|
+
body = await req.json();
|
|
879
|
+
} catch {
|
|
880
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Validate policy structure
|
|
884
|
+
if (!body.policy || typeof body.policy !== "object") {
|
|
885
|
+
return Response.json({ error: "Missing or invalid 'policy' field" }, { status: 400 });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Policy structure should have name and rules
|
|
889
|
+
if (!(body.policy.name && Array.isArray(body.policy.rules))) {
|
|
890
|
+
return Response.json(
|
|
891
|
+
{ error: "Policy must have 'name' (string) and 'rules' (array)" },
|
|
892
|
+
{ status: 400 }
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Generate policy ID (session-id-policy format)
|
|
897
|
+
const policyId = `${sessionId}-policy`;
|
|
898
|
+
|
|
899
|
+
// Check if policy already exists for this session
|
|
900
|
+
const existingPolicies = ctx.db.listPolicies({ sessionId });
|
|
901
|
+
|
|
902
|
+
if (existingPolicies.length > 0) {
|
|
903
|
+
// Update existing policy
|
|
904
|
+
const existingPolicy = existingPolicies[0]; // Use first one
|
|
905
|
+
if (!existingPolicy) {
|
|
906
|
+
return Response.json({ error: "Failed to load existing policy" }, { status: 500 });
|
|
907
|
+
}
|
|
908
|
+
ctx.db.updatePolicy(existingPolicy.id, {
|
|
909
|
+
name: body.policy.name,
|
|
910
|
+
description: body.policy.description,
|
|
911
|
+
enabled: body.policy.enabled !== undefined ? body.policy.enabled : true,
|
|
912
|
+
priority: body.policy.priority !== undefined ? body.policy.priority : 50,
|
|
913
|
+
rules: body.policy.rules,
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
return Response.json({
|
|
917
|
+
success: true,
|
|
918
|
+
policyId: existingPolicy.id,
|
|
919
|
+
action: "updated",
|
|
920
|
+
});
|
|
921
|
+
} else {
|
|
922
|
+
// Create new policy
|
|
923
|
+
ctx.db.createPolicy({
|
|
924
|
+
id: policyId,
|
|
925
|
+
name: body.policy.name,
|
|
926
|
+
description: body.policy.description,
|
|
927
|
+
enabled: body.policy.enabled !== undefined ? body.policy.enabled : true,
|
|
928
|
+
priority: body.policy.priority !== undefined ? body.policy.priority : 50,
|
|
929
|
+
sessionId, // Link to this session
|
|
930
|
+
rules: body.policy.rules,
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
return Response.json({
|
|
934
|
+
success: true,
|
|
935
|
+
policyId,
|
|
936
|
+
action: "created",
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
} catch (error) {
|
|
940
|
+
return errorResponse(error);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export async function handleUploadImage(
|
|
945
|
+
req: Request,
|
|
946
|
+
ctx: RouteContext,
|
|
947
|
+
sessionId: string
|
|
948
|
+
): Promise<Response> {
|
|
949
|
+
// Verify session exists (in-memory or DB)
|
|
950
|
+
const session = ctx.sessionManager.getSession(sessionId);
|
|
951
|
+
if (!session) {
|
|
952
|
+
const dbSession = ctx.db.getSession(sessionId);
|
|
953
|
+
if (!dbSession) {
|
|
954
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
let formData: Awaited<ReturnType<Request["formData"]>>;
|
|
959
|
+
try {
|
|
960
|
+
formData = await req.formData();
|
|
961
|
+
} catch {
|
|
962
|
+
return Response.json(
|
|
963
|
+
{ error: "Invalid multipart form data. Send image as 'image' field." },
|
|
964
|
+
{ status: 400 }
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const validation = validateImageUpload(formData);
|
|
969
|
+
if (!(validation.valid && validation.file)) {
|
|
970
|
+
return Response.json({ error: validation.error }, { status: 400 });
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const imageDir = ctx.sessionManager.getImageDir(sessionId);
|
|
974
|
+
const filename = `upload-${Date.now()}-${validation.sanitizedName}`;
|
|
975
|
+
const filePath = `${imageDir}/${filename}`;
|
|
976
|
+
|
|
977
|
+
try {
|
|
978
|
+
// Ensure directory exists (may have been cleaned up if session was stopped)
|
|
979
|
+
const fs = await import("node:fs");
|
|
980
|
+
if (!fs.existsSync(imageDir)) {
|
|
981
|
+
fs.mkdirSync(imageDir, { recursive: true, mode: 0o700 });
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
fs.chmodSync(imageDir, 0o700);
|
|
985
|
+
} catch {
|
|
986
|
+
// best-effort on non-POSIX filesystems
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Write the file using Bun.write for optimal performance
|
|
990
|
+
await Bun.write(filePath, validation.file);
|
|
991
|
+
try {
|
|
992
|
+
fs.chmodSync(filePath, 0o600);
|
|
993
|
+
} catch {
|
|
994
|
+
// best-effort on non-POSIX filesystems
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return Response.json({
|
|
998
|
+
path: filePath,
|
|
999
|
+
filename: validation.file.name || validation.sanitizedName,
|
|
1000
|
+
});
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
return errorResponse(error);
|
|
1003
|
+
}
|
|
1004
|
+
}
|