@cospacehq/server 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 (44) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/dist/agent-runtime.d.ts +45 -0
  4. package/dist/agent-runtime.d.ts.map +1 -0
  5. package/dist/agent-runtime.js +374 -0
  6. package/dist/agent-runtime.js.map +1 -0
  7. package/dist/config.d.ts +8 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +14 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/db.d.ts +128 -0
  12. package/dist/db.d.ts.map +1 -0
  13. package/dist/db.js +854 -0
  14. package/dist/db.js.map +1 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +34 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/model-client.d.ts +43 -0
  20. package/dist/model-client.d.ts.map +1 -0
  21. package/dist/model-client.js +138 -0
  22. package/dist/model-client.js.map +1 -0
  23. package/dist/provider-crypto.d.ts +4 -0
  24. package/dist/provider-crypto.d.ts.map +1 -0
  25. package/dist/provider-crypto.js +30 -0
  26. package/dist/provider-crypto.js.map +1 -0
  27. package/dist/sandbox.d.ts +3 -0
  28. package/dist/sandbox.d.ts.map +1 -0
  29. package/dist/sandbox.js +22 -0
  30. package/dist/sandbox.js.map +1 -0
  31. package/dist/server.d.ts +15 -0
  32. package/dist/server.d.ts.map +1 -0
  33. package/dist/server.js +1296 -0
  34. package/dist/server.js.map +1 -0
  35. package/package.json +33 -0
  36. package/src/agent-runtime.ts +479 -0
  37. package/src/config.ts +21 -0
  38. package/src/db.ts +1197 -0
  39. package/src/index.ts +44 -0
  40. package/src/model-client.ts +187 -0
  41. package/src/provider-crypto.ts +39 -0
  42. package/src/sandbox.ts +26 -0
  43. package/src/server.ts +1548 -0
  44. package/tsconfig.json +8 -0
