@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3
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 +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +636 -613
- package/apps/agent-terminal/app.js +125 -65
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +171 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +187 -159
- package/apps/agent-terminal/right-workspace-dock.js +623 -519
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +13 -13
- package/docs/sdk/openapi.yaml +8 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1442 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2392 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- package/src/sdk-wave-e.ts +1 -1
|
@@ -1,339 +1,339 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { DEFAULT_API_PREFIX, cleanId } = require('../../contracts');
|
|
4
|
-
const { pickFolder: defaultPickFolder } = require('./folder-picker');
|
|
5
|
-
const crypto = require('crypto');
|
|
6
|
-
|
|
7
|
-
function createHttpHandler(service, options = {}) {
|
|
8
|
-
const prefix = String(options.apiPrefix || DEFAULT_API_PREFIX).replace(/\/$/, '');
|
|
9
|
-
// Stateless host capability — the picker module is required directly.
|
|
10
|
-
const pickFolder = options.pickFolder || defaultPickFolder;
|
|
11
|
-
return async function handle(req, res) {
|
|
12
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
13
|
-
if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return false;
|
|
14
|
-
const path = url.pathname.slice(prefix.length) || '/';
|
|
15
|
-
if (path.startsWith('/internal/')) {
|
|
16
|
-
const supplied = String(req.headers.authorization || '').replace(/^Bearer /, '');
|
|
17
|
-
const token = options.runtimeToken;
|
|
18
|
-
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket && req.socket.remoteAddress);
|
|
19
|
-
if (!local || req.headers.origin || !token || Buffer.byteLength(supplied) !== Buffer.byteLength(token) || !crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) return json(res, 403, { ok: false, error: 'trusted local service required' });
|
|
20
|
-
if (req.method === 'POST' && path === '/internal/maintenance-config') {
|
|
21
|
-
try { return json(res, 200, service.profiles.maintenanceRuntime()); }
|
|
22
|
-
catch { return json(res, 409, { ok: false, error: 'maintenance default profile is not configured' }); }
|
|
23
|
-
}
|
|
24
|
-
if (req.method === 'POST' && path === '/internal/import-profiles') {
|
|
25
|
-
try { return json(res, 200, { mapping: service.profiles.importLegacy(await body(req)) }); }
|
|
26
|
-
catch (error) { return json(res, error.status || 409, { ok: false, error: 'legacy profile migration did not complete; original configuration is preserved' }); }
|
|
27
|
-
}
|
|
28
|
-
if (req.method === 'POST' && path === '/internal/embedding-config') {
|
|
29
|
-
try { return json(res, 200, service.profiles.embeddingRuntime()); }
|
|
30
|
-
catch { return json(res, 409, { ok: false, error: '向量模型未配置。' }); }
|
|
31
|
-
}
|
|
32
|
-
if (req.method === 'POST' && path === '/internal/import-embedding') {
|
|
33
|
-
try { return json(res, 200, service.profiles.importEmbedding(await body(req))); }
|
|
34
|
-
catch { return json(res, 409, { ok: false, error: '向量配置迁移待恢复,原配置保留。' }); }
|
|
35
|
-
}
|
|
36
|
-
return json(res, 404, { ok: false, error: 'route not found' });
|
|
37
|
-
}
|
|
38
|
-
// System endpoints trigger host-native side effects (a modal folder
|
|
39
|
-
// dialog) and answer with a local filesystem path. The API-wide CORS `*`
|
|
40
|
-
// would let any website stack dialogs and read the choice, so they are
|
|
41
|
-
// restricted to loopback origins — same-origin GETs and non-browser
|
|
42
|
-
// clients send no Origin header at all and always pass.
|
|
43
|
-
if (path.startsWith('/system/')) {
|
|
44
|
-
const origin = req.headers.origin;
|
|
45
|
-
if (origin && !isLoopbackOrigin(origin) && origin !== options.corsOrigin) {
|
|
46
|
-
return json(res, 403, { ok: false, error: 'cross-origin access to system endpoints is not allowed' });
|
|
47
|
-
}
|
|
48
|
-
if (origin) {
|
|
49
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
50
|
-
res.setHeader('Vary', 'Origin');
|
|
51
|
-
}
|
|
52
|
-
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
|
|
53
|
-
} else {
|
|
54
|
-
setCors(req, res, options.corsOrigin);
|
|
55
|
-
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
|
|
56
|
-
}
|
|
57
|
-
try {
|
|
58
|
-
if (req.method === 'GET' && path === '/health') return json(res, 200, service.health());
|
|
59
|
-
// {path} on success, {path:null} when the user cancelled, 501 when the
|
|
60
|
-
// host has no folder picker, 500 on real failures. `win=x,y,w,h` (the
|
|
61
|
-
// calling browser window's screen rect) anchors the dialog so it opens
|
|
62
|
-
// over the UI instead of the screen's top-left corner.
|
|
63
|
-
if (req.method === 'GET' && path === '/system/pick-folder') {
|
|
64
|
-
const parts = (url.searchParams.get('win') || '').split(',').map(Number);
|
|
65
|
-
const windowRect = parts.length === 4 && parts.every(Number.isFinite)
|
|
66
|
-
? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
|
|
67
|
-
: undefined;
|
|
68
|
-
return json(res, 200, await pickFolder({ windowRect, title: '选择项目文件夹' }));
|
|
69
|
-
}
|
|
70
|
-
// Environment diagnostics: resolved executables, live version probes,
|
|
71
|
-
// login-state files (no secrets). Answers "discovery showed the agent
|
|
72
|
-
// but turns failed" on fresh machines.
|
|
73
|
-
if (req.method === 'GET' && path === '/system/doctor') {
|
|
74
|
-
return json(res, 200, (options.doctor && await options.doctor()) || {
|
|
75
|
-
schema: 'agent-terminal-doctor/v1',
|
|
76
|
-
error: 'doctor not wired',
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
if (req.method === 'GET' && path === '/runtime') return json(res, 200, await service.runtime());
|
|
80
|
-
if (req.method === 'GET' && path === '/agents') return json(res, 200, service.listAgents());
|
|
81
|
-
if (req.method === 'POST' && path === '/agents/refresh') { await body(req); return json(res, 202, service.refreshAgents()); }
|
|
82
|
-
if (req.method === 'GET' && path === '/profiles') return json(res, 200, service.listProfiles());
|
|
83
|
-
if (req.method === 'GET' && path === '/maintenance-default') return json(res, 200, service.profiles.maintenanceDefault());
|
|
84
|
-
if (req.method === 'GET' && path === '/embedding-config') return json(res, 200, service.profiles.embeddingConfig());
|
|
85
|
-
if (req.method === 'PUT' && path === '/embedding-config') return json(res, 200, service.profiles.setEmbeddingConfig(await body(req)));
|
|
86
|
-
if (req.method === 'PUT' && path === '/maintenance-default') return json(res, 200, service.profiles.setMaintenanceDefault((await body(req)).profileId));
|
|
87
|
-
if (req.method === 'POST' && path === '/profiles') return json(res, 201, service.saveProfile(await body(req)));
|
|
88
|
-
if (req.method === 'POST' && path === '/profiles/test') return json(res, 200, await service.testProfileDraft(await body(req)));
|
|
89
|
-
if (req.method === 'POST' && path === '/profiles/detect-models') return json(res, 200, await service.detectProfileModels(await body(req)));
|
|
90
|
-
if (req.method === 'GET' && path === '/capture-settings') return json(res, 200, service.getCaptureSettings());
|
|
91
|
-
if (req.method === 'PUT' && path === '/capture-settings') return json(res, 200, service.saveCaptureSettings(await body(req)));
|
|
92
|
-
if (req.method === 'GET' && path === '/ui-preferences') return json(res, 200, service.getUiPreferences());
|
|
93
|
-
if (req.method === 'PUT' && path === '/ui-preferences') return json(res, 200, service.saveUiPreferences(await body(req)));
|
|
94
|
-
|
|
95
|
-
if (path === '/projects' && req.method === 'GET') return json(res, 200, service.listProjects());
|
|
96
|
-
if (path === '/projects' && req.method === 'POST') return json(res, 201, service.createProject(await body(req)));
|
|
97
|
-
const projectMatch = path.match(/^\/projects\/([^/]+)$/);
|
|
98
|
-
if (projectMatch) {
|
|
99
|
-
const id = decode(projectMatch[1]);
|
|
100
|
-
if (req.method === 'GET') return json(res, 200, service.getProject(id));
|
|
101
|
-
if (req.method === 'PATCH') return json(res, 200, service.renameProject(id, await body(req)));
|
|
102
|
-
if (req.method === 'DELETE') {
|
|
103
|
-
service.removeProject(id, { keepSessions: url.searchParams.get('keepSessions') === '1' });
|
|
104
|
-
res.writeHead(204); res.end(); return true;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
let match = path.match(/^\/profiles\/([^/]+)$/);
|
|
109
|
-
if (match) {
|
|
110
|
-
const id = decode(match[1]);
|
|
111
|
-
if (req.method === 'GET') {
|
|
112
|
-
const profile = service.getProfile(id);
|
|
113
|
-
if (!profile) throw Object.assign(new Error('profile not found'), { status: 404 });
|
|
114
|
-
return json(res, 200, profile);
|
|
115
|
-
}
|
|
116
|
-
if (req.method === 'PUT') return json(res, 200, service.saveProfile(await body(req), id));
|
|
117
|
-
if (req.method === 'DELETE') {
|
|
118
|
-
if (!service.deleteProfile(id)) throw Object.assign(new Error('profile not found'), { status: 404 });
|
|
119
|
-
res.writeHead(204); res.end(); return true;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
match = path.match(/^\/profiles\/([^/]+)\/credential$/);
|
|
124
|
-
if (match && req.method === 'PUT') {
|
|
125
|
-
service.setCredential(decode(match[1]), await body(req));
|
|
126
|
-
res.writeHead(204); res.end(); return true;
|
|
127
|
-
}
|
|
128
|
-
match = path.match(/^\/profiles\/([^/]+)\/test$/);
|
|
129
|
-
if (match && req.method === 'POST') return json(res, 200, await service.testProfile(decode(match[1])));
|
|
130
|
-
|
|
131
|
-
if (path === '/sessions' && req.method === 'GET') {
|
|
132
|
-
const sessions = service.listSessions(url.searchParams.get('contextId'), {
|
|
133
|
-
includeArchived: ['1', 'true'].includes(url.searchParams.get('archived')),
|
|
134
|
-
});
|
|
135
|
-
return json(res, 200, { sessions });
|
|
136
|
-
}
|
|
137
|
-
if (path === '/sessions' && req.method === 'POST') {
|
|
138
|
-
const input = await body(req);
|
|
139
|
-
const session = service.startSession(input.context, input.aiProfileId, input);
|
|
140
|
-
return json(res, 201, { session });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
match = path.match(/^\/sessions\/([^/]+)\/archive$/);
|
|
144
|
-
if (match && req.method === 'PUT') return json(res, 200, service.archiveSession(decode(match[1])));
|
|
145
|
-
if (match && req.method === 'DELETE') {
|
|
146
|
-
service.restoreSession(decode(match[1]));
|
|
147
|
-
res.writeHead(204); res.end(); return true;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
match = path.match(/^\/sessions\/([^/]+)\/context-usage$/);
|
|
151
|
-
if (match && req.method === 'GET') return json(res, 200, service.contextUsage(decode(match[1])));
|
|
152
|
-
match = path.match(/^\/sessions\/([^/]+)\/knowledge$/);
|
|
153
|
-
if (match && req.method === 'GET') return json(res, 200, service.knowledgeContext(decode(match[1])));
|
|
154
|
-
match = path.match(/^\/sessions\/([^/]+)\/history$/);
|
|
155
|
-
if (match && req.method === 'GET') return json(res, 200, service.loadHistory(decode(match[1]), {
|
|
156
|
-
before: url.searchParams.get('before'), limit: url.searchParams.get('limit') || 20,
|
|
157
|
-
}));
|
|
158
|
-
// Native background-task history is a pure read over the session's own
|
|
159
|
-
// durable journal: the scope is compared against the trusted service
|
|
160
|
-
// identity (never client paths), and 409 answers carry resync so the
|
|
161
|
-
// client drops its cursor and restarts from the task list.
|
|
162
|
-
match = path.match(/^\/sessions\/([^/]+)\/native-tasks$/);
|
|
163
|
-
if (match && req.method === 'GET') {
|
|
164
|
-
return json(res, 200, await service.listNativeTasks(decode(match[1]), {
|
|
165
|
-
storeScope: url.searchParams.get('storeScope'),
|
|
166
|
-
contextId: url.searchParams.get('contextId'),
|
|
167
|
-
}));
|
|
168
|
-
}
|
|
169
|
-
match = path.match(/^\/sessions\/([^/]+)\/native-tasks\/history$/);
|
|
170
|
-
if (match && req.method === 'POST') {
|
|
171
|
-
return json(res, 200, await service.loadNativeTaskHistory(decode(match[1]), await body(req)));
|
|
172
|
-
}
|
|
173
|
-
// Delegated sessions: POST issues one trusted Workbench delegation
|
|
174
|
-
// command (idempotent by requestId; accepted ≠ model receipt), GET is a
|
|
175
|
-
// pure read of the durable relations/commands and real child status.
|
|
176
|
-
match = path.match(/^\/sessions\/([^/]+)\/delegations$/);
|
|
177
|
-
if (match) {
|
|
178
|
-
const sessionId = decode(match[1]);
|
|
179
|
-
if (req.method === 'POST') {
|
|
180
|
-
return json(res, 202, await service.createDelegation(sessionId, await body(req)));
|
|
181
|
-
}
|
|
182
|
-
if (req.method === 'GET') {
|
|
183
|
-
return json(res, 200, service.listDelegations(sessionId, {
|
|
184
|
-
storeScope: url.searchParams.get('storeScope'),
|
|
185
|
-
contextId: url.searchParams.get('contextId'),
|
|
186
|
-
}));
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (path === '/sessions/changes' && req.method === 'GET') {
|
|
191
|
-
const contextId = url.searchParams.get('contextId') === '*' ? '*' : cleanId(url.searchParams.get('contextId'), 'contextId');
|
|
192
|
-
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
193
|
-
res.write(': workbench session summaries\n\n');
|
|
194
|
-
const unsubscribe = service.subscribeSessionChanges(contextId, sequenceFromRequest(req, url), event => writeSse(res, { ...event, sequence: event.revision }));
|
|
195
|
-
const heartbeat = setInterval(() => { if (!res.destroyed) res.write(': keepalive\n\n'); }, 15000);
|
|
196
|
-
heartbeat.unref?.();
|
|
197
|
-
const close = () => { clearInterval(heartbeat); unsubscribe(); };
|
|
198
|
-
req.on('close', close); res.on('close', close);
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
match = path.match(/^\/sessions\/([^/]+)$/);
|
|
203
|
-
if (match && req.method === 'GET') return json(res, 200, service.loadSession(decode(match[1])));
|
|
204
|
-
|
|
205
|
-
match = path.match(/^\/sessions\/([^/]+)\/events$/);
|
|
206
|
-
if (match && req.method === 'GET') {
|
|
207
|
-
const sessionId = decode(match[1]);
|
|
208
|
-
const after = sequenceFromRequest(req, url);
|
|
209
|
-
service.loadSession(sessionId);
|
|
210
|
-
res.writeHead(200, {
|
|
211
|
-
'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform',
|
|
212
|
-
Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
|
|
213
|
-
'Access-Control-Allow-Origin': options.corsOrigin || '*',
|
|
214
|
-
});
|
|
215
|
-
res.write(': claude-workbench stream\n\n');
|
|
216
|
-
const unsubscribe = service.subscribe(sessionId, after, event => writeSse(res, event));
|
|
217
|
-
const heartbeat = setInterval(() => { try { res.write(`: keepalive ${Date.now()}\n\n`); } catch {} }, 15000);
|
|
218
|
-
heartbeat.unref && heartbeat.unref();
|
|
219
|
-
let closed = false;
|
|
220
|
-
const close = () => { if (closed) return; closed = true; clearInterval(heartbeat); try { unsubscribe(); } catch {} };
|
|
221
|
-
req.on('close', close); req.on('error', close); res.on('close', close);
|
|
222
|
-
return true;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
match = path.match(/^\/sessions\/([^/]+)\/input$/);
|
|
226
|
-
if (match && req.method === 'POST') {
|
|
227
|
-
const input = await body(req, 32 * 1024 * 1024);
|
|
228
|
-
return json(res, 202, await service.send(decode(match[1]), input.text, input.options, input.attachments));
|
|
229
|
-
}
|
|
230
|
-
match = path.match(/^\/sessions\/([^/]+)\/commands$/);
|
|
231
|
-
if (match && req.method === 'GET') return json(res, 200, await service.listCommands(decode(match[1])));
|
|
232
|
-
|
|
233
|
-
match = path.match(/^\/sessions\/([^/]+)\/attachments\/([^/]+)$/);
|
|
234
|
-
if (match && req.method === 'GET') {
|
|
235
|
-
const attachment = service.readAttachment(decode(match[1]), decode(match[2]));
|
|
236
|
-
const headers = {
|
|
237
|
-
'Content-Type': attachment.metadata.mediaType,
|
|
238
|
-
'Content-Length': attachment.data.length,
|
|
239
|
-
'Cache-Control': 'private, max-age=3600',
|
|
240
|
-
'X-Content-Type-Options': 'nosniff',
|
|
241
|
-
};
|
|
242
|
-
// Images stay inline for the chat UI; anything textual is forced to
|
|
243
|
-
// download so a crafted .html/.svg attachment can never render in the
|
|
244
|
-
// page origin.
|
|
245
|
-
if (!String(attachment.metadata.mediaType || '').startsWith('image/')) {
|
|
246
|
-
const safeName = String(attachment.metadata.name || 'attachment').replace(/[^\w.\-]+/g, '_').slice(0, 100) || 'attachment';
|
|
247
|
-
headers['Content-Disposition'] = `attachment; filename="${safeName}"`;
|
|
248
|
-
}
|
|
249
|
-
res.writeHead(200, headers);
|
|
250
|
-
res.end(attachment.data);
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
match = path.match(/^\/sessions\/([^/]+)\/permissions\/([^/]+)\/resolve$/);
|
|
254
|
-
if (match && req.method === 'POST') {
|
|
255
|
-
return json(res, 200, service.resolvePermission(decode(match[1]), decode(match[2]), await body(req)));
|
|
256
|
-
}
|
|
257
|
-
match = path.match(/^\/sessions\/([^/]+)\/abort$/);
|
|
258
|
-
if (match && req.method === 'POST') return json(res, 202, service.abort(decode(match[1])));
|
|
259
|
-
|
|
260
|
-
match = path.match(/^\/sessions\/([^/]+)\/permission-mode$/);
|
|
261
|
-
if (match && req.method === 'PUT') {
|
|
262
|
-
const input = await body(req);
|
|
263
|
-
return json(res, 200, service.setPermissionMode(decode(match[1]), input.mode));
|
|
264
|
-
}
|
|
265
|
-
match = path.match(/^\/sessions\/([^/]+)\/selection$/);
|
|
266
|
-
if (match && req.method === 'PUT') {
|
|
267
|
-
const input = await body(req);
|
|
268
|
-
return json(res, 200, service.updateSessionSelection(decode(match[1]), input.aiProfileId, input.model));
|
|
269
|
-
}
|
|
270
|
-
throw Object.assign(new Error('route not found'), { status: 404 });
|
|
271
|
-
} catch (error) {
|
|
272
|
-
if (res.headersSent) {
|
|
273
|
-
if (!res.writableEnded) try { res.end(); } catch {}
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
const status = Number(error.status) || (error.message === 'request body too large' ? 413 : 400);
|
|
277
|
-
return json(res, status, {
|
|
278
|
-
ok: false, error: safeError(error),
|
|
279
|
-
...(error.code ? { code: error.code } : {}),
|
|
280
|
-
...(error.resync ? { resync: true } : {}),
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function body(req, limit = 1024 * 1024) {
|
|
287
|
-
return new Promise((resolve, reject) => {
|
|
288
|
-
const chunks = []; let size = 0; let settled = false;
|
|
289
|
-
req.on('data', chunk => {
|
|
290
|
-
if (settled) return;
|
|
291
|
-
size += chunk.length;
|
|
292
|
-
if (size > limit) { settled = true; reject(new Error('request body too large')); return; }
|
|
293
|
-
chunks.push(chunk);
|
|
294
|
-
});
|
|
295
|
-
req.on('end', () => {
|
|
296
|
-
if (settled) return;
|
|
297
|
-
try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
|
|
298
|
-
catch { reject(Object.assign(new Error('invalid JSON body'), { status: 400 })); }
|
|
299
|
-
});
|
|
300
|
-
req.on('error', reject);
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function json(res, status, value) {
|
|
305
|
-
const payload = status === 204 ? undefined : JSON.stringify(value);
|
|
306
|
-
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
307
|
-
res.end(payload);
|
|
308
|
-
return true;
|
|
309
|
-
}
|
|
310
|
-
function writeSse(res, event) {
|
|
311
|
-
// Control events (workbench/resync) own no sequence; an `id:` line would
|
|
312
|
-
// otherwise poison the browser's Last-Event-ID reconnect cursor.
|
|
313
|
-
const sequence = Number(event.sequence);
|
|
314
|
-
if (Number.isFinite(sequence) && sequence > 0) res.write(`id: ${sequence}\n`);
|
|
315
|
-
res.write(`event: ${event.type}\n`);
|
|
316
|
-
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
317
|
-
}
|
|
318
|
-
function sequenceFromRequest(req, url) {
|
|
319
|
-
const value = req.headers['last-event-id'] || url.searchParams.get('after') || 0;
|
|
320
|
-
const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
|
321
|
-
}
|
|
322
|
-
function setCors(req, res, origin) {
|
|
323
|
-
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
324
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Last-Event-ID');
|
|
325
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
326
|
-
}
|
|
327
|
-
// `Origin` values a system endpoint may serve: the local machine itself
|
|
328
|
-
// (the app is same-origin already; a dev server on another loopback port is
|
|
329
|
-
// fine). `Origin: null` (sandboxed/data frames) and foreign hosts are not.
|
|
330
|
-
function isLoopbackOrigin(origin) {
|
|
331
|
-
let parsed;
|
|
332
|
-
try { parsed = new URL(origin); } catch { return false; }
|
|
333
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
|
334
|
-
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(parsed.hostname);
|
|
335
|
-
}
|
|
336
|
-
function decode(value) { try { return cleanId(decodeURIComponent(value)); } catch (error) { error.status = 400; throw error; } }
|
|
337
|
-
function safeError(error) { return String(error && error.message || 'request failed').replace(/[\r\n]+/g, ' ').slice(0, 500); }
|
|
338
|
-
|
|
339
|
-
module.exports = { createHttpHandler, body, writeSse, sequenceFromRequest };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { DEFAULT_API_PREFIX, cleanId } = require('../../contracts');
|
|
4
|
+
const { pickFolder: defaultPickFolder } = require('./folder-picker');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
function createHttpHandler(service, options = {}) {
|
|
8
|
+
const prefix = String(options.apiPrefix || DEFAULT_API_PREFIX).replace(/\/$/, '');
|
|
9
|
+
// Stateless host capability — the picker module is required directly.
|
|
10
|
+
const pickFolder = options.pickFolder || defaultPickFolder;
|
|
11
|
+
return async function handle(req, res) {
|
|
12
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
13
|
+
if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return false;
|
|
14
|
+
const path = url.pathname.slice(prefix.length) || '/';
|
|
15
|
+
if (path.startsWith('/internal/')) {
|
|
16
|
+
const supplied = String(req.headers.authorization || '').replace(/^Bearer /, '');
|
|
17
|
+
const token = options.runtimeToken;
|
|
18
|
+
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket && req.socket.remoteAddress);
|
|
19
|
+
if (!local || req.headers.origin || !token || Buffer.byteLength(supplied) !== Buffer.byteLength(token) || !crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) return json(res, 403, { ok: false, error: 'trusted local service required' });
|
|
20
|
+
if (req.method === 'POST' && path === '/internal/maintenance-config') {
|
|
21
|
+
try { return json(res, 200, service.profiles.maintenanceRuntime()); }
|
|
22
|
+
catch { return json(res, 409, { ok: false, error: 'maintenance default profile is not configured' }); }
|
|
23
|
+
}
|
|
24
|
+
if (req.method === 'POST' && path === '/internal/import-profiles') {
|
|
25
|
+
try { return json(res, 200, { mapping: service.profiles.importLegacy(await body(req)) }); }
|
|
26
|
+
catch (error) { return json(res, error.status || 409, { ok: false, error: 'legacy profile migration did not complete; original configuration is preserved' }); }
|
|
27
|
+
}
|
|
28
|
+
if (req.method === 'POST' && path === '/internal/embedding-config') {
|
|
29
|
+
try { return json(res, 200, service.profiles.embeddingRuntime()); }
|
|
30
|
+
catch { return json(res, 409, { ok: false, error: '向量模型未配置。' }); }
|
|
31
|
+
}
|
|
32
|
+
if (req.method === 'POST' && path === '/internal/import-embedding') {
|
|
33
|
+
try { return json(res, 200, service.profiles.importEmbedding(await body(req))); }
|
|
34
|
+
catch { return json(res, 409, { ok: false, error: '向量配置迁移待恢复,原配置保留。' }); }
|
|
35
|
+
}
|
|
36
|
+
return json(res, 404, { ok: false, error: 'route not found' });
|
|
37
|
+
}
|
|
38
|
+
// System endpoints trigger host-native side effects (a modal folder
|
|
39
|
+
// dialog) and answer with a local filesystem path. The API-wide CORS `*`
|
|
40
|
+
// would let any website stack dialogs and read the choice, so they are
|
|
41
|
+
// restricted to loopback origins — same-origin GETs and non-browser
|
|
42
|
+
// clients send no Origin header at all and always pass.
|
|
43
|
+
if (path.startsWith('/system/')) {
|
|
44
|
+
const origin = req.headers.origin;
|
|
45
|
+
if (origin && !isLoopbackOrigin(origin) && origin !== options.corsOrigin) {
|
|
46
|
+
return json(res, 403, { ok: false, error: 'cross-origin access to system endpoints is not allowed' });
|
|
47
|
+
}
|
|
48
|
+
if (origin) {
|
|
49
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
50
|
+
res.setHeader('Vary', 'Origin');
|
|
51
|
+
}
|
|
52
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
|
|
53
|
+
} else {
|
|
54
|
+
setCors(req, res, options.corsOrigin);
|
|
55
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
if (req.method === 'GET' && path === '/health') return json(res, 200, service.health());
|
|
59
|
+
// {path} on success, {path:null} when the user cancelled, 501 when the
|
|
60
|
+
// host has no folder picker, 500 on real failures. `win=x,y,w,h` (the
|
|
61
|
+
// calling browser window's screen rect) anchors the dialog so it opens
|
|
62
|
+
// over the UI instead of the screen's top-left corner.
|
|
63
|
+
if (req.method === 'GET' && path === '/system/pick-folder') {
|
|
64
|
+
const parts = (url.searchParams.get('win') || '').split(',').map(Number);
|
|
65
|
+
const windowRect = parts.length === 4 && parts.every(Number.isFinite)
|
|
66
|
+
? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
|
|
67
|
+
: undefined;
|
|
68
|
+
return json(res, 200, await pickFolder({ windowRect, title: '选择项目文件夹' }));
|
|
69
|
+
}
|
|
70
|
+
// Environment diagnostics: resolved executables, live version probes,
|
|
71
|
+
// login-state files (no secrets). Answers "discovery showed the agent
|
|
72
|
+
// but turns failed" on fresh machines.
|
|
73
|
+
if (req.method === 'GET' && path === '/system/doctor') {
|
|
74
|
+
return json(res, 200, (options.doctor && await options.doctor()) || {
|
|
75
|
+
schema: 'agent-terminal-doctor/v1',
|
|
76
|
+
error: 'doctor not wired',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (req.method === 'GET' && path === '/runtime') return json(res, 200, await service.runtime());
|
|
80
|
+
if (req.method === 'GET' && path === '/agents') return json(res, 200, service.listAgents());
|
|
81
|
+
if (req.method === 'POST' && path === '/agents/refresh') { await body(req); return json(res, 202, service.refreshAgents()); }
|
|
82
|
+
if (req.method === 'GET' && path === '/profiles') return json(res, 200, service.listProfiles());
|
|
83
|
+
if (req.method === 'GET' && path === '/maintenance-default') return json(res, 200, service.profiles.maintenanceDefault());
|
|
84
|
+
if (req.method === 'GET' && path === '/embedding-config') return json(res, 200, service.profiles.embeddingConfig());
|
|
85
|
+
if (req.method === 'PUT' && path === '/embedding-config') return json(res, 200, service.profiles.setEmbeddingConfig(await body(req)));
|
|
86
|
+
if (req.method === 'PUT' && path === '/maintenance-default') return json(res, 200, service.profiles.setMaintenanceDefault((await body(req)).profileId));
|
|
87
|
+
if (req.method === 'POST' && path === '/profiles') return json(res, 201, service.saveProfile(await body(req)));
|
|
88
|
+
if (req.method === 'POST' && path === '/profiles/test') return json(res, 200, await service.testProfileDraft(await body(req)));
|
|
89
|
+
if (req.method === 'POST' && path === '/profiles/detect-models') return json(res, 200, await service.detectProfileModels(await body(req)));
|
|
90
|
+
if (req.method === 'GET' && path === '/capture-settings') return json(res, 200, service.getCaptureSettings());
|
|
91
|
+
if (req.method === 'PUT' && path === '/capture-settings') return json(res, 200, service.saveCaptureSettings(await body(req)));
|
|
92
|
+
if (req.method === 'GET' && path === '/ui-preferences') return json(res, 200, service.getUiPreferences());
|
|
93
|
+
if (req.method === 'PUT' && path === '/ui-preferences') return json(res, 200, service.saveUiPreferences(await body(req)));
|
|
94
|
+
|
|
95
|
+
if (path === '/projects' && req.method === 'GET') return json(res, 200, service.listProjects());
|
|
96
|
+
if (path === '/projects' && req.method === 'POST') return json(res, 201, service.createProject(await body(req)));
|
|
97
|
+
const projectMatch = path.match(/^\/projects\/([^/]+)$/);
|
|
98
|
+
if (projectMatch) {
|
|
99
|
+
const id = decode(projectMatch[1]);
|
|
100
|
+
if (req.method === 'GET') return json(res, 200, service.getProject(id));
|
|
101
|
+
if (req.method === 'PATCH') return json(res, 200, service.renameProject(id, await body(req)));
|
|
102
|
+
if (req.method === 'DELETE') {
|
|
103
|
+
service.removeProject(id, { keepSessions: url.searchParams.get('keepSessions') === '1' });
|
|
104
|
+
res.writeHead(204); res.end(); return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let match = path.match(/^\/profiles\/([^/]+)$/);
|
|
109
|
+
if (match) {
|
|
110
|
+
const id = decode(match[1]);
|
|
111
|
+
if (req.method === 'GET') {
|
|
112
|
+
const profile = service.getProfile(id);
|
|
113
|
+
if (!profile) throw Object.assign(new Error('profile not found'), { status: 404 });
|
|
114
|
+
return json(res, 200, profile);
|
|
115
|
+
}
|
|
116
|
+
if (req.method === 'PUT') return json(res, 200, service.saveProfile(await body(req), id));
|
|
117
|
+
if (req.method === 'DELETE') {
|
|
118
|
+
if (!service.deleteProfile(id)) throw Object.assign(new Error('profile not found'), { status: 404 });
|
|
119
|
+
res.writeHead(204); res.end(); return true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
match = path.match(/^\/profiles\/([^/]+)\/credential$/);
|
|
124
|
+
if (match && req.method === 'PUT') {
|
|
125
|
+
service.setCredential(decode(match[1]), await body(req));
|
|
126
|
+
res.writeHead(204); res.end(); return true;
|
|
127
|
+
}
|
|
128
|
+
match = path.match(/^\/profiles\/([^/]+)\/test$/);
|
|
129
|
+
if (match && req.method === 'POST') return json(res, 200, await service.testProfile(decode(match[1])));
|
|
130
|
+
|
|
131
|
+
if (path === '/sessions' && req.method === 'GET') {
|
|
132
|
+
const sessions = service.listSessions(url.searchParams.get('contextId'), {
|
|
133
|
+
includeArchived: ['1', 'true'].includes(url.searchParams.get('archived')),
|
|
134
|
+
});
|
|
135
|
+
return json(res, 200, { sessions });
|
|
136
|
+
}
|
|
137
|
+
if (path === '/sessions' && req.method === 'POST') {
|
|
138
|
+
const input = await body(req);
|
|
139
|
+
const session = service.startSession(input.context, input.aiProfileId, input);
|
|
140
|
+
return json(res, 201, { session });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
match = path.match(/^\/sessions\/([^/]+)\/archive$/);
|
|
144
|
+
if (match && req.method === 'PUT') return json(res, 200, service.archiveSession(decode(match[1])));
|
|
145
|
+
if (match && req.method === 'DELETE') {
|
|
146
|
+
service.restoreSession(decode(match[1]));
|
|
147
|
+
res.writeHead(204); res.end(); return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
match = path.match(/^\/sessions\/([^/]+)\/context-usage$/);
|
|
151
|
+
if (match && req.method === 'GET') return json(res, 200, service.contextUsage(decode(match[1])));
|
|
152
|
+
match = path.match(/^\/sessions\/([^/]+)\/knowledge$/);
|
|
153
|
+
if (match && req.method === 'GET') return json(res, 200, service.knowledgeContext(decode(match[1])));
|
|
154
|
+
match = path.match(/^\/sessions\/([^/]+)\/history$/);
|
|
155
|
+
if (match && req.method === 'GET') return json(res, 200, service.loadHistory(decode(match[1]), {
|
|
156
|
+
before: url.searchParams.get('before'), limit: url.searchParams.get('limit') || 20,
|
|
157
|
+
}));
|
|
158
|
+
// Native background-task history is a pure read over the session's own
|
|
159
|
+
// durable journal: the scope is compared against the trusted service
|
|
160
|
+
// identity (never client paths), and 409 answers carry resync so the
|
|
161
|
+
// client drops its cursor and restarts from the task list.
|
|
162
|
+
match = path.match(/^\/sessions\/([^/]+)\/native-tasks$/);
|
|
163
|
+
if (match && req.method === 'GET') {
|
|
164
|
+
return json(res, 200, await service.listNativeTasks(decode(match[1]), {
|
|
165
|
+
storeScope: url.searchParams.get('storeScope'),
|
|
166
|
+
contextId: url.searchParams.get('contextId'),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
match = path.match(/^\/sessions\/([^/]+)\/native-tasks\/history$/);
|
|
170
|
+
if (match && req.method === 'POST') {
|
|
171
|
+
return json(res, 200, await service.loadNativeTaskHistory(decode(match[1]), await body(req)));
|
|
172
|
+
}
|
|
173
|
+
// Delegated sessions: POST issues one trusted Workbench delegation
|
|
174
|
+
// command (idempotent by requestId; accepted ≠ model receipt), GET is a
|
|
175
|
+
// pure read of the durable relations/commands and real child status.
|
|
176
|
+
match = path.match(/^\/sessions\/([^/]+)\/delegations$/);
|
|
177
|
+
if (match) {
|
|
178
|
+
const sessionId = decode(match[1]);
|
|
179
|
+
if (req.method === 'POST') {
|
|
180
|
+
return json(res, 202, await service.createDelegation(sessionId, await body(req)));
|
|
181
|
+
}
|
|
182
|
+
if (req.method === 'GET') {
|
|
183
|
+
return json(res, 200, service.listDelegations(sessionId, {
|
|
184
|
+
storeScope: url.searchParams.get('storeScope'),
|
|
185
|
+
contextId: url.searchParams.get('contextId'),
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (path === '/sessions/changes' && req.method === 'GET') {
|
|
191
|
+
const contextId = url.searchParams.get('contextId') === '*' ? '*' : cleanId(url.searchParams.get('contextId'), 'contextId');
|
|
192
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
193
|
+
res.write(': workbench session summaries\n\n');
|
|
194
|
+
const unsubscribe = service.subscribeSessionChanges(contextId, sequenceFromRequest(req, url), event => writeSse(res, { ...event, sequence: event.revision }));
|
|
195
|
+
const heartbeat = setInterval(() => { if (!res.destroyed) res.write(': keepalive\n\n'); }, 15000);
|
|
196
|
+
heartbeat.unref?.();
|
|
197
|
+
const close = () => { clearInterval(heartbeat); unsubscribe(); };
|
|
198
|
+
req.on('close', close); res.on('close', close);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
match = path.match(/^\/sessions\/([^/]+)$/);
|
|
203
|
+
if (match && req.method === 'GET') return json(res, 200, service.loadSession(decode(match[1])));
|
|
204
|
+
|
|
205
|
+
match = path.match(/^\/sessions\/([^/]+)\/events$/);
|
|
206
|
+
if (match && req.method === 'GET') {
|
|
207
|
+
const sessionId = decode(match[1]);
|
|
208
|
+
const after = sequenceFromRequest(req, url);
|
|
209
|
+
service.loadSession(sessionId);
|
|
210
|
+
res.writeHead(200, {
|
|
211
|
+
'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform',
|
|
212
|
+
Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
|
|
213
|
+
'Access-Control-Allow-Origin': options.corsOrigin || '*',
|
|
214
|
+
});
|
|
215
|
+
res.write(': claude-workbench stream\n\n');
|
|
216
|
+
const unsubscribe = service.subscribe(sessionId, after, event => writeSse(res, event));
|
|
217
|
+
const heartbeat = setInterval(() => { try { res.write(`: keepalive ${Date.now()}\n\n`); } catch {} }, 15000);
|
|
218
|
+
heartbeat.unref && heartbeat.unref();
|
|
219
|
+
let closed = false;
|
|
220
|
+
const close = () => { if (closed) return; closed = true; clearInterval(heartbeat); try { unsubscribe(); } catch {} };
|
|
221
|
+
req.on('close', close); req.on('error', close); res.on('close', close);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
match = path.match(/^\/sessions\/([^/]+)\/input$/);
|
|
226
|
+
if (match && req.method === 'POST') {
|
|
227
|
+
const input = await body(req, 32 * 1024 * 1024);
|
|
228
|
+
return json(res, 202, await service.send(decode(match[1]), input.text, input.options, input.attachments));
|
|
229
|
+
}
|
|
230
|
+
match = path.match(/^\/sessions\/([^/]+)\/commands$/);
|
|
231
|
+
if (match && req.method === 'GET') return json(res, 200, await service.listCommands(decode(match[1])));
|
|
232
|
+
|
|
233
|
+
match = path.match(/^\/sessions\/([^/]+)\/attachments\/([^/]+)$/);
|
|
234
|
+
if (match && req.method === 'GET') {
|
|
235
|
+
const attachment = service.readAttachment(decode(match[1]), decode(match[2]));
|
|
236
|
+
const headers = {
|
|
237
|
+
'Content-Type': attachment.metadata.mediaType,
|
|
238
|
+
'Content-Length': attachment.data.length,
|
|
239
|
+
'Cache-Control': 'private, max-age=3600',
|
|
240
|
+
'X-Content-Type-Options': 'nosniff',
|
|
241
|
+
};
|
|
242
|
+
// Images stay inline for the chat UI; anything textual is forced to
|
|
243
|
+
// download so a crafted .html/.svg attachment can never render in the
|
|
244
|
+
// page origin.
|
|
245
|
+
if (!String(attachment.metadata.mediaType || '').startsWith('image/')) {
|
|
246
|
+
const safeName = String(attachment.metadata.name || 'attachment').replace(/[^\w.\-]+/g, '_').slice(0, 100) || 'attachment';
|
|
247
|
+
headers['Content-Disposition'] = `attachment; filename="${safeName}"`;
|
|
248
|
+
}
|
|
249
|
+
res.writeHead(200, headers);
|
|
250
|
+
res.end(attachment.data);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
match = path.match(/^\/sessions\/([^/]+)\/permissions\/([^/]+)\/resolve$/);
|
|
254
|
+
if (match && req.method === 'POST') {
|
|
255
|
+
return json(res, 200, service.resolvePermission(decode(match[1]), decode(match[2]), await body(req)));
|
|
256
|
+
}
|
|
257
|
+
match = path.match(/^\/sessions\/([^/]+)\/abort$/);
|
|
258
|
+
if (match && req.method === 'POST') return json(res, 202, service.abort(decode(match[1])));
|
|
259
|
+
|
|
260
|
+
match = path.match(/^\/sessions\/([^/]+)\/permission-mode$/);
|
|
261
|
+
if (match && req.method === 'PUT') {
|
|
262
|
+
const input = await body(req);
|
|
263
|
+
return json(res, 200, service.setPermissionMode(decode(match[1]), input.mode));
|
|
264
|
+
}
|
|
265
|
+
match = path.match(/^\/sessions\/([^/]+)\/selection$/);
|
|
266
|
+
if (match && req.method === 'PUT') {
|
|
267
|
+
const input = await body(req);
|
|
268
|
+
return json(res, 200, service.updateSessionSelection(decode(match[1]), input.aiProfileId, input.model));
|
|
269
|
+
}
|
|
270
|
+
throw Object.assign(new Error('route not found'), { status: 404 });
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (res.headersSent) {
|
|
273
|
+
if (!res.writableEnded) try { res.end(); } catch {}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
const status = Number(error.status) || (error.message === 'request body too large' ? 413 : 400);
|
|
277
|
+
return json(res, status, {
|
|
278
|
+
ok: false, error: safeError(error),
|
|
279
|
+
...(error.code ? { code: error.code } : {}),
|
|
280
|
+
...(error.resync ? { resync: true } : {}),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function body(req, limit = 1024 * 1024) {
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const chunks = []; let size = 0; let settled = false;
|
|
289
|
+
req.on('data', chunk => {
|
|
290
|
+
if (settled) return;
|
|
291
|
+
size += chunk.length;
|
|
292
|
+
if (size > limit) { settled = true; reject(new Error('request body too large')); return; }
|
|
293
|
+
chunks.push(chunk);
|
|
294
|
+
});
|
|
295
|
+
req.on('end', () => {
|
|
296
|
+
if (settled) return;
|
|
297
|
+
try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
|
|
298
|
+
catch { reject(Object.assign(new Error('invalid JSON body'), { status: 400 })); }
|
|
299
|
+
});
|
|
300
|
+
req.on('error', reject);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function json(res, status, value) {
|
|
305
|
+
const payload = status === 204 ? undefined : JSON.stringify(value);
|
|
306
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
307
|
+
res.end(payload);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
function writeSse(res, event) {
|
|
311
|
+
// Control events (workbench/resync) own no sequence; an `id:` line would
|
|
312
|
+
// otherwise poison the browser's Last-Event-ID reconnect cursor.
|
|
313
|
+
const sequence = Number(event.sequence);
|
|
314
|
+
if (Number.isFinite(sequence) && sequence > 0) res.write(`id: ${sequence}\n`);
|
|
315
|
+
res.write(`event: ${event.type}\n`);
|
|
316
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
317
|
+
}
|
|
318
|
+
function sequenceFromRequest(req, url) {
|
|
319
|
+
const value = req.headers['last-event-id'] || url.searchParams.get('after') || 0;
|
|
320
|
+
const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
|
321
|
+
}
|
|
322
|
+
function setCors(req, res, origin) {
|
|
323
|
+
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
324
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Last-Event-ID');
|
|
325
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
326
|
+
}
|
|
327
|
+
// `Origin` values a system endpoint may serve: the local machine itself
|
|
328
|
+
// (the app is same-origin already; a dev server on another loopback port is
|
|
329
|
+
// fine). `Origin: null` (sandboxed/data frames) and foreign hosts are not.
|
|
330
|
+
function isLoopbackOrigin(origin) {
|
|
331
|
+
let parsed;
|
|
332
|
+
try { parsed = new URL(origin); } catch { return false; }
|
|
333
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
|
334
|
+
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(parsed.hostname);
|
|
335
|
+
}
|
|
336
|
+
function decode(value) { try { return cleanId(decodeURIComponent(value)); } catch (error) { error.status = 400; throw error; } }
|
|
337
|
+
function safeError(error) { return String(error && error.message || 'request failed').replace(/[\r\n]+/g, ' ').slice(0, 500); }
|
|
338
|
+
|
|
339
|
+
module.exports = { createHttpHandler, body, writeSse, sequenceFromRequest };
|