codepiper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,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
+ }