package/src/server.ts ADDED
@@ -0,0 +1,1548 @@
1
+ import fs from "node:fs/promises";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+ import { URL } from "node:url";
5
+ import Fastify from "fastify";
6
+ import cors from "@fastify/cors";
7
+ import { WebSocketServer } from "ws";
8
+ import {
9
+ AgentCreateInputSchema,
10
+ AgentBindingUpdateInputSchema,
11
+ BootstrapPayloadSchema,
12
+ ClientMessageInputSchema,
13
+ FileDeleteInputSchema,
14
+ FileListInputSchema,
15
+ FileMkdirInputSchema,
16
+ FileRenameInputSchema,
17
+ FileReadInputSchema,
18
+ FileWriteInputSchema,
19
+ ProjectAgentAssignmentInputSchema,
20
+ ProjectCreateInputSchema,
21
+ ProviderConnectionTestInputSchema,
22
+ TaskCreateInputSchema,
23
+ TaskStatusUpdateInputSchema,
24
+ ProviderUpsertInputSchema,
25
+ type CoSpaceConfig,
26
+ type ProviderConfig,
27
+ type Task,
28
+ type TaskEvent,
29
+ type TaskApprovalStatus,
30
+ type TaskActionType,
31
+ type TaskActionPayload
32
+ } from "@cospacehq/shared";
33
+ import { AgentRuntime, type ServerEvent } from "./agent-runtime.js";
34
+ import { resolveConfig, type ResolvedConfig } from "./config.js";
35
+ import { CoSpaceStore, type ProviderRecord } from "./db.js";
36
+ import { OpenAICompatibleModelClient, type ProviderRuntimeConfig } from "./model-client.js";
37
+ import {
38
+ decryptProviderSecret,
39
+ encryptProviderSecret,
40
+ encryptionSecretFromToken
41
+ } from "./provider-crypto.js";
42
+ import { listSandboxFiles, resolveSandboxPath } from "./sandbox.js";
43
+
44
+ export type StartServerOptions = {
45
+ config: CoSpaceConfig;
46
+ configPath: string;
47
+ webDistPath?: string;
48
+ host?: string;
49
+ };
50
+
51
+ export type StartedServer = {
52
+ localUrl: string;
53
+ resolvedConfig: ResolvedConfig;
54
+ stop: () => Promise<void>;
55
+ };
56
+
57
+ function readTokenFromRequest(urlPath: string): string | null {
58
+ const parsed = new URL(urlPath, "http://127.0.0.1");
59
+ return parsed.searchParams.get("token");
60
+ }
61
+
62
+ function readPasscodeFromRequest(urlPath: string): string | null {
63
+ const parsed = new URL(urlPath, "http://127.0.0.1");
64
+ return parsed.searchParams.get("passcode");
65
+ }
66
+
67
+ function passcodeHashFromRaw(rawPasscode: string): string {
68
+ return crypto.createHash("sha256").update(rawPasscode, "utf8").digest("hex");
69
+ }
70
+
71
+ function eventEnvelope(event: ServerEvent): string {
72
+ return JSON.stringify(event);
73
+ }
74
+
75
+ function maybeWebDist(webDistPath: string | undefined): string | null {
76
+ if (!webDistPath) {
77
+ return null;
78
+ }
79
+ return path.resolve(webDistPath);
80
+ }
81
+
82
+ function mimeTypeForStaticFile(filePath: string): string | null {
83
+ switch (path.extname(filePath).toLowerCase()) {
84
+ case ".css":
85
+ return "text/css; charset=utf-8";
86
+ case ".html":
87
+ return "text/html; charset=utf-8";
88
+ case ".js":
89
+ case ".mjs":
90
+ return "text/javascript; charset=utf-8";
91
+ case ".json":
92
+ case ".map":
93
+ return "application/json; charset=utf-8";
94
+ case ".ico":
95
+ return "image/x-icon";
96
+ case ".jpeg":
97
+ case ".jpg":
98
+ return "image/jpeg";
99
+ case ".png":
100
+ return "image/png";
101
+ case ".svg":
102
+ return "image/svg+xml";
103
+ case ".txt":
104
+ return "text/plain; charset=utf-8";
105
+ case ".woff":
106
+ return "font/woff";
107
+ case ".woff2":
108
+ return "font/woff2";
109
+ default:
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function sanitizeProviderKey(rawValue: string): string {
115
+ const trimmed = rawValue.trim();
116
+ if (!/^[a-zA-Z0-9._-]{2,64}$/.test(trimmed)) {
117
+ throw new Error("Provider key must be 2-64 chars and contain only letters, numbers, ., _, -");
118
+ }
119
+ return trimmed;
120
+ }
121
+
122
+ function mapProviderRecordToPublic(record: ProviderRecord): ProviderConfig {
123
+ return {
124
+ id: record.id,
125
+ providerKey: record.providerKey,
126
+ label: record.label,
127
+ kind: record.kind,
128
+ baseUrl: record.baseUrl,
129
+ defaultModel: record.defaultModel,
130
+ hasApiKey: Boolean(record.encryptedApiKey),
131
+ createdAt: record.createdAt,
132
+ updatedAt: record.updatedAt
133
+ };
134
+ }
135
+
136
+ function toProviderRuntimeConfig(
137
+ record: ProviderRecord,
138
+ encryptionSecret: string,
139
+ overrideApiKey?: string,
140
+ overrideBaseUrl?: string,
141
+ overrideKind?: ProviderRecord["kind"]
142
+ ): ProviderRuntimeConfig {
143
+ const apiKey = overrideApiKey ?? (record.encryptedApiKey ? decryptProviderSecret(record.encryptedApiKey, encryptionSecret) : null);
144
+
145
+ if (!apiKey) {
146
+ throw new Error(`Provider ${record.providerKey} does not have an API key configured`);
147
+ }
148
+
149
+ return {
150
+ providerKey: record.providerKey,
151
+ label: record.label,
152
+ kind: overrideKind ?? record.kind,
153
+ baseUrl: overrideBaseUrl ?? record.baseUrl,
154
+ apiKey
155
+ };
156
+ }
157
+
158
+ export async function startCoSpaceServer(options: StartServerOptions): Promise<StartedServer> {
159
+ const resolvedConfig = resolveConfig(options.config, options.configPath);
160
+
161
+ await fs.mkdir(resolvedConfig.workspaceRoot, { recursive: true });
162
+ await fs.mkdir(resolvedConfig.dataDir, { recursive: true });
163
+
164
+ const app = Fastify({ logger: true });
165
+ await app.register(cors, { origin: true });
166
+
167
+ const store = new CoSpaceStore(path.join(resolvedConfig.dataDir, "cospace.db"), {
168
+ projectName: resolvedConfig.projectName
169
+ });
170
+
171
+ let sessionToken = resolvedConfig.auth.token;
172
+ let sessionPasscodeHash = resolvedConfig.auth.passcodeHash ?? null;
173
+
174
+ const sockets = new Set<WebSocket>();
175
+ const wss = new WebSocketServer({ noServer: true });
176
+
177
+ const emit = (event: ServerEvent): void => {
178
+ const payload = eventEnvelope(event);
179
+
180
+ for (const socket of sockets) {
181
+ if (socket.readyState === socket.OPEN) {
182
+ socket.send(payload);
183
+ }
184
+ }
185
+ };
186
+
187
+ const emitTaskUpdated = (task: Task): void => {
188
+ emit({ type: "task.updated", data: task });
189
+ };
190
+
191
+ const emitTaskEvent = (event: TaskEvent): void => {
192
+ emit({ type: "task.event", data: event });
193
+ };
194
+
195
+ const recordAndEmitTaskEvent = (input: {
196
+ taskId: string;
197
+ projectId: string;
198
+ eventType: TaskEvent["eventType"];
199
+ actorId: string;
200
+ detail: string | null;
201
+ }): void => {
202
+ const event = store.recordTaskEvent(input);
203
+ emitTaskEvent(event);
204
+ };
205
+
206
+ const emitTaskMessage = (task: Task, body: string, trace: string): void => {
207
+ const assignee = task.assigneeAgentId ? store.getAgentById(task.assigneeAgentId) : null;
208
+ const senderId = assignee?.id ?? "task-system";
209
+ const senderName = assignee ? `${assignee.name} (${assignee.title})` : "Task System";
210
+ const internalTrace = assignee?.traceEnabled ? trace : null;
211
+
212
+ const message = store.createAgentMessage({
213
+ projectId: task.projectId,
214
+ senderId,
215
+ senderName,
216
+ body,
217
+ internalTrace
218
+ });
219
+
220
+ emit({ type: "message.created", data: message });
221
+
222
+ if (assignee?.seenEnabled) {
223
+ store.createSeenReceipt({
224
+ messageId: message.id,
225
+ agentId: assignee.id,
226
+ agentName: assignee.name,
227
+ model: assignee.model
228
+ });
229
+ emit({
230
+ type: "seen.updated",
231
+ data: {
232
+ messageId: message.id,
233
+ receipts: store.listReceiptsForMessage(message.id)
234
+ }
235
+ });
236
+ }
237
+ };
238
+
239
+ const taskActorId = (task: Task): string => task.assigneeAgentId ?? "task-system";
240
+
241
+ const taskPathFromPayload = (task: Task): string | null => {
242
+ const raw = task.actionPayload?.path?.trim() ?? null;
243
+ return raw && raw.length > 0 ? raw : null;
244
+ };
245
+
246
+ const normalizePolicyPath = (value: string | null): string | null => {
247
+ if (!value) {
248
+ return null;
249
+ }
250
+ return value.replace(/^\/+/g, "");
251
+ };
252
+
253
+ const policyPrefixMatch = (targetPath: string | null, prefixes: string[]): boolean => {
254
+ const normalized = normalizePolicyPath(targetPath);
255
+ if (!normalized) {
256
+ return false;
257
+ }
258
+ return prefixes.some((prefix) => normalized.startsWith(prefix));
259
+ };
260
+
261
+ const resolveApprovalPolicy = (task: Task): { approvalStatus: TaskApprovalStatus; reason: string } => {
262
+ const actionType = task.actionType;
263
+ const pathValue = taskPathFromPayload(task);
264
+ const assignee = task.assigneeAgentId ? store.getAgentById(task.assigneeAgentId) : null;
265
+ const assigneeTitle = assignee?.title.toLowerCase() ?? "";
266
+
267
+ if (actionType === "none") {
268
+ return { approvalStatus: "not_required", reason: "Non-file task auto-executes." };
269
+ }
270
+
271
+ if (actionType === "file_delete" || actionType === "file_rename") {
272
+ return { approvalStatus: "pending", reason: "High-risk file action always requires approval." };
273
+ }
274
+
275
+ const backendTrusted =
276
+ assigneeTitle.includes("backend") &&
277
+ policyPrefixMatch(pathValue, ["trusted/backend/", "automation/backend/"]);
278
+ const uiTrusted =
279
+ (assigneeTitle.includes("ui") || assigneeTitle.includes("design")) &&
280
+ policyPrefixMatch(pathValue, ["trusted/ui/", "automation/ui/"]);
281
+
282
+ if (backendTrusted || uiTrusted) {
283
+ return {
284
+ approvalStatus: "approved",
285
+ reason: "Trusted agent and trusted path matched auto-approval policy."
286
+ };
287
+ }
288
+
289
+ return { approvalStatus: "pending", reason: "Task requires manual approval by default policy." };
290
+ };
291
+
292
+ const executeTaskAction = async (task: Task): Promise<string> => {
293
+ const payload: TaskActionPayload = task.actionPayload ?? {};
294
+
295
+ switch (task.actionType) {
296
+ case "none":
297
+ return "No file action requested.";
298
+ case "file_write": {
299
+ const filePath = payload.path?.trim();
300
+ if (!filePath) {
301
+ throw new Error("Task payload missing path for file_write");
302
+ }
303
+ const content = payload.content ?? "";
304
+
305
+ try {
306
+ const absolutePath = resolveSandboxPath(resolvedConfig.workspaceRoot, filePath);
307
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
308
+ await fs.writeFile(absolutePath, content, "utf8");
309
+ store.recordFileAction({
310
+ actorId: taskActorId(task),
311
+ action: "write",
312
+ targetPath: filePath,
313
+ status: "ok",
314
+ errorMessage: null
315
+ });
316
+ return `Wrote /${filePath}`;
317
+ } catch (error) {
318
+ const message = error instanceof Error ? error.message : "Unknown write error";
319
+ store.recordFileAction({
320
+ actorId: taskActorId(task),
321
+ action: "write",
322
+ targetPath: filePath,
323
+ status: "error",
324
+ errorMessage: message
325
+ });
326
+ throw error;
327
+ }
328
+ }
329
+ case "file_mkdir": {
330
+ const directoryPath = payload.path?.trim();
331
+ if (!directoryPath) {
332
+ throw new Error("Task payload missing path for file_mkdir");
333
+ }
334
+
335
+ try {
336
+ const absolutePath = resolveSandboxPath(resolvedConfig.workspaceRoot, directoryPath);
337
+ await fs.mkdir(absolutePath, { recursive: true });
338
+ store.recordFileAction({
339
+ actorId: taskActorId(task),
340
+ action: "mkdir",
341
+ targetPath: directoryPath,
342
+ status: "ok",
343
+ errorMessage: null
344
+ });
345
+ return `Created folder /${directoryPath}`;
346
+ } catch (error) {
347
+ const message = error instanceof Error ? error.message : "Unknown mkdir error";
348
+ store.recordFileAction({
349
+ actorId: taskActorId(task),
350
+ action: "mkdir",
351
+ targetPath: directoryPath,
352
+ status: "error",
353
+ errorMessage: message
354
+ });
355
+ throw error;
356
+ }
357
+ }
358
+ case "file_rename": {
359
+ const currentPath = payload.path?.trim();
360
+ const nextPath = payload.nextPath?.trim();
361
+ if (!currentPath || !nextPath) {
362
+ throw new Error("Task payload missing path/nextPath for file_rename");
363
+ }
364
+ if (currentPath === "." || nextPath === ".") {
365
+ throw new Error("Cannot rename workspace root");
366
+ }
367
+
368
+ try {
369
+ const fromPath = resolveSandboxPath(resolvedConfig.workspaceRoot, currentPath);
370
+ const toPath = resolveSandboxPath(resolvedConfig.workspaceRoot, nextPath);
371
+ try {
372
+ await fs.access(toPath);
373
+ throw new Error(`Destination already exists: ${nextPath}`);
374
+ } catch (accessError) {
375
+ const code = (accessError as NodeJS.ErrnoException).code;
376
+ if (code !== "ENOENT") {
377
+ throw accessError;
378
+ }
379
+ }
380
+ await fs.mkdir(path.dirname(toPath), { recursive: true });
381
+ await fs.rename(fromPath, toPath);
382
+ store.recordFileAction({
383
+ actorId: taskActorId(task),
384
+ action: "rename",
385
+ targetPath: `${currentPath} -> ${nextPath}`,
386
+ status: "ok",
387
+ errorMessage: null
388
+ });
389
+ return `Renamed /${currentPath} to /${nextPath}`;
390
+ } catch (error) {
391
+ const message = error instanceof Error ? error.message : "Unknown rename error";
392
+ store.recordFileAction({
393
+ actorId: taskActorId(task),
394
+ action: "rename",
395
+ targetPath: `${currentPath} -> ${nextPath}`,
396
+ status: "error",
397
+ errorMessage: message
398
+ });
399
+ throw error;
400
+ }
401
+ }
402
+ case "file_delete": {
403
+ const targetPath = payload.path?.trim();
404
+ if (!targetPath) {
405
+ throw new Error("Task payload missing path for file_delete");
406
+ }
407
+ if (targetPath === ".") {
408
+ throw new Error("Cannot delete workspace root");
409
+ }
410
+
411
+ try {
412
+ const absolutePath = resolveSandboxPath(resolvedConfig.workspaceRoot, targetPath);
413
+ const stats = await fs.lstat(absolutePath);
414
+ if (stats.isDirectory()) {
415
+ await fs.rm(absolutePath, { recursive: true, force: false });
416
+ } else {
417
+ await fs.unlink(absolutePath);
418
+ }
419
+ store.recordFileAction({
420
+ actorId: taskActorId(task),
421
+ action: "delete",
422
+ targetPath,
423
+ status: "ok",
424
+ errorMessage: null
425
+ });
426
+ return `Deleted /${targetPath}`;
427
+ } catch (error) {
428
+ const message = error instanceof Error ? error.message : "Unknown delete error";
429
+ store.recordFileAction({
430
+ actorId: taskActorId(task),
431
+ action: "delete",
432
+ targetPath,
433
+ status: "error",
434
+ errorMessage: message
435
+ });
436
+ throw error;
437
+ }
438
+ }
439
+ default:
440
+ throw new Error(`Unsupported task action type: ${task.actionType}`);
441
+ }
442
+ };
443
+
444
+ const executeTask = async (
445
+ task: Task,
446
+ approvalStatusOverride?: TaskApprovalStatus,
447
+ actorId = "task-system"
448
+ ): Promise<{ task: Task; error: string | null }> => {
449
+ const approvalStatus = approvalStatusOverride ?? task.approvalStatus;
450
+
451
+ const inProgress = store.updateTaskState({
452
+ taskId: task.id,
453
+ status: "in_progress",
454
+ approvalStatus,
455
+ errorMessage: null,
456
+ completedAt: null
457
+ });
458
+ emitTaskUpdated(inProgress);
459
+ recordAndEmitTaskEvent({
460
+ taskId: inProgress.id,
461
+ projectId: inProgress.projectId,
462
+ eventType: "execution_started",
463
+ actorId,
464
+ detail: `Execution started (${approvalStatus.replace(/_/g, " ")}).`
465
+ });
466
+
467
+ try {
468
+ const resultSummary = await executeTaskAction(inProgress);
469
+ const completed = store.updateTaskState({
470
+ taskId: task.id,
471
+ status: "done",
472
+ approvalStatus,
473
+ errorMessage: null
474
+ });
475
+ emitTaskUpdated(completed);
476
+ recordAndEmitTaskEvent({
477
+ taskId: completed.id,
478
+ projectId: completed.projectId,
479
+ eventType: "execution_succeeded",
480
+ actorId,
481
+ detail: resultSummary
482
+ });
483
+ emitTaskMessage(
484
+ completed,
485
+ `Task completed: ${completed.title}. ${resultSummary}`,
486
+ `Approval: ${approvalStatus.replace(/_/g, " ")}\nAction: ${completed.actionType}\nResult: ${resultSummary}`
487
+ );
488
+ return { task: completed, error: null };
489
+ } catch (error) {
490
+ const message = error instanceof Error ? error.message : String(error);
491
+ const blocked = store.updateTaskState({
492
+ taskId: task.id,
493
+ status: "blocked",
494
+ approvalStatus,
495
+ errorMessage: message,
496
+ completedAt: null
497
+ });
498
+ emitTaskUpdated(blocked);
499
+ recordAndEmitTaskEvent({
500
+ taskId: blocked.id,
501
+ projectId: blocked.projectId,
502
+ eventType: "execution_failed",
503
+ actorId,
504
+ detail: message
505
+ });
506
+ emitTaskMessage(
507
+ blocked,
508
+ `Task failed: ${blocked.title}. ${message}`,
509
+ `Approval: ${approvalStatus.replace(/_/g, " ")}\nAction: ${blocked.actionType}\nFailure: ${message}`
510
+ );
511
+ return { task: blocked, error: message };
512
+ }
513
+ };
514
+
515
+ const encryptionSecret = encryptionSecretFromToken(
516
+ resolvedConfig.auth.encryptionSecret ?? resolvedConfig.auth.token
517
+ );
518
+ const modelClient = new OpenAICompatibleModelClient();
519
+
520
+ const buildBootstrapPayload = (preferredProjectId?: string) => {
521
+ const projects = store.listProjectThreads();
522
+
523
+ if (projects.length === 0) {
524
+ throw new Error("No projects available");
525
+ }
526
+
527
+ const selectedThread = preferredProjectId
528
+ ? projects.find((projectThread) => projectThread.id === preferredProjectId) ?? projects[0]
529
+ : projects[0];
530
+
531
+ const activeProject = store.getProjectById(selectedThread.id);
532
+ if (!activeProject) {
533
+ throw new Error(`Project ${selectedThread.id} not found`);
534
+ }
535
+
536
+ return BootstrapPayloadSchema.parse({
537
+ project: activeProject,
538
+ projects,
539
+ projectAgentIds: store.listProjectAgentIds(activeProject.id),
540
+ projectAgentIdsByProject: store.listProjectAgentIdsByProject(),
541
+ agents: store.listAgents(),
542
+ providers: store.listProviderRecords().map(mapProviderRecordToPublic),
543
+ messages: store.listMessages(activeProject.id),
544
+ receiptsByMessage: store.listReceiptsByProject(activeProject.id),
545
+ tasks: store.listTasksByProject(activeProject.id),
546
+ taskEventsByTask: store.listTaskEventsByProject(activeProject.id)
547
+ });
548
+ };
549
+
550
+ const runtime = new AgentRuntime(
551
+ store,
552
+ emit,
553
+ async ({ agent, latestMessages }) => {
554
+ if (!agent.providerKey) {
555
+ return {
556
+ body: "I need a configured provider before I can generate real model responses. Set one in Integrations.",
557
+ trace: "Provider missing\nAction: open Settings > Integrations and bind a provider to this agent.",
558
+ model: agent.model
559
+ };
560
+ }
561
+
562
+ const providerRecord = store.getProviderRecord(agent.providerKey);
563
+ if (!providerRecord) {
564
+ return {
565
+ body: `My assigned provider '${agent.providerKey}' is missing. Please reconfigure agent routing.`,
566
+ trace: `Provider lookup failed for key: ${agent.providerKey}`,
567
+ model: agent.model
568
+ };
569
+ }
570
+
571
+ const provider = toProviderRuntimeConfig(providerRecord, encryptionSecret);
572
+
573
+ const generated = await modelClient.generateAgentReply({
574
+ provider,
575
+ model: agent.model,
576
+ agentName: agent.name,
577
+ agentTitle: agent.title,
578
+ agentInstruction: agent.systemPrompt,
579
+ latestMessages,
580
+ includeTrace: agent.traceEnabled
581
+ });
582
+
583
+ return {
584
+ body: generated.body,
585
+ trace: generated.trace,
586
+ model: agent.model
587
+ };
588
+ },
589
+ async (delegatedTask) => {
590
+ const latestTask = store.getTaskById(delegatedTask.id);
591
+ if (!latestTask) {
592
+ return;
593
+ }
594
+
595
+ const policy = resolveApprovalPolicy(latestTask);
596
+ let policyTask = latestTask;
597
+ if (policy.approvalStatus !== latestTask.approvalStatus) {
598
+ policyTask = store.updateTaskState({
599
+ taskId: latestTask.id,
600
+ approvalStatus: policy.approvalStatus,
601
+ errorMessage: null
602
+ });
603
+ emitTaskUpdated(policyTask);
604
+ }
605
+
606
+ recordAndEmitTaskEvent({
607
+ taskId: policyTask.id,
608
+ projectId: policyTask.projectId,
609
+ eventType: "policy_applied",
610
+ actorId: "policy-engine",
611
+ detail: policy.reason
612
+ });
613
+
614
+ if (policyTask.approvalStatus === "pending") {
615
+ return;
616
+ }
617
+
618
+ await executeTask(policyTask, policyTask.approvalStatus, "policy-engine");
619
+ }
620
+ );
621
+
622
+ const persistAuthConfig = async (): Promise<void> => {
623
+ const raw = await fs.readFile(options.configPath, "utf8");
624
+ const parsed = JSON.parse(raw) as CoSpaceConfig;
625
+ parsed.auth = {
626
+ ...parsed.auth,
627
+ token: sessionToken,
628
+ passcodeHash: sessionPasscodeHash ?? undefined
629
+ };
630
+ await fs.writeFile(options.configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
631
+ };
632
+
633
+ app.addHook("onRequest", async (request, reply) => {
634
+ if (!request.url.startsWith("/api")) {
635
+ return;
636
+ }
637
+
638
+ const token = request.headers["x-cospace-token"];
639
+ if (typeof token !== "string" || token !== sessionToken) {
640
+ reply.code(401).send({ error: "Unauthorized" });
641
+ return;
642
+ }
643
+
644
+ const allowWithoutPasscode = request.url.startsWith("/api/session/challenge");
645
+ if (sessionPasscodeHash && !allowWithoutPasscode) {
646
+ const provided = request.headers["x-cospace-passcode"];
647
+ if (typeof provided !== "string" || passcodeHashFromRaw(provided) !== sessionPasscodeHash) {
648
+ reply.code(401).send({ error: "Passcode required" });
649
+ return;
650
+ }
651
+ }
652
+ });
653
+
654
+ app.get("/api/session/challenge", async () => {
655
+ return {
656
+ passcodeRequired: Boolean(sessionPasscodeHash),
657
+ warning: sessionPasscodeHash
658
+ ? "Passcode required for API and WebSocket access."
659
+ : "Passcode not configured. Recommended when using public tunnel."
660
+ };
661
+ });
662
+
663
+ app.post("/api/session/passcode", async (request, reply) => {
664
+ const body = request.body as { passcode?: string | null };
665
+ const provided = typeof body?.passcode === "string" ? body.passcode.trim() : "";
666
+
667
+ if (provided.length > 0 && provided.length < 6) {
668
+ reply.code(400);
669
+ return { error: "Passcode must be at least 6 characters" };
670
+ }
671
+
672
+ sessionPasscodeHash = provided.length > 0 ? passcodeHashFromRaw(provided) : null;
673
+ await persistAuthConfig();
674
+
675
+ for (const socket of sockets) {
676
+ socket.close();
677
+ }
678
+
679
+ return {
680
+ ok: true,
681
+ passcodeRequired: Boolean(sessionPasscodeHash)
682
+ };
683
+ });
684
+
685
+ app.post("/api/session/rotate-token", async () => {
686
+ sessionToken = crypto.randomBytes(18).toString("hex");
687
+ await persistAuthConfig();
688
+
689
+ for (const socket of sockets) {
690
+ socket.close();
691
+ }
692
+
693
+ const host = options.host ?? "127.0.0.1";
694
+ const base = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${resolvedConfig.port}`;
695
+ return {
696
+ ok: true,
697
+ token: sessionToken,
698
+ localUrl: `${base}?token=${encodeURIComponent(sessionToken)}`
699
+ };
700
+ });
701
+
702
+ app.get("/api/bootstrap", async (request, reply) => {
703
+ const query = request.query as { projectId?: string };
704
+
705
+ if (query.projectId && !store.getProjectById(query.projectId)) {
706
+ reply.code(404);
707
+ return { error: `Project ${query.projectId} not found` };
708
+ }
709
+
710
+ return buildBootstrapPayload(query.projectId);
711
+ });
712
+
713
+ app.get("/api/projects", async () => {
714
+ return { projects: store.listProjectThreads() };
715
+ });
716
+
717
+ app.post("/api/projects", async (request, reply) => {
718
+ const parsed = ProjectCreateInputSchema.parse(request.body);
719
+ const project = store.createProject({ name: parsed.name });
720
+ reply.code(201);
721
+ return {
722
+ project,
723
+ projects: store.listProjectThreads()
724
+ };
725
+ });
726
+
727
+ app.put("/api/projects/:projectId/agents", async (request, reply) => {
728
+ const params = request.params as { projectId: string };
729
+ const parsed = ProjectAgentAssignmentInputSchema.parse(request.body);
730
+ const project = store.getProjectById(params.projectId);
731
+
732
+ if (!project) {
733
+ reply.code(404);
734
+ return { error: `Project ${params.projectId} not found` };
735
+ }
736
+
737
+ const knownAgentIds = new Set(store.listAgents().map((agent) => agent.id));
738
+ const unknownAgentId = parsed.agentIds.find((agentId) => !knownAgentIds.has(agentId));
739
+
740
+ if (unknownAgentId) {
741
+ reply.code(400);
742
+ return { error: `Unknown agent id: ${unknownAgentId}` };
743
+ }
744
+
745
+ const projectAgentIds = store.setProjectAgentIds(project.id, parsed.agentIds);
746
+ return {
747
+ projectAgentIds,
748
+ projects: store.listProjectThreads(),
749
+ projectAgentIdsByProject: store.listProjectAgentIdsByProject()
750
+ };
751
+ });
752
+
753
+ app.get("/api/projects/:projectId/tasks", async (request, reply) => {
754
+ const params = request.params as { projectId: string };
755
+ const project = store.getProjectById(params.projectId);
756
+ if (!project) {
757
+ reply.code(404);
758
+ return { error: `Project ${params.projectId} not found` };
759
+ }
760
+
761
+ return { tasks: store.listTasksByProject(project.id) };
762
+ });
763
+
764
+ app.get("/api/projects/:projectId/task-events", async (request, reply) => {
765
+ const params = request.params as { projectId: string };
766
+ const project = store.getProjectById(params.projectId);
767
+ if (!project) {
768
+ reply.code(404);
769
+ return { error: `Project ${params.projectId} not found` };
770
+ }
771
+
772
+ return { taskEventsByTask: store.listTaskEventsByProject(project.id) };
773
+ });
774
+
775
+ app.post("/api/projects/:projectId/tasks", async (request, reply) => {
776
+ const params = request.params as { projectId: string };
777
+ const parsed = TaskCreateInputSchema.parse(request.body);
778
+ const project = store.getProjectById(params.projectId);
779
+
780
+ if (!project) {
781
+ reply.code(404);
782
+ return { error: `Project ${params.projectId} not found` };
783
+ }
784
+
785
+ const assigneeAgentId = parsed.assigneeAgentId?.trim() ?? null;
786
+ if (assigneeAgentId) {
787
+ const assigneeAgent = store.getAgentById(assigneeAgentId);
788
+ if (!assigneeAgent) {
789
+ reply.code(400);
790
+ return { error: `Unknown assignee agent ${assigneeAgentId}` };
791
+ }
792
+ }
793
+
794
+ const requestedApprovalStatus =
795
+ parsed.approvalStatus ?? (parsed.actionType === "none" ? "not_required" : "pending");
796
+ let task = store.createTask({
797
+ projectId: project.id,
798
+ title: parsed.title,
799
+ description: parsed.description?.trim() || null,
800
+ status: "queued",
801
+ approvalStatus: requestedApprovalStatus,
802
+ actionType: parsed.actionType,
803
+ actionPayload: parsed.actionPayload ?? null,
804
+ assigneeAgentId,
805
+ delegatedByMessageId: null,
806
+ createdBy: "web-user"
807
+ });
808
+
809
+ emitTaskUpdated(task);
810
+ recordAndEmitTaskEvent({
811
+ taskId: task.id,
812
+ projectId: task.projectId,
813
+ eventType: "created",
814
+ actorId: "web-user",
815
+ detail: "Task created from web panel."
816
+ });
817
+ const policy = resolveApprovalPolicy(task);
818
+ if (policy.approvalStatus !== task.approvalStatus) {
819
+ task = store.updateTaskState({
820
+ taskId: task.id,
821
+ approvalStatus: policy.approvalStatus,
822
+ errorMessage: null
823
+ });
824
+ emitTaskUpdated(task);
825
+ }
826
+ recordAndEmitTaskEvent({
827
+ taskId: task.id,
828
+ projectId: task.projectId,
829
+ eventType: "policy_applied",
830
+ actorId: "policy-engine",
831
+ detail: policy.reason
832
+ });
833
+
834
+ if (task.approvalStatus !== "pending") {
835
+ const execution = await executeTask(task, task.approvalStatus, "web-user");
836
+ task = execution.task;
837
+ }
838
+
839
+ reply.code(201);
840
+ return { task };
841
+ });
842
+
843
+ app.post("/api/tasks/:taskId/approve", async (request, reply) => {
844
+ const params = request.params as { taskId: string };
845
+ const task = store.getTaskById(params.taskId);
846
+ if (!task) {
847
+ reply.code(404);
848
+ return { error: `Task ${params.taskId} not found` };
849
+ }
850
+
851
+ if (task.approvalStatus !== "pending") {
852
+ reply.code(400);
853
+ return { error: `Task ${params.taskId} is not awaiting approval` };
854
+ }
855
+
856
+ recordAndEmitTaskEvent({
857
+ taskId: task.id,
858
+ projectId: task.projectId,
859
+ eventType: "approved",
860
+ actorId: "web-user",
861
+ detail: "Task approved by user."
862
+ });
863
+
864
+ const result = await executeTask(task, "approved", "web-user");
865
+ if (result.error) {
866
+ reply.code(500);
867
+ return { error: result.error, task: result.task };
868
+ }
869
+ return { task: result.task };
870
+ });
871
+
872
+ app.post("/api/tasks/:taskId/retry", async (request, reply) => {
873
+ const params = request.params as { taskId: string };
874
+ const task = store.getTaskById(params.taskId);
875
+ if (!task) {
876
+ reply.code(404);
877
+ return { error: `Task ${params.taskId} not found` };
878
+ }
879
+
880
+ if (task.status === "in_progress") {
881
+ reply.code(409);
882
+ return { error: `Task ${params.taskId} is currently running` };
883
+ }
884
+
885
+ if (task.status === "done") {
886
+ reply.code(400);
887
+ return { error: `Task ${params.taskId} is already completed` };
888
+ }
889
+
890
+ if (task.status !== "blocked" && task.status !== "cancelled") {
891
+ reply.code(400);
892
+ return { error: `Task ${params.taskId} cannot be retried from status ${task.status}` };
893
+ }
894
+
895
+ const nextApprovalStatus: TaskApprovalStatus =
896
+ task.actionType === "none"
897
+ ? "not_required"
898
+ : task.approvalStatus === "rejected"
899
+ ? "pending"
900
+ : task.approvalStatus;
901
+
902
+ const retried = store.updateTaskState({
903
+ taskId: task.id,
904
+ status: "queued",
905
+ approvalStatus: nextApprovalStatus,
906
+ errorMessage: null,
907
+ completedAt: null
908
+ });
909
+ emitTaskUpdated(retried);
910
+ recordAndEmitTaskEvent({
911
+ taskId: retried.id,
912
+ projectId: retried.projectId,
913
+ eventType: "retry_requested",
914
+ actorId: "web-user",
915
+ detail: "Retry requested by user."
916
+ });
917
+
918
+ if (retried.approvalStatus === "pending") {
919
+ emitTaskMessage(
920
+ retried,
921
+ `Task retried: ${retried.title}. Awaiting approval in the Tasks panel.`,
922
+ `Approval: pending\nAction: ${retried.actionType}\nReason: retry requested`
923
+ );
924
+ return { task: retried };
925
+ }
926
+
927
+ const result = await executeTask(retried, nextApprovalStatus, "web-user");
928
+ if (result.error) {
929
+ reply.code(500);
930
+ return { error: result.error, task: result.task };
931
+ }
932
+ return { task: result.task };
933
+ });
934
+
935
+ app.post("/api/tasks/:taskId/cancel", async (request, reply) => {
936
+ const params = request.params as { taskId: string };
937
+ const task = store.getTaskById(params.taskId);
938
+ if (!task) {
939
+ reply.code(404);
940
+ return { error: `Task ${params.taskId} not found` };
941
+ }
942
+
943
+ if (task.status === "done" || task.status === "cancelled") {
944
+ reply.code(400);
945
+ return { error: `Task ${params.taskId} cannot be cancelled from status ${task.status}` };
946
+ }
947
+
948
+ if (task.status === "in_progress") {
949
+ reply.code(409);
950
+ return { error: `Task ${params.taskId} is currently running` };
951
+ }
952
+
953
+ const cancelled = store.updateTaskState({
954
+ taskId: task.id,
955
+ status: "cancelled",
956
+ approvalStatus: task.approvalStatus === "pending" ? "rejected" : task.approvalStatus,
957
+ errorMessage: "Cancelled by user"
958
+ });
959
+ emitTaskUpdated(cancelled);
960
+ recordAndEmitTaskEvent({
961
+ taskId: cancelled.id,
962
+ projectId: cancelled.projectId,
963
+ eventType: "cancelled",
964
+ actorId: "web-user",
965
+ detail: "Task cancelled by user."
966
+ });
967
+ emitTaskMessage(
968
+ cancelled,
969
+ `Task cancelled: ${cancelled.title}.`,
970
+ `Approval: ${cancelled.approvalStatus.replace(/_/g, " ")}\nAction: ${cancelled.actionType}\nReason: cancelled by user`
971
+ );
972
+ return { task: cancelled };
973
+ });
974
+
975
+ app.post("/api/tasks/:taskId/reject", async (request, reply) => {
976
+ const params = request.params as { taskId: string };
977
+ const task = store.getTaskById(params.taskId);
978
+ if (!task) {
979
+ reply.code(404);
980
+ return { error: `Task ${params.taskId} not found` };
981
+ }
982
+
983
+ if (task.approvalStatus !== "pending") {
984
+ reply.code(400);
985
+ return { error: `Task ${params.taskId} is not awaiting approval` };
986
+ }
987
+
988
+ const blocked = store.updateTaskState({
989
+ taskId: task.id,
990
+ status: "blocked",
991
+ approvalStatus: "rejected",
992
+ errorMessage: "Rejected by user",
993
+ completedAt: null
994
+ });
995
+ emitTaskUpdated(blocked);
996
+ recordAndEmitTaskEvent({
997
+ taskId: blocked.id,
998
+ projectId: blocked.projectId,
999
+ eventType: "rejected",
1000
+ actorId: "web-user",
1001
+ detail: "Task rejected by user."
1002
+ });
1003
+ emitTaskMessage(
1004
+ blocked,
1005
+ `Task rejected: ${blocked.title}. Waiting for an updated instruction.`,
1006
+ `Approval: rejected\nAction: ${blocked.actionType}\nReason: user rejected task`
1007
+ );
1008
+ return { task: blocked };
1009
+ });
1010
+
1011
+ app.post("/api/tasks/:taskId/status", async (request, reply) => {
1012
+ const params = request.params as { taskId: string };
1013
+ const parsed = TaskStatusUpdateInputSchema.parse(request.body);
1014
+ const task = store.getTaskById(params.taskId);
1015
+ if (!task) {
1016
+ reply.code(404);
1017
+ return { error: `Task ${params.taskId} not found` };
1018
+ }
1019
+
1020
+ const updated = store.updateTaskState({
1021
+ taskId: task.id,
1022
+ status: parsed.status,
1023
+ completedAt: parsed.status === "done" || parsed.status === "cancelled" ? undefined : null
1024
+ });
1025
+ emitTaskUpdated(updated);
1026
+ recordAndEmitTaskEvent({
1027
+ taskId: updated.id,
1028
+ projectId: updated.projectId,
1029
+ eventType: "status_changed",
1030
+ actorId: "web-user",
1031
+ detail: `Status changed to ${updated.status}.`
1032
+ });
1033
+ return { task: updated };
1034
+ });
1035
+
1036
+ app.get("/api/providers", async () => {
1037
+ return { providers: store.listProviderRecords().map(mapProviderRecordToPublic) };
1038
+ });
1039
+
1040
+ app.put("/api/providers/:providerKey", async (request, reply) => {
1041
+ const params = request.params as { providerKey: string };
1042
+ const parsed = ProviderUpsertInputSchema.parse(request.body);
1043
+
1044
+ let providerKey: string;
1045
+ try {
1046
+ providerKey = sanitizeProviderKey(params.providerKey);
1047
+ } catch (error) {
1048
+ reply.code(400);
1049
+ return { error: error instanceof Error ? error.message : String(error) };
1050
+ }
1051
+
1052
+ const encryptedApiKey = parsed.clearApiKey
1053
+ ? null
1054
+ : parsed.apiKey
1055
+ ? encryptProviderSecret(parsed.apiKey, encryptionSecret)
1056
+ : undefined;
1057
+
1058
+ const updated = store.upsertProviderRecord({
1059
+ providerKey,
1060
+ label: parsed.label,
1061
+ kind: parsed.kind,
1062
+ baseUrl: parsed.baseUrl,
1063
+ defaultModel: parsed.defaultModel,
1064
+ encryptedApiKey
1065
+ });
1066
+
1067
+ return { provider: mapProviderRecordToPublic(updated) };
1068
+ });
1069
+
1070
+ app.post("/api/providers/test", async (request, reply) => {
1071
+ const parsed = ProviderConnectionTestInputSchema.parse(request.body);
1072
+
1073
+ let provider: ProviderRuntimeConfig;
1074
+
1075
+ if (parsed.providerKey) {
1076
+ const providerKey = sanitizeProviderKey(parsed.providerKey);
1077
+ const record = store.getProviderRecord(providerKey);
1078
+ if (!record) {
1079
+ reply.code(404);
1080
+ return { ok: false, error: `Provider ${providerKey} not found` };
1081
+ }
1082
+
1083
+ try {
1084
+ provider = toProviderRuntimeConfig(
1085
+ record,
1086
+ encryptionSecret,
1087
+ parsed.apiKey,
1088
+ parsed.baseUrl,
1089
+ parsed.kind
1090
+ );
1091
+ } catch (error) {
1092
+ reply.code(400);
1093
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1094
+ }
1095
+ } else {
1096
+ provider = {
1097
+ providerKey: "adhoc",
1098
+ label: "Ad hoc",
1099
+ kind: parsed.kind,
1100
+ baseUrl: parsed.baseUrl as string,
1101
+ apiKey: parsed.apiKey as string
1102
+ };
1103
+ }
1104
+
1105
+ const startedAt = Date.now();
1106
+ try {
1107
+ const result = await modelClient.testConnection({
1108
+ provider,
1109
+ model: parsed.model
1110
+ });
1111
+
1112
+ return {
1113
+ ok: true,
1114
+ latencyMs: Date.now() - startedAt,
1115
+ preview: result.preview
1116
+ };
1117
+ } catch (error) {
1118
+ reply.code(502);
1119
+ return {
1120
+ ok: false,
1121
+ error: error instanceof Error ? error.message : String(error)
1122
+ };
1123
+ }
1124
+ });
1125
+
1126
+ app.get("/api/agents", async () => {
1127
+ return { agents: store.listAgents() };
1128
+ });
1129
+
1130
+ app.post("/api/agents", async (request, reply) => {
1131
+ const parsed = AgentCreateInputSchema.parse(request.body);
1132
+
1133
+ const providerKey = parsed.providerKey ?? null;
1134
+ if (providerKey) {
1135
+ const provider = store.getProviderRecord(providerKey);
1136
+ if (!provider) {
1137
+ reply.code(400);
1138
+ return { error: `Provider ${providerKey} is not configured` };
1139
+ }
1140
+ }
1141
+
1142
+ const projectId = parsed.projectId?.trim();
1143
+ if (projectId) {
1144
+ const project = store.getProjectById(projectId);
1145
+ if (!project) {
1146
+ reply.code(404);
1147
+ return { error: `Project ${projectId} not found` };
1148
+ }
1149
+ }
1150
+
1151
+ const agent = store.createAgent({
1152
+ name: parsed.name,
1153
+ title: parsed.title,
1154
+ model: parsed.model,
1155
+ providerKey,
1156
+ systemPrompt: parsed.systemPrompt ?? null,
1157
+ seenEnabled: parsed.seenEnabled,
1158
+ traceEnabled: parsed.traceEnabled,
1159
+ projectId
1160
+ });
1161
+
1162
+ reply.code(201);
1163
+ return {
1164
+ agent,
1165
+ agents: store.listAgents(),
1166
+ projects: store.listProjectThreads(),
1167
+ projectAgentIdsByProject: store.listProjectAgentIdsByProject(),
1168
+ projectAgentIds: projectId ? store.listProjectAgentIds(projectId) : undefined
1169
+ };
1170
+ });
1171
+
1172
+ app.put("/api/agents/:agentId/config", async (request, reply) => {
1173
+ const params = request.params as { agentId: string };
1174
+ const parsed = AgentBindingUpdateInputSchema.parse(request.body);
1175
+
1176
+ const existingAgent = store.listAgents().find((agent) => agent.id === params.agentId);
1177
+ if (!existingAgent) {
1178
+ reply.code(404);
1179
+ return { error: `Agent ${params.agentId} not found` };
1180
+ }
1181
+
1182
+ const providerKey = parsed.providerKey ?? null;
1183
+ const model = parsed.model ?? null;
1184
+ const seenEnabled = parsed.seenEnabled ?? existingAgent.seenEnabled;
1185
+ const traceEnabled = parsed.traceEnabled ?? existingAgent.traceEnabled;
1186
+
1187
+ if (providerKey) {
1188
+ const configuredProvider = store.getProviderRecord(providerKey);
1189
+ if (!configuredProvider) {
1190
+ reply.code(400);
1191
+ return { error: `Provider ${providerKey} is not configured` };
1192
+ }
1193
+ }
1194
+
1195
+ store.setAgentBinding({
1196
+ agentId: params.agentId,
1197
+ providerKey,
1198
+ model
1199
+ });
1200
+ store.setAgentVisibility({
1201
+ agentId: params.agentId,
1202
+ seenEnabled,
1203
+ traceEnabled
1204
+ });
1205
+
1206
+ const updatedAgent = store.listAgents().find((agent) => agent.id === params.agentId);
1207
+ if (!updatedAgent) {
1208
+ reply.code(500);
1209
+ return { error: "Agent update failed" };
1210
+ }
1211
+
1212
+ return { agent: updatedAgent };
1213
+ });
1214
+
1215
+ app.post("/api/projects/:projectId/messages", async (request, reply) => {
1216
+ const params = request.params as { projectId: string };
1217
+ const parsedBody = ClientMessageInputSchema.parse(request.body);
1218
+ const project = store.getProjectById(params.projectId);
1219
+
1220
+ if (!project) {
1221
+ reply.code(404);
1222
+ return { error: `Project ${params.projectId} not found` };
1223
+ }
1224
+
1225
+ const message = store.createHumanMessage({
1226
+ projectId: params.projectId,
1227
+ body: parsedBody.body,
1228
+ senderName: parsedBody.senderName
1229
+ });
1230
+
1231
+ const agents = store.listAgentsForProject(params.projectId);
1232
+ for (const agent of agents) {
1233
+ if (!agent.seenEnabled) {
1234
+ continue;
1235
+ }
1236
+ store.createSeenReceipt({
1237
+ messageId: message.id,
1238
+ agentId: agent.id,
1239
+ agentName: agent.name,
1240
+ model: agent.model
1241
+ });
1242
+ }
1243
+
1244
+ emit({ type: "message.created", data: message });
1245
+ emit({
1246
+ type: "seen.updated",
1247
+ data: {
1248
+ messageId: message.id,
1249
+ receipts: store.listReceiptsForMessage(message.id)
1250
+ }
1251
+ });
1252
+
1253
+ void runtime.reactToHumanMessage(message);
1254
+
1255
+ reply.code(201);
1256
+ return { message };
1257
+ });
1258
+
1259
+ app.post("/api/files/read", async (request) => {
1260
+ const input = FileReadInputSchema.parse(request.body);
1261
+ const actorId = input.actorId ?? "manual";
1262
+
1263
+ try {
1264
+ const absolutePath = resolveSandboxPath(resolvedConfig.workspaceRoot, input.path);
1265
+ const content = await fs.readFile(absolutePath, "utf8");
1266
+ store.recordFileAction({
1267
+ actorId,
1268
+ action: "read",
1269
+ targetPath: input.path,
1270
+ status: "ok",
1271
+ errorMessage: null
1272
+ });
1273
+ return { path: input.path, content };
1274
+ } catch (error) {
1275
+ const message = error instanceof Error ? error.message : "Unknown read error";
1276
+ store.recordFileAction({
1277
+ actorId,
1278
+ action: "read",
1279
+ targetPath: input.path,
1280
+ status: "error",
1281
+ errorMessage: message
1282
+ });
1283
+ throw error;
1284
+ }
1285
+ });
1286
+
1287
+ app.post("/api/files/write", async (request) => {
1288
+ const input = FileWriteInputSchema.parse(request.body);
1289
+ const actorId = input.actorId ?? "manual";
1290
+
1291
+ try {
1292
+ const absolutePath = resolveSandboxPath(resolvedConfig.workspaceRoot, input.path);
1293
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
1294
+ await fs.writeFile(absolutePath, input.content, "utf8");
1295
+ store.recordFileAction({
1296
+ actorId,
1297
+ action: "write",
1298
+ targetPath: input.path,
1299
+ status: "ok",
1300
+ errorMessage: null
1301
+ });
1302
+ return { ok: true };
1303
+ } catch (error) {
1304
+ const message = error instanceof Error ? error.message : "Unknown write error";
1305
+ store.recordFileAction({
1306
+ actorId,
1307
+ action: "write",
1308
+ targetPath: input.path,
1309
+ status: "error",
1310
+ errorMessage: message
1311
+ });
1312
+ throw error;
1313
+ }
1314
+ });
1315
+
1316
+ app.post("/api/files/mkdir", async (request) => {
1317
+ const input = FileMkdirInputSchema.parse(request.body);
1318
+ const actorId = input.actorId ?? "manual";
1319
+
1320
+ try {
1321
+ const absolutePath = resolveSandboxPath(resolvedConfig.workspaceRoot, input.path);
1322
+ await fs.mkdir(absolutePath, { recursive: true });
1323
+ store.recordFileAction({
1324
+ actorId,
1325
+ action: "mkdir",
1326
+ targetPath: input.path,
1327
+ status: "ok",
1328
+ errorMessage: null
1329
+ });
1330
+ return { ok: true, path: input.path };
1331
+ } catch (error) {
1332
+ const message = error instanceof Error ? error.message : "Unknown mkdir error";
1333
+ store.recordFileAction({
1334
+ actorId,
1335
+ action: "mkdir",
1336
+ targetPath: input.path,
1337
+ status: "error",
1338
+ errorMessage: message
1339
+ });
1340
+ throw error;
1341
+ }
1342
+ });
1343
+
1344
+ app.post("/api/files/rename", async (request) => {
1345
+ const input = FileRenameInputSchema.parse(request.body);
1346
+ const actorId = input.actorId ?? "manual";
1347
+
1348
+ if (input.path === "." || input.nextPath === ".") {
1349
+ throw new Error("Cannot rename the workspace root");
1350
+ }
1351
+
1352
+ try {
1353
+ const fromPath = resolveSandboxPath(resolvedConfig.workspaceRoot, input.path);
1354
+ const toPath = resolveSandboxPath(resolvedConfig.workspaceRoot, input.nextPath);
1355
+ try {
1356
+ await fs.access(toPath);
1357
+ throw new Error(`Destination already exists: ${input.nextPath}`);
1358
+ } catch (accessError) {
1359
+ const code = (accessError as NodeJS.ErrnoException).code;
1360
+ if (code !== "ENOENT") {
1361
+ throw accessError;
1362
+ }
1363
+ }
1364
+ await fs.mkdir(path.dirname(toPath), { recursive: true });
1365
+ await fs.rename(fromPath, toPath);
1366
+ store.recordFileAction({
1367
+ actorId,
1368
+ action: "rename",
1369
+ targetPath: `${input.path} -> ${input.nextPath}`,
1370
+ status: "ok",
1371
+ errorMessage: null
1372
+ });
1373
+ return { ok: true, path: input.path, nextPath: input.nextPath };
1374
+ } catch (error) {
1375
+ const message = error instanceof Error ? error.message : "Unknown rename error";
1376
+ store.recordFileAction({
1377
+ actorId,
1378
+ action: "rename",
1379
+ targetPath: `${input.path} -> ${input.nextPath}`,
1380
+ status: "error",
1381
+ errorMessage: message
1382
+ });
1383
+ throw error;
1384
+ }
1385
+ });
1386
+
1387
+ app.post("/api/files/delete", async (request) => {
1388
+ const input = FileDeleteInputSchema.parse(request.body);
1389
+ const actorId = input.actorId ?? "manual";
1390
+
1391
+ if (input.path === ".") {
1392
+ throw new Error("Cannot delete the workspace root");
1393
+ }
1394
+
1395
+ try {
1396
+ const absolutePath = resolveSandboxPath(resolvedConfig.workspaceRoot, input.path);
1397
+ const stats = await fs.lstat(absolutePath);
1398
+ if (stats.isDirectory()) {
1399
+ await fs.rm(absolutePath, { recursive: true, force: false });
1400
+ } else {
1401
+ await fs.unlink(absolutePath);
1402
+ }
1403
+
1404
+ store.recordFileAction({
1405
+ actorId,
1406
+ action: "delete",
1407
+ targetPath: input.path,
1408
+ status: "ok",
1409
+ errorMessage: null
1410
+ });
1411
+ return { ok: true, path: input.path };
1412
+ } catch (error) {
1413
+ const message = error instanceof Error ? error.message : "Unknown delete error";
1414
+ store.recordFileAction({
1415
+ actorId,
1416
+ action: "delete",
1417
+ targetPath: input.path,
1418
+ status: "error",
1419
+ errorMessage: message
1420
+ });
1421
+ throw error;
1422
+ }
1423
+ });
1424
+
1425
+ app.post("/api/files/list", async (request) => {
1426
+ const input = FileListInputSchema.parse(request.body);
1427
+ const actorId = input.actorId ?? "manual";
1428
+
1429
+ try {
1430
+ const files = await listSandboxFiles(resolvedConfig.workspaceRoot, input.path);
1431
+ store.recordFileAction({
1432
+ actorId,
1433
+ action: "list",
1434
+ targetPath: input.path,
1435
+ status: "ok",
1436
+ errorMessage: null
1437
+ });
1438
+ return { files };
1439
+ } catch (error) {
1440
+ const message = error instanceof Error ? error.message : "Unknown list error";
1441
+ store.recordFileAction({
1442
+ actorId,
1443
+ action: "list",
1444
+ targetPath: input.path,
1445
+ status: "error",
1446
+ errorMessage: message
1447
+ });
1448
+ throw error;
1449
+ }
1450
+ });
1451
+
1452
+ const webDistRoot = maybeWebDist(options.webDistPath);
1453
+ if (webDistRoot) {
1454
+ app.get("/", async (_, reply) => {
1455
+ try {
1456
+ const html = await fs.readFile(path.join(webDistRoot, "index.html"), "utf8");
1457
+ reply.type("text/html").send(html);
1458
+ } catch {
1459
+ reply
1460
+ .type("text/html")
1461
+ .send(
1462
+ "<h1>CoSpace is running</h1><p>Web bundle missing. Build apps/web and restart. Example: <code>pnpm --filter @cospacehq/web build</code>.</p>"
1463
+ );
1464
+ }
1465
+ });
1466
+
1467
+ app.get("/*", async (request, reply) => {
1468
+ const requested = (request.params as { "*": string })["*"];
1469
+ const staticPath = path.resolve(webDistRoot, requested);
1470
+ const inDist = staticPath.startsWith(webDistRoot);
1471
+
1472
+ if (inDist) {
1473
+ try {
1474
+ const buffer = await fs.readFile(staticPath);
1475
+ const mimeType = mimeTypeForStaticFile(staticPath);
1476
+ if (mimeType) {
1477
+ reply.type(mimeType);
1478
+ }
1479
+ return reply.send(buffer);
1480
+ } catch {
1481
+ if (path.extname(requested)) {
1482
+ reply.code(404);
1483
+ return { error: "Asset not found" };
1484
+ }
1485
+ const html = await fs.readFile(path.join(webDistRoot, "index.html"), "utf8");
1486
+ reply.type("text/html").send(html);
1487
+ return;
1488
+ }
1489
+ }
1490
+
1491
+ reply.code(404).send({ error: "Not found" });
1492
+ });
1493
+ }
1494
+
1495
+ app.server.on("upgrade", (request, socket, head) => {
1496
+ const token = readTokenFromRequest(request.url ?? "");
1497
+ const passcode = readPasscodeFromRequest(request.url ?? "");
1498
+
1499
+ if (token !== sessionToken) {
1500
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1501
+ socket.destroy();
1502
+ return;
1503
+ }
1504
+
1505
+ if (sessionPasscodeHash && (!passcode || passcodeHashFromRaw(passcode) !== sessionPasscodeHash)) {
1506
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1507
+ socket.destroy();
1508
+ return;
1509
+ }
1510
+
1511
+ wss.handleUpgrade(request, socket, head, (ws) => {
1512
+ wss.emit("connection", ws);
1513
+ });
1514
+ });
1515
+
1516
+ wss.on("connection", (socket) => {
1517
+ sockets.add(socket as unknown as WebSocket);
1518
+
1519
+ socket.send(
1520
+ JSON.stringify({
1521
+ type: "bootstrap",
1522
+ data: buildBootstrapPayload()
1523
+ })
1524
+ );
1525
+
1526
+ socket.on("close", () => {
1527
+ sockets.delete(socket as unknown as WebSocket);
1528
+ });
1529
+ });
1530
+
1531
+ const host = options.host ?? "127.0.0.1";
1532
+ await app.listen({ port: resolvedConfig.port, host });
1533
+
1534
+ const localUrl = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${resolvedConfig.port}`;
1535
+
1536
+ return {
1537
+ localUrl,
1538
+ resolvedConfig,
1539
+ stop: async () => {
1540
+ for (const socket of sockets) {
1541
+ socket.close();
1542
+ }
1543
+ wss.close();
1544
+ store.close();
1545
+ await app.close();
1546
+ }
1547
+ };
1548
+ }