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