@drawsy/companion 0.0.0-stage → 0.1.28
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/LICENSE +21 -0
- package/README.md +98 -2
- package/dist/drawsy/bridge-main.js +15 -0
- package/dist/drawsy/bridge.js +1794 -0
- package/dist/drawsy/cli-autostart.js +201 -0
- package/dist/drawsy/cli.js +303 -0
- package/dist/drawsy/codex-app-server.js +2199 -0
- package/dist/drawsy/codex-binary.js +26 -0
- package/dist/drawsy/connector-backend.js +57 -0
- package/dist/drawsy/conversation-history.js +35 -0
- package/dist/drawsy/engine-status.js +20 -0
- package/dist/drawsy/environment.js +17 -0
- package/dist/drawsy/executable-resolver.js +95 -0
- package/dist/drawsy/folder-picker.js +66 -0
- package/dist/drawsy/image-asset.js +173 -0
- package/dist/drawsy/local-conversation-store.js +241 -0
- package/dist/drawsy/mcp-launcher.js +15 -0
- package/dist/drawsy/mcp.js +31588 -0
- package/dist/drawsy/opencode-app-server.js +1251 -0
- package/dist/drawsy/opencode-binary.js +26 -0
- package/dist/drawsy/protocol.js +519 -0
- package/package.json +40 -4
- package/skills/drawsy-browser-use/SKILL.md +128 -0
- package/skills/drawsy-browser-use/references/drawsy-tool-catalog.md +90 -0
- package/skills/drawsy-fast-diagrams/SKILL.md +15 -0
- package/skills/drawsy-teaching-diagrams/SKILL.md +117 -0
|
@@ -0,0 +1,1794 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CodexAppServer, isCodexThreadMissingError, isCodexThreadResumeUnsupportedError } from "./codex-app-server.js";
|
|
6
|
+
import { OpenCodeAppServer } from "./opencode-app-server.js";
|
|
7
|
+
import { LocalConversationStore } from "./local-conversation-store.js";
|
|
8
|
+
import { normalizeFolder, pickFolder } from "./folder-picker.js";
|
|
9
|
+
import { readLocalEngineStatus } from "./engine-status.js";
|
|
10
|
+
import { createCanvasImageAsset, createCanvasImageAssetFromBytes, createCanvasImageFileFromBytes, inspectCanvasImage } from "./image-asset.js";
|
|
11
|
+
import { CANVAS_REQUEST_TIMEOUT_MS, MAX_CANVAS_ASSET_BYTES, MAX_BODY_BYTES, isRecord, parseCanvasContextReference, parseCanvasContextRequest, parseCanvasConnectorRequest, parseCanvasDiagramCodeRequest, parseCanvasLabelRequest, parseCanvasImageRequest, parseLivePreviewRequest, parseCanvasOperations, parseAgentConnectorTurn, parseAgentResourceTurn, parseDrawsyTabId, parseDrawsyTabUrl, parseGenerationMode, isConnectorCapability, } from "./protocol.js";
|
|
12
|
+
import { createConnectorBackendResolver } from "./connector-backend.js";
|
|
13
|
+
const MAX_DRAW_DOCUMENT_BYTES = 512 * 1024;
|
|
14
|
+
const readDrawDocument = async (folder) => {
|
|
15
|
+
const candidate = path.join(folder.path, "DRAW.md");
|
|
16
|
+
let resolved;
|
|
17
|
+
try {
|
|
18
|
+
resolved = await realpath(candidate);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === "ENOENT") {
|
|
22
|
+
return { exists: false };
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
const relative = path.relative(folder.path, resolved);
|
|
27
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
28
|
+
throw new BridgeRequestError(400, "draw_document_outside_folder", "DRAW.md must be inside the selected folder.");
|
|
29
|
+
}
|
|
30
|
+
const file = await stat(resolved);
|
|
31
|
+
if (!file.isFile()) {
|
|
32
|
+
throw new BridgeRequestError(400, "draw_document_invalid", "DRAW.md must be a regular file.");
|
|
33
|
+
}
|
|
34
|
+
if (file.size > MAX_DRAW_DOCUMENT_BYTES) {
|
|
35
|
+
throw new BridgeRequestError(413, "draw_document_too_large", "DRAW.md must be 512 KiB or smaller.");
|
|
36
|
+
}
|
|
37
|
+
const content = await readFile(resolved, "utf8");
|
|
38
|
+
return {
|
|
39
|
+
exists: true,
|
|
40
|
+
name: "DRAW.md",
|
|
41
|
+
content,
|
|
42
|
+
hash: createHash("sha256").update(content).digest("hex"),
|
|
43
|
+
sourceId: createHash("sha256")
|
|
44
|
+
.update(folder.path)
|
|
45
|
+
.digest("hex")
|
|
46
|
+
.slice(0, 24)
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
class BridgeRequestError extends Error {
|
|
50
|
+
status;
|
|
51
|
+
code;
|
|
52
|
+
constructor(status, code, message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.code = code;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const assertCanvasActionAllowed = (session, action) => {
|
|
59
|
+
if (session.generationMode === "fast" &&
|
|
60
|
+
![
|
|
61
|
+
"read",
|
|
62
|
+
"capabilities",
|
|
63
|
+
"connector",
|
|
64
|
+
"label",
|
|
65
|
+
"diagram",
|
|
66
|
+
"apply",
|
|
67
|
+
"inspect",
|
|
68
|
+
"capture",
|
|
69
|
+
"image",
|
|
70
|
+
"context",
|
|
71
|
+
"replace-image",
|
|
72
|
+
"replaceImage",
|
|
73
|
+
"preview"
|
|
74
|
+
].includes(action)) {
|
|
75
|
+
throw new BridgeRequestError(403, "fast_mode_action_blocked", "Fast Mode does not allow this canvas action.");
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const json = (response, status, body) => {
|
|
79
|
+
response.writeHead(status, {
|
|
80
|
+
"content-type": "application/json; charset=utf-8"
|
|
81
|
+
});
|
|
82
|
+
response.end(JSON.stringify(body));
|
|
83
|
+
};
|
|
84
|
+
const safeEqual = (left, right) => {
|
|
85
|
+
const a = Buffer.from(left);
|
|
86
|
+
const b = Buffer.from(right);
|
|
87
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
88
|
+
};
|
|
89
|
+
const bearerToken = (request) => {
|
|
90
|
+
const value = request.headers.authorization;
|
|
91
|
+
return value?.startsWith("Bearer ") ? value.slice(7) : "";
|
|
92
|
+
};
|
|
93
|
+
const readJson = async (request) => {
|
|
94
|
+
const chunks = [];
|
|
95
|
+
let size = 0;
|
|
96
|
+
for await (const chunk of request) {
|
|
97
|
+
const buffer = Buffer.from(chunk);
|
|
98
|
+
size += buffer.length;
|
|
99
|
+
if (size > MAX_BODY_BYTES) {
|
|
100
|
+
throw new Error(`Request body exceeds ${MAX_BODY_BYTES / (1024 * 1024)} MiB.`);
|
|
101
|
+
}
|
|
102
|
+
chunks.push(buffer);
|
|
103
|
+
}
|
|
104
|
+
if (!chunks.length) {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
108
|
+
if (!isRecord(value)) {
|
|
109
|
+
throw new Error("Request body must be a JSON object.");
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
};
|
|
113
|
+
const readBytes = async (request, maxBytes = MAX_CANVAS_ASSET_BYTES) => {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let size = 0;
|
|
116
|
+
for await (const chunk of request) {
|
|
117
|
+
const buffer = Buffer.from(chunk);
|
|
118
|
+
size += buffer.length;
|
|
119
|
+
if (size > maxBytes) {
|
|
120
|
+
throw new Error(`Canvas context asset exceeds ${maxBytes / 1024 / 1024} MiB.`);
|
|
121
|
+
}
|
|
122
|
+
chunks.push(buffer);
|
|
123
|
+
}
|
|
124
|
+
if (!size) {
|
|
125
|
+
throw new Error("Canvas context asset is empty.");
|
|
126
|
+
}
|
|
127
|
+
return Buffer.concat(chunks);
|
|
128
|
+
};
|
|
129
|
+
const readFetchText = async (response, maxBytes) => {
|
|
130
|
+
if (!response.body)
|
|
131
|
+
return "";
|
|
132
|
+
const reader = response.body.getReader();
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
while (true) {
|
|
136
|
+
const { done, value } = await reader.read();
|
|
137
|
+
if (done)
|
|
138
|
+
break;
|
|
139
|
+
size += value.byteLength;
|
|
140
|
+
if (size > maxBytes) {
|
|
141
|
+
await reader.cancel();
|
|
142
|
+
throw new BridgeRequestError(502, "connector_response_too_large", "The connected source returned too much data. Narrow the request.");
|
|
143
|
+
}
|
|
144
|
+
chunks.push(value);
|
|
145
|
+
}
|
|
146
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
147
|
+
};
|
|
148
|
+
const parsePromptTags = (value, label) => {
|
|
149
|
+
if (value === undefined)
|
|
150
|
+
return [];
|
|
151
|
+
if (!Array.isArray(value) || value.length > 20) {
|
|
152
|
+
throw new Error(`${label} must be an array of at most 20 tags.`);
|
|
153
|
+
}
|
|
154
|
+
const tags = value.map((tag) => {
|
|
155
|
+
if (!isRecord(tag) ||
|
|
156
|
+
typeof tag.name !== "string" ||
|
|
157
|
+
!tag.name.trim() ||
|
|
158
|
+
typeof tag.path !== "string" ||
|
|
159
|
+
!tag.path.trim()) {
|
|
160
|
+
throw new Error(`${label} contains an invalid tag.`);
|
|
161
|
+
}
|
|
162
|
+
return { name: tag.name.trim(), path: tag.path };
|
|
163
|
+
});
|
|
164
|
+
return tags.filter((tag, index) => tags.findIndex((candidate) => candidate.name === tag.name && candidate.path === tag.path) === index);
|
|
165
|
+
};
|
|
166
|
+
const parseLocalPreferences = (value) => {
|
|
167
|
+
if (Object.keys(value).some((key) => key !== "engine" && key !== "codex" && key !== "opencode") ||
|
|
168
|
+
(value.engine !== "codex" && value.engine !== "opencode")) {
|
|
169
|
+
throw new BridgeRequestError(400, "preferences_invalid", "The local AI preferences are invalid.");
|
|
170
|
+
}
|
|
171
|
+
const parsePreference = (candidate) => {
|
|
172
|
+
if (!isRecord(candidate)) {
|
|
173
|
+
throw new BridgeRequestError(400, "preferences_invalid", "The local AI preferences are invalid.");
|
|
174
|
+
}
|
|
175
|
+
if (Object.keys(candidate).some((key) => ![
|
|
176
|
+
"model",
|
|
177
|
+
"modelProvider",
|
|
178
|
+
"effort",
|
|
179
|
+
"accessMode",
|
|
180
|
+
"internetEnabled"
|
|
181
|
+
].includes(key)) ||
|
|
182
|
+
(candidate.model !== undefined &&
|
|
183
|
+
candidate.model !== null &&
|
|
184
|
+
typeof candidate.model !== "string") ||
|
|
185
|
+
(candidate.modelProvider !== undefined &&
|
|
186
|
+
candidate.modelProvider !== null &&
|
|
187
|
+
typeof candidate.modelProvider !== "string") ||
|
|
188
|
+
(candidate.effort !== undefined &&
|
|
189
|
+
candidate.effort !== null &&
|
|
190
|
+
typeof candidate.effort !== "string") ||
|
|
191
|
+
(candidate.accessMode !== undefined &&
|
|
192
|
+
candidate.accessMode !== "workspace" &&
|
|
193
|
+
candidate.accessMode !== "readOnly" &&
|
|
194
|
+
candidate.accessMode !== null) ||
|
|
195
|
+
(candidate.internetEnabled !== undefined &&
|
|
196
|
+
candidate.internetEnabled !== null &&
|
|
197
|
+
typeof candidate.internetEnabled !== "boolean")) {
|
|
198
|
+
throw new BridgeRequestError(400, "preferences_invalid", "The local AI preferences are invalid.");
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
model: typeof candidate.model === "string" ? candidate.model : null,
|
|
202
|
+
modelProvider: typeof candidate.modelProvider === "string"
|
|
203
|
+
? candidate.modelProvider
|
|
204
|
+
: null,
|
|
205
|
+
effort: typeof candidate.effort === "string" ? candidate.effort : null,
|
|
206
|
+
accessMode: candidate.accessMode === "workspace" ||
|
|
207
|
+
candidate.accessMode === "readOnly"
|
|
208
|
+
? candidate.accessMode
|
|
209
|
+
: null,
|
|
210
|
+
internetEnabled: typeof candidate.internetEnabled === "boolean"
|
|
211
|
+
? candidate.internetEnabled
|
|
212
|
+
: null
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
engine: value.engine,
|
|
217
|
+
codex: parsePreference(value.codex),
|
|
218
|
+
opencode: parsePreference(value.opencode)
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
const parseContextReferences = (value) => {
|
|
222
|
+
if (value === undefined)
|
|
223
|
+
return [];
|
|
224
|
+
if (!Array.isArray(value) || value.length > 3) {
|
|
225
|
+
throw new Error("contexts must contain at most 3 canvas captures.");
|
|
226
|
+
}
|
|
227
|
+
const contexts = value.map(parseCanvasContextReference);
|
|
228
|
+
if (new Set(contexts.map((context) => context.id)).size !== contexts.length) {
|
|
229
|
+
throw new Error("contexts must be unique.");
|
|
230
|
+
}
|
|
231
|
+
return contexts;
|
|
232
|
+
};
|
|
233
|
+
export const createDrawsyBridge = (options = {}) => {
|
|
234
|
+
const port = options.port ?? Number(process.env.PORT || 3031);
|
|
235
|
+
const host = options.host ?? "127.0.0.1";
|
|
236
|
+
const allowedOrigins = new Set(options.allowedOrigins ??
|
|
237
|
+
(process.env.DRAWSY_ALLOWED_ORIGINS ||
|
|
238
|
+
(process.env.NODE_ENV === "production"
|
|
239
|
+
? "https://drawsyai.com"
|
|
240
|
+
: "http://localhost:3001,http://127.0.0.1:3001,https://drawsyai.com"))
|
|
241
|
+
.split(",")
|
|
242
|
+
.map((origin) => origin.trim())
|
|
243
|
+
.filter(Boolean));
|
|
244
|
+
const configuredCanvasRequestTimeout = Number(process.env.DRAWSY_CANVAS_REQUEST_TIMEOUT_MS || CANVAS_REQUEST_TIMEOUT_MS);
|
|
245
|
+
if (!Number.isInteger(configuredCanvasRequestTimeout) ||
|
|
246
|
+
configuredCanvasRequestTimeout < 5_000 ||
|
|
247
|
+
configuredCanvasRequestTimeout > 120_000) {
|
|
248
|
+
throw new Error("DRAWSY_CANVAS_REQUEST_TIMEOUT_MS must be between 5000 and 120000.");
|
|
249
|
+
}
|
|
250
|
+
const connectHost = host === "0.0.0.0" ? "127.0.0.1" : host;
|
|
251
|
+
const bridgeUrl = `http://${connectHost}:${port}`;
|
|
252
|
+
const connectorBackendResolver = createConnectorBackendResolver({
|
|
253
|
+
configuredUrl: options.connectorBackendUrl
|
|
254
|
+
});
|
|
255
|
+
const version = options.version || process.env.DRAWSY_COMPANION_VERSION || "0.1.0";
|
|
256
|
+
const selections = new Map();
|
|
257
|
+
const sessions = new Map();
|
|
258
|
+
const conversationSessions = new Map();
|
|
259
|
+
const conversationStartLocks = new Map();
|
|
260
|
+
const localConversations = new LocalConversationStore();
|
|
261
|
+
const emit = (session, event) => {
|
|
262
|
+
session.touchedAt = Date.now();
|
|
263
|
+
if (event.type === "error" ||
|
|
264
|
+
(event.type === "turn.status" && event.data.status !== "inProgress")) {
|
|
265
|
+
session.activeConnectorTurn = null;
|
|
266
|
+
session.activeResourceTurn = null;
|
|
267
|
+
session.activeGenerationMode = null;
|
|
268
|
+
}
|
|
269
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
270
|
+
for (const client of session.clients) {
|
|
271
|
+
client.write(line);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const attachmentReference = /attachment:\/\/([A-Za-z0-9][A-Za-z0-9._-]*)(?=[)\s"'<>]|$)/g;
|
|
275
|
+
const generatedImageForReference = (session, referenceId) => {
|
|
276
|
+
const normalizedId = referenceId.replace(/\.(?:png|jpe?g|gif|webp)$/i, "");
|
|
277
|
+
return session.generatedImages.find((image) => image.id === referenceId || image.id === normalizedId);
|
|
278
|
+
};
|
|
279
|
+
const attachmentUrl = (session, imageId) => `${bridgeUrl}/v1/sessions/${encodeURIComponent(session.id)}/attachments/${encodeURIComponent(imageId)}?token=${encodeURIComponent(session.token)}`;
|
|
280
|
+
const rewriteAttachmentReferences = (session, text) => text.replace(attachmentReference, (reference, referenceId) => {
|
|
281
|
+
const image = generatedImageForReference(session, referenceId);
|
|
282
|
+
return image ? attachmentUrl(session, image.id) : reference;
|
|
283
|
+
});
|
|
284
|
+
const emitCodexEvent = (session, event) => {
|
|
285
|
+
if (!session)
|
|
286
|
+
return;
|
|
287
|
+
if (event.type === "assistant.delta") {
|
|
288
|
+
emit(session, {
|
|
289
|
+
...event,
|
|
290
|
+
data: {
|
|
291
|
+
...event.data,
|
|
292
|
+
delta: rewriteAttachmentReferences(session, event.data.delta)
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (event.type === "assistant.final") {
|
|
298
|
+
emit(session, {
|
|
299
|
+
...event,
|
|
300
|
+
data: {
|
|
301
|
+
...event.data,
|
|
302
|
+
text: rewriteAttachmentReferences(session, event.data.text)
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
emit(session, event);
|
|
308
|
+
};
|
|
309
|
+
const sessionContextPath = (session) => path.join(session.folder.path, ".drawsy", "context", session.id);
|
|
310
|
+
const prepareContextStore = async (folderPath) => {
|
|
311
|
+
const drawsyPath = path.join(folderPath, ".drawsy");
|
|
312
|
+
const contextPath = path.join(drawsyPath, "context");
|
|
313
|
+
await mkdir(contextPath, { recursive: true });
|
|
314
|
+
try {
|
|
315
|
+
await writeFile(path.join(drawsyPath, ".gitignore"), "*\n", {
|
|
316
|
+
flag: "wx"
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (!isRecord(error) || error.code !== "EEXIST")
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
const staleBefore = Date.now() - 24 * 60 * 60 * 1000;
|
|
324
|
+
for (const entry of await readdir(contextPath, { withFileTypes: true })) {
|
|
325
|
+
if (!entry.isDirectory())
|
|
326
|
+
continue;
|
|
327
|
+
const candidate = path.join(contextPath, entry.name);
|
|
328
|
+
const details = await stat(candidate).catch(() => null);
|
|
329
|
+
if (details && details.mtimeMs < staleBefore) {
|
|
330
|
+
await rm(candidate, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const closeSession = (session) => {
|
|
335
|
+
sessions.delete(session.id);
|
|
336
|
+
if (session.conversationId &&
|
|
337
|
+
conversationSessions.get(session.conversationId)?.id === session.id) {
|
|
338
|
+
conversationSessions.delete(session.conversationId);
|
|
339
|
+
}
|
|
340
|
+
session.agent.close();
|
|
341
|
+
for (const pending of session.canvasPending.values()) {
|
|
342
|
+
clearTimeout(pending.timer);
|
|
343
|
+
pending.reject(new Error("Drawsy session closed."));
|
|
344
|
+
}
|
|
345
|
+
for (const client of session.clients) {
|
|
346
|
+
client.end();
|
|
347
|
+
}
|
|
348
|
+
void rm(sessionContextPath(session), { recursive: true, force: true });
|
|
349
|
+
};
|
|
350
|
+
const folderNameForClient = (folder) => folder.userSelected ? folder.name : null;
|
|
351
|
+
const folderForClient = (folder) => folder.userSelected
|
|
352
|
+
? { selectionId: folder.id, name: folder.name }
|
|
353
|
+
: null;
|
|
354
|
+
const resolveSessionFolder = async (input) => {
|
|
355
|
+
if (input.selectionId) {
|
|
356
|
+
const selected = selections.get(input.selectionId);
|
|
357
|
+
if (!selected || selected.expiresAt <= Date.now()) {
|
|
358
|
+
selections.delete(input.selectionId);
|
|
359
|
+
throw new BridgeRequestError(400, "folder_expired", "Choose the folder again.");
|
|
360
|
+
}
|
|
361
|
+
return selected;
|
|
362
|
+
}
|
|
363
|
+
if (input.surfaceKind !== "canvas" && input.surfaceKind !== "presentation") {
|
|
364
|
+
throw new BridgeRequestError(400, "folder_required", "Choose a folder for this Drawsy surface.");
|
|
365
|
+
}
|
|
366
|
+
const memoryKey = input.canvasId;
|
|
367
|
+
const rememberedPath = await localConversations.getRememberedFolder(memoryKey);
|
|
368
|
+
if (rememberedPath) {
|
|
369
|
+
try {
|
|
370
|
+
const remembered = await normalizeFolder(rememberedPath);
|
|
371
|
+
const selection = {
|
|
372
|
+
id: randomUUID(),
|
|
373
|
+
...remembered,
|
|
374
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
375
|
+
userSelected: true,
|
|
376
|
+
privateWorkspace: false
|
|
377
|
+
};
|
|
378
|
+
selections.set(selection.id, selection);
|
|
379
|
+
return selection;
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
await localConversations.forgetRememberedFolder(memoryKey);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const workspacePath = path.join(localConversations.stateDirectory, "session-workspaces", input.conversationId);
|
|
386
|
+
await mkdir(workspacePath, { recursive: true });
|
|
387
|
+
return {
|
|
388
|
+
id: randomUUID(),
|
|
389
|
+
path: workspacePath,
|
|
390
|
+
name: "Private session workspace",
|
|
391
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
392
|
+
userSelected: false,
|
|
393
|
+
privateWorkspace: true
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
const requirePublicOrigin = (request, response) => {
|
|
397
|
+
const origin = request.headers.origin;
|
|
398
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
399
|
+
json(response, 403, {
|
|
400
|
+
error: { code: "origin_denied", message: "Origin is not allowed." }
|
|
401
|
+
});
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
response.setHeader("access-control-allow-origin", origin);
|
|
405
|
+
response.setHeader("vary", "Origin");
|
|
406
|
+
return true;
|
|
407
|
+
};
|
|
408
|
+
const publicSession = (request, response, sessionId) => {
|
|
409
|
+
const session = sessions.get(sessionId);
|
|
410
|
+
if (!session || !safeEqual(bearerToken(request), session.token)) {
|
|
411
|
+
json(response, 401, {
|
|
412
|
+
error: { code: "authentication_required", message: "Invalid session." }
|
|
413
|
+
});
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
session.touchedAt = Date.now();
|
|
417
|
+
return session;
|
|
418
|
+
};
|
|
419
|
+
const internalSession = (request, response, sessionId) => {
|
|
420
|
+
const session = sessions.get(sessionId);
|
|
421
|
+
if (!session || !safeEqual(bearerToken(request), session.internalSecret)) {
|
|
422
|
+
json(response, 401, {
|
|
423
|
+
error: {
|
|
424
|
+
code: "authentication_required",
|
|
425
|
+
message: "Invalid MCP scope."
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
session.touchedAt = Date.now();
|
|
431
|
+
return session;
|
|
432
|
+
};
|
|
433
|
+
const connectorSource = (session, capability, connectionId) => {
|
|
434
|
+
if (!session.activeConnectorTurn) {
|
|
435
|
+
throw new BridgeRequestError(403, "connector_not_attached", "No connected source was attached to this turn.");
|
|
436
|
+
}
|
|
437
|
+
if (!isConnectorCapability(capability)) {
|
|
438
|
+
throw new BridgeRequestError(400, "connector_capability_invalid", "The connected-source capability is invalid.");
|
|
439
|
+
}
|
|
440
|
+
if (connectionId !== undefined &&
|
|
441
|
+
(typeof connectionId !== "string" || !connectionId.trim())) {
|
|
442
|
+
throw new BridgeRequestError(400, "connector_connection_invalid", "The connected-source connection is invalid.");
|
|
443
|
+
}
|
|
444
|
+
const matches = session.activeConnectorTurn.sources.filter((source) => source.capability === capability &&
|
|
445
|
+
(connectionId === undefined || source.connectionId === connectionId));
|
|
446
|
+
if (!matches.length) {
|
|
447
|
+
throw new BridgeRequestError(403, "connector_not_attached", "That connected source was not attached to this turn.");
|
|
448
|
+
}
|
|
449
|
+
if (matches.length > 1) {
|
|
450
|
+
throw new BridgeRequestError(409, "connector_account_required", "More than one matching account is attached. List sources and pass connectionId.");
|
|
451
|
+
}
|
|
452
|
+
const source = matches[0];
|
|
453
|
+
const grant = session.activeConnectorTurn.grants.find((candidate) => candidate.connectionId === source.connectionId);
|
|
454
|
+
if (!grant || grant.expiresAt <= Date.now()) {
|
|
455
|
+
throw new BridgeRequestError(401, "connector_grant_expired", "Connected-source access expired. Send the message again.");
|
|
456
|
+
}
|
|
457
|
+
return { source, grant };
|
|
458
|
+
};
|
|
459
|
+
const executeConnectorRequest = async (session, action, body) => {
|
|
460
|
+
const connectorBackendUrl = session.connectorBackendUrl;
|
|
461
|
+
if (!connectorBackendUrl) {
|
|
462
|
+
throw new BridgeRequestError(503, "cloud_connectors_unavailable", "Connected sources are available only when cloud mode is configured.");
|
|
463
|
+
}
|
|
464
|
+
const allowedKeys = action === "search"
|
|
465
|
+
? new Set([
|
|
466
|
+
"capability",
|
|
467
|
+
"connectionId",
|
|
468
|
+
"query",
|
|
469
|
+
"region",
|
|
470
|
+
"cursor",
|
|
471
|
+
"limit"
|
|
472
|
+
])
|
|
473
|
+
: action === "read"
|
|
474
|
+
? new Set(["capability", "connectionId", "resourceId"])
|
|
475
|
+
: action === "mcp-tools"
|
|
476
|
+
? new Set(["capability", "connectionId"])
|
|
477
|
+
: action === "mcp-call"
|
|
478
|
+
? new Set(["capability", "connectionId", "toolName", "arguments"])
|
|
479
|
+
: new Set([
|
|
480
|
+
"capability",
|
|
481
|
+
"connectionId",
|
|
482
|
+
"kind",
|
|
483
|
+
"query",
|
|
484
|
+
"after",
|
|
485
|
+
"before",
|
|
486
|
+
"from",
|
|
487
|
+
"to",
|
|
488
|
+
"subject",
|
|
489
|
+
"label",
|
|
490
|
+
"includeSpamTrash",
|
|
491
|
+
"calendarId",
|
|
492
|
+
"channelId",
|
|
493
|
+
"startTime",
|
|
494
|
+
"endTime",
|
|
495
|
+
"timeZone",
|
|
496
|
+
"mimeType",
|
|
497
|
+
"orderBy",
|
|
498
|
+
"object",
|
|
499
|
+
"sortDirection",
|
|
500
|
+
"owner",
|
|
501
|
+
"visibility",
|
|
502
|
+
"repository",
|
|
503
|
+
"path",
|
|
504
|
+
"ref",
|
|
505
|
+
"state",
|
|
506
|
+
"labels",
|
|
507
|
+
"since",
|
|
508
|
+
"sort",
|
|
509
|
+
"direction",
|
|
510
|
+
"region",
|
|
511
|
+
"head",
|
|
512
|
+
"base",
|
|
513
|
+
"cursor",
|
|
514
|
+
"limit"
|
|
515
|
+
]);
|
|
516
|
+
if (Object.keys(body).some((key) => !allowedKeys.has(key))) {
|
|
517
|
+
throw new BridgeRequestError(400, "connector_request_invalid", "The connected-source request contains an unknown field.");
|
|
518
|
+
}
|
|
519
|
+
const { source, grant } = connectorSource(session, body.capability, body.connectionId);
|
|
520
|
+
let operation;
|
|
521
|
+
if (action === "search") {
|
|
522
|
+
const query = typeof body.query === "string" ? body.query.trim() : "";
|
|
523
|
+
const awsSearch = source.capability === "aws";
|
|
524
|
+
const maxQueryLength = awsSearch ? 1_280 : 2_000;
|
|
525
|
+
const maxResults = awsSearch ? 100 : 20;
|
|
526
|
+
if ((!awsSearch && !query) ||
|
|
527
|
+
query.length > maxQueryLength ||
|
|
528
|
+
(body.cursor !== undefined &&
|
|
529
|
+
(typeof body.cursor !== "string" || body.cursor.length > 4_096)) ||
|
|
530
|
+
(body.limit !== undefined &&
|
|
531
|
+
(typeof body.limit !== "number" ||
|
|
532
|
+
!Number.isInteger(body.limit) ||
|
|
533
|
+
body.limit < 1 ||
|
|
534
|
+
body.limit > maxResults))) {
|
|
535
|
+
throw new BridgeRequestError(400, "connector_request_invalid", "The connected-source search is invalid.");
|
|
536
|
+
}
|
|
537
|
+
operation = {
|
|
538
|
+
operation: "search",
|
|
539
|
+
query,
|
|
540
|
+
...(typeof body.region === "string" ? { region: body.region } : {}),
|
|
541
|
+
...(typeof body.cursor === "string" ? { cursor: body.cursor } : {}),
|
|
542
|
+
...(typeof body.limit === "number" ? { limit: body.limit } : {})
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
else if (action === "read") {
|
|
546
|
+
const resourceId = typeof body.resourceId === "string" ? body.resourceId.trim() : "";
|
|
547
|
+
if (!resourceId || resourceId.length > 4_096) {
|
|
548
|
+
throw new BridgeRequestError(400, "connector_request_invalid", "The connected item reference is invalid.");
|
|
549
|
+
}
|
|
550
|
+
operation = { operation: "read", resourceId };
|
|
551
|
+
}
|
|
552
|
+
else if (action === "mcp-tools") {
|
|
553
|
+
operation = { operation: "mcp_tools" };
|
|
554
|
+
}
|
|
555
|
+
else if (action === "mcp-call") {
|
|
556
|
+
const toolName = typeof body.toolName === "string" ? body.toolName.trim() : "";
|
|
557
|
+
const args = body.arguments;
|
|
558
|
+
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(toolName) ||
|
|
559
|
+
!isRecord(args) ||
|
|
560
|
+
Buffer.byteLength(JSON.stringify(args), "utf8") > 64 * 1024) {
|
|
561
|
+
throw new BridgeRequestError(400, "connector_request_invalid", "The remote MCP tool request is invalid.");
|
|
562
|
+
}
|
|
563
|
+
operation = { operation: "mcp_call", toolName, arguments: args };
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const { capability: _capability, connectionId: _connectionId, ...input } = body;
|
|
567
|
+
operation = { operation: "list", ...input };
|
|
568
|
+
}
|
|
569
|
+
const response = await fetch(new URL("/v1/connectors/ai/execute", connectorBackendUrl), {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: {
|
|
572
|
+
authorization: `Bearer ${grant.grant}`,
|
|
573
|
+
"content-type": "application/json"
|
|
574
|
+
},
|
|
575
|
+
body: JSON.stringify({
|
|
576
|
+
sessionId: session.id,
|
|
577
|
+
turnId: session.activeConnectorTurn.turnId,
|
|
578
|
+
connectionId: source.connectionId,
|
|
579
|
+
capability: source.capability,
|
|
580
|
+
...operation
|
|
581
|
+
}),
|
|
582
|
+
signal: AbortSignal.timeout(30_000)
|
|
583
|
+
});
|
|
584
|
+
const text = await readFetchText(response, 512 * 1024);
|
|
585
|
+
let payload;
|
|
586
|
+
try {
|
|
587
|
+
payload = JSON.parse(text);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
throw new BridgeRequestError(502, "connector_response_invalid", "The connected source returned an invalid response.");
|
|
591
|
+
}
|
|
592
|
+
if (!response.ok) {
|
|
593
|
+
const message = isRecord(payload) &&
|
|
594
|
+
isRecord(payload.error) &&
|
|
595
|
+
typeof payload.error.message === "string"
|
|
596
|
+
? payload.error.message
|
|
597
|
+
: `Connected-source request failed (${response.status}).`;
|
|
598
|
+
throw new BridgeRequestError(response.status >= 400 && response.status < 500 ? response.status : 502, "connector_request_failed", message);
|
|
599
|
+
}
|
|
600
|
+
return payload;
|
|
601
|
+
};
|
|
602
|
+
const executeResourceRequest = async (session, body) => {
|
|
603
|
+
const connectorBackendUrl = session.connectorBackendUrl;
|
|
604
|
+
if (!connectorBackendUrl) {
|
|
605
|
+
throw new BridgeRequestError(503, "cloud_resources_unavailable", "Drawsy resources are available only when cloud mode is configured.");
|
|
606
|
+
}
|
|
607
|
+
const active = session.activeResourceTurn;
|
|
608
|
+
if (!active) {
|
|
609
|
+
throw new BridgeRequestError(403, "resource_not_attached", "No Drawsy resource was attached to this turn.");
|
|
610
|
+
}
|
|
611
|
+
if (active.expiresAt <= Date.now()) {
|
|
612
|
+
throw new BridgeRequestError(401, "resource_grant_expired", "Drawsy resource access expired. Send the message again.");
|
|
613
|
+
}
|
|
614
|
+
const operation = typeof body.operation === "string" ? body.operation.trim() : "";
|
|
615
|
+
const resource = operation.startsWith("kanban_")
|
|
616
|
+
? "kanban"
|
|
617
|
+
: operation.startsWith("jira_")
|
|
618
|
+
? "jira"
|
|
619
|
+
: null;
|
|
620
|
+
if (!resource || !active.resources.includes(resource)) {
|
|
621
|
+
throw new BridgeRequestError(403, "resource_not_attached", "That Drawsy resource was not attached to this turn.");
|
|
622
|
+
}
|
|
623
|
+
if (Object.keys(body).some((key) => ["sessionId", "turnId", "grant"].includes(key))) {
|
|
624
|
+
throw new BridgeRequestError(400, "resource_request_invalid", "The Drawsy resource request contains a protected field.");
|
|
625
|
+
}
|
|
626
|
+
const requestBody = { ...body };
|
|
627
|
+
if (operation === "kanban_read_current_board") {
|
|
628
|
+
if (session.surfaceKind !== "kanban" || !session.surfaceId) {
|
|
629
|
+
throw new BridgeRequestError(400, "resource_request_invalid", "No current Kanban board is attached to this chat.");
|
|
630
|
+
}
|
|
631
|
+
requestBody.operation = "kanban_read_board";
|
|
632
|
+
requestBody.boardId = session.surfaceId;
|
|
633
|
+
}
|
|
634
|
+
if (operation === "kanban_link_current_canvas") {
|
|
635
|
+
if (session.surfaceKind !== "canvas" || !session.canvasId) {
|
|
636
|
+
throw new BridgeRequestError(400, "resource_request_invalid", "Only a Drawsy canvas can be linked to a Kanban card.");
|
|
637
|
+
}
|
|
638
|
+
requestBody.operation = "kanban_link_canvas";
|
|
639
|
+
requestBody.canvasId = session.canvasId;
|
|
640
|
+
}
|
|
641
|
+
if (operation === "kanban_create_card") {
|
|
642
|
+
const linkCurrentCanvas = requestBody.linkCurrentCanvas;
|
|
643
|
+
delete requestBody.linkCurrentCanvas;
|
|
644
|
+
if (linkCurrentCanvas === true) {
|
|
645
|
+
if (session.surfaceKind !== "canvas" || !session.canvasId) {
|
|
646
|
+
throw new BridgeRequestError(400, "resource_request_invalid", "Only a Drawsy canvas can be linked to a Kanban card.");
|
|
647
|
+
}
|
|
648
|
+
requestBody.linkCanvasId = session.canvasId;
|
|
649
|
+
}
|
|
650
|
+
else if (linkCurrentCanvas !== undefined &&
|
|
651
|
+
linkCurrentCanvas !== false) {
|
|
652
|
+
throw new BridgeRequestError(400, "resource_request_invalid", "linkCurrentCanvas must be true or false.");
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const response = await fetch(new URL("/v1/ai/resources/execute", connectorBackendUrl), {
|
|
656
|
+
method: "POST",
|
|
657
|
+
headers: {
|
|
658
|
+
authorization: `Bearer ${active.grant}`,
|
|
659
|
+
"content-type": "application/json"
|
|
660
|
+
},
|
|
661
|
+
body: JSON.stringify({
|
|
662
|
+
...requestBody,
|
|
663
|
+
sessionId: session.id,
|
|
664
|
+
turnId: active.turnId
|
|
665
|
+
}),
|
|
666
|
+
signal: AbortSignal.timeout(30_000)
|
|
667
|
+
});
|
|
668
|
+
const text = await readFetchText(response, 512 * 1024);
|
|
669
|
+
let payload;
|
|
670
|
+
try {
|
|
671
|
+
payload = JSON.parse(text);
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
throw new BridgeRequestError(502, "resource_response_invalid", "The Drawsy resource returned an invalid response.");
|
|
675
|
+
}
|
|
676
|
+
if (!response.ok) {
|
|
677
|
+
const message = isRecord(payload) &&
|
|
678
|
+
isRecord(payload.error) &&
|
|
679
|
+
typeof payload.error.message === "string"
|
|
680
|
+
? payload.error.message
|
|
681
|
+
: `Drawsy resource request failed (${response.status}).`;
|
|
682
|
+
throw new BridgeRequestError(response.status >= 400 && response.status < 500 ? response.status : 502, "resource_request_failed", message);
|
|
683
|
+
}
|
|
684
|
+
return payload;
|
|
685
|
+
};
|
|
686
|
+
const storeContextAsset = async (session, input) => {
|
|
687
|
+
if (!/^[a-f0-9-]{36}$/i.test(input.captureId) ||
|
|
688
|
+
!/^[a-zA-Z0-9_-]{1,128}$/.test(input.assetId)) {
|
|
689
|
+
throw new Error("Canvas context asset identifier is invalid.");
|
|
690
|
+
}
|
|
691
|
+
const metadata = inspectCanvasImage(input.bytes);
|
|
692
|
+
if (input.role === "preview" && metadata.mimeType !== "image/png") {
|
|
693
|
+
throw new Error("Canvas context previews must be PNG images.");
|
|
694
|
+
}
|
|
695
|
+
const capture = session.contextCaptures.get(input.captureId) || {
|
|
696
|
+
id: input.captureId,
|
|
697
|
+
sources: new Map(),
|
|
698
|
+
createdAt: Date.now()
|
|
699
|
+
};
|
|
700
|
+
if (input.role === "source" &&
|
|
701
|
+
!capture.sources.has(input.assetId) &&
|
|
702
|
+
capture.sources.size >= 4) {
|
|
703
|
+
throw new Error("A canvas context can include at most 4 source images.");
|
|
704
|
+
}
|
|
705
|
+
const extension = metadata.mimeType === "image/jpeg"
|
|
706
|
+
? "jpg"
|
|
707
|
+
: metadata.mimeType.slice("image/".length);
|
|
708
|
+
const digest = createHash("sha256")
|
|
709
|
+
.update(input.bytes)
|
|
710
|
+
.digest("hex")
|
|
711
|
+
.slice(0, 24);
|
|
712
|
+
const directory = path.join(sessionContextPath(session), input.captureId);
|
|
713
|
+
await mkdir(directory, { recursive: true });
|
|
714
|
+
const assetPath = path.join(directory, `${input.role}-${input.assetId}-${digest}.${extension}`);
|
|
715
|
+
await writeFile(assetPath, input.bytes, { flag: "wx" }).catch((error) => {
|
|
716
|
+
if (!isRecord(error) || error.code !== "EEXIST")
|
|
717
|
+
throw error;
|
|
718
|
+
});
|
|
719
|
+
const asset = {
|
|
720
|
+
id: input.assetId,
|
|
721
|
+
path: assetPath,
|
|
722
|
+
mimeType: metadata.mimeType
|
|
723
|
+
};
|
|
724
|
+
if (input.role === "preview")
|
|
725
|
+
capture.preview = asset;
|
|
726
|
+
else
|
|
727
|
+
capture.sources.set(input.assetId, asset);
|
|
728
|
+
session.contextCaptures.set(input.captureId, capture);
|
|
729
|
+
return asset;
|
|
730
|
+
};
|
|
731
|
+
const resolveContextCaptures = (session, references) => references.map((reference) => {
|
|
732
|
+
const stored = session.contextCaptures.get(reference.id);
|
|
733
|
+
if (!stored?.preview) {
|
|
734
|
+
throw new Error("Canvas context is missing its selection preview.");
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
...reference,
|
|
738
|
+
previewPath: stored.preview.path,
|
|
739
|
+
sourceImages: [...stored.sources.values()].map((source) => ({
|
|
740
|
+
id: source.id,
|
|
741
|
+
path: source.path
|
|
742
|
+
}))
|
|
743
|
+
};
|
|
744
|
+
});
|
|
745
|
+
const requestCanvas = (session, action, options = {}) => {
|
|
746
|
+
assertCanvasActionAllowed(session, action);
|
|
747
|
+
if ((session.surfaceKind !== "canvas" &&
|
|
748
|
+
session.surfaceKind !== "presentation") ||
|
|
749
|
+
!session.canvasId) {
|
|
750
|
+
throw new Error("No Drawsy canvas is attached to this chat.");
|
|
751
|
+
}
|
|
752
|
+
if (!session.clients.size) {
|
|
753
|
+
throw new Error("The Drawsy canvas is not connected.");
|
|
754
|
+
}
|
|
755
|
+
const requestId = randomUUID();
|
|
756
|
+
emit(session, {
|
|
757
|
+
type: "canvas.request",
|
|
758
|
+
data: {
|
|
759
|
+
requestId,
|
|
760
|
+
action,
|
|
761
|
+
canvasId: session.canvasId,
|
|
762
|
+
...options
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
return new Promise((resolve, reject) => {
|
|
766
|
+
const timer = setTimeout(() => {
|
|
767
|
+
session.canvasPending.delete(requestId);
|
|
768
|
+
reject(new Error("Canvas response timed out."));
|
|
769
|
+
}, configuredCanvasRequestTimeout);
|
|
770
|
+
session.canvasPending.set(requestId, { resolve, reject, timer });
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
const readGeneratedImageBytes = async (generated) => {
|
|
774
|
+
let bytes;
|
|
775
|
+
const result = generated.result?.trim();
|
|
776
|
+
if (result) {
|
|
777
|
+
let encoded = result;
|
|
778
|
+
if (result.startsWith("data:image/")) {
|
|
779
|
+
const separator = result.indexOf(",");
|
|
780
|
+
if (separator < 0 ||
|
|
781
|
+
!result.slice(0, separator).endsWith(";base64")) {
|
|
782
|
+
throw new Error("The generated image payload is invalid.");
|
|
783
|
+
}
|
|
784
|
+
encoded = result.slice(separator + 1);
|
|
785
|
+
}
|
|
786
|
+
bytes = Buffer.from(encoded, "base64");
|
|
787
|
+
}
|
|
788
|
+
else if (generated.savedPath) {
|
|
789
|
+
const details = await stat(generated.savedPath);
|
|
790
|
+
if (!details.isFile() || details.size > MAX_CANVAS_ASSET_BYTES) {
|
|
791
|
+
throw new Error(`The generated image must be at most ${MAX_CANVAS_ASSET_BYTES / (1024 * 1024)} MiB.`);
|
|
792
|
+
}
|
|
793
|
+
bytes = await readFile(generated.savedPath);
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
throw new Error("The generated image has no readable raster output.");
|
|
797
|
+
}
|
|
798
|
+
if (bytes.length <= 0 || bytes.length > MAX_CANVAS_ASSET_BYTES) {
|
|
799
|
+
throw new Error(`The generated image must be at most ${MAX_CANVAS_ASSET_BYTES / (1024 * 1024)} MiB.`);
|
|
800
|
+
}
|
|
801
|
+
const metadata = inspectCanvasImage(bytes);
|
|
802
|
+
return { bytes, mimeType: metadata.mimeType };
|
|
803
|
+
};
|
|
804
|
+
const readImportableImage = async (session, sourcePath) => {
|
|
805
|
+
const normalizedSource = path.isAbsolute(sourcePath)
|
|
806
|
+
? path.resolve(sourcePath)
|
|
807
|
+
: null;
|
|
808
|
+
const generated = sourcePath === "imagegen://latest"
|
|
809
|
+
? session.generatedImages.at(-1)
|
|
810
|
+
: session.generatedImages.find((image) => image.savedPath &&
|
|
811
|
+
path.resolve(image.savedPath) === normalizedSource);
|
|
812
|
+
if (generated) {
|
|
813
|
+
const { bytes } = await readGeneratedImageBytes(generated);
|
|
814
|
+
return {
|
|
815
|
+
bytes,
|
|
816
|
+
sourceName: generated.savedPath
|
|
817
|
+
? path.basename(generated.savedPath)
|
|
818
|
+
: `${generated.id}.png`
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
const asset = await createCanvasImageAsset({
|
|
822
|
+
workspaceRoot: session.folder.path,
|
|
823
|
+
sourcePath,
|
|
824
|
+
x: 0,
|
|
825
|
+
y: 0,
|
|
826
|
+
maxWidth: 1
|
|
827
|
+
});
|
|
828
|
+
const separator = asset.file.dataURL.indexOf(",");
|
|
829
|
+
return {
|
|
830
|
+
bytes: Buffer.from(asset.file.dataURL.slice(separator + 1), "base64"),
|
|
831
|
+
sourceName: path.basename(sourcePath)
|
|
832
|
+
};
|
|
833
|
+
};
|
|
834
|
+
const importCanvasImage = async (session, value) => {
|
|
835
|
+
const input = parseCanvasImageRequest(value);
|
|
836
|
+
const source = await readImportableImage(session, input.sourcePath);
|
|
837
|
+
const asset = createCanvasImageAssetFromBytes({ ...input, ...source });
|
|
838
|
+
await requestCanvas(session, "apply", {
|
|
839
|
+
operations: {
|
|
840
|
+
upsertElements: [asset.element],
|
|
841
|
+
deleteElementIds: [],
|
|
842
|
+
files: [asset.file]
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
return {
|
|
846
|
+
elementId: asset.elementId,
|
|
847
|
+
width: asset.width,
|
|
848
|
+
height: asset.height
|
|
849
|
+
};
|
|
850
|
+
};
|
|
851
|
+
const replaceCanvasImage = async (session, value) => {
|
|
852
|
+
if (!isRecord(value) ||
|
|
853
|
+
typeof value.targetElementId !== "string" ||
|
|
854
|
+
!value.targetElementId.trim() ||
|
|
855
|
+
value.targetElementId.length > 128 ||
|
|
856
|
+
typeof value.sourcePath !== "string" ||
|
|
857
|
+
!value.sourcePath.trim()) {
|
|
858
|
+
throw new Error("Canvas image replacement is invalid.");
|
|
859
|
+
}
|
|
860
|
+
const source = await readImportableImage(session, value.sourcePath.trim());
|
|
861
|
+
const { file, metadata } = createCanvasImageFileFromBytes(source.bytes);
|
|
862
|
+
await requestCanvas(session, "replaceImage", {
|
|
863
|
+
imageReplacement: {
|
|
864
|
+
targetElementId: value.targetElementId.trim(),
|
|
865
|
+
file,
|
|
866
|
+
naturalWidth: metadata.width,
|
|
867
|
+
naturalHeight: metadata.height
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
return { targetElementId: value.targetElementId.trim(), fileId: file.id };
|
|
871
|
+
};
|
|
872
|
+
const server = createServer(async (request, response) => {
|
|
873
|
+
try {
|
|
874
|
+
const url = new URL(request.url || "/", bridgeUrl);
|
|
875
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
876
|
+
json(response, 200, {
|
|
877
|
+
ok: true,
|
|
878
|
+
service: "drawsy-ai-bridge",
|
|
879
|
+
version,
|
|
880
|
+
connectorRouting: connectorBackendResolver.summary
|
|
881
|
+
});
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (request.method === "OPTIONS") {
|
|
885
|
+
if (!requirePublicOrigin(request, response))
|
|
886
|
+
return;
|
|
887
|
+
response.setHeader("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS");
|
|
888
|
+
response.setHeader("access-control-allow-headers", "authorization,content-type");
|
|
889
|
+
response.writeHead(204);
|
|
890
|
+
response.end();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const internalCanvas = url.pathname.match(/^\/internal\/sessions\/([^/]+)\/canvas\/(read|capabilities|connector|label|diagram|apply|inspect|image|context|replace-image|preview)$/);
|
|
894
|
+
if (request.method === "POST" && internalCanvas) {
|
|
895
|
+
const session = internalSession(request, response, decodeURIComponent(internalCanvas[1]));
|
|
896
|
+
if (!session)
|
|
897
|
+
return;
|
|
898
|
+
const action = internalCanvas[2];
|
|
899
|
+
const body = await readJson(request);
|
|
900
|
+
assertCanvasActionAllowed(session, action === "context" ? "capture" : action);
|
|
901
|
+
const parsedPreview = action === "preview" ? parseLivePreviewRequest(body) : null;
|
|
902
|
+
const parsedDiagram = action === "diagram" ? parseCanvasDiagramCodeRequest(body) : null;
|
|
903
|
+
if (parsedPreview && session.previewPort !== null) {
|
|
904
|
+
const requestedPort = Number(new URL(parsedPreview.url).port);
|
|
905
|
+
if (requestedPort !== session.previewPort) {
|
|
906
|
+
throw new BridgeRequestError(409, "preview_port_mismatch", `This session's live preview must use port ${session.previewPort}.`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
const result = action === "image"
|
|
910
|
+
? await importCanvasImage(session, body)
|
|
911
|
+
: action === "replace-image"
|
|
912
|
+
? await replaceCanvasImage(session, body)
|
|
913
|
+
: action === "preview"
|
|
914
|
+
? await requestCanvas(session, "preview", {
|
|
915
|
+
previewRequest: parsedPreview
|
|
916
|
+
})
|
|
917
|
+
: action === "diagram"
|
|
918
|
+
? await requestCanvas(session, "diagram", {
|
|
919
|
+
diagramRequest: parsedDiagram
|
|
920
|
+
})
|
|
921
|
+
: action === "context"
|
|
922
|
+
? resolveContextCaptures(session, [
|
|
923
|
+
parseCanvasContextReference(await requestCanvas(session, "capture", {
|
|
924
|
+
contextRequest: parseCanvasContextRequest(body)
|
|
925
|
+
}))
|
|
926
|
+
])[0]
|
|
927
|
+
: await requestCanvas(session, action, action === "apply"
|
|
928
|
+
? {
|
|
929
|
+
operations: {
|
|
930
|
+
...parseCanvasOperations(body),
|
|
931
|
+
...(session.generationMode === "fast"
|
|
932
|
+
? { existingElementsOnly: true }
|
|
933
|
+
: {})
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
: action === "connector"
|
|
937
|
+
? { connectorRequest: parseCanvasConnectorRequest(body) }
|
|
938
|
+
: action === "label"
|
|
939
|
+
? { labelRequest: parseCanvasLabelRequest(body) }
|
|
940
|
+
: undefined);
|
|
941
|
+
json(response, 200, result);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const internalConnector = url.pathname.match(/^\/internal\/sessions\/([^/]+)\/connectors\/(list|search|read|query|mcp-tools|mcp-call)$/);
|
|
945
|
+
if (request.method === "POST" && internalConnector) {
|
|
946
|
+
const session = internalSession(request, response, decodeURIComponent(internalConnector[1]));
|
|
947
|
+
if (!session)
|
|
948
|
+
return;
|
|
949
|
+
const action = internalConnector[2];
|
|
950
|
+
const body = await readJson(request);
|
|
951
|
+
if (action === "list") {
|
|
952
|
+
if (Object.keys(body).length) {
|
|
953
|
+
throw new BridgeRequestError(400, "connector_request_invalid", "The source list request must be empty.");
|
|
954
|
+
}
|
|
955
|
+
json(response, 200, {
|
|
956
|
+
sources: session.activeConnectorTurn?.sources || []
|
|
957
|
+
});
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
json(response, 200, await executeConnectorRequest(session, action, body));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const internalResource = url.pathname.match(/^\/internal\/sessions\/([^/]+)\/resources\/execute$/);
|
|
964
|
+
if (request.method === "POST" && internalResource) {
|
|
965
|
+
const session = internalSession(request, response, decodeURIComponent(internalResource[1]));
|
|
966
|
+
if (!session)
|
|
967
|
+
return;
|
|
968
|
+
json(response, 200, await executeResourceRequest(session, await readJson(request)));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const attachmentMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/attachments\/([^/]+)$/);
|
|
972
|
+
if (request.method === "GET" && attachmentMatch) {
|
|
973
|
+
const session = sessions.get(decodeURIComponent(attachmentMatch[1]));
|
|
974
|
+
const token = url.searchParams.get("token") || bearerToken(request);
|
|
975
|
+
if (!session || !safeEqual(token, session.token)) {
|
|
976
|
+
json(response, 401, {
|
|
977
|
+
error: {
|
|
978
|
+
code: "authentication_required",
|
|
979
|
+
message: "Invalid session."
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const origin = request.headers.origin;
|
|
985
|
+
if (origin && !allowedOrigins.has(origin)) {
|
|
986
|
+
json(response, 403, {
|
|
987
|
+
error: { code: "origin_denied", message: "Origin is not allowed." }
|
|
988
|
+
});
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (origin) {
|
|
992
|
+
response.setHeader("access-control-allow-origin", origin);
|
|
993
|
+
response.setHeader("vary", "Origin");
|
|
994
|
+
}
|
|
995
|
+
const imageId = decodeURIComponent(attachmentMatch[2]);
|
|
996
|
+
const generated = session.generatedImages.find((image) => image.id === imageId);
|
|
997
|
+
if (!generated) {
|
|
998
|
+
json(response, 404, {
|
|
999
|
+
error: {
|
|
1000
|
+
code: "attachment_not_found",
|
|
1001
|
+
message: "The generated image is no longer available."
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
session.touchedAt = Date.now();
|
|
1007
|
+
const image = await readGeneratedImageBytes(generated);
|
|
1008
|
+
response.writeHead(200, {
|
|
1009
|
+
"content-type": image.mimeType,
|
|
1010
|
+
"content-length": String(image.bytes.length),
|
|
1011
|
+
"cache-control": "private, max-age=300",
|
|
1012
|
+
"x-content-type-options": "nosniff"
|
|
1013
|
+
});
|
|
1014
|
+
response.end(image.bytes);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (!requirePublicOrigin(request, response))
|
|
1018
|
+
return;
|
|
1019
|
+
if (request.method === "GET" && url.pathname === "/v1/engines") {
|
|
1020
|
+
json(response, 200, {
|
|
1021
|
+
engines: readLocalEngineStatus(),
|
|
1022
|
+
generationModes: ["draw", "fast"]
|
|
1023
|
+
});
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (request.method === "GET" && url.pathname === "/v1/preferences") {
|
|
1027
|
+
json(response, 200, {
|
|
1028
|
+
preferences: await localConversations.getPreferences()
|
|
1029
|
+
});
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (request.method === "PUT" && url.pathname === "/v1/preferences") {
|
|
1033
|
+
const preferences = await localConversations.setPreferences(parseLocalPreferences(await readJson(request)));
|
|
1034
|
+
json(response, 200, { preferences });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (request.method === "GET" && url.pathname === "/v1/conversations") {
|
|
1038
|
+
const scope = url.searchParams.get("scope");
|
|
1039
|
+
const canvasId = url.searchParams.get("canvasId");
|
|
1040
|
+
if ((scope !== "canvas" && scope !== "general") ||
|
|
1041
|
+
(scope === "canvas" && !canvasId)) {
|
|
1042
|
+
json(response, 400, {
|
|
1043
|
+
error: {
|
|
1044
|
+
code: "conversation_scope_invalid",
|
|
1045
|
+
message: "Choose a valid conversation history scope."
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (scope === "general") {
|
|
1051
|
+
json(response, 200, { conversations: [] });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
json(response, 200, {
|
|
1055
|
+
conversations: await localConversations.list(scope, canvasId)
|
|
1056
|
+
});
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const contextAssetMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/context-assets\/([^/]+)\/(preview|source)\/([^/]+)$/);
|
|
1060
|
+
if (request.method === "POST" && contextAssetMatch) {
|
|
1061
|
+
const session = publicSession(request, response, decodeURIComponent(contextAssetMatch[1]));
|
|
1062
|
+
if (!session)
|
|
1063
|
+
return;
|
|
1064
|
+
const asset = await storeContextAsset(session, {
|
|
1065
|
+
captureId: decodeURIComponent(contextAssetMatch[2]),
|
|
1066
|
+
role: contextAssetMatch[3],
|
|
1067
|
+
assetId: decodeURIComponent(contextAssetMatch[4]),
|
|
1068
|
+
bytes: await readBytes(request)
|
|
1069
|
+
});
|
|
1070
|
+
json(response, 201, {
|
|
1071
|
+
id: asset.id,
|
|
1072
|
+
mimeType: asset.mimeType
|
|
1073
|
+
});
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (request.method === "POST" && url.pathname === "/v1/folders/pick") {
|
|
1077
|
+
const folder = await (options.folderPicker ?? pickFolder)();
|
|
1078
|
+
const selection = {
|
|
1079
|
+
id: randomUUID(),
|
|
1080
|
+
...folder,
|
|
1081
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
1082
|
+
userSelected: true,
|
|
1083
|
+
privateWorkspace: false
|
|
1084
|
+
};
|
|
1085
|
+
selections.set(selection.id, selection);
|
|
1086
|
+
json(response, 200, {
|
|
1087
|
+
selectionId: selection.id,
|
|
1088
|
+
name: selection.name
|
|
1089
|
+
});
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const drawDocumentMatch = url.pathname.match(/^\/v1\/folders\/([^/]+)\/draw-document$/);
|
|
1093
|
+
if (request.method === "GET" && drawDocumentMatch) {
|
|
1094
|
+
const selectionId = decodeURIComponent(drawDocumentMatch[1]);
|
|
1095
|
+
const folder = selections.get(selectionId);
|
|
1096
|
+
if (!folder || folder.expiresAt <= Date.now()) {
|
|
1097
|
+
selections.delete(selectionId);
|
|
1098
|
+
json(response, 400, {
|
|
1099
|
+
error: {
|
|
1100
|
+
code: "folder_expired",
|
|
1101
|
+
message: "Choose the folder again."
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
json(response, 200, await readDrawDocument(folder));
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (request.method === "POST" && url.pathname === "/v1/sessions") {
|
|
1110
|
+
const body = await readJson(request);
|
|
1111
|
+
const selectionId = typeof body.selectionId === "string" ? body.selectionId : "";
|
|
1112
|
+
const canvasId = typeof body.canvasId === "string" ? body.canvasId : "";
|
|
1113
|
+
const canvasName = typeof body.canvasName === "string" ? body.canvasName : "Untitled";
|
|
1114
|
+
const conversationId = typeof body.conversationId === "string" &&
|
|
1115
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.conversationId)
|
|
1116
|
+
? body.conversationId
|
|
1117
|
+
: null;
|
|
1118
|
+
const clientId = typeof body.clientId === "string" &&
|
|
1119
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.clientId)
|
|
1120
|
+
? body.clientId
|
|
1121
|
+
: null;
|
|
1122
|
+
const engine = body.engine === undefined ? "codex" : body.engine;
|
|
1123
|
+
const surfaceKind = body.surfaceKind ?? "canvas";
|
|
1124
|
+
const surfaceId = typeof body.surfaceId === "string" && body.surfaceId.trim()
|
|
1125
|
+
? body.surfaceId.trim()
|
|
1126
|
+
: null;
|
|
1127
|
+
const surfaceName = typeof body.surfaceName === "string" && body.surfaceName.trim()
|
|
1128
|
+
? body.surfaceName.trim().slice(0, 200)
|
|
1129
|
+
: surfaceKind === "neutral"
|
|
1130
|
+
? "Drawsy"
|
|
1131
|
+
: canvasName;
|
|
1132
|
+
if ((surfaceKind === "canvas" || surfaceKind === "presentation") &&
|
|
1133
|
+
!canvasId) {
|
|
1134
|
+
json(response, 400, {
|
|
1135
|
+
error: {
|
|
1136
|
+
code: "canvas_required",
|
|
1137
|
+
message: "A current canvas is required."
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (surfaceKind !== "canvas" &&
|
|
1143
|
+
surfaceKind !== "presentation" &&
|
|
1144
|
+
surfaceKind !== "kanban" &&
|
|
1145
|
+
surfaceKind !== "jira" &&
|
|
1146
|
+
surfaceKind !== "neutral") {
|
|
1147
|
+
json(response, 400, {
|
|
1148
|
+
error: {
|
|
1149
|
+
code: "surface_invalid",
|
|
1150
|
+
message: "The Drawsy surface type is invalid."
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const validatedSurfaceKind = surfaceKind;
|
|
1156
|
+
if (engine !== "codex" && engine !== "opencode") {
|
|
1157
|
+
json(response, 400, {
|
|
1158
|
+
error: {
|
|
1159
|
+
code: "engine_invalid",
|
|
1160
|
+
message: "The Drawsy agent engine is invalid."
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (!conversationId) {
|
|
1166
|
+
json(response, 400, {
|
|
1167
|
+
error: {
|
|
1168
|
+
code: "conversation_required",
|
|
1169
|
+
message: "A local conversation id is required."
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (surfaceId &&
|
|
1175
|
+
(surfaceId.length > 128 || !/^[A-Za-z0-9:_-]+$/.test(surfaceId))) {
|
|
1176
|
+
json(response, 400, {
|
|
1177
|
+
error: {
|
|
1178
|
+
code: "surface_invalid",
|
|
1179
|
+
message: "The Drawsy surface id is invalid."
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const previousStart = conversationStartLocks.get(conversationId);
|
|
1185
|
+
let releaseStart;
|
|
1186
|
+
const currentStart = new Promise((resolve) => {
|
|
1187
|
+
releaseStart = resolve;
|
|
1188
|
+
});
|
|
1189
|
+
const startTail = (previousStart || Promise.resolve()).then(() => currentStart);
|
|
1190
|
+
conversationStartLocks.set(conversationId, startTail);
|
|
1191
|
+
if (previousStart)
|
|
1192
|
+
await previousStart;
|
|
1193
|
+
try {
|
|
1194
|
+
let folder;
|
|
1195
|
+
try {
|
|
1196
|
+
folder = await resolveSessionFolder({
|
|
1197
|
+
selectionId,
|
|
1198
|
+
conversationId,
|
|
1199
|
+
canvasId,
|
|
1200
|
+
surfaceKind: validatedSurfaceKind
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
catch (error) {
|
|
1204
|
+
const activeSession = conversationSessions.get(conversationId);
|
|
1205
|
+
const sameSurface = !!activeSession &&
|
|
1206
|
+
activeSession.engine === engine &&
|
|
1207
|
+
activeSession.canvasId === (canvasId || null) &&
|
|
1208
|
+
activeSession.surfaceKind === validatedSurfaceKind &&
|
|
1209
|
+
activeSession.surfaceId === surfaceId;
|
|
1210
|
+
if (!sameSurface ||
|
|
1211
|
+
(validatedSurfaceKind !== "canvas" &&
|
|
1212
|
+
validatedSurfaceKind !== "presentation")) {
|
|
1213
|
+
throw error;
|
|
1214
|
+
}
|
|
1215
|
+
// An already-open canvas/presentation session is authoritative. A
|
|
1216
|
+
// stale picker token is not needed to reconnect to that session.
|
|
1217
|
+
folder = activeSession.folder;
|
|
1218
|
+
}
|
|
1219
|
+
const scope = canvasId ? "canvas" : "general";
|
|
1220
|
+
const existingConversation = await localConversations.get(conversationId);
|
|
1221
|
+
if (existingConversation &&
|
|
1222
|
+
(existingConversation.engine !== engine ||
|
|
1223
|
+
existingConversation.scope !== scope ||
|
|
1224
|
+
existingConversation.canvasId !== (canvasId || null))) {
|
|
1225
|
+
json(response, 409, {
|
|
1226
|
+
error: {
|
|
1227
|
+
code: "conversation_scope_conflict",
|
|
1228
|
+
message: "This conversation belongs to a different Drawsy scope."
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
let localConversation = await localConversations.upsert({
|
|
1234
|
+
id: conversationId,
|
|
1235
|
+
scope,
|
|
1236
|
+
canvasId: canvasId || null,
|
|
1237
|
+
canvasName: canvasId ? canvasName : null,
|
|
1238
|
+
engine,
|
|
1239
|
+
title: "New conversation",
|
|
1240
|
+
createdAt: existingConversation?.createdAt || Date.now(),
|
|
1241
|
+
updatedAt: existingConversation?.updatedAt || Date.now(),
|
|
1242
|
+
messageCount: existingConversation?.messageCount || 0,
|
|
1243
|
+
workspacePath: folder.path
|
|
1244
|
+
});
|
|
1245
|
+
let hadNativeSession = Boolean(engine === "codex"
|
|
1246
|
+
? localConversation.codexThreadId
|
|
1247
|
+
: localConversation.openCodeSessionId);
|
|
1248
|
+
const existingConversationSession = conversationId
|
|
1249
|
+
? conversationSessions.get(conversationId)
|
|
1250
|
+
: null;
|
|
1251
|
+
if (existingConversationSession) {
|
|
1252
|
+
if (existingConversationSession.clientId !== clientId) {
|
|
1253
|
+
json(response, 409, {
|
|
1254
|
+
error: {
|
|
1255
|
+
code: "conversation_active_elsewhere",
|
|
1256
|
+
message: "This chat is already open in another tab. Close it there, then retry here."
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (existingConversationSession.engine !== engine) {
|
|
1262
|
+
json(response, 409, {
|
|
1263
|
+
error: {
|
|
1264
|
+
code: "conversation_scope_conflict",
|
|
1265
|
+
message: "This conversation belongs to a different Drawsy engine."
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
const requestedCanvasId = canvasId || null;
|
|
1271
|
+
if (existingConversationSession.canvasId !== requestedCanvasId) {
|
|
1272
|
+
json(response, 409, {
|
|
1273
|
+
error: {
|
|
1274
|
+
code: "conversation_scope_conflict",
|
|
1275
|
+
message: "This conversation belongs to a different Drawsy canvas."
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const matchesCurrentSurface = existingConversationSession.surfaceKind ===
|
|
1281
|
+
validatedSurfaceKind &&
|
|
1282
|
+
existingConversationSession.surfaceId === surfaceId;
|
|
1283
|
+
if (requestedCanvasId && !matchesCurrentSurface) {
|
|
1284
|
+
json(response, 409, {
|
|
1285
|
+
error: {
|
|
1286
|
+
code: "conversation_scope_conflict",
|
|
1287
|
+
message: "This conversation belongs to a different Drawsy canvas."
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (matchesCurrentSurface &&
|
|
1293
|
+
existingConversationSession.folder.path === folder.path) {
|
|
1294
|
+
try {
|
|
1295
|
+
const messages = (await existingConversationSession.agent.getConversationMessages()).map((message) => message.role === "assistant"
|
|
1296
|
+
? {
|
|
1297
|
+
...message,
|
|
1298
|
+
text: rewriteAttachmentReferences(existingConversationSession, message.text)
|
|
1299
|
+
}
|
|
1300
|
+
: message);
|
|
1301
|
+
existingConversationSession.token =
|
|
1302
|
+
randomBytes(32).toString("base64url");
|
|
1303
|
+
existingConversationSession.touchedAt = Date.now();
|
|
1304
|
+
json(response, 200, {
|
|
1305
|
+
id: existingConversationSession.id,
|
|
1306
|
+
token: existingConversationSession.token,
|
|
1307
|
+
folderName: folderNameForClient(existingConversationSession.folder),
|
|
1308
|
+
folder: folderForClient(existingConversationSession.folder),
|
|
1309
|
+
resumed: true,
|
|
1310
|
+
conversation: localConversation,
|
|
1311
|
+
messages
|
|
1312
|
+
});
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
catch (error) {
|
|
1316
|
+
console.warn("Drawsy native conversation runtime was unavailable; recreating it.", error);
|
|
1317
|
+
closeSession(existingConversationSession);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// General history can be resumed in a different non-canvas surface.
|
|
1321
|
+
// It gets a new correctly-scoped runtime while retaining its native
|
|
1322
|
+
// local Codex/OpenCode session identity.
|
|
1323
|
+
closeSession(existingConversationSession);
|
|
1324
|
+
}
|
|
1325
|
+
const id = randomUUID();
|
|
1326
|
+
const token = randomBytes(32).toString("base64url");
|
|
1327
|
+
const internalSecret = randomBytes(32).toString("base64url");
|
|
1328
|
+
const connectorBackend = connectorBackendResolver.resolve(request.headers.origin);
|
|
1329
|
+
let sessionRef = null;
|
|
1330
|
+
let agent;
|
|
1331
|
+
try {
|
|
1332
|
+
await prepareContextStore(folder.path);
|
|
1333
|
+
const agentOptions = {
|
|
1334
|
+
id,
|
|
1335
|
+
secret: internalSecret,
|
|
1336
|
+
bridgeUrl,
|
|
1337
|
+
surfaceKind: validatedSurfaceKind,
|
|
1338
|
+
surfaceId,
|
|
1339
|
+
surfaceName,
|
|
1340
|
+
workspaceMode: folder.userSelected
|
|
1341
|
+
? "selected"
|
|
1342
|
+
: "private",
|
|
1343
|
+
isolateProcessGroup: false,
|
|
1344
|
+
previewPort: null
|
|
1345
|
+
};
|
|
1346
|
+
if (engine === "opencode") {
|
|
1347
|
+
agent = await OpenCodeAppServer.start(folder.path, {
|
|
1348
|
+
...agentOptions,
|
|
1349
|
+
runtimePath: path.join(localConversations.stateDirectory, "opencode", conversationId),
|
|
1350
|
+
nativeSessionId: localConversation.openCodeSessionId
|
|
1351
|
+
}, (event) => sessionRef && emit(sessionRef, event));
|
|
1352
|
+
hadNativeSession = agent.resumed;
|
|
1353
|
+
}
|
|
1354
|
+
else {
|
|
1355
|
+
const startCodex = (nativeThreadId) => CodexAppServer.start(folder.path, { ...agentOptions, nativeThreadId }, (event) => emitCodexEvent(sessionRef, event), (image) => {
|
|
1356
|
+
if (!sessionRef)
|
|
1357
|
+
return;
|
|
1358
|
+
const savedPath = image.savedPath && path.isAbsolute(image.savedPath)
|
|
1359
|
+
? path.resolve(image.savedPath)
|
|
1360
|
+
: undefined;
|
|
1361
|
+
const maxDataUrlLength = Math.ceil((MAX_CANVAS_ASSET_BYTES * 4) / 3) + 64;
|
|
1362
|
+
const result = typeof image.result === "string" &&
|
|
1363
|
+
image.result.trim().length <= maxDataUrlLength
|
|
1364
|
+
? image.result.trim()
|
|
1365
|
+
: undefined;
|
|
1366
|
+
if (!savedPath && !result)
|
|
1367
|
+
return;
|
|
1368
|
+
const existing = sessionRef.generatedImages.find((generated) => generated.id === image.id);
|
|
1369
|
+
if (existing) {
|
|
1370
|
+
existing.savedPath ||= savedPath;
|
|
1371
|
+
existing.result ||= result;
|
|
1372
|
+
existing.createdAt = Date.now();
|
|
1373
|
+
}
|
|
1374
|
+
else {
|
|
1375
|
+
sessionRef.generatedImages.push({
|
|
1376
|
+
id: image.id,
|
|
1377
|
+
savedPath,
|
|
1378
|
+
result,
|
|
1379
|
+
createdAt: Date.now()
|
|
1380
|
+
});
|
|
1381
|
+
if (sessionRef.generatedImages.length > 8) {
|
|
1382
|
+
sessionRef.generatedImages.shift();
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
try {
|
|
1387
|
+
agent = await startCodex(localConversation.codexThreadId);
|
|
1388
|
+
}
|
|
1389
|
+
catch (error) {
|
|
1390
|
+
if (!localConversation.codexThreadId ||
|
|
1391
|
+
(!isCodexThreadMissingError(error) &&
|
|
1392
|
+
!isCodexThreadResumeUnsupportedError(error))) {
|
|
1393
|
+
throw error;
|
|
1394
|
+
}
|
|
1395
|
+
localConversation = await localConversations.setNativeSession(conversationId, "codex", null);
|
|
1396
|
+
hadNativeSession = false;
|
|
1397
|
+
agent = await startCodex(null);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch (error) {
|
|
1402
|
+
throw error;
|
|
1403
|
+
}
|
|
1404
|
+
const session = {
|
|
1405
|
+
id,
|
|
1406
|
+
conversationId,
|
|
1407
|
+
clientId,
|
|
1408
|
+
token,
|
|
1409
|
+
internalSecret,
|
|
1410
|
+
canvasId: canvasId || null,
|
|
1411
|
+
canvasName,
|
|
1412
|
+
surfaceKind: validatedSurfaceKind,
|
|
1413
|
+
surfaceId,
|
|
1414
|
+
surfaceName,
|
|
1415
|
+
folder,
|
|
1416
|
+
clients: new Set(),
|
|
1417
|
+
canvasPending: new Map(),
|
|
1418
|
+
generatedImages: [],
|
|
1419
|
+
contextCaptures: new Map(),
|
|
1420
|
+
activeConnectorTurn: null,
|
|
1421
|
+
activeResourceTurn: null,
|
|
1422
|
+
generationMode: "draw",
|
|
1423
|
+
activeGenerationMode: null,
|
|
1424
|
+
turnStartPending: false,
|
|
1425
|
+
connectorBackendUrl: connectorBackend.url,
|
|
1426
|
+
engine,
|
|
1427
|
+
agent,
|
|
1428
|
+
previewPort: null,
|
|
1429
|
+
touchedAt: Date.now()
|
|
1430
|
+
};
|
|
1431
|
+
sessionRef = session;
|
|
1432
|
+
sessions.set(id, session);
|
|
1433
|
+
if (conversationId)
|
|
1434
|
+
conversationSessions.set(conversationId, session);
|
|
1435
|
+
if (folder.userSelected &&
|
|
1436
|
+
(validatedSurfaceKind === "canvas" ||
|
|
1437
|
+
validatedSurfaceKind === "presentation")) {
|
|
1438
|
+
await localConversations.rememberFolder(canvasId, folder.path);
|
|
1439
|
+
}
|
|
1440
|
+
localConversation = await localConversations.setNativeSession(conversationId, engine, agent.nativeSessionId);
|
|
1441
|
+
let messages;
|
|
1442
|
+
try {
|
|
1443
|
+
messages = (await agent.getConversationMessages()).map((message) => message.role === "assistant"
|
|
1444
|
+
? {
|
|
1445
|
+
...message,
|
|
1446
|
+
text: rewriteAttachmentReferences(session, message.text)
|
|
1447
|
+
}
|
|
1448
|
+
: message);
|
|
1449
|
+
}
|
|
1450
|
+
catch (error) {
|
|
1451
|
+
closeSession(session);
|
|
1452
|
+
throw error;
|
|
1453
|
+
}
|
|
1454
|
+
json(response, 201, {
|
|
1455
|
+
id,
|
|
1456
|
+
token,
|
|
1457
|
+
folderName: folderNameForClient(folder),
|
|
1458
|
+
folder: folderForClient(folder),
|
|
1459
|
+
resumed: hadNativeSession,
|
|
1460
|
+
conversation: localConversation,
|
|
1461
|
+
messages
|
|
1462
|
+
});
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
finally {
|
|
1466
|
+
releaseStart();
|
|
1467
|
+
if (conversationStartLocks.get(conversationId) === startTail) {
|
|
1468
|
+
conversationStartLocks.delete(conversationId);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const eventsMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/events$/);
|
|
1473
|
+
if (request.method === "GET" && eventsMatch) {
|
|
1474
|
+
const session = publicSession(request, response, decodeURIComponent(eventsMatch[1]));
|
|
1475
|
+
if (!session)
|
|
1476
|
+
return;
|
|
1477
|
+
response.writeHead(200, {
|
|
1478
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
1479
|
+
"cache-control": "no-store",
|
|
1480
|
+
connection: "keep-alive"
|
|
1481
|
+
});
|
|
1482
|
+
session.clients.add(response);
|
|
1483
|
+
response.write(`${JSON.stringify({
|
|
1484
|
+
type: "session.ready",
|
|
1485
|
+
data: {
|
|
1486
|
+
folderName: folderNameForClient(session.folder),
|
|
1487
|
+
agent: session.agent.metadata
|
|
1488
|
+
}
|
|
1489
|
+
})}\n`);
|
|
1490
|
+
const heartbeat = setInterval(() => response.write("\n"), 15_000);
|
|
1491
|
+
request.on("close", () => {
|
|
1492
|
+
clearInterval(heartbeat);
|
|
1493
|
+
session.clients.delete(response);
|
|
1494
|
+
});
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
const turnMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/turns$/);
|
|
1498
|
+
if (request.method === "POST" && turnMatch) {
|
|
1499
|
+
const session = publicSession(request, response, decodeURIComponent(turnMatch[1]));
|
|
1500
|
+
if (!session)
|
|
1501
|
+
return;
|
|
1502
|
+
if (session.turnStartPending || session.activeGenerationMode !== null) {
|
|
1503
|
+
throw new BridgeRequestError(409, "turn_already_active", "A Drawsy turn is already running.");
|
|
1504
|
+
}
|
|
1505
|
+
const body = await readJson(request);
|
|
1506
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
1507
|
+
if (!message || message.length > 20_000) {
|
|
1508
|
+
json(response, 400, {
|
|
1509
|
+
error: {
|
|
1510
|
+
code: "invalid_message",
|
|
1511
|
+
message: "Message is empty or too long."
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
const connectorTurn = parseAgentConnectorTurn(body.connectors);
|
|
1517
|
+
const resourceTurn = parseAgentResourceTurn(body.resources);
|
|
1518
|
+
let generationMode;
|
|
1519
|
+
try {
|
|
1520
|
+
generationMode = parseGenerationMode(body.generationMode);
|
|
1521
|
+
}
|
|
1522
|
+
catch {
|
|
1523
|
+
throw new BridgeRequestError(400, "generation_mode_invalid", "generationMode must be 'fast' or 'draw'.");
|
|
1524
|
+
}
|
|
1525
|
+
if (generationMode === "fast" &&
|
|
1526
|
+
session.surfaceKind !== "canvas" &&
|
|
1527
|
+
session.surfaceKind !== "presentation") {
|
|
1528
|
+
throw new BridgeRequestError(400, "generation_mode_unsupported", "Fast Mode requires an attached Drawsy canvas.");
|
|
1529
|
+
}
|
|
1530
|
+
const drawMode = body.drawMode === true && generationMode === "draw";
|
|
1531
|
+
let drawsyTabId;
|
|
1532
|
+
let drawsyTabUrl;
|
|
1533
|
+
try {
|
|
1534
|
+
drawsyTabId = parseDrawsyTabId(body.drawsyTabId);
|
|
1535
|
+
drawsyTabUrl = parseDrawsyTabUrl(body.drawsyTabUrl);
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
throw new BridgeRequestError(400, "draw_target_invalid", "Draw mode received an invalid calling-tab identity. Refresh this tab and retry.");
|
|
1539
|
+
}
|
|
1540
|
+
if (drawMode && (!drawsyTabId || !drawsyTabUrl)) {
|
|
1541
|
+
throw new BridgeRequestError(400, "draw_target_missing", "Draw mode needs the calling Drawsy tab identity. Refresh this tab and retry.");
|
|
1542
|
+
}
|
|
1543
|
+
const contexts = resolveContextCaptures(session, parseContextReferences(body.contexts));
|
|
1544
|
+
if (session.turnStartPending || session.activeGenerationMode !== null) {
|
|
1545
|
+
throw new BridgeRequestError(409, "turn_already_active", "A Drawsy turn is already running.");
|
|
1546
|
+
}
|
|
1547
|
+
session.generationMode = generationMode;
|
|
1548
|
+
session.activeConnectorTurn = connectorTurn;
|
|
1549
|
+
session.activeResourceTurn = resourceTurn;
|
|
1550
|
+
session.activeGenerationMode = generationMode;
|
|
1551
|
+
session.turnStartPending = true;
|
|
1552
|
+
try {
|
|
1553
|
+
await session.agent.startTurn(message, {
|
|
1554
|
+
skills: parsePromptTags(body.skills, "skills"),
|
|
1555
|
+
plugins: parsePromptTags(body.plugins, "plugins")
|
|
1556
|
+
}, contexts, connectorTurn?.sources || [], resourceTurn?.resources || [], drawMode, drawsyTabId, drawsyTabUrl, generationMode);
|
|
1557
|
+
if (session.conversationId) {
|
|
1558
|
+
await localConversations
|
|
1559
|
+
.recordUserMessage(session.conversationId, message)
|
|
1560
|
+
.catch((error) => console.warn("Drawsy local conversation title could not be saved.", error));
|
|
1561
|
+
}
|
|
1562
|
+
json(response, 202, { accepted: true });
|
|
1563
|
+
}
|
|
1564
|
+
catch (error) {
|
|
1565
|
+
session.activeConnectorTurn = null;
|
|
1566
|
+
session.activeResourceTurn = null;
|
|
1567
|
+
session.activeGenerationMode = null;
|
|
1568
|
+
throw error;
|
|
1569
|
+
}
|
|
1570
|
+
finally {
|
|
1571
|
+
session.turnStartPending = false;
|
|
1572
|
+
}
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
const turnActionMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/turns\/(interrupt|steer)$/);
|
|
1576
|
+
if (request.method === "POST" && turnActionMatch) {
|
|
1577
|
+
const session = publicSession(request, response, decodeURIComponent(turnActionMatch[1]));
|
|
1578
|
+
if (!session)
|
|
1579
|
+
return;
|
|
1580
|
+
const action = turnActionMatch[2];
|
|
1581
|
+
if (action === "interrupt") {
|
|
1582
|
+
await session.agent.interruptTurn();
|
|
1583
|
+
json(response, 202, { accepted: true });
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
const body = await readJson(request);
|
|
1587
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
1588
|
+
if (!message || message.length > 20_000) {
|
|
1589
|
+
json(response, 400, {
|
|
1590
|
+
error: {
|
|
1591
|
+
code: "invalid_message",
|
|
1592
|
+
message: "Message is empty or too long."
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
await session.agent.steerTurn(message);
|
|
1598
|
+
if (session.conversationId) {
|
|
1599
|
+
await localConversations
|
|
1600
|
+
.recordUserMessage(session.conversationId, message)
|
|
1601
|
+
.catch((error) => console.warn("Drawsy local conversation title could not be saved.", error));
|
|
1602
|
+
}
|
|
1603
|
+
json(response, 202, { accepted: true });
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
const controlsMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/controls$/);
|
|
1607
|
+
if (request.method === "GET" && controlsMatch) {
|
|
1608
|
+
const session = publicSession(request, response, decodeURIComponent(controlsMatch[1]));
|
|
1609
|
+
if (!session)
|
|
1610
|
+
return;
|
|
1611
|
+
json(response, 200, await session.agent.getControls());
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
const settingsMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/settings$/);
|
|
1615
|
+
if (request.method === "POST" && settingsMatch) {
|
|
1616
|
+
const session = publicSession(request, response, decodeURIComponent(settingsMatch[1]));
|
|
1617
|
+
if (!session)
|
|
1618
|
+
return;
|
|
1619
|
+
const body = await readJson(request);
|
|
1620
|
+
const allowedKeys = new Set([
|
|
1621
|
+
"model",
|
|
1622
|
+
"modelProvider",
|
|
1623
|
+
"effort",
|
|
1624
|
+
"accessMode",
|
|
1625
|
+
"internetEnabled"
|
|
1626
|
+
]);
|
|
1627
|
+
if (Object.keys(body).some((key) => !allowedKeys.has(key))) {
|
|
1628
|
+
json(response, 400, {
|
|
1629
|
+
error: {
|
|
1630
|
+
code: "invalid_settings",
|
|
1631
|
+
message: "Unknown agent setting."
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
if ((body.model !== undefined && typeof body.model !== "string") ||
|
|
1637
|
+
(body.modelProvider !== undefined &&
|
|
1638
|
+
typeof body.modelProvider !== "string") ||
|
|
1639
|
+
(body.effort !== undefined && typeof body.effort !== "string") ||
|
|
1640
|
+
(body.accessMode !== undefined &&
|
|
1641
|
+
body.accessMode !== "workspace" &&
|
|
1642
|
+
body.accessMode !== "readOnly") ||
|
|
1643
|
+
(body.internetEnabled !== undefined &&
|
|
1644
|
+
typeof body.internetEnabled !== "boolean")) {
|
|
1645
|
+
json(response, 400, {
|
|
1646
|
+
error: {
|
|
1647
|
+
code: "invalid_settings",
|
|
1648
|
+
message: "Invalid agent setting value."
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
const result = await session.agent.updateSettings(body);
|
|
1654
|
+
if (session.conversationId) {
|
|
1655
|
+
await localConversations.setNativeSession(session.conversationId, session.engine, session.agent.nativeSessionId);
|
|
1656
|
+
}
|
|
1657
|
+
json(response, 200, result);
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
const providerKeyMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/provider-key$/);
|
|
1661
|
+
if (request.method === "POST" && providerKeyMatch) {
|
|
1662
|
+
const session = publicSession(request, response, decodeURIComponent(providerKeyMatch[1]));
|
|
1663
|
+
if (!session)
|
|
1664
|
+
return;
|
|
1665
|
+
if (session.engine !== "opencode" ||
|
|
1666
|
+
!(session.agent instanceof OpenCodeAppServer)) {
|
|
1667
|
+
json(response, 400, {
|
|
1668
|
+
error: {
|
|
1669
|
+
code: "provider_key_unsupported",
|
|
1670
|
+
message: "Session-only API keys are available with OpenCode."
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const body = await readJson(request);
|
|
1676
|
+
const metadata = body.metadata === undefined ? {} : body.metadata;
|
|
1677
|
+
if (Object.keys(body).some((key) => key !== "providerId" && key !== "apiKey" && key !== "metadata") ||
|
|
1678
|
+
typeof body.providerId !== "string" ||
|
|
1679
|
+
typeof body.apiKey !== "string" ||
|
|
1680
|
+
!isRecord(metadata) ||
|
|
1681
|
+
Object.keys(metadata).length > 16 ||
|
|
1682
|
+
Object.entries(metadata).some(([key, value]) => !key ||
|
|
1683
|
+
key.length > 128 ||
|
|
1684
|
+
typeof value !== "string" ||
|
|
1685
|
+
value.length > 16_384)) {
|
|
1686
|
+
json(response, 400, {
|
|
1687
|
+
error: {
|
|
1688
|
+
code: "provider_key_invalid",
|
|
1689
|
+
message: "A provider, API key, and valid provider fields are required."
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
json(response, 200, await session.agent.setProviderApiKey({
|
|
1695
|
+
providerId: body.providerId,
|
|
1696
|
+
apiKey: body.apiKey,
|
|
1697
|
+
metadata: metadata
|
|
1698
|
+
}));
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
const canvasResponseMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/canvas-responses$/);
|
|
1702
|
+
if (request.method === "POST" && canvasResponseMatch) {
|
|
1703
|
+
const session = publicSession(request, response, decodeURIComponent(canvasResponseMatch[1]));
|
|
1704
|
+
if (!session)
|
|
1705
|
+
return;
|
|
1706
|
+
const body = await readJson(request);
|
|
1707
|
+
const requestId = typeof body.requestId === "string" ? body.requestId : "";
|
|
1708
|
+
const pending = session.canvasPending.get(requestId);
|
|
1709
|
+
if (!pending) {
|
|
1710
|
+
json(response, 404, {
|
|
1711
|
+
error: {
|
|
1712
|
+
code: "request_not_found",
|
|
1713
|
+
message: "Canvas request expired."
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
session.canvasPending.delete(requestId);
|
|
1719
|
+
clearTimeout(pending.timer);
|
|
1720
|
+
if (body.ok === true) {
|
|
1721
|
+
pending.resolve(body.data ?? { ok: true });
|
|
1722
|
+
}
|
|
1723
|
+
else {
|
|
1724
|
+
pending.reject(new Error(typeof body.error === "string"
|
|
1725
|
+
? body.error
|
|
1726
|
+
: "Canvas operation failed."));
|
|
1727
|
+
}
|
|
1728
|
+
json(response, 200, { accepted: true });
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
const sessionMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
|
|
1732
|
+
if (request.method === "DELETE" && sessionMatch) {
|
|
1733
|
+
const session = publicSession(request, response, decodeURIComponent(sessionMatch[1]));
|
|
1734
|
+
if (!session)
|
|
1735
|
+
return;
|
|
1736
|
+
closeSession(session);
|
|
1737
|
+
response.writeHead(204);
|
|
1738
|
+
response.end();
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
json(response, 404, {
|
|
1742
|
+
error: { code: "not_found", message: "Route not found." }
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
catch (error) {
|
|
1746
|
+
const message = error instanceof Error ? error.message : "Unexpected bridge error.";
|
|
1747
|
+
const status = error instanceof BridgeRequestError
|
|
1748
|
+
? error.status
|
|
1749
|
+
: message.includes("cancelled")
|
|
1750
|
+
? 409
|
|
1751
|
+
: message.includes("MiB")
|
|
1752
|
+
? 413
|
|
1753
|
+
: 500;
|
|
1754
|
+
json(response, status, {
|
|
1755
|
+
error: {
|
|
1756
|
+
code: error instanceof BridgeRequestError ? error.code : "bridge_error",
|
|
1757
|
+
message
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
});
|
|
1762
|
+
server.on("upgrade", (_request, socket) => socket.destroy());
|
|
1763
|
+
const cleanup = setInterval(() => {
|
|
1764
|
+
const now = Date.now();
|
|
1765
|
+
for (const [id, selection] of selections) {
|
|
1766
|
+
if (selection.expiresAt <= now)
|
|
1767
|
+
selections.delete(id);
|
|
1768
|
+
}
|
|
1769
|
+
for (const session of sessions.values()) {
|
|
1770
|
+
if (now - session.touchedAt > 30 * 60 * 1000)
|
|
1771
|
+
closeSession(session);
|
|
1772
|
+
}
|
|
1773
|
+
}, 60_000);
|
|
1774
|
+
cleanup.unref();
|
|
1775
|
+
return {
|
|
1776
|
+
server,
|
|
1777
|
+
listen: () => new Promise((resolve, reject) => {
|
|
1778
|
+
server.once("error", reject);
|
|
1779
|
+
server.listen(port, host, () => {
|
|
1780
|
+
server.off("error", reject);
|
|
1781
|
+
resolve();
|
|
1782
|
+
});
|
|
1783
|
+
}),
|
|
1784
|
+
close: () => new Promise((resolve, reject) => {
|
|
1785
|
+
clearInterval(cleanup);
|
|
1786
|
+
for (const session of [...sessions.values()])
|
|
1787
|
+
closeSession(session);
|
|
1788
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
1789
|
+
}),
|
|
1790
|
+
address: bridgeUrl,
|
|
1791
|
+
connectorRouting: connectorBackendResolver.summary,
|
|
1792
|
+
version
|
|
1793
|
+
};
|
|
1794
|
+
};
|