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,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal mode API route handlers
|
|
3
|
+
*
|
|
4
|
+
* Provides scroll, search, and mode control for tmux-based sessions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RouteContext } from "./routes";
|
|
8
|
+
import { SttNotConfiguredError, transcribeAudioFile } from "./stt";
|
|
9
|
+
|
|
10
|
+
const MAX_AUDIO_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function errorResponse(error: unknown): Response {
|
|
13
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
14
|
+
return Response.json({ error: msg }, { status: 400 });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function parseBody(req: Request): Promise<any | null> {
|
|
18
|
+
try {
|
|
19
|
+
return await req.json();
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function handleGetTerminalInfo(
|
|
26
|
+
_req: Request,
|
|
27
|
+
ctx: RouteContext,
|
|
28
|
+
sessionId: string
|
|
29
|
+
): Promise<Response> {
|
|
30
|
+
try {
|
|
31
|
+
const info = await ctx.sessionManager.getTerminalInfo(sessionId);
|
|
32
|
+
return Response.json(info);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return errorResponse(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleTerminalMode(
|
|
39
|
+
req: Request,
|
|
40
|
+
ctx: RouteContext,
|
|
41
|
+
sessionId: string
|
|
42
|
+
): Promise<Response> {
|
|
43
|
+
const body = await parseBody(req);
|
|
44
|
+
if (!body) {
|
|
45
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { mode } = body;
|
|
49
|
+
if (mode !== "interactive" && mode !== "scroll") {
|
|
50
|
+
return Response.json({ error: 'mode must be "interactive" or "scroll"' }, { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (mode === "scroll") {
|
|
55
|
+
await ctx.sessionManager.enterScrollMode(sessionId);
|
|
56
|
+
} else {
|
|
57
|
+
await ctx.sessionManager.exitScrollMode(sessionId);
|
|
58
|
+
}
|
|
59
|
+
return Response.json({ success: true, mode });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return errorResponse(error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function handleTerminalScroll(
|
|
66
|
+
req: Request,
|
|
67
|
+
ctx: RouteContext,
|
|
68
|
+
sessionId: string
|
|
69
|
+
): Promise<Response> {
|
|
70
|
+
const body = await parseBody(req);
|
|
71
|
+
if (!body) {
|
|
72
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { direction, lines, page, edge } = body;
|
|
76
|
+
|
|
77
|
+
// Validate: either edge or direction must be specified
|
|
78
|
+
if (edge) {
|
|
79
|
+
if (edge !== "top" && edge !== "bottom") {
|
|
80
|
+
return Response.json({ error: 'edge must be "top" or "bottom"' }, { status: 400 });
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await ctx.sessionManager.scrollToEdge(sessionId, edge);
|
|
84
|
+
return Response.json({ success: true });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return errorResponse(error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (direction !== "up" && direction !== "down") {
|
|
91
|
+
return Response.json({ error: 'direction must be "up" or "down"' }, { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (lines !== undefined) {
|
|
95
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > 1000) {
|
|
96
|
+
return Response.json(
|
|
97
|
+
{ error: "lines must be an integer between 1 and 1000" },
|
|
98
|
+
{ status: 400 }
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await ctx.sessionManager.scrollTerminal(sessionId, direction, { lines, page: !!page });
|
|
105
|
+
return Response.json({ success: true });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return errorResponse(error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function handleTerminalSearch(
|
|
112
|
+
req: Request,
|
|
113
|
+
ctx: RouteContext,
|
|
114
|
+
sessionId: string
|
|
115
|
+
): Promise<Response> {
|
|
116
|
+
const body = await parseBody(req);
|
|
117
|
+
if (!body) {
|
|
118
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { query, action } = body;
|
|
122
|
+
|
|
123
|
+
// action-based: next, previous, cancel
|
|
124
|
+
if (action) {
|
|
125
|
+
if (action !== "next" && action !== "previous" && action !== "cancel") {
|
|
126
|
+
return Response.json(
|
|
127
|
+
{ error: 'action must be "next", "previous", or "cancel"' },
|
|
128
|
+
{ status: 400 }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
if (action === "cancel") {
|
|
133
|
+
await ctx.sessionManager.exitScrollMode(sessionId);
|
|
134
|
+
} else if (action === "next") {
|
|
135
|
+
await ctx.sessionManager.searchNext(sessionId);
|
|
136
|
+
} else {
|
|
137
|
+
await ctx.sessionManager.searchPrevious(sessionId);
|
|
138
|
+
}
|
|
139
|
+
return Response.json({ success: true });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return errorResponse(error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// query-based: start a new search
|
|
146
|
+
if (!query || typeof query !== "string") {
|
|
147
|
+
return Response.json({ error: "query (string) or action is required" }, { status: 400 });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (query.length > 1000) {
|
|
151
|
+
return Response.json({ error: "query must be at most 1000 characters" }, { status: 400 });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await ctx.sessionManager.searchTerminal(sessionId, query);
|
|
156
|
+
return Response.json({ success: true });
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return errorResponse(error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function handleTerminalTranscribe(
|
|
163
|
+
req: Request,
|
|
164
|
+
_ctx: RouteContext,
|
|
165
|
+
_sessionId: string
|
|
166
|
+
): Promise<Response> {
|
|
167
|
+
let formData: Awaited<ReturnType<Request["formData"]>>;
|
|
168
|
+
try {
|
|
169
|
+
formData = await req.formData();
|
|
170
|
+
} catch {
|
|
171
|
+
return Response.json(
|
|
172
|
+
{ error: "Invalid multipart form data. Send audio as 'audio' field." },
|
|
173
|
+
{ status: 400 }
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const audio = formData.get("audio");
|
|
178
|
+
if (!(audio instanceof File)) {
|
|
179
|
+
return Response.json({ error: "Missing 'audio' file field" }, { status: 400 });
|
|
180
|
+
}
|
|
181
|
+
if (!audio.type.startsWith("audio/")) {
|
|
182
|
+
return Response.json({ error: "Uploaded file must be audio/*" }, { status: 400 });
|
|
183
|
+
}
|
|
184
|
+
if (audio.size <= 0) {
|
|
185
|
+
return Response.json({ error: "Audio file is empty" }, { status: 400 });
|
|
186
|
+
}
|
|
187
|
+
if (audio.size > MAX_AUDIO_UPLOAD_BYTES) {
|
|
188
|
+
return Response.json({ error: "Audio too large (max 10MB)" }, { status: 413 });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const result = await transcribeAudioFile(audio);
|
|
193
|
+
return Response.json(result);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof SttNotConfiguredError) {
|
|
196
|
+
return Response.json({ error: error.message }, { status: 503 });
|
|
197
|
+
}
|
|
198
|
+
return errorResponse(error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class ValidationError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ValidationError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ValidationResult {
|
|
13
|
+
valid: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate working directory path
|
|
19
|
+
*/
|
|
20
|
+
export function validateCwd(cwd: string): ValidationResult {
|
|
21
|
+
// Check type
|
|
22
|
+
if (typeof cwd !== "string") {
|
|
23
|
+
return { valid: false, error: "cwd must be a string" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check length (4KB max for path)
|
|
27
|
+
if (cwd.length > 4096) {
|
|
28
|
+
return {
|
|
29
|
+
valid: false,
|
|
30
|
+
error: `cwd path too long (${cwd.length} chars, max 4096)`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check if absolute path
|
|
35
|
+
if (!cwd.startsWith("/")) {
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
error: "cwd must be an absolute path (start with /)",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check for null bytes (security)
|
|
43
|
+
if (cwd.includes("\0")) {
|
|
44
|
+
return {
|
|
45
|
+
valid: false,
|
|
46
|
+
error: "cwd contains invalid null byte",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reject ".." path traversal components
|
|
51
|
+
if (cwd.split("/").includes("..")) {
|
|
52
|
+
return {
|
|
53
|
+
valid: false,
|
|
54
|
+
error: "cwd must not contain '..' path traversal",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { valid: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate text input
|
|
63
|
+
*/
|
|
64
|
+
export function validateText(text: string): ValidationResult {
|
|
65
|
+
// Check type
|
|
66
|
+
if (typeof text !== "string") {
|
|
67
|
+
return { valid: false, error: "text must be a string" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check length (1MB max to prevent memory issues)
|
|
71
|
+
if (text.length > 1024 * 1024) {
|
|
72
|
+
return {
|
|
73
|
+
valid: false,
|
|
74
|
+
error: `text too long (${text.length} chars, max 1MB)`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { valid: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate keys array
|
|
83
|
+
*/
|
|
84
|
+
export function validateKeys(keys: any): ValidationResult {
|
|
85
|
+
if (!Array.isArray(keys)) {
|
|
86
|
+
return { valid: false, error: "keys must be an array" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (keys.length === 0) {
|
|
90
|
+
return { valid: false, error: "keys array cannot be empty" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (keys.length > 100) {
|
|
94
|
+
return {
|
|
95
|
+
valid: false,
|
|
96
|
+
error: `too many keys (${keys.length}, max 100)`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const key of keys) {
|
|
101
|
+
if (typeof key !== "string") {
|
|
102
|
+
return { valid: false, error: "all keys must be strings" };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { valid: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validate model name
|
|
111
|
+
*/
|
|
112
|
+
export function validateModel(model: string): ValidationResult {
|
|
113
|
+
if (typeof model !== "string") {
|
|
114
|
+
return { valid: false, error: "model must be a string" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (model.length === 0) {
|
|
118
|
+
return { valid: false, error: "model cannot be empty" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (model.length > 100) {
|
|
122
|
+
return {
|
|
123
|
+
valid: false,
|
|
124
|
+
error: `model name too long (${model.length} chars, max 100)`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { valid: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate environment variables
|
|
133
|
+
*/
|
|
134
|
+
export function validateEnv(env: any): ValidationResult {
|
|
135
|
+
if (env === undefined || env === null) {
|
|
136
|
+
return { valid: true }; // Optional field
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (typeof env !== "object" || Array.isArray(env)) {
|
|
140
|
+
return { valid: false, error: "env must be an object" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check number of env vars
|
|
144
|
+
const keys = Object.keys(env);
|
|
145
|
+
if (keys.length > 1000) {
|
|
146
|
+
return {
|
|
147
|
+
valid: false,
|
|
148
|
+
error: `too many environment variables (${keys.length}, max 1000)`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Valid env var key pattern: starts with letter or underscore, contains only
|
|
153
|
+
// alphanumeric and underscores. Prevents injection via malformed key names.
|
|
154
|
+
const VALID_ENV_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
155
|
+
|
|
156
|
+
// Check each key-value pair
|
|
157
|
+
for (const [key, value] of Object.entries(env)) {
|
|
158
|
+
if (typeof key !== "string") {
|
|
159
|
+
return { valid: false, error: "all env keys must be strings" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof value !== "string") {
|
|
163
|
+
return {
|
|
164
|
+
valid: false,
|
|
165
|
+
error: `env value for key "${key}" must be a string`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (key.length > 256) {
|
|
170
|
+
return {
|
|
171
|
+
valid: false,
|
|
172
|
+
error: `env key too long: "${key.substring(0, 50)}..." (max 256 chars)`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!VALID_ENV_KEY.test(key)) {
|
|
177
|
+
return {
|
|
178
|
+
valid: false,
|
|
179
|
+
error: `env key "${key.substring(0, 50)}" contains invalid characters (must match [a-zA-Z_][a-zA-Z0-9_]*)`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (value.length > 65536) {
|
|
184
|
+
return {
|
|
185
|
+
valid: false,
|
|
186
|
+
error: `env value for key "${key}" too long (max 64KB)`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { valid: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Allowed image MIME types
|
|
195
|
+
const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
196
|
+
|
|
197
|
+
// Max image size: 10MB
|
|
198
|
+
const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
|
|
199
|
+
|
|
200
|
+
export interface ImageValidationResult {
|
|
201
|
+
valid: boolean;
|
|
202
|
+
error?: string;
|
|
203
|
+
file?: File;
|
|
204
|
+
sanitizedName?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface ImageFormDataLike {
|
|
208
|
+
get(name: string): unknown;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Validate an image upload from multipart FormData
|
|
213
|
+
*/
|
|
214
|
+
export function validateImageUpload(formData: ImageFormDataLike): ImageValidationResult {
|
|
215
|
+
const file = formData.get("image");
|
|
216
|
+
|
|
217
|
+
if (!(file && file instanceof File)) {
|
|
218
|
+
return { valid: false, error: "Missing required field: image" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check file size
|
|
222
|
+
if (file.size > MAX_IMAGE_SIZE) {
|
|
223
|
+
return {
|
|
224
|
+
valid: false,
|
|
225
|
+
error: `Image too large (${(file.size / 1024 / 1024).toFixed(1)}MB, max 10MB)`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (file.size === 0) {
|
|
230
|
+
return { valid: false, error: "Image file is empty" };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check MIME type
|
|
234
|
+
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
|
235
|
+
return {
|
|
236
|
+
valid: false,
|
|
237
|
+
error: `Invalid image type: ${file.type}. Allowed: PNG, JPEG, GIF, WebP`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Sanitize filename: strip path components, replace special chars
|
|
242
|
+
const rawName = file.name || "image";
|
|
243
|
+
const baseName = rawName.split(/[/\\]/).pop() || "image";
|
|
244
|
+
const sanitized = baseName
|
|
245
|
+
.replace(/[^a-zA-Z0-9._-]/g, "_") // Replace special chars with underscore
|
|
246
|
+
.replace(/\.{2,}/g, ".") // Collapse multiple dots
|
|
247
|
+
.replace(/^\.+/, ""); // Strip leading dots
|
|
248
|
+
|
|
249
|
+
const sanitizedName = sanitized || "image";
|
|
250
|
+
|
|
251
|
+
return { valid: true, file, sanitizedName };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function validateArgs(args: any): ValidationResult {
|
|
255
|
+
if (args === undefined || args === null) {
|
|
256
|
+
return { valid: true }; // Optional field
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!Array.isArray(args)) {
|
|
260
|
+
return { valid: false, error: "args must be an array" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (args.length > 100) {
|
|
264
|
+
return {
|
|
265
|
+
valid: false,
|
|
266
|
+
error: `too many arguments (${args.length}, max 100)`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (let i = 0; i < args.length; i++) {
|
|
271
|
+
if (typeof args[i] !== "string") {
|
|
272
|
+
return {
|
|
273
|
+
valid: false,
|
|
274
|
+
error: `args[${i}] must be a string`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (args[i].length > 10240) {
|
|
279
|
+
return {
|
|
280
|
+
valid: false,
|
|
281
|
+
error: `args[${i}] too long (max 10KB)`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { valid: true };
|
|
287
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session validation API route handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import { GitUtils } from "../git/gitUtils";
|
|
7
|
+
import type { RouteContext } from "./routes";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /sessions/validate - Full pre-flight validation for session creation
|
|
11
|
+
*/
|
|
12
|
+
export async function handleValidateSession(req: Request, _ctx: RouteContext): Promise<Response> {
|
|
13
|
+
let body: any;
|
|
14
|
+
try {
|
|
15
|
+
body = await req.json();
|
|
16
|
+
} catch {
|
|
17
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!body.cwd || typeof body.cwd !== "string") {
|
|
21
|
+
return Response.json({ error: "Missing required field: cwd" }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const errors: string[] = [];
|
|
25
|
+
const warnings: string[] = [];
|
|
26
|
+
|
|
27
|
+
// Check directory existence
|
|
28
|
+
const directoryExists = fs.existsSync(body.cwd);
|
|
29
|
+
if (!directoryExists) {
|
|
30
|
+
errors.push(`Directory does not exist: ${body.cwd}`);
|
|
31
|
+
return Response.json({
|
|
32
|
+
valid: false,
|
|
33
|
+
directoryExists: false,
|
|
34
|
+
isGitRepo: false,
|
|
35
|
+
errors,
|
|
36
|
+
warnings,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check if stat shows a directory
|
|
41
|
+
const stat = fs.statSync(body.cwd);
|
|
42
|
+
if (!stat.isDirectory()) {
|
|
43
|
+
errors.push(`Path is not a directory: ${body.cwd}`);
|
|
44
|
+
return Response.json({
|
|
45
|
+
valid: false,
|
|
46
|
+
directoryExists: true,
|
|
47
|
+
isGitRepo: false,
|
|
48
|
+
errors,
|
|
49
|
+
warnings,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if git repo
|
|
54
|
+
const isGitRepo = await GitUtils.isGitRepo(body.cwd);
|
|
55
|
+
let gitInfo: any;
|
|
56
|
+
|
|
57
|
+
if (isGitRepo) {
|
|
58
|
+
try {
|
|
59
|
+
const repoRoot = await GitUtils.getRepoRoot(body.cwd);
|
|
60
|
+
const currentBranch = await GitUtils.getCurrentBranch(body.cwd);
|
|
61
|
+
const branches = await GitUtils.listBranches(body.cwd);
|
|
62
|
+
const hasUncommittedChanges = await GitUtils.hasUncommittedChanges(body.cwd);
|
|
63
|
+
|
|
64
|
+
if (hasUncommittedChanges) {
|
|
65
|
+
warnings.push("Repository has uncommitted changes");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
gitInfo = {
|
|
69
|
+
repoRoot,
|
|
70
|
+
currentBranch,
|
|
71
|
+
hasUncommittedChanges,
|
|
72
|
+
branches,
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
warnings.push(
|
|
76
|
+
`Could not read git info: ${err instanceof Error ? err.message : "unknown error"}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Response.json({
|
|
82
|
+
valid: errors.length === 0,
|
|
83
|
+
directoryExists: true,
|
|
84
|
+
isGitRepo,
|
|
85
|
+
errors,
|
|
86
|
+
warnings,
|
|
87
|
+
gitInfo,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* POST /sessions/validate-git - Validate git worktree options
|
|
93
|
+
*/
|
|
94
|
+
export async function handleValidateGit(req: Request, _ctx: RouteContext): Promise<Response> {
|
|
95
|
+
let body: any;
|
|
96
|
+
try {
|
|
97
|
+
body = await req.json();
|
|
98
|
+
} catch {
|
|
99
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!body.cwd || typeof body.cwd !== "string") {
|
|
103
|
+
return Response.json({ error: "Missing required field: cwd" }, { status: 400 });
|
|
104
|
+
}
|
|
105
|
+
if (!body.branch || typeof body.branch !== "string") {
|
|
106
|
+
return Response.json({ error: "Missing required field: branch" }, { status: 400 });
|
|
107
|
+
}
|
|
108
|
+
if (body.createBranch === undefined) {
|
|
109
|
+
return Response.json({ error: "Missing required field: createBranch" }, { status: 400 });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const errors: string[] = [];
|
|
113
|
+
const warnings: string[] = [];
|
|
114
|
+
|
|
115
|
+
// Validate git repo
|
|
116
|
+
const isGitRepo = await GitUtils.isGitRepo(body.cwd);
|
|
117
|
+
if (!isGitRepo) {
|
|
118
|
+
return Response.json({
|
|
119
|
+
valid: false,
|
|
120
|
+
errors: ["Not a git repository"],
|
|
121
|
+
warnings: [],
|
|
122
|
+
branchExists: false,
|
|
123
|
+
branchCheckedOut: false,
|
|
124
|
+
uncommittedChanges: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check branch existence
|
|
129
|
+
const branchExists = await GitUtils.branchExists(body.cwd, body.branch);
|
|
130
|
+
|
|
131
|
+
// Check if branch is checked out in another worktree
|
|
132
|
+
let branchCheckedOut = false;
|
|
133
|
+
let checkedOutIn: string | undefined;
|
|
134
|
+
|
|
135
|
+
if (branchExists) {
|
|
136
|
+
const checkResult = await GitUtils.isBranchCheckedOut(body.cwd, body.branch);
|
|
137
|
+
branchCheckedOut = checkResult.checkedOut;
|
|
138
|
+
checkedOutIn = checkResult.worktreePath;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Validate based on createBranch flag
|
|
142
|
+
if (body.createBranch) {
|
|
143
|
+
if (branchExists) {
|
|
144
|
+
errors.push(`Branch already exists: ${body.branch}. Uncheck 'Create branch' to use it.`);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
if (!branchExists) {
|
|
148
|
+
errors.push(`Branch does not exist: ${body.branch}. Enable 'Create branch' to create it.`);
|
|
149
|
+
}
|
|
150
|
+
if (branchCheckedOut) {
|
|
151
|
+
errors.push(
|
|
152
|
+
`Branch '${body.branch}' is already checked out${checkedOutIn ? ` in ${checkedOutIn}` : ""}. Choose a different branch or create a new one.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check uncommitted changes
|
|
158
|
+
const uncommittedChanges = await GitUtils.hasUncommittedChanges(body.cwd);
|
|
159
|
+
if (uncommittedChanges) {
|
|
160
|
+
warnings.push(
|
|
161
|
+
"Repository has uncommitted changes. These will remain in the original working tree."
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Response.json({
|
|
166
|
+
valid: errors.length === 0,
|
|
167
|
+
errors,
|
|
168
|
+
warnings,
|
|
169
|
+
branchExists,
|
|
170
|
+
branchCheckedOut,
|
|
171
|
+
checkedOutIn,
|
|
172
|
+
uncommittedChanges,
|
|
173
|
+
});
|
|
174
|
+
}
|