@oharness/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analytics.d.ts +47 -0
- package/dist/analytics.js +323 -0
- package/dist/analytics.js.map +1 -0
- package/dist/artifacts.d.ts +53 -0
- package/dist/artifacts.js +129 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/canvas.d.ts +28 -0
- package/dist/canvas.js +646 -0
- package/dist/canvas.js.map +1 -0
- package/dist/changes.d.ts +54 -0
- package/dist/changes.js +154 -0
- package/dist/changes.js.map +1 -0
- package/dist/files.d.ts +48 -0
- package/dist/files.js +168 -0
- package/dist/files.js.map +1 -0
- package/dist/i18n.d.ts +15 -0
- package/dist/i18n.js +625 -0
- package/dist/i18n.js.map +1 -0
- package/dist/icons.d.ts +9 -0
- package/dist/icons.js +197 -0
- package/dist/icons.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +148 -0
- package/dist/main.js.map +1 -0
- package/dist/markdown.d.ts +10 -0
- package/dist/markdown.js +253 -0
- package/dist/markdown.js.map +1 -0
- package/dist/page.d.ts +1 -0
- package/dist/page.js +8591 -0
- package/dist/page.js.map +1 -0
- package/dist/pick.d.ts +62 -0
- package/dist/pick.js +215 -0
- package/dist/pick.js.map +1 -0
- package/dist/prefs.d.ts +59 -0
- package/dist/prefs.js +66 -0
- package/dist/prefs.js.map +1 -0
- package/dist/projects.d.ts +34 -0
- package/dist/projects.js +129 -0
- package/dist/projects.js.map +1 -0
- package/dist/server.d.ts +50 -0
- package/dist/server.js +2789 -0
- package/dist/server.js.map +1 -0
- package/package.json +36 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,2789 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { Harness } from "@oharness/engine/harness.ts";
|
|
7
|
+
import { updateUserConfig } from "@oharness/engine/config.ts";
|
|
8
|
+
import { connectorConfig, officialConnectors } from "@oharness/engine/mcp/registry.ts";
|
|
9
|
+
import { missingSecrets } from "@oharness/engine/mcp/secrets.ts";
|
|
10
|
+
import { connectorIcon } from "./icons.js";
|
|
11
|
+
import { PERMISSION_MODES } from "@oharness/engine/agent/permissions.ts";
|
|
12
|
+
import { isReadable } from "@oharness/engine/agent/workspace.ts";
|
|
13
|
+
import { messageText } from "@oharness/engine/core/types.ts";
|
|
14
|
+
import { errorMessage } from "@oharness/engine/core/errors.ts";
|
|
15
|
+
import { officialBaseUrl } from "@oharness/engine/auth/providers/official.ts";
|
|
16
|
+
import { browserSignInUrl, exchangeHandoff, siteUrl } from "@oharness/engine/auth/providers/account.ts";
|
|
17
|
+
import { ChangeLedger } from "./changes.js";
|
|
18
|
+
import { addProject, ensureProject, listProjects, removeProject } from "./projects.js";
|
|
19
|
+
import { readPrefs, writePrefs } from "./prefs.js";
|
|
20
|
+
import { listDirectories, pickDirectory, pickFiles } from "./pick.js";
|
|
21
|
+
import { browseFiles, searchFiles, within } from "./files.js";
|
|
22
|
+
import { analyticsState } from "./analytics.js";
|
|
23
|
+
import { PAGE } from "./page.js";
|
|
24
|
+
import { artifactDocument, sendArtifact } from "./artifacts.js";
|
|
25
|
+
import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreArtifacts, } from "@oharness/engine/artifacts/store.ts";
|
|
26
|
+
import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engine/artifacts/publish.ts";
|
|
27
|
+
/**
|
|
28
|
+
* How many stored sessions the usage page counts.
|
|
29
|
+
*
|
|
30
|
+
* A cap rather than "everything", because the totals are read by walking the
|
|
31
|
+
* session metadata on disk and someone who has been using this for months has
|
|
32
|
+
* thousands of them. The answer says how many it looked at, so a total that
|
|
33
|
+
* stops short says so rather than reading as the whole history.
|
|
34
|
+
*/
|
|
35
|
+
const USAGE_SESSION_SCAN = 200;
|
|
36
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
/**
|
|
38
|
+
* The windows the usage page offers.
|
|
39
|
+
*
|
|
40
|
+
* Days rather than calendar months: "the last 30 days" is a question with one
|
|
41
|
+
* answer, where "this month" changes what it means on the first of the month
|
|
42
|
+
* and makes two visits a day apart incomparable.
|
|
43
|
+
*/
|
|
44
|
+
const USAGE_RANGES = {
|
|
45
|
+
"7d": { id: "7d", days: 7 },
|
|
46
|
+
"30d": { id: "30d", days: 30 },
|
|
47
|
+
"90d": { id: "90d", days: 90 },
|
|
48
|
+
all: { id: "all", days: 0 },
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* What was spent over time, per model.
|
|
52
|
+
*
|
|
53
|
+
* Bucketed by day while a day is a readable width, and by week once the window
|
|
54
|
+
* is long enough that a daily column is a hair. Empty buckets are included on
|
|
55
|
+
* purpose: a week with no work is a fact about the week, and dropping it would
|
|
56
|
+
* pull the weeks on either side together and put time on an axis that does not
|
|
57
|
+
* pass evenly.
|
|
58
|
+
*
|
|
59
|
+
* A session's spend lands in the bucket it was last touched, because that is
|
|
60
|
+
* what the metadata records — a session worked on across three days counts on
|
|
61
|
+
* the third. Said out loud on the page rather than smoothed over: the
|
|
62
|
+
* alternative is reading every transcript to place each turn, which is a lot
|
|
63
|
+
* of disk for a curve nobody reads to four decimal places.
|
|
64
|
+
*/
|
|
65
|
+
function usageSeries(rows, since) {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const span = Math.max(now - since, DAY_MS);
|
|
68
|
+
const step = span > 60 * DAY_MS ? "week" : "day";
|
|
69
|
+
const width = step === "week" ? 7 * DAY_MS : DAY_MS;
|
|
70
|
+
// Anchored to the end rather than to midnight of the first day: the last
|
|
71
|
+
// bucket should be the one happening now, so the newest column is never a
|
|
72
|
+
// sliver that reads as a collapse in spending.
|
|
73
|
+
const end = Math.floor(now / width) * width + width;
|
|
74
|
+
const count = Math.min(Math.ceil((end - since) / width), 400);
|
|
75
|
+
const from = end - count * width;
|
|
76
|
+
const buckets = Array.from({ length: count }, (_, i) => ({
|
|
77
|
+
at: from + i * width,
|
|
78
|
+
costUsd: 0,
|
|
79
|
+
models: {},
|
|
80
|
+
}));
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
const at = Math.floor((row.updatedAt - from) / width);
|
|
83
|
+
const bucket = buckets[at];
|
|
84
|
+
if (!bucket)
|
|
85
|
+
continue;
|
|
86
|
+
bucket.costUsd += row.costUsd;
|
|
87
|
+
bucket.models[row.model] = (bucket.models[row.model] ?? 0) + row.costUsd;
|
|
88
|
+
}
|
|
89
|
+
return { step, from, buckets };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The four token counts, with the optional halves filled in.
|
|
93
|
+
*
|
|
94
|
+
* Cache reads and writes are optional on the wire because a provider without
|
|
95
|
+
* a prompt cache never reports them. Undefined and zero mean the same thing to
|
|
96
|
+
* anyone reading a total, so the page is spared the distinction.
|
|
97
|
+
*/
|
|
98
|
+
function tokensOf(usage) {
|
|
99
|
+
return {
|
|
100
|
+
input: usage.inputTokens || 0,
|
|
101
|
+
output: usage.outputTokens || 0,
|
|
102
|
+
cacheRead: usage.cacheReadTokens || 0,
|
|
103
|
+
cacheWrite: usage.cacheWriteTokens || 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export async function startWebServer(opts) {
|
|
107
|
+
const token = randomUUID();
|
|
108
|
+
/**
|
|
109
|
+
* A second credential, for artifact bodies only.
|
|
110
|
+
*
|
|
111
|
+
* An artifact is loaded into an iframe by URL, and a sandboxed document can
|
|
112
|
+
* read its own URL — so whatever authorises that route is legible to the
|
|
113
|
+
* content served through it. With one shared token, an HTML artifact could
|
|
114
|
+
* read the credential that drives the agent. This one only unlocks
|
|
115
|
+
* `/artifact/view`, so the worst an artifact can learn is how to read other
|
|
116
|
+
* artifacts in the same session.
|
|
117
|
+
*/
|
|
118
|
+
const artifactToken = randomUUID();
|
|
119
|
+
const host = opts.host ?? "127.0.0.1";
|
|
120
|
+
const clients = new Set();
|
|
121
|
+
const pending = new Map();
|
|
122
|
+
const runners = new Map();
|
|
123
|
+
const anyRunning = () => [...runners.values()].some((runner) => Boolean(runner.running));
|
|
124
|
+
const makeRunner = (session, harness) => {
|
|
125
|
+
const ledger = new ChangeLedger(session.cwd);
|
|
126
|
+
ledger.attach(harness.hooks);
|
|
127
|
+
return { session, agent: harness.agent(session), harness, ledger };
|
|
128
|
+
};
|
|
129
|
+
/** The runner a request names, or the most recently focused one. */
|
|
130
|
+
let focused = opts.session.id;
|
|
131
|
+
const runnerFor = (id) => runners.get(typeof id === "string" && id ? id : focused);
|
|
132
|
+
runners.set(opts.session.id, makeRunner(opts.session, opts.harness));
|
|
133
|
+
let current = { session: opts.session, agent: opts.agent, harness: opts.harness };
|
|
134
|
+
/**
|
|
135
|
+
* Sign-ins waiting for their emailed code.
|
|
136
|
+
*
|
|
137
|
+
* A login is two requests — send the code, then verify it — and the second
|
|
138
|
+
* one needs the closure the first produced. Dropped after ten minutes so a
|
|
139
|
+
* tab closed halfway through does not leave one open indefinitely.
|
|
140
|
+
*/
|
|
141
|
+
const pendingSignIns = new Map();
|
|
142
|
+
const SIGNIN_TTL_MS = 10 * 60_000;
|
|
143
|
+
/**
|
|
144
|
+
* Sign-ins happening in the user's browser, keyed by the nonce they carry.
|
|
145
|
+
*
|
|
146
|
+
* The browser comes back to a callback on this server, and it comes back
|
|
147
|
+
* without the token — it is the *system* browser, opened by a click, and it
|
|
148
|
+
* never had one. The nonce is what stands in for it: 32 bytes this process
|
|
149
|
+
* generated, held only in memory, good once and only for the few minutes
|
|
150
|
+
* somebody spends signing in.
|
|
151
|
+
*/
|
|
152
|
+
const pendingBrowserSignIns = new Map();
|
|
153
|
+
const BROWSER_SIGNIN_TTL_MS = 10 * 60_000;
|
|
154
|
+
/**
|
|
155
|
+
* One harness per project directory.
|
|
156
|
+
*
|
|
157
|
+
* A harness binds to its cwd at construction, so a second project needs a
|
|
158
|
+
* second harness rather than a mutated field. They are kept after a switch
|
|
159
|
+
* because rebuilding one reconnects every MCP server.
|
|
160
|
+
*/
|
|
161
|
+
const harnesses = new Map([[opts.session.cwd, opts.harness]]);
|
|
162
|
+
void ensureProject(opts.session.cwd);
|
|
163
|
+
/**
|
|
164
|
+
* The model the user last picked, carried into every session started here.
|
|
165
|
+
*
|
|
166
|
+
* Read once at startup and kept in memory: the file is written when the
|
|
167
|
+
* choice changes, so re-reading it per session would only add a disk hit to
|
|
168
|
+
* answer a question this already knows.
|
|
169
|
+
*/
|
|
170
|
+
let preferredModel = (await readPrefs()).model;
|
|
171
|
+
/**
|
|
172
|
+
* A new session on a harness, honouring that preference.
|
|
173
|
+
*
|
|
174
|
+
* Guarded by the harness's own catalog: a remembered model from a provider
|
|
175
|
+
* that has since been disconnected would otherwise fail at the first request
|
|
176
|
+
* rather than falling back to the configured default.
|
|
177
|
+
*/
|
|
178
|
+
const freshSession = (harness) => {
|
|
179
|
+
const known = preferredModel && harness.listModels().some((model) => model.id === preferredModel);
|
|
180
|
+
return harness.createSession(known ? { model: preferredModel } : {});
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Files staged for the next prompt.
|
|
184
|
+
*
|
|
185
|
+
* Held in memory and dropped once used: writing an upload into the working
|
|
186
|
+
* tree would put a file the agent never asked for into the user's repo, and
|
|
187
|
+
* it would show up in the change ledger as work the agent did.
|
|
188
|
+
*
|
|
189
|
+
* Memory is the whole cost of that choice, so nothing staged is allowed to
|
|
190
|
+
* stay indefinitely. A prompt claims what it uses; a dropped chip says so;
|
|
191
|
+
* and what neither happens to — a tab closed on a pasted screenshot — is
|
|
192
|
+
* reclaimed by `sweepAttachments`.
|
|
193
|
+
*/
|
|
194
|
+
const attachments = new Map();
|
|
195
|
+
/**
|
|
196
|
+
* Carry a chosen file whose bytes are the only way it can mean anything.
|
|
197
|
+
*
|
|
198
|
+
* The dialog reaches the whole machine; the agent's readable roots are the
|
|
199
|
+
* project and little else. A path from outside them is a reference `read`
|
|
200
|
+
* will refuse, so for those the file travels with the message instead —
|
|
201
|
+
* which is what the row promising "files or photos" implied all along.
|
|
202
|
+
* Returns nothing when it cannot be carried either, and the page then says
|
|
203
|
+
* so rather than staging a turn that is already lost.
|
|
204
|
+
*/
|
|
205
|
+
const stageFromDisk = async (target) => {
|
|
206
|
+
try {
|
|
207
|
+
const info = await stat(target);
|
|
208
|
+
if (!info.isFile() || info.size > MAX_UPLOAD_BYTES)
|
|
209
|
+
return undefined;
|
|
210
|
+
const data = await readFile(target);
|
|
211
|
+
const name = path.basename(target);
|
|
212
|
+
// Images by extension, and everything else left unnamed: `buildMessage`
|
|
213
|
+
// decides text from bytes rather than from a type, so guessing one for
|
|
214
|
+
// a .log would add nothing and could be wrong.
|
|
215
|
+
const mediaType = PICKED_IMAGE_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
|
|
216
|
+
const id = randomUUID();
|
|
217
|
+
attachments.set(id, { name, mediaType, data, at: Date.now() });
|
|
218
|
+
sweepAttachments();
|
|
219
|
+
return {
|
|
220
|
+
id,
|
|
221
|
+
name,
|
|
222
|
+
mediaType,
|
|
223
|
+
bytes: data.byteLength,
|
|
224
|
+
kind: mediaType.startsWith("image/") ? "image" : "file",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Forget staged uploads nobody is going to send.
|
|
233
|
+
*
|
|
234
|
+
* Age first, since a file staged half an hour ago belongs to a composer
|
|
235
|
+
* that is gone. Then total size, oldest first, because the bound that
|
|
236
|
+
* actually protects the process is bytes rather than entries: one map of
|
|
237
|
+
* screenshots can outweigh a thousand pasted snippets.
|
|
238
|
+
*/
|
|
239
|
+
const sweepAttachments = () => {
|
|
240
|
+
const cutoff = Date.now() - STAGED_TTL_MS;
|
|
241
|
+
let total = 0;
|
|
242
|
+
for (const [id, file] of attachments) {
|
|
243
|
+
if (file.at < cutoff)
|
|
244
|
+
attachments.delete(id);
|
|
245
|
+
else
|
|
246
|
+
total += file.data.byteLength;
|
|
247
|
+
}
|
|
248
|
+
// Insertion order is arrival order, so the first entries are the oldest.
|
|
249
|
+
for (const [id, file] of attachments) {
|
|
250
|
+
if (total <= MAX_STAGED_BYTES)
|
|
251
|
+
break;
|
|
252
|
+
attachments.delete(id);
|
|
253
|
+
total -= file.data.byteLength;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
// The review pane's whole reason to exist: what the agent did to the tree.
|
|
257
|
+
const broadcast = (event) => {
|
|
258
|
+
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
259
|
+
for (const client of clients)
|
|
260
|
+
client.write(payload);
|
|
261
|
+
};
|
|
262
|
+
const permissionPrompt = (request) => new Promise((resolve) => {
|
|
263
|
+
const id = randomUUID();
|
|
264
|
+
pending.set(id, { id, request, resolve });
|
|
265
|
+
broadcast({ type: "permission_request", id, request });
|
|
266
|
+
// No timeout here on purpose. A prompt that auto-denies after a while
|
|
267
|
+
// would silently fail long-running work while the user is away; a prompt
|
|
268
|
+
// that auto-allows would be worse. It waits.
|
|
269
|
+
});
|
|
270
|
+
const server = createServer((req, res) => {
|
|
271
|
+
void handle(req, res).catch((err) => {
|
|
272
|
+
send(res, 500, { error: errorMessage(err) });
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
async function handle(req, res) {
|
|
276
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
277
|
+
// The page itself is the only thing served without a token, and only so a
|
|
278
|
+
// bare URL with the token in the query string can bootstrap.
|
|
279
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
280
|
+
if (!authorized(url, req))
|
|
281
|
+
return send(res, 401, { error: "missing or bad token" });
|
|
282
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
283
|
+
res.end(PAGE);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// The other exception, and for the same kind of reason: this one is
|
|
287
|
+
// arrived at by the system browser coming back from the site, which never
|
|
288
|
+
// held the token. The nonce it carries is the credential — unguessable,
|
|
289
|
+
// single-use, and issued by the request that opened the browser.
|
|
290
|
+
if (url.pathname === "/signin/handoff" && req.method === "GET") {
|
|
291
|
+
return browserSignInReturn(url, res);
|
|
292
|
+
}
|
|
293
|
+
// Third exception, and the reason it is one is in artifacts.ts: this route
|
|
294
|
+
// is read by a sandboxed iframe that can see its own URL, so it is gated
|
|
295
|
+
// on the artifact credential rather than the session token.
|
|
296
|
+
if (url.pathname === "/artifact/view" && req.method === "GET") {
|
|
297
|
+
const supplied = url.searchParams.get("k") ?? "";
|
|
298
|
+
if (!constantTimeEqual(supplied, artifactToken)) {
|
|
299
|
+
return send(res, 401, { error: "missing or bad artifact key" });
|
|
300
|
+
}
|
|
301
|
+
return serveArtifactBody(url, res);
|
|
302
|
+
}
|
|
303
|
+
if (!authorized(url, req))
|
|
304
|
+
return send(res, 401, { error: "missing or bad token" });
|
|
305
|
+
switch (`${req.method} ${url.pathname}`) {
|
|
306
|
+
case "GET /events":
|
|
307
|
+
return stream(res);
|
|
308
|
+
case "GET /state": {
|
|
309
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
310
|
+
if (!runner)
|
|
311
|
+
return send(res, 404, { error: "no such session" });
|
|
312
|
+
focused = runner.session.id;
|
|
313
|
+
return send(res, 200, state(runner));
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* A staged upload, before any turn has carried it.
|
|
317
|
+
*
|
|
318
|
+
* The composer's own uploads are previewed from the bytes the browser
|
|
319
|
+
* already holds; one the server read off the disk has never been near
|
|
320
|
+
* the page, and without this its chip would be the only one in the row
|
|
321
|
+
* with no picture on it.
|
|
322
|
+
*/
|
|
323
|
+
case "GET /upload/preview": {
|
|
324
|
+
const staged = attachments.get(url.searchParams.get("id") ?? "");
|
|
325
|
+
if (!staged)
|
|
326
|
+
return send(res, 404, { error: "no such upload" });
|
|
327
|
+
// The same allowlist as the transcript's images, for the same reason:
|
|
328
|
+
// this is served same-origin to a page holding the session token.
|
|
329
|
+
if (!VIEWABLE_IMAGE_TYPES.has(staged.mediaType)) {
|
|
330
|
+
return send(res, 415, { error: `cannot display ${staged.mediaType}` });
|
|
331
|
+
}
|
|
332
|
+
res.writeHead(200, {
|
|
333
|
+
"content-type": staged.mediaType,
|
|
334
|
+
"content-length": staged.data.byteLength,
|
|
335
|
+
"x-content-type-options": "nosniff",
|
|
336
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
337
|
+
// Staged bytes are replaced by a new id, never edited under this
|
|
338
|
+
// one, but they do go away when the prompt claims them.
|
|
339
|
+
"cache-control": "private, no-store",
|
|
340
|
+
});
|
|
341
|
+
res.end(staged.data);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* One image out of the transcript.
|
|
346
|
+
*
|
|
347
|
+
* Addressed by its position in the session rather than by an id of its
|
|
348
|
+
* own, because a message block has no identity to give it: the bytes
|
|
349
|
+
* live in the transcript, and the transcript is the only copy. Reads
|
|
350
|
+
* are bounded to what the URL names and refuse anything that is not an
|
|
351
|
+
* image, so the route cannot be turned into a way to read arbitrary
|
|
352
|
+
* message content back out as a file.
|
|
353
|
+
*/
|
|
354
|
+
case "GET /attachment": {
|
|
355
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
356
|
+
if (!runner)
|
|
357
|
+
return send(res, 404, { error: "no such session" });
|
|
358
|
+
const index = Number(url.searchParams.get("message"));
|
|
359
|
+
const position = Number(url.searchParams.get("block"));
|
|
360
|
+
const message = Number.isInteger(index) ? runner.session.messages[index - 1] : undefined;
|
|
361
|
+
const block = Number.isInteger(position) ? message?.content[position] : undefined;
|
|
362
|
+
if (!block || block.type !== "image")
|
|
363
|
+
return send(res, 404, { error: "no such image" });
|
|
364
|
+
// The media type on a block is whatever the upload claimed, and this
|
|
365
|
+
// response is same-origin with the page's own token. An allowlist of
|
|
366
|
+
// raster types is the whole defence: SVG is scriptable, and anything
|
|
367
|
+
// outside the list could be sniffed into markup that runs here.
|
|
368
|
+
if (!VIEWABLE_IMAGE_TYPES.has(block.mimeType)) {
|
|
369
|
+
return send(res, 415, { error: `cannot display ${block.mimeType}` });
|
|
370
|
+
}
|
|
371
|
+
const data = Buffer.from(block.data, "base64");
|
|
372
|
+
res.writeHead(200, {
|
|
373
|
+
"content-type": block.mimeType,
|
|
374
|
+
"content-length": data.byteLength,
|
|
375
|
+
"x-content-type-options": "nosniff",
|
|
376
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
377
|
+
// The transcript is append-only, so this position holds the same
|
|
378
|
+
// bytes forever: worth saying, since the page asks for every image
|
|
379
|
+
// in a conversation again on every switch between tabs.
|
|
380
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
381
|
+
});
|
|
382
|
+
res.end(data);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
case "GET /artifacts": {
|
|
386
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
387
|
+
if (!runner)
|
|
388
|
+
return send(res, 404, { error: "no such session" });
|
|
389
|
+
// The key travels with the list so the page never has to construct a
|
|
390
|
+
// credentialled URL from a token it holds separately — one place
|
|
391
|
+
// builds these, and it is the one that knows which key is right.
|
|
392
|
+
return send(res, 200, {
|
|
393
|
+
artifacts: getArtifactStore(runner.session.state).list(),
|
|
394
|
+
viewKey: artifactToken,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Every artifact on this machine, grouped by the session that made it.
|
|
399
|
+
*
|
|
400
|
+
* This used to list only what the process had loaded — the open tabs —
|
|
401
|
+
* because the store is memory and lives on the session. That made the
|
|
402
|
+
* panel's answer depend on which tabs happened to be open, which is not
|
|
403
|
+
* an answer anybody can act on: something you made yesterday was simply
|
|
404
|
+
* absent, with nothing on screen to say it existed.
|
|
405
|
+
*
|
|
406
|
+
* The revision logs are the durable record, so the listing is folded
|
|
407
|
+
* from those and a loaded session overrides its own log — it holds the
|
|
408
|
+
* revision a person just made in a canvas, and it knows about publishing,
|
|
409
|
+
* which the log does not record.
|
|
410
|
+
*/
|
|
411
|
+
case "GET /artifacts/all": {
|
|
412
|
+
const catalogue = await artifactCatalogue();
|
|
413
|
+
return send(res, 200, { ...catalogue, viewKey: artifactToken });
|
|
414
|
+
}
|
|
415
|
+
// The body, for the kinds the page renders itself into DOM nodes. Gated
|
|
416
|
+
// on the session token, not the artifact key: this response is read by
|
|
417
|
+
// the page's own script, which already holds the stronger credential.
|
|
418
|
+
case "GET /artifact/raw": {
|
|
419
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
420
|
+
if (!runner)
|
|
421
|
+
return send(res, 404, { error: "no such session" });
|
|
422
|
+
const id = url.searchParams.get("id") ?? "";
|
|
423
|
+
const versionParam = url.searchParams.get("version");
|
|
424
|
+
const found = getArtifactStore(runner.session.state).get(id, versionParam ? Number(versionParam) : undefined);
|
|
425
|
+
if (!found)
|
|
426
|
+
return send(res, 404, { error: "no such artifact" });
|
|
427
|
+
return send(res, 200, {
|
|
428
|
+
id: found.artifact.id,
|
|
429
|
+
title: found.artifact.title,
|
|
430
|
+
mime: found.artifact.mime,
|
|
431
|
+
kind: found.artifact.kind,
|
|
432
|
+
language: found.artifact.language,
|
|
433
|
+
version: found.revision.version,
|
|
434
|
+
versions: found.artifact.versions.map((v) => v.version),
|
|
435
|
+
content: found.revision.content,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* A revision written by the person looking at it.
|
|
440
|
+
*
|
|
441
|
+
* The frontend posts this on the canvas editor's behalf; the editor
|
|
442
|
+
* itself never can. It runs in a sandbox with `connect-src 'none'` and
|
|
443
|
+
* has no session token — deliberately, since it is showing model-authored
|
|
444
|
+
* markup — so the only thing it can do is postMessage its new content to
|
|
445
|
+
* the page that framed it, and let that page, which already holds the
|
|
446
|
+
* credential, decide whether to make the call. The sandbox stays intact
|
|
447
|
+
* and the editor gains no reach it did not have.
|
|
448
|
+
*
|
|
449
|
+
* Restricted to canvases because a canvas is the only artifact with an
|
|
450
|
+
* editor. Accepting an edit for a `page` would mean any framed artifact
|
|
451
|
+
* could rewrite itself by asking the parent nicely.
|
|
452
|
+
*/
|
|
453
|
+
case "POST /artifact/edit": {
|
|
454
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
455
|
+
if (!runner)
|
|
456
|
+
return send(res, 404, { error: "no such session" });
|
|
457
|
+
const body = await readJson(req);
|
|
458
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
459
|
+
const content = typeof body.content === "string" ? body.content : null;
|
|
460
|
+
if (!id || content === null)
|
|
461
|
+
return send(res, 400, { error: "id and content are required" });
|
|
462
|
+
const store = getArtifactStore(runner.session.state);
|
|
463
|
+
const found = store.get(id);
|
|
464
|
+
if (!found)
|
|
465
|
+
return send(res, 404, { error: "no such artifact" });
|
|
466
|
+
if (!isCanvas(found.artifact.mime)) {
|
|
467
|
+
return send(res, 400, { error: "only a canvas can be edited in place" });
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const descriptor = store.edit(id, content);
|
|
471
|
+
if (!descriptor)
|
|
472
|
+
return send(res, 404, { error: "no such artifact" });
|
|
473
|
+
// Broadcast for the same reason publishing does: this page is not
|
|
474
|
+
// the only one open, and the version picker everywhere else is now
|
|
475
|
+
// one revision behind.
|
|
476
|
+
broadcast({ type: "artifact", artifact: descriptor, session: runner.session.id });
|
|
477
|
+
return send(res, 200, descriptor);
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
case "POST /artifact/publish": {
|
|
484
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
485
|
+
if (!runner)
|
|
486
|
+
return send(res, 404, { error: "no such session" });
|
|
487
|
+
const body = await readJson(req);
|
|
488
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
489
|
+
const found = getArtifactStore(runner.session.state).get(id);
|
|
490
|
+
if (!found)
|
|
491
|
+
return send(res, 404, { error: "no such artifact" });
|
|
492
|
+
const visibility = body.visibility === "team" ? "team" : "unlisted";
|
|
493
|
+
const teamId = typeof body.teamId === "string" ? body.teamId : undefined;
|
|
494
|
+
if (visibility === "team" && !teamId) {
|
|
495
|
+
return send(res, 400, { error: "publishing to a team needs a team" });
|
|
496
|
+
}
|
|
497
|
+
// A canvas is stored as a fragment of artboards and only becomes a
|
|
498
|
+
// document when this server wraps it in the editor. The website has no
|
|
499
|
+
// such wrapper, so publishing the stored bytes would put a row of
|
|
500
|
+
// unstyled boxes behind the link. What travels instead is the rendered
|
|
501
|
+
// document — self-contained, and read-only on the other side because
|
|
502
|
+
// nothing there speaks the save protocol.
|
|
503
|
+
const canvas = isCanvas(found.artifact.mime);
|
|
504
|
+
const published = canvas
|
|
505
|
+
? artifactDocument(found.artifact, found.revision).body
|
|
506
|
+
: found.revision.content;
|
|
507
|
+
try {
|
|
508
|
+
const result = await publishArtifact({
|
|
509
|
+
sourceId: found.artifact.id,
|
|
510
|
+
title: found.artifact.title,
|
|
511
|
+
mime: canvas ? "text/html" : found.artifact.mime,
|
|
512
|
+
kind: found.artifact.kind,
|
|
513
|
+
...(found.artifact.language ? { language: found.artifact.language } : {}),
|
|
514
|
+
content: published,
|
|
515
|
+
version: found.revision.version,
|
|
516
|
+
visibility,
|
|
517
|
+
...(teamId ? { teamId } : {}),
|
|
518
|
+
}, { accountToken: await current.harness.accountToken() });
|
|
519
|
+
// Recorded against the artifact, not just returned: the page that
|
|
520
|
+
// asked is not the only one open, and a reload should not offer to
|
|
521
|
+
// publish something that already has a link. The revision goes in
|
|
522
|
+
// too — a link serves what was last published, so an artifact
|
|
523
|
+
// revised since is a link showing something older.
|
|
524
|
+
const descriptor = getArtifactStore(runner.session.state).markPublished(id, {
|
|
525
|
+
url: result.url,
|
|
526
|
+
visibility,
|
|
527
|
+
...(teamId ? { teamId } : {}),
|
|
528
|
+
version: found.revision.version,
|
|
529
|
+
});
|
|
530
|
+
if (descriptor)
|
|
531
|
+
broadcast({ type: "artifact", artifact: descriptor, session: runner.session.id });
|
|
532
|
+
return send(res, 200, result);
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
// Not signed in, offline, over the limit, or the site said no — all
|
|
536
|
+
// things the person clicking Publish can act on, so they travel as a
|
|
537
|
+
// message rather than a 500.
|
|
538
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
case "POST /artifact/unpublish": {
|
|
542
|
+
const body = await readJson(req);
|
|
543
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
544
|
+
if (!id)
|
|
545
|
+
return send(res, 400, { error: "id is required" });
|
|
546
|
+
try {
|
|
547
|
+
await unpublishArtifact(id, { accountToken: await current.harness.accountToken() });
|
|
548
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
549
|
+
if (runner) {
|
|
550
|
+
const descriptor = getArtifactStore(runner.session.state).clearPublication(id);
|
|
551
|
+
if (descriptor) {
|
|
552
|
+
broadcast({ type: "artifact", artifact: descriptor, session: runner.session.id });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return send(res, 200, { withdrawn: true });
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
case "GET /teams": {
|
|
562
|
+
// For the Publish menu. Empty when signed out, which is the same
|
|
563
|
+
// answer the menu needs in order to offer an unlisted link only.
|
|
564
|
+
return send(res, 200, {
|
|
565
|
+
teams: await accountTeams({ accountToken: await current.harness.accountToken() }),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
case "GET /history":
|
|
569
|
+
{
|
|
570
|
+
// Opening the page mid-session should show what has happened, not an
|
|
571
|
+
// empty transcript that silently drops the context the model still has.
|
|
572
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
573
|
+
if (!runner)
|
|
574
|
+
return send(res, 404, { error: "no such session" });
|
|
575
|
+
return send(res, 200, { entries: history(runner) });
|
|
576
|
+
}
|
|
577
|
+
case "GET /projects": {
|
|
578
|
+
const projects = await listProjects();
|
|
579
|
+
const prefs = await readPrefs();
|
|
580
|
+
const pinned = new Set(prefs.pinnedProjects ?? []);
|
|
581
|
+
return send(res, 200, {
|
|
582
|
+
order: prefs.projectOrder ?? [],
|
|
583
|
+
projects: projects.map((project) => ({
|
|
584
|
+
...project,
|
|
585
|
+
pinned: pinned.has(project.path),
|
|
586
|
+
current: project.path === current.session.cwd,
|
|
587
|
+
})),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Keep one session or project at the top of its list.
|
|
592
|
+
*
|
|
593
|
+
* A preference about this sidebar, so it is written where the other ones
|
|
594
|
+
* are and never into the session record: a pin is not a fact about the
|
|
595
|
+
* work, and it has no business travelling to another machine to
|
|
596
|
+
* rearrange a list somebody there had arranged for themselves.
|
|
597
|
+
*/
|
|
598
|
+
case "POST /pin": {
|
|
599
|
+
const body = await readJson(req);
|
|
600
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
601
|
+
if (!id)
|
|
602
|
+
return send(res, 400, { error: "id is required" });
|
|
603
|
+
const key = body.kind === "project" ? "pinnedProjects" : "pinnedSessions";
|
|
604
|
+
const held = (await readPrefs())[key] ?? [];
|
|
605
|
+
const rest = held.filter((one) => one !== id);
|
|
606
|
+
// Newest pin first, so pinning something does not bury it under
|
|
607
|
+
// everything pinned before it.
|
|
608
|
+
const next = body.pinned === false ? rest : [id, ...rest];
|
|
609
|
+
await writePrefs({ [key]: next });
|
|
610
|
+
return send(res, 200, { pinned: body.pinned !== false, order: next });
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Remember an order somebody made by hand.
|
|
614
|
+
*
|
|
615
|
+
* Only the list that was dragged, and only the ids it held: a session
|
|
616
|
+
* started tomorrow has no place in an order recorded today, and it
|
|
617
|
+
* should arrive where a new session belongs rather than at the end of a
|
|
618
|
+
* snapshot taken before it existed.
|
|
619
|
+
*/
|
|
620
|
+
case "POST /order": {
|
|
621
|
+
const body = await readJson(req);
|
|
622
|
+
const ids = Array.isArray(body.ids)
|
|
623
|
+
? body.ids.filter((id) => typeof id === "string")
|
|
624
|
+
: undefined;
|
|
625
|
+
if (!ids)
|
|
626
|
+
return send(res, 400, { error: "ids must be an array" });
|
|
627
|
+
if (body.kind === "project") {
|
|
628
|
+
await writePrefs({ projectOrder: ids });
|
|
629
|
+
return send(res, 200, { order: ids });
|
|
630
|
+
}
|
|
631
|
+
const cwd = typeof body.cwd === "string" ? body.cwd : "";
|
|
632
|
+
if (!cwd)
|
|
633
|
+
return send(res, 400, { error: "cwd is required" });
|
|
634
|
+
const order = { ...((await readPrefs()).sessionOrder ?? {}), [cwd]: ids };
|
|
635
|
+
await writePrefs({ sessionOrder: order });
|
|
636
|
+
return send(res, 200, { order: ids });
|
|
637
|
+
}
|
|
638
|
+
case "POST /pick": {
|
|
639
|
+
const body = await readJson(req);
|
|
640
|
+
// The dialog opens on this machine's desktop, which is the same
|
|
641
|
+
// machine the agent already runs on — reachable only with the token.
|
|
642
|
+
const start = typeof body.start === "string" ? body.start : current.session.cwd;
|
|
643
|
+
if (body.kind === "files") {
|
|
644
|
+
const chosen = await pickFiles(start);
|
|
645
|
+
if (!chosen.paths)
|
|
646
|
+
return send(res, 200, chosen);
|
|
647
|
+
// A path under the project comes back relative: it is shorter to
|
|
648
|
+
// read, it survives the project moving, and it is the form the
|
|
649
|
+
// agent's own tools take. Which of the two it is comes from the
|
|
650
|
+
// disk rather than from the name — one dialog returns both.
|
|
651
|
+
const entries = await Promise.all(chosen.paths.map(async (chosenPath) => {
|
|
652
|
+
const dir = await isDirectory(chosenPath);
|
|
653
|
+
const reachable = isReadable(current.harness.workspace, chosenPath);
|
|
654
|
+
const carried = dir || reachable ? undefined : await stageFromDisk(chosenPath);
|
|
655
|
+
return {
|
|
656
|
+
path: within(current.session.cwd, chosenPath),
|
|
657
|
+
dir,
|
|
658
|
+
// Said even when a copy was staged, because the two chips
|
|
659
|
+
// differ and the page has to know which one it is drawing.
|
|
660
|
+
...(reachable ? {} : { unreachable: true }),
|
|
661
|
+
...(carried ? { staged: carried } : {}),
|
|
662
|
+
};
|
|
663
|
+
}));
|
|
664
|
+
return send(res, 200, { entries });
|
|
665
|
+
}
|
|
666
|
+
const result = await pickDirectory(start);
|
|
667
|
+
return send(res, 200, {
|
|
668
|
+
...result,
|
|
669
|
+
...(result.path
|
|
670
|
+
? {
|
|
671
|
+
relative: within(current.session.cwd, result.path),
|
|
672
|
+
// A folder cannot be carried, so all this can do is say so
|
|
673
|
+
// before the turn spends itself finding out.
|
|
674
|
+
...(isReadable(current.harness.workspace, result.path)
|
|
675
|
+
? {}
|
|
676
|
+
: { unreachable: true }),
|
|
677
|
+
}
|
|
678
|
+
: {}),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
case "GET /fs":
|
|
682
|
+
return send(res, 200, await listDirectories(url.searchParams.get("path") ?? undefined));
|
|
683
|
+
// The project's own tree, for the composer's reference menu. Scoped to
|
|
684
|
+
// the session's working directory and answered in relative paths: the
|
|
685
|
+
// page has no business learning where on the disk the project sits.
|
|
686
|
+
case "GET /files": {
|
|
687
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
688
|
+
if (!runner)
|
|
689
|
+
return send(res, 404, { error: "no such session" });
|
|
690
|
+
const query = (url.searchParams.get("q") ?? "").trim();
|
|
691
|
+
// Searching and browsing answer in the same shape, so the menu paints
|
|
692
|
+
// one list either way and only the header changes.
|
|
693
|
+
return send(res, 200, query
|
|
694
|
+
? { query, ...(await searchFiles(runner.session.cwd, query)) }
|
|
695
|
+
: {
|
|
696
|
+
query: "",
|
|
697
|
+
...(await browseFiles(runner.session.cwd, url.searchParams.get("dir") ?? "")),
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
// The other two things a message can point at: an MCP server this
|
|
701
|
+
// harness is connected to, and a plugin it loaded.
|
|
702
|
+
case "GET /context":
|
|
703
|
+
return send(res, 200, contextOffer());
|
|
704
|
+
// The connectors on offer rather than the ones already here: the list
|
|
705
|
+
// ships as data, so this is a read of a file plus what is installed.
|
|
706
|
+
case "GET /registry": {
|
|
707
|
+
const installed = new Set(contextOffer().connectors.map((one) => one.name));
|
|
708
|
+
const entries = await officialConnectors();
|
|
709
|
+
return send(res, 200, {
|
|
710
|
+
connectors: entries.map((entry) => ({
|
|
711
|
+
id: entry.id,
|
|
712
|
+
label: entry.label,
|
|
713
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
714
|
+
...(entry.publisher ? { publisher: entry.publisher } : {}),
|
|
715
|
+
...(entry.docsUrl ? { docsUrl: entry.docsUrl } : {}),
|
|
716
|
+
installed: installed.has(entry.id),
|
|
717
|
+
// Said before the click, not after it. An entry that needs a key
|
|
718
|
+
// fails at connect with a perfectly clear message, but a menu that
|
|
719
|
+
// knew and stayed quiet spent a click to deliver it.
|
|
720
|
+
...(missingSecrets(entry.config).length
|
|
721
|
+
? { missingEnv: missingSecrets(entry.config) }
|
|
722
|
+
: {}),
|
|
723
|
+
})),
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* A connector's vendor icon, fetched by this server and cached on disk.
|
|
728
|
+
*
|
|
729
|
+
* Served from here rather than linked from the page: twelve rows would
|
|
730
|
+
* otherwise be twelve third-party requests, each one telling a vendor
|
|
731
|
+
* that this user opened the menu before connecting anything.
|
|
732
|
+
*/
|
|
733
|
+
case "GET /connector-icon": {
|
|
734
|
+
const id = url.searchParams.get("id") ?? "";
|
|
735
|
+
const entry = (await officialConnectors()).find((one) => one.id === id);
|
|
736
|
+
const icon = entry?.siteUrl ? await connectorIcon(entry.id, entry.siteUrl) : undefined;
|
|
737
|
+
// A row without a picture draws a monogram, so this is a normal answer
|
|
738
|
+
// rather than something worth reporting.
|
|
739
|
+
if (!icon)
|
|
740
|
+
return send(res, 404, { error: "no icon" });
|
|
741
|
+
res.writeHead(200, {
|
|
742
|
+
"content-type": icon.type,
|
|
743
|
+
"content-length": icon.bytes.length,
|
|
744
|
+
"cache-control": "private, max-age=86400",
|
|
745
|
+
});
|
|
746
|
+
res.end(icon.bytes);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Add or remove one connector.
|
|
751
|
+
*
|
|
752
|
+
* Both halves happen: the server is connected now, so its tools are
|
|
753
|
+
* usable in the next turn without a restart, and the declaration is
|
|
754
|
+
* written to the user's config so it comes back tomorrow. Doing only the
|
|
755
|
+
* first would make an install evaporate; only the second would make it
|
|
756
|
+
* look like nothing happened.
|
|
757
|
+
*/
|
|
758
|
+
case "POST /connector": {
|
|
759
|
+
const body = await readJson(req);
|
|
760
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
761
|
+
if (!id)
|
|
762
|
+
return send(res, 400, { error: "id is required" });
|
|
763
|
+
if (body.remove === true) {
|
|
764
|
+
await current.harness.removeMcpServer(id);
|
|
765
|
+
await updateUserConfig((config) => {
|
|
766
|
+
const servers = { ...config.mcpServers };
|
|
767
|
+
delete servers[id];
|
|
768
|
+
return { ...config, mcpServers: servers };
|
|
769
|
+
});
|
|
770
|
+
return send(res, 200, { removed: id, ...contextOffer() });
|
|
771
|
+
}
|
|
772
|
+
const entry = (await officialConnectors()).find((one) => one.id === id);
|
|
773
|
+
if (!entry)
|
|
774
|
+
return send(res, 404, { error: `no connector named "${id}"` });
|
|
775
|
+
const config = connectorConfig(entry, {
|
|
776
|
+
project: current.session.cwd,
|
|
777
|
+
home: homedir(),
|
|
778
|
+
});
|
|
779
|
+
try {
|
|
780
|
+
const status = await current.harness.addMcpServer(id, config);
|
|
781
|
+
// A server that starts and then fails to speak MCP is worth
|
|
782
|
+
// reporting as the failure it is, rather than leaving a dead entry
|
|
783
|
+
// in the menu and a line in the config file.
|
|
784
|
+
//
|
|
785
|
+
// A server that asked to be logged in to is not that. It is working
|
|
786
|
+
// exactly as intended and is one browser round trip from being
|
|
787
|
+
// usable, so it stays — rolling it back here would delete the thing
|
|
788
|
+
// the user is about to authorize.
|
|
789
|
+
if (!status.connected && !status.needsAuth) {
|
|
790
|
+
await current.harness.removeMcpServer(id);
|
|
791
|
+
return send(res, 400, { error: status.error ?? `${id} did not start` });
|
|
792
|
+
}
|
|
793
|
+
await updateUserConfig((existing) => ({
|
|
794
|
+
...existing,
|
|
795
|
+
mcpServers: { ...existing.mcpServers, [id]: config },
|
|
796
|
+
}));
|
|
797
|
+
return send(res, 200, { status, ...contextOffer() });
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
await current.harness.removeMcpServer(id).catch(() => { });
|
|
801
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Log in to a connector that asked for it.
|
|
806
|
+
*
|
|
807
|
+
* The URL comes back immediately and the flow finishes on its own: the
|
|
808
|
+
* redirect lands on a loopback listener this process opened, not on this
|
|
809
|
+
* server, so there is nothing for the page to post back. It waits for
|
|
810
|
+
* the broadcast instead.
|
|
811
|
+
*/
|
|
812
|
+
case "POST /connector/authorize": {
|
|
813
|
+
const body = await readJson(req);
|
|
814
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
815
|
+
if (!id)
|
|
816
|
+
return send(res, 400, { error: "id is required" });
|
|
817
|
+
let flow;
|
|
818
|
+
try {
|
|
819
|
+
flow = await current.harness.mcpLogin(id);
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
823
|
+
}
|
|
824
|
+
if (flow.method !== "auto" || !flow.url) {
|
|
825
|
+
return send(res, 400, { error: `${id} did not offer a browser login` });
|
|
826
|
+
}
|
|
827
|
+
send(res, 200, { url: flow.url, instructions: flow.instructions });
|
|
828
|
+
// Broadcast either way. The page has a row waiting on this, and a
|
|
829
|
+
// login that was refused has to release it just as much as one that
|
|
830
|
+
// worked — otherwise the button stays disabled until a reload.
|
|
831
|
+
void flow
|
|
832
|
+
.callback()
|
|
833
|
+
.then((result) => {
|
|
834
|
+
if (result.type !== "success") {
|
|
835
|
+
broadcast({
|
|
836
|
+
type: "notice",
|
|
837
|
+
message: `${id} was not authorized: ${result.error ?? "the login was refused"}`,
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
broadcast({
|
|
841
|
+
type: "connectors_changed",
|
|
842
|
+
connector: id,
|
|
843
|
+
authorized: result.type === "success",
|
|
844
|
+
...contextOffer(),
|
|
845
|
+
});
|
|
846
|
+
})
|
|
847
|
+
.catch((err) => {
|
|
848
|
+
broadcast({
|
|
849
|
+
type: "notice",
|
|
850
|
+
message: `${id} could not be authorized: ${errorMessage(err)}`,
|
|
851
|
+
});
|
|
852
|
+
broadcast({
|
|
853
|
+
type: "connectors_changed",
|
|
854
|
+
connector: id,
|
|
855
|
+
authorized: false,
|
|
856
|
+
...contextOffer(),
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
/** Forget a connector's credential without removing the connector. */
|
|
862
|
+
case "POST /connector/signout": {
|
|
863
|
+
const body = await readJson(req);
|
|
864
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
865
|
+
if (!id)
|
|
866
|
+
return send(res, 400, { error: "id is required" });
|
|
867
|
+
const forgotten = await current.harness.mcpLogout(id);
|
|
868
|
+
return send(res, 200, { forgotten, ...contextOffer() });
|
|
869
|
+
}
|
|
870
|
+
case "POST /projects": {
|
|
871
|
+
const body = await readJson(req);
|
|
872
|
+
const dir = typeof body.path === "string" ? body.path.trim() : "";
|
|
873
|
+
if (!dir)
|
|
874
|
+
return send(res, 400, { error: "path is required" });
|
|
875
|
+
try {
|
|
876
|
+
const project = await addProject(dir);
|
|
877
|
+
return send(res, 200, { project });
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
case "POST /project": {
|
|
884
|
+
const body = await readJson(req);
|
|
885
|
+
const dir = typeof body.path === "string" ? body.path : "";
|
|
886
|
+
// Any tab: switching projects rebuilds the harness under every runner.
|
|
887
|
+
if (anyRunning())
|
|
888
|
+
return send(res, 409, { error: "a turn is running" });
|
|
889
|
+
if (body.remove === true) {
|
|
890
|
+
return send(res, 200, { removed: await removeProject(dir) });
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Throw away everything recorded about one tree.
|
|
894
|
+
*
|
|
895
|
+
* Separate from `remove`, and deliberately harder to reach, because
|
|
896
|
+
* the two are not degrees of the same act: forgetting a project tidies
|
|
897
|
+
* a list, and this destroys transcripts. The directory and its files
|
|
898
|
+
* are untouched either way — what goes is what the harness wrote about
|
|
899
|
+
* the work.
|
|
900
|
+
*/
|
|
901
|
+
if (body.purge === true) {
|
|
902
|
+
const deleted = await current.harness.deleteSessionsIn(dir);
|
|
903
|
+
for (const id of deleted)
|
|
904
|
+
runners.delete(id);
|
|
905
|
+
// Their artifact logs went with them, and a listing built a moment
|
|
906
|
+
// ago still names them.
|
|
907
|
+
cachedLogs = undefined;
|
|
908
|
+
const here = current.session.cwd === dir;
|
|
909
|
+
// Whether the window ended up somewhere else, which is also what
|
|
910
|
+
// decides whether the directory is forgotten: staying and then
|
|
911
|
+
// dropping it from the list would forget the project under the
|
|
912
|
+
// window showing it.
|
|
913
|
+
let left = !here;
|
|
914
|
+
let moved;
|
|
915
|
+
if (here) {
|
|
916
|
+
// A listed directory that no longer exists is not a destination —
|
|
917
|
+
// the switch throws and the window is left pointing at transcripts
|
|
918
|
+
// that are gone — so those are skipped, each candidate is tried in
|
|
919
|
+
// turn, and this directory is the last resort: nothing was deleted
|
|
920
|
+
// from it but records, and it is where the window already is.
|
|
921
|
+
const others = (await listProjects()).filter((project) => project.path !== dir && !project.missing);
|
|
922
|
+
for (const target of [...others.map((project) => project.path), dir]) {
|
|
923
|
+
try {
|
|
924
|
+
moved = await switchProject(target);
|
|
925
|
+
left = target !== dir;
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
// Gone since it was listed. Try the next one.
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
// Nowhere reachable at all. The harness for this tree is already
|
|
933
|
+
// built, so it gets a fresh session rather than the window being
|
|
934
|
+
// left on a deleted one.
|
|
935
|
+
if (!moved)
|
|
936
|
+
moved = await switchSession({ action: "new" });
|
|
937
|
+
}
|
|
938
|
+
else if (deleted.includes(focused)) {
|
|
939
|
+
// Not the open project, but the focused tab was showing one of its
|
|
940
|
+
// sessions. Focus has to move off it before any route that falls
|
|
941
|
+
// back to `current` answers for a transcript that is gone.
|
|
942
|
+
const next = runners.values().next().value;
|
|
943
|
+
if (next) {
|
|
944
|
+
focused = next.session.id;
|
|
945
|
+
current = { session: next.session, agent: next.agent, harness: next.harness };
|
|
946
|
+
}
|
|
947
|
+
else {
|
|
948
|
+
moved = await switchSession({ action: "new" });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (left)
|
|
952
|
+
await removeProject(dir);
|
|
953
|
+
return send(res, 200, {
|
|
954
|
+
deleted: deleted.length,
|
|
955
|
+
removed: left,
|
|
956
|
+
open: [...runners.keys()],
|
|
957
|
+
...(moved ? { state: moved } : {}),
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
const resume = typeof body.session === "string" ? body.session : undefined;
|
|
962
|
+
return send(res, 200, await switchProject(dir, resume));
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
case "GET /routines":
|
|
969
|
+
// The scheduler is real machinery; a panel that invented its own list
|
|
970
|
+
// would drift from what actually fires.
|
|
971
|
+
return send(res, 200, { routines: await current.harness.scheduler.status() });
|
|
972
|
+
case "POST /routine": {
|
|
973
|
+
const body = await readJson(req);
|
|
974
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
975
|
+
if (!name)
|
|
976
|
+
return send(res, 400, { error: "name is required" });
|
|
977
|
+
if (anyRunning())
|
|
978
|
+
return send(res, 409, { error: "a turn is running" });
|
|
979
|
+
try {
|
|
980
|
+
const run = await current.harness.scheduler.runNow(name);
|
|
981
|
+
broadcast({
|
|
982
|
+
type: "notice",
|
|
983
|
+
message: `routine ${name} ${run.ok ? "finished" : "failed"} in ${run.turns} turns`,
|
|
984
|
+
});
|
|
985
|
+
return send(res, 200, { run });
|
|
986
|
+
}
|
|
987
|
+
catch (err) {
|
|
988
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
case "GET /settings":
|
|
992
|
+
return send(res, 200, {
|
|
993
|
+
cwd: current.session.cwd,
|
|
994
|
+
workspace: current.harness.workspace,
|
|
995
|
+
// Beside the full list, not instead of it: what the page needs to
|
|
996
|
+
// show is which of those roots someone chose, because those are the
|
|
997
|
+
// only ones a control on that page can change.
|
|
998
|
+
configuredRoots: current.harness.configuredWritableRoots(),
|
|
999
|
+
permissions: current.session.permissions.listRules(),
|
|
1000
|
+
// The whole status, not just the path: the page needs to know
|
|
1001
|
+
// whether the switch it draws is the user's to flip.
|
|
1002
|
+
audit: await current.harness.auditStatus(),
|
|
1003
|
+
settingsPaths: {
|
|
1004
|
+
user: current.harness.settings.pathFor("user"),
|
|
1005
|
+
project: current.harness.settings.pathFor("project"),
|
|
1006
|
+
local: current.harness.settings.pathFor("local"),
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
/**
|
|
1010
|
+
* Replace the write roots this machine allows.
|
|
1011
|
+
*
|
|
1012
|
+
* Only the configured ones travel here. The rest of that list is
|
|
1013
|
+
* contributed by installed plugins and is rebuilt at launch, so
|
|
1014
|
+
* accepting an edit to it would be accepting a change that silently
|
|
1015
|
+
* reverts.
|
|
1016
|
+
*/
|
|
1017
|
+
case "POST /settings/writable-roots": {
|
|
1018
|
+
const body = await readJson(req);
|
|
1019
|
+
if (!Array.isArray(body.roots) || body.roots.some((r) => typeof r !== "string")) {
|
|
1020
|
+
return send(res, 400, { error: "roots must be an array of paths" });
|
|
1021
|
+
}
|
|
1022
|
+
const saved = await current.harness.setWritableRoots(body.roots);
|
|
1023
|
+
return send(res, 200, { roots: saved });
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Who is signed in, if anyone.
|
|
1027
|
+
*
|
|
1028
|
+
* The same entry `accountToken` reads, so this answers for the
|
|
1029
|
+
* credential the rest of the app would actually use — an account row
|
|
1030
|
+
* reporting a different one from the thing doing the work would be
|
|
1031
|
+
* worse than none.
|
|
1032
|
+
*/
|
|
1033
|
+
case "GET /account": {
|
|
1034
|
+
const credential = await current.harness.auth.store.get("official").catch(() => undefined);
|
|
1035
|
+
if (credential?.type === "oauth") {
|
|
1036
|
+
// The site as well as the account: the row's menu leads to the
|
|
1037
|
+
// dashboard, and where that lives is this process's setting rather
|
|
1038
|
+
// than something a page should carry a copy of.
|
|
1039
|
+
return send(res, 200, {
|
|
1040
|
+
signedIn: true,
|
|
1041
|
+
provider: "official",
|
|
1042
|
+
site: siteUrl(),
|
|
1043
|
+
...accountIdentity(credential.access),
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
// Named even when signed out, so the button knows which sign-in to
|
|
1047
|
+
// start without the page hard-coding a provider id of its own.
|
|
1048
|
+
return send(res, 200, { signedIn: false, provider: "official" });
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* What the account has left, and what it could buy.
|
|
1052
|
+
*
|
|
1053
|
+
* Proxied rather than called from the page. The gateway wants the
|
|
1054
|
+
* account's bearer token, and that token is the one thing that must not
|
|
1055
|
+
* reach a browser tab: anything able to read the page could then spend
|
|
1056
|
+
* money as well as read a transcript.
|
|
1057
|
+
*
|
|
1058
|
+
* Every failure answers 200 with a reason rather than a status. The
|
|
1059
|
+
* caller is a paywall that has to render something either way, and "the
|
|
1060
|
+
* shop cannot be reached" is a sentence someone can act on where a 502
|
|
1061
|
+
* is not.
|
|
1062
|
+
*/
|
|
1063
|
+
case "GET /billing": {
|
|
1064
|
+
const token = await current.harness.accountToken();
|
|
1065
|
+
if (!token)
|
|
1066
|
+
return send(res, 200, { signedIn: false, provider: "official" });
|
|
1067
|
+
const [state, catalogue] = await Promise.all([
|
|
1068
|
+
gatewayJson("/api/v1/billing/balance", { token }),
|
|
1069
|
+
gatewayJson("/api/v1/billing/plans"),
|
|
1070
|
+
]);
|
|
1071
|
+
if (!state.ok) {
|
|
1072
|
+
// 501 is a deployment that sells nothing — self-hosted, or a gateway
|
|
1073
|
+
// with no Stripe key. Not an error to report: it is the reason the
|
|
1074
|
+
// buttons should not be on screen at all.
|
|
1075
|
+
return send(res, 200, {
|
|
1076
|
+
signedIn: true,
|
|
1077
|
+
unavailable: state.status === 501 ? "not_for_sale" : "unreachable",
|
|
1078
|
+
error: state.error,
|
|
1079
|
+
site: siteUrl(),
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
return send(res, 200, {
|
|
1083
|
+
signedIn: true,
|
|
1084
|
+
balanceMicro: state.value["balanceMicro"] ?? 0,
|
|
1085
|
+
credits: state.value["credits"] ?? 0,
|
|
1086
|
+
...(state.value["subscription"] ? { subscription: state.value["subscription"] } : {}),
|
|
1087
|
+
// An empty list is the honest answer when the price list cannot be
|
|
1088
|
+
// read; the paywall falls back to sending people to the site, which
|
|
1089
|
+
// is where buying works without this app at all.
|
|
1090
|
+
plans: catalogue.ok ? (catalogue.value["plans"] ?? []) : [],
|
|
1091
|
+
site: siteUrl(),
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* A Stripe checkout, as a URL for the browser to open.
|
|
1096
|
+
*
|
|
1097
|
+
* No card field is ever rendered here and none is ever seen: what comes
|
|
1098
|
+
* back is a link to Stripe's own page, opened the way the sign-in is —
|
|
1099
|
+
* in the real browser, where 3-D Secure and a bank's own app can do what
|
|
1100
|
+
* they need to and a local window cannot.
|
|
1101
|
+
*/
|
|
1102
|
+
case "POST /billing/checkout": {
|
|
1103
|
+
const body = await readJson(req);
|
|
1104
|
+
const plan = typeof body.plan === "string" ? body.plan : "";
|
|
1105
|
+
if (!plan)
|
|
1106
|
+
return send(res, 400, { error: "which plan?" });
|
|
1107
|
+
const token = await current.harness.accountToken();
|
|
1108
|
+
if (!token)
|
|
1109
|
+
return send(res, 400, { error: "sign in first" });
|
|
1110
|
+
const locale = typeof body.locale === "string" ? body.locale : undefined;
|
|
1111
|
+
const result = await gatewayJson("/api/v1/billing/checkout", {
|
|
1112
|
+
token,
|
|
1113
|
+
body: { plan, ...(locale ? { locale } : {}) },
|
|
1114
|
+
});
|
|
1115
|
+
if (!result.ok)
|
|
1116
|
+
return send(res, 400, { error: result.error });
|
|
1117
|
+
return send(res, 200, { url: result.value["url"] });
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Whether transcripts and artifacts are leaving this machine.
|
|
1121
|
+
*
|
|
1122
|
+
* `signedIn` travels beside the decision because the two off-states need
|
|
1123
|
+
* opposite advice and look identical otherwise: "you turned this off" is
|
|
1124
|
+
* fixed by the switch, "you are not signed in" is not, and a toggle that
|
|
1125
|
+
* silently does nothing in the second case is the worst of both.
|
|
1126
|
+
*/
|
|
1127
|
+
case "GET /sync": {
|
|
1128
|
+
const status = current.harness.syncStatus();
|
|
1129
|
+
return send(res, 200, {
|
|
1130
|
+
...status,
|
|
1131
|
+
signedIn: Boolean(await current.harness.accountToken()),
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
case "POST /sync": {
|
|
1135
|
+
const body = await readJson(req);
|
|
1136
|
+
if (typeof body.enabled !== "boolean") {
|
|
1137
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
1138
|
+
}
|
|
1139
|
+
await current.harness.setSyncEnabled(body.enabled);
|
|
1140
|
+
const status = current.harness.syncStatus();
|
|
1141
|
+
return send(res, 200, {
|
|
1142
|
+
...status,
|
|
1143
|
+
signedIn: Boolean(await current.harness.accountToken()),
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Push what is waiting, now.
|
|
1148
|
+
*
|
|
1149
|
+
* The mirror already pushes on its own, so this is not how sync works —
|
|
1150
|
+
* it is how someone finds out that it has, before closing a laptop. That
|
|
1151
|
+
* is not a question a background timer can answer.
|
|
1152
|
+
*/
|
|
1153
|
+
case "POST /sync/now": {
|
|
1154
|
+
const pushed = await current.harness.syncNow();
|
|
1155
|
+
if (!pushed) {
|
|
1156
|
+
const status = current.harness.syncStatus();
|
|
1157
|
+
return send(res, 400, {
|
|
1158
|
+
error: status.reason === "no-credential"
|
|
1159
|
+
? "not signed in, so there is nowhere to push to"
|
|
1160
|
+
: status.reason === "excluded"
|
|
1161
|
+
? "this project is excluded from sync"
|
|
1162
|
+
: "sync is off",
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
return send(res, 200, { pushed: true });
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Start or stop recording permission decisions.
|
|
1169
|
+
*
|
|
1170
|
+
* Its own route rather than a field on a settings PUT, because that is
|
|
1171
|
+
* the shape every other switch on that page already has, and because
|
|
1172
|
+
* the refusal — a project that decided this for you — needs somewhere to
|
|
1173
|
+
* be said.
|
|
1174
|
+
*/
|
|
1175
|
+
case "POST /audit": {
|
|
1176
|
+
const body = await readJson(req);
|
|
1177
|
+
if (typeof body.enabled !== "boolean") {
|
|
1178
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
return send(res, 200, await current.harness.setAudit(body.enabled));
|
|
1182
|
+
}
|
|
1183
|
+
catch (err) {
|
|
1184
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* The trail, as a file.
|
|
1189
|
+
*
|
|
1190
|
+
* The same JSON lines that are on disk, with the session id on every one
|
|
1191
|
+
* — not a report generated for the occasion. An export whose shape
|
|
1192
|
+
* differs from the record it came from is a second format to keep
|
|
1193
|
+
* honest, and the first question anybody asks of an audit file is
|
|
1194
|
+
* whether it is the audit.
|
|
1195
|
+
*
|
|
1196
|
+
* `session=all` walks every session on this machine. That is a file read
|
|
1197
|
+
* per session and nobody does it twice a minute; it is the answer to
|
|
1198
|
+
* "give me the record", which is what an export is for.
|
|
1199
|
+
*/
|
|
1200
|
+
case "GET /audit/export": {
|
|
1201
|
+
const asked = url.searchParams.get("session") ?? current.session.id;
|
|
1202
|
+
const ids = asked === "all"
|
|
1203
|
+
? (await current.harness.listSessions(Number.MAX_SAFE_INTEGER))
|
|
1204
|
+
.sort((a, b) => a.updatedAt - b.updatedAt)
|
|
1205
|
+
.map((meta) => meta.id)
|
|
1206
|
+
: [asked];
|
|
1207
|
+
let body = "";
|
|
1208
|
+
for (const id of ids) {
|
|
1209
|
+
for (const entry of await current.harness.decisions(id)) {
|
|
1210
|
+
body += `${JSON.stringify({ ...entry, session: id })}\n`;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// Named for what it holds and when it was taken: an audit file called
|
|
1214
|
+
// audit.jsonl in a downloads folder full of them is one nobody can
|
|
1215
|
+
// place afterwards.
|
|
1216
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
1217
|
+
const name = `oharness-audit-${asked === "all" ? "all" : asked}-${stamp}.jsonl`;
|
|
1218
|
+
res.writeHead(200, {
|
|
1219
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
1220
|
+
"content-length": Buffer.byteLength(body),
|
|
1221
|
+
"content-disposition": `attachment; filename="${name}"`,
|
|
1222
|
+
"cache-control": "private, no-store",
|
|
1223
|
+
});
|
|
1224
|
+
return void res.end(body);
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* One session's permission trail.
|
|
1228
|
+
*
|
|
1229
|
+
* Scoped to a session because that is where it is written and how it is
|
|
1230
|
+
* meant to be read — beside the conversation it describes. Defaults to
|
|
1231
|
+
* the focused one so the panel does not have to know an id to open.
|
|
1232
|
+
*/
|
|
1233
|
+
case "GET /audit": {
|
|
1234
|
+
const id = url.searchParams.get("session") ?? current.session.id;
|
|
1235
|
+
return send(res, 200, {
|
|
1236
|
+
session: id,
|
|
1237
|
+
entries: await current.harness.decisions(id),
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
// The key lives behind this rather than in the HTML: a build that has one
|
|
1241
|
+
// still hands it to nobody until they have opted in.
|
|
1242
|
+
case "GET /analytics":
|
|
1243
|
+
return send(res, 200, await analyticsState());
|
|
1244
|
+
case "POST /analytics": {
|
|
1245
|
+
const body = await readJson(req);
|
|
1246
|
+
if (typeof body.enabled !== "boolean") {
|
|
1247
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
1248
|
+
}
|
|
1249
|
+
await writePrefs({ analytics: body.enabled });
|
|
1250
|
+
return send(res, 200, await analyticsState());
|
|
1251
|
+
}
|
|
1252
|
+
case "GET /changes": {
|
|
1253
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
1254
|
+
return send(res, 200, { changes: runner ? runner.ledger.list() : [] });
|
|
1255
|
+
}
|
|
1256
|
+
case "GET /sessions": {
|
|
1257
|
+
const prefs = await readPrefs();
|
|
1258
|
+
const pinned = new Set(prefs.pinnedSessions ?? []);
|
|
1259
|
+
return send(res, 200, {
|
|
1260
|
+
// What somebody arranged by hand travels beside the list rather than
|
|
1261
|
+
// being applied to it here: the sidebar groups by project and hides
|
|
1262
|
+
// the tail of each group, so it is the only side that knows which
|
|
1263
|
+
// rows an order has to survive.
|
|
1264
|
+
order: prefs.sessionOrder ?? {},
|
|
1265
|
+
sessions: (await current.harness.listSessions(50)).map((meta) => ({
|
|
1266
|
+
id: meta.id,
|
|
1267
|
+
pinned: pinned.has(meta.id),
|
|
1268
|
+
title: meta.title,
|
|
1269
|
+
updatedAt: meta.updatedAt,
|
|
1270
|
+
turns: meta.turns,
|
|
1271
|
+
costUsd: meta.costUsd,
|
|
1272
|
+
current: meta.id === current.session.id,
|
|
1273
|
+
forkedFrom: meta.forkedFrom,
|
|
1274
|
+
// Sessions are stored per home, not per project, so this list spans
|
|
1275
|
+
// every tree opened here. The page groups by it rather than showing
|
|
1276
|
+
// one project's transcripts as another's history.
|
|
1277
|
+
cwd: meta.cwd,
|
|
1278
|
+
})),
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* What has been spent on this machine, and on what.
|
|
1283
|
+
*
|
|
1284
|
+
* Read back from the session metadata on disk rather than kept as a
|
|
1285
|
+
* running total. A counter of its own would have to survive restarts,
|
|
1286
|
+
* deleted sessions and runs started from the terminal, and it would be
|
|
1287
|
+
* wrong in a different way after each of those — the transcripts are the
|
|
1288
|
+
* only record that cannot drift from what actually happened.
|
|
1289
|
+
*
|
|
1290
|
+
* Sessions open in this process are laid over their stored copy: one
|
|
1291
|
+
* mid-turn has already spent money its metadata will not know about
|
|
1292
|
+
* until the turn lands, and the page asking is the page that spent it.
|
|
1293
|
+
*/
|
|
1294
|
+
case "GET /usage": {
|
|
1295
|
+
// How far back the page is asking about. Everything below — the
|
|
1296
|
+
// totals, the models, the sessions and the curve — is that window and
|
|
1297
|
+
// nothing else, because a figure that answers a different question
|
|
1298
|
+
// from the chart under it is worse than no figure.
|
|
1299
|
+
const range = USAGE_RANGES[url.searchParams.get("range") ?? ""] ?? USAGE_RANGES["30d"];
|
|
1300
|
+
const since = range.days ? Date.now() - range.days * DAY_MS : 0;
|
|
1301
|
+
const stored = await current.harness.listSessions(USAGE_SESSION_SCAN);
|
|
1302
|
+
const rows = new Map();
|
|
1303
|
+
for (const meta of stored) {
|
|
1304
|
+
rows.set(meta.id, {
|
|
1305
|
+
id: meta.id,
|
|
1306
|
+
title: meta.title,
|
|
1307
|
+
model: meta.model,
|
|
1308
|
+
updatedAt: meta.updatedAt,
|
|
1309
|
+
turns: meta.turns,
|
|
1310
|
+
costUsd: meta.costUsd || 0,
|
|
1311
|
+
...tokensOf(meta.usage),
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
for (const runner of runners.values()) {
|
|
1315
|
+
const sess = runner.session;
|
|
1316
|
+
rows.set(sess.id, {
|
|
1317
|
+
id: sess.id,
|
|
1318
|
+
title: sess.title,
|
|
1319
|
+
model: sess.model.id,
|
|
1320
|
+
// A session opened but not yet spoken to has no stored copy and no
|
|
1321
|
+
// last-updated time of its own; it is here, so it is now.
|
|
1322
|
+
updatedAt: rows.get(sess.id)?.updatedAt ?? Date.now(),
|
|
1323
|
+
turns: sess.turns,
|
|
1324
|
+
costUsd: sess.costUsd,
|
|
1325
|
+
...tokensOf(sess.usage),
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
// Sessions that never sent anything are dropped: they contribute zero
|
|
1329
|
+
// to every total here, and a page about what was spent that opens with
|
|
1330
|
+
// a screen of "$0.0000 · 0 tokens" buries the ones that cost money.
|
|
1331
|
+
// They are still counted in `scanned`, which is what says how far back
|
|
1332
|
+
// the numbers above them reach.
|
|
1333
|
+
const list = [...rows.values()]
|
|
1334
|
+
.filter((row) => row.updatedAt >= since)
|
|
1335
|
+
.filter((row) => row.costUsd > 0 || row.input || row.output || row.cacheRead || row.cacheWrite)
|
|
1336
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1337
|
+
const zero = { costUsd: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
1338
|
+
const add = (into, row) => {
|
|
1339
|
+
into.costUsd += row.costUsd;
|
|
1340
|
+
into.input += row.input;
|
|
1341
|
+
into.output += row.output;
|
|
1342
|
+
into.cacheRead += row.cacheRead;
|
|
1343
|
+
into.cacheWrite += row.cacheWrite;
|
|
1344
|
+
return into;
|
|
1345
|
+
};
|
|
1346
|
+
const totals = list.reduce(add, { ...zero });
|
|
1347
|
+
// Per model, because that is the axis a bill is read along: the same
|
|
1348
|
+
// work costs an order of magnitude more on one model than another, and
|
|
1349
|
+
// the only way to see that is to have them side by side.
|
|
1350
|
+
const byModel = new Map();
|
|
1351
|
+
for (const row of list) {
|
|
1352
|
+
const bucket = byModel.get(row.model)
|
|
1353
|
+
?? { model: row.model, sessions: 0, lastUsedAt: 0, ...zero };
|
|
1354
|
+
bucket.sessions += 1;
|
|
1355
|
+
bucket.lastUsedAt = Math.max(bucket.lastUsedAt, row.updatedAt);
|
|
1356
|
+
byModel.set(row.model, add(bucket, row));
|
|
1357
|
+
}
|
|
1358
|
+
return send(res, 200, {
|
|
1359
|
+
range: range.id,
|
|
1360
|
+
series: usageSeries(list, since || (list.length ? list[list.length - 1].updatedAt : Date.now())),
|
|
1361
|
+
totals: { ...totals, sessions: list.length },
|
|
1362
|
+
// Sorted by what was spent, not by name: the row worth seeing first
|
|
1363
|
+
// is the expensive one.
|
|
1364
|
+
models: [...byModel.values()].sort((a, b) => b.costUsd - a.costUsd),
|
|
1365
|
+
// A page of them is a page nobody reads. The rest are counted in the
|
|
1366
|
+
// totals above, which is where a number that large belongs.
|
|
1367
|
+
sessions: list.slice(0, 20).map((row) => ({
|
|
1368
|
+
...row,
|
|
1369
|
+
current: row.id === current.session.id,
|
|
1370
|
+
})),
|
|
1371
|
+
scanned: rows.size,
|
|
1372
|
+
// Said out loud so a total that stops short is not read as final.
|
|
1373
|
+
truncated: stored.length >= USAGE_SESSION_SCAN,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
case "GET /models": {
|
|
1377
|
+
// Which providers actually have credentials, so the picker can say
|
|
1378
|
+
// "sign in" instead of offering a model whose first request 401s.
|
|
1379
|
+
const configured = await current.harness.configuredProviders();
|
|
1380
|
+
// Where each credential came from. The engine already distinguishes an
|
|
1381
|
+
// environment variable from a stored key; the panel could not, so a
|
|
1382
|
+
// provider connected purely by env looked the same as an unconnected
|
|
1383
|
+
// one with a stale entry.
|
|
1384
|
+
const sources = new Map((await current.harness.authStatus()).map((entry) => [entry.providerId, entry.source]));
|
|
1385
|
+
// Who is signed in, where anyone is. "Connected" answers whether a
|
|
1386
|
+
// credential exists; on an account-backed plan the useful question is
|
|
1387
|
+
// *whose* account, which is the one thing a stale login gets wrong.
|
|
1388
|
+
const accounts = new Map();
|
|
1389
|
+
for (const provider of current.harness.auth.list()) {
|
|
1390
|
+
const credential = await current.harness.auth.store.get(provider.id);
|
|
1391
|
+
const email = credential?.type === "oauth" ? accountEmail(credential.access) : undefined;
|
|
1392
|
+
if (email)
|
|
1393
|
+
accounts.set(provider.id, email);
|
|
1394
|
+
}
|
|
1395
|
+
return send(res, 200, {
|
|
1396
|
+
models: current.harness.listModels().map((model) => ({
|
|
1397
|
+
id: model.id,
|
|
1398
|
+
provider: model.provider,
|
|
1399
|
+
contextWindow: model.contextWindow,
|
|
1400
|
+
configured: configured.has(model.provider),
|
|
1401
|
+
current: model.id === current.session.model.id,
|
|
1402
|
+
})),
|
|
1403
|
+
providers: current.harness.listCatalogProviders().map((entry) => ({
|
|
1404
|
+
id: entry.id,
|
|
1405
|
+
label: entry.label,
|
|
1406
|
+
envVars: entry.envVars,
|
|
1407
|
+
docsUrl: entry.docsUrl,
|
|
1408
|
+
// Where it would be reached. The only useful thing to say when a
|
|
1409
|
+
// local server discovers nothing is which address was tried.
|
|
1410
|
+
baseUrl: entry.baseUrl,
|
|
1411
|
+
popular: Boolean(entry.popular),
|
|
1412
|
+
recommended: Boolean(entry.recommended),
|
|
1413
|
+
description: entry.description,
|
|
1414
|
+
source: sources.get(entry.id),
|
|
1415
|
+
account: accounts.get(entry.id),
|
|
1416
|
+
configured: configured.has(entry.id),
|
|
1417
|
+
// Which vendors can be signed into from here rather than pasted a
|
|
1418
|
+
// key into. The panel has no other way to tell, and offering a key
|
|
1419
|
+
// field for an account-backed plan is a dead end.
|
|
1420
|
+
signIn: signInMethod(entry.id),
|
|
1421
|
+
})),
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
case "POST /auth": {
|
|
1425
|
+
const body = await readJson(req);
|
|
1426
|
+
const providerId = typeof body.provider === "string" ? body.provider : "";
|
|
1427
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
1428
|
+
if (!providerId || !key)
|
|
1429
|
+
return send(res, 400, { error: "provider and key are required" });
|
|
1430
|
+
const provider = current.harness.auth.get(providerId);
|
|
1431
|
+
if (!provider)
|
|
1432
|
+
return send(res, 404, { error: `unknown provider: ${providerId}` });
|
|
1433
|
+
const index = provider.methods.findIndex((method) => method.type === "api");
|
|
1434
|
+
if (index === -1) {
|
|
1435
|
+
return send(res, 400, {
|
|
1436
|
+
error: `${provider.label} does not take an API key — sign in from the terminal with \`oharness auth login ${providerId}\``,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
// Same path the CLI uses, so the credential lands in auth.json with
|
|
1440
|
+
// the same shape and the same 0600 permissions.
|
|
1441
|
+
await current.harness.auth.submit(providerId, index, { key });
|
|
1442
|
+
// Answer as soon as the credential is stored. Discovery reaches out to
|
|
1443
|
+
// the vendor, and holding the response until it finishes made the save
|
|
1444
|
+
// button depend on a third party being reachable.
|
|
1445
|
+
send(res, 200, { ok: true });
|
|
1446
|
+
void current.harness
|
|
1447
|
+
.refreshModels(providerId)
|
|
1448
|
+
.then((found) => {
|
|
1449
|
+
broadcast({
|
|
1450
|
+
type: "models_changed",
|
|
1451
|
+
provider: providerId,
|
|
1452
|
+
discovered: found.length,
|
|
1453
|
+
});
|
|
1454
|
+
})
|
|
1455
|
+
.catch((err) => {
|
|
1456
|
+
broadcast({
|
|
1457
|
+
type: "notice",
|
|
1458
|
+
message: `signed in to ${providerId}, but discovering its models failed: ${errorMessage(err)}`,
|
|
1459
|
+
});
|
|
1460
|
+
});
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
case "POST /signin": {
|
|
1464
|
+
const body = await readJson(req);
|
|
1465
|
+
const providerId = typeof body.provider === "string" ? body.provider : "";
|
|
1466
|
+
const method = providerId ? signInMethod(providerId) : undefined;
|
|
1467
|
+
if (!method)
|
|
1468
|
+
return send(res, 400, { error: "that provider has no sign-in" });
|
|
1469
|
+
// Whatever the method asked for — an email for the account plans. Sent
|
|
1470
|
+
// through verbatim so a provider can add a field without this endpoint
|
|
1471
|
+
// learning about it.
|
|
1472
|
+
const answers = {};
|
|
1473
|
+
for (const [key, value] of Object.entries(body.answers ?? {})) {
|
|
1474
|
+
if (typeof value === "string")
|
|
1475
|
+
answers[key] = value;
|
|
1476
|
+
}
|
|
1477
|
+
try {
|
|
1478
|
+
const flow = await current.harness.auth.authorize(providerId, method.index, answers);
|
|
1479
|
+
if (flow.method !== "code") {
|
|
1480
|
+
return send(res, 400, {
|
|
1481
|
+
error: `${providerId} signs in a way this page cannot drive — use \`oharness auth login ${providerId}\``,
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
// The callback is a closure over the flow's state, so it has to
|
|
1485
|
+
// outlive this request. Kept in memory only: it is worthless after
|
|
1486
|
+
// the code is used, and writing a half-finished login to disk would
|
|
1487
|
+
// leave one there whenever the tab is closed mid-flow.
|
|
1488
|
+
const id = randomUUID();
|
|
1489
|
+
pendingSignIns.set(id, { provider: providerId, callback: flow.callback });
|
|
1490
|
+
setTimeout(() => pendingSignIns.delete(id), SIGNIN_TTL_MS).unref?.();
|
|
1491
|
+
return send(res, 200, { flow: id, instructions: flow.instructions });
|
|
1492
|
+
}
|
|
1493
|
+
catch (err) {
|
|
1494
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
case "POST /signin/browser": {
|
|
1498
|
+
const body = await readJson(req);
|
|
1499
|
+
const providerId = typeof body.provider === "string" ? body.provider : "";
|
|
1500
|
+
// The same providers the emailed code serves: this is the OHarness
|
|
1501
|
+
// account, and a vendor with no account here has no site to go to.
|
|
1502
|
+
if (!providerId || !signInMethod(providerId)) {
|
|
1503
|
+
return send(res, 400, { error: "that provider has no sign-in" });
|
|
1504
|
+
}
|
|
1505
|
+
// Built from the host the request came in on, so the browser is sent
|
|
1506
|
+
// back to the address this server is actually reachable at. It
|
|
1507
|
+
// defaults to 4310 but falls back to whatever was free when 4310 was
|
|
1508
|
+
// taken, so the running process is still the only thing that knows.
|
|
1509
|
+
const host = req.headers.host ?? "127.0.0.1";
|
|
1510
|
+
// Asked before a tab is opened, not after. The site redirects a
|
|
1511
|
+
// session only to loopback — anything else would make it an open
|
|
1512
|
+
// redirect that hands out sessions — so a UI reached over a LAN
|
|
1513
|
+
// address or a tunnel cannot use this route at all. Left to the site,
|
|
1514
|
+
// the refusal arrives as a 400 in a tab the user has already switched
|
|
1515
|
+
// to, with the emailed code that would have worked nowhere in sight.
|
|
1516
|
+
if (!isLoopbackHost(host)) {
|
|
1517
|
+
return send(res, 400, {
|
|
1518
|
+
error: `this window is open at ${host}, and the sign-in can only come back to a copy reached at localhost — use the emailed code instead`,
|
|
1519
|
+
fallback: "code",
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
const state = randomUUID() + randomUUID();
|
|
1523
|
+
pendingBrowserSignIns.set(state, { provider: providerId, started: Date.now() });
|
|
1524
|
+
setTimeout(() => pendingBrowserSignIns.delete(state), BROWSER_SIGNIN_TTL_MS).unref?.();
|
|
1525
|
+
const origin = `http://${host}`;
|
|
1526
|
+
const locale = typeof body.locale === "string" ? body.locale : undefined;
|
|
1527
|
+
return send(res, 200, {
|
|
1528
|
+
url: browserSignInUrl({
|
|
1529
|
+
state,
|
|
1530
|
+
redirectUri: `${origin}/signin/handoff`,
|
|
1531
|
+
...(locale ? { locale } : {}),
|
|
1532
|
+
}),
|
|
1533
|
+
site: siteUrl(),
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
case "POST /signin/code": {
|
|
1537
|
+
const body = await readJson(req);
|
|
1538
|
+
const id = typeof body.flow === "string" ? body.flow : "";
|
|
1539
|
+
const code = typeof body.code === "string" ? body.code.trim() : "";
|
|
1540
|
+
const pending = pendingSignIns.get(id);
|
|
1541
|
+
if (!pending)
|
|
1542
|
+
return send(res, 400, { error: "that sign-in expired — start it again" });
|
|
1543
|
+
if (!code)
|
|
1544
|
+
return send(res, 400, { error: "code is required" });
|
|
1545
|
+
try {
|
|
1546
|
+
const result = await pending.callback(code);
|
|
1547
|
+
if (result.type !== "success") {
|
|
1548
|
+
// The flow stays open: a mistyped code should cost a retry, not
|
|
1549
|
+
// another email.
|
|
1550
|
+
return send(res, 400, { error: result.error ?? "that code was not accepted" });
|
|
1551
|
+
}
|
|
1552
|
+
await current.harness.auth.complete(pending.provider, result);
|
|
1553
|
+
}
|
|
1554
|
+
catch (err) {
|
|
1555
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
1556
|
+
}
|
|
1557
|
+
pendingSignIns.delete(id);
|
|
1558
|
+
send(res, 200, { ok: true });
|
|
1559
|
+
// Same as a saved key: answer first, then go ask the gateway what the
|
|
1560
|
+
// account can actually call.
|
|
1561
|
+
void current.harness
|
|
1562
|
+
.refreshModels(pending.provider)
|
|
1563
|
+
.then((found) => {
|
|
1564
|
+
broadcast({
|
|
1565
|
+
type: "models_changed",
|
|
1566
|
+
provider: pending.provider,
|
|
1567
|
+
discovered: found.length,
|
|
1568
|
+
});
|
|
1569
|
+
})
|
|
1570
|
+
.catch((err) => {
|
|
1571
|
+
broadcast({
|
|
1572
|
+
type: "notice",
|
|
1573
|
+
message: `signed in to ${pending.provider}, but discovering its models failed: ${errorMessage(err)}`,
|
|
1574
|
+
});
|
|
1575
|
+
});
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
case "POST /logout": {
|
|
1579
|
+
const body = await readJson(req);
|
|
1580
|
+
const providerId = typeof body.provider === "string" ? body.provider : "";
|
|
1581
|
+
if (!providerId)
|
|
1582
|
+
return send(res, 400, { error: "provider is required" });
|
|
1583
|
+
const removed = await current.harness.auth.logout(providerId);
|
|
1584
|
+
return send(res, 200, { removed });
|
|
1585
|
+
}
|
|
1586
|
+
case "POST /models/refresh": {
|
|
1587
|
+
const body = await readJson(req);
|
|
1588
|
+
const providerId = typeof body.provider === "string" ? body.provider : undefined;
|
|
1589
|
+
try {
|
|
1590
|
+
// Asks each vendor's own /models endpoint. Model ids change faster
|
|
1591
|
+
// than this package ships, so discovery is the only list that can be
|
|
1592
|
+
// right — the built-in catalog carries providers, not model names.
|
|
1593
|
+
const found = await current.harness.refreshModels(providerId);
|
|
1594
|
+
return send(res, 200, { discovered: found.length });
|
|
1595
|
+
}
|
|
1596
|
+
catch (err) {
|
|
1597
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
case "POST /model": {
|
|
1601
|
+
const body = await readJson(req);
|
|
1602
|
+
const runner = runnerFor(body.session);
|
|
1603
|
+
if (!runner)
|
|
1604
|
+
return send(res, 404, { error: "no such session" });
|
|
1605
|
+
if (runner.running)
|
|
1606
|
+
return send(res, 409, { error: "a turn is running" });
|
|
1607
|
+
if (typeof body.id !== "string")
|
|
1608
|
+
return send(res, 400, { error: "id is required" });
|
|
1609
|
+
try {
|
|
1610
|
+
const change = runner.session.setModel(body.id);
|
|
1611
|
+
// Remembered for the sessions started after this one, here and in the
|
|
1612
|
+
// next run: picking a model is a decision, not a per-session detail.
|
|
1613
|
+
preferredModel = change.to.id;
|
|
1614
|
+
void writePrefs({ model: preferredModel });
|
|
1615
|
+
broadcast({
|
|
1616
|
+
type: "model_changed",
|
|
1617
|
+
session: runner.session.id,
|
|
1618
|
+
from: change.from.id,
|
|
1619
|
+
to: change.to.id,
|
|
1620
|
+
});
|
|
1621
|
+
return send(res, 200, state(runner));
|
|
1622
|
+
}
|
|
1623
|
+
catch (err) {
|
|
1624
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
case "POST /mode": {
|
|
1628
|
+
const body = await readJson(req);
|
|
1629
|
+
const runner = runnerFor(body.session);
|
|
1630
|
+
if (!runner)
|
|
1631
|
+
return send(res, 404, { error: "no such session" });
|
|
1632
|
+
const mode = String(body.mode ?? "");
|
|
1633
|
+
if (!PERMISSION_MODES.includes(mode)) {
|
|
1634
|
+
return send(res, 400, { error: `unknown mode: ${mode}` });
|
|
1635
|
+
}
|
|
1636
|
+
runner.session.permissions.setMode(mode);
|
|
1637
|
+
broadcast({ type: "state", session: runner.session.id, state: state(runner) });
|
|
1638
|
+
return send(res, 200, state(runner));
|
|
1639
|
+
}
|
|
1640
|
+
case "POST /session": {
|
|
1641
|
+
const body = await readJson(req);
|
|
1642
|
+
// Closing frees the runner but leaves the transcript on disk, so the
|
|
1643
|
+
// session reopens from the sidebar exactly as it was.
|
|
1644
|
+
if (body.action === "close") {
|
|
1645
|
+
const id = String(body.id ?? "");
|
|
1646
|
+
const runner = runners.get(id);
|
|
1647
|
+
runner?.running?.abort();
|
|
1648
|
+
runners.delete(id);
|
|
1649
|
+
// Its artifacts move from memory to the log half of the catalogue,
|
|
1650
|
+
// and a fold taken before this session existed does not have them.
|
|
1651
|
+
cachedLogs = undefined;
|
|
1652
|
+
if (focused === id)
|
|
1653
|
+
focused = [...runners.keys()][0] ?? focused;
|
|
1654
|
+
return send(res, 200, { closed: Boolean(runner), open: [...runners.keys()] });
|
|
1655
|
+
}
|
|
1656
|
+
// Deleting throws the transcript away for good, so unlike `close` it
|
|
1657
|
+
// refuses to touch a session that is mid-turn: aborting only asks the
|
|
1658
|
+
// loop to stop, and a turn still writing would recreate the log we
|
|
1659
|
+
// just removed.
|
|
1660
|
+
if (body.action === "delete") {
|
|
1661
|
+
const id = String(body.id ?? "");
|
|
1662
|
+
if (!id)
|
|
1663
|
+
return send(res, 400, { error: "id is required" });
|
|
1664
|
+
if (runners.get(id)?.running)
|
|
1665
|
+
return send(res, 409, { error: "session is busy" });
|
|
1666
|
+
// Reported rather than blocked: a fork records its parent, so the
|
|
1667
|
+
// page can say what the lineage loses instead of the branches
|
|
1668
|
+
// quietly pointing at nothing.
|
|
1669
|
+
const forks = (await current.harness.store.children(id)).length;
|
|
1670
|
+
const deleted = await current.harness.deleteSession(id);
|
|
1671
|
+
// A session nobody has spoken to has nothing on disk yet — the store
|
|
1672
|
+
// writes on the first message — but it is still a session someone
|
|
1673
|
+
// opened, and it is exactly the kind that gets thrown away when the
|
|
1674
|
+
// workspace under it changes. What makes this a 404 is nothing
|
|
1675
|
+
// knowing about it at all, not the absence of a transcript.
|
|
1676
|
+
if (!deleted && !runners.has(id))
|
|
1677
|
+
return send(res, 404, { error: "no such session" });
|
|
1678
|
+
runners.delete(id);
|
|
1679
|
+
// Its artifact log went with it, and a fold taken a moment ago
|
|
1680
|
+
// still names it.
|
|
1681
|
+
cachedLogs = undefined;
|
|
1682
|
+
if (focused === id)
|
|
1683
|
+
focused = [...runners.keys()][0] ?? focused;
|
|
1684
|
+
// `current` is what routes that name no session fall back to. Left
|
|
1685
|
+
// pointing at a deleted one it would answer for a transcript that is
|
|
1686
|
+
// no longer on disk, so it moves to whatever is still open.
|
|
1687
|
+
if (current.session.id === id) {
|
|
1688
|
+
const next = runners.get(focused);
|
|
1689
|
+
if (next)
|
|
1690
|
+
current = { session: next.session, agent: next.agent, harness: next.harness };
|
|
1691
|
+
}
|
|
1692
|
+
return send(res, 200, { deleted: true, forks, open: [...runners.keys()] });
|
|
1693
|
+
}
|
|
1694
|
+
if (body.action === "focus") {
|
|
1695
|
+
const runner = runnerFor(body.id);
|
|
1696
|
+
if (!runner)
|
|
1697
|
+
return send(res, 404, { error: "no such session" });
|
|
1698
|
+
focused = runner.session.id;
|
|
1699
|
+
// Tabs can sit in different trees, so focus moves the fallback every
|
|
1700
|
+
// route without a session name uses — which project is current, and
|
|
1701
|
+
// which tree the folder dialog and the file search start from.
|
|
1702
|
+
current = { session: runner.session, agent: runner.agent, harness: runner.harness };
|
|
1703
|
+
return send(res, 200, state(runner));
|
|
1704
|
+
}
|
|
1705
|
+
return send(res, 200, await switchSession(body));
|
|
1706
|
+
}
|
|
1707
|
+
case "GET /open":
|
|
1708
|
+
// What the page should draw tabs for after a reload.
|
|
1709
|
+
return send(res, 200, {
|
|
1710
|
+
open: [...runners.values()].map((runner) => ({
|
|
1711
|
+
id: runner.session.id,
|
|
1712
|
+
title: runner.session.title,
|
|
1713
|
+
cwd: runner.session.cwd,
|
|
1714
|
+
busy: Boolean(runner.running),
|
|
1715
|
+
current: runner.session.id === focused,
|
|
1716
|
+
})),
|
|
1717
|
+
});
|
|
1718
|
+
/**
|
|
1719
|
+
* Rename a session. The focused one, or any other by id.
|
|
1720
|
+
*
|
|
1721
|
+
* The id arrived with the sidebar's row menu: renaming from a list means
|
|
1722
|
+
* renaming something other than what is on screen, and a route that
|
|
1723
|
+
* always renamed `current` would have quietly retitled the open session
|
|
1724
|
+
* instead of the row that was clicked.
|
|
1725
|
+
*/
|
|
1726
|
+
case "POST /rename": {
|
|
1727
|
+
const body = await readJson(req);
|
|
1728
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
1729
|
+
if (!title)
|
|
1730
|
+
return send(res, 400, { error: "title is required" });
|
|
1731
|
+
const id = typeof body.id === "string" && body.id ? body.id : current.session.id;
|
|
1732
|
+
const renamed = await current.harness.renameSession(id, title);
|
|
1733
|
+
if (!renamed)
|
|
1734
|
+
return send(res, 404, { error: "no such session" });
|
|
1735
|
+
// The one on screen carries its title in memory too, or the tab keeps
|
|
1736
|
+
// the old one until something else reloads it.
|
|
1737
|
+
const runner = runnerFor(id);
|
|
1738
|
+
if (runner)
|
|
1739
|
+
runner.session.title = title;
|
|
1740
|
+
return send(res, 200, { ok: true, id, title });
|
|
1741
|
+
}
|
|
1742
|
+
case "POST /upload": {
|
|
1743
|
+
const name = String(req.headers["x-filename"] ?? "file");
|
|
1744
|
+
const mediaType = String(req.headers["content-type"] ?? "application/octet-stream");
|
|
1745
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
1746
|
+
// Refused here rather than at send time, while the person is still
|
|
1747
|
+
// looking at the file they chose: a model that cannot read an image
|
|
1748
|
+
// answers a screenshot with a guess about its filename, which reads
|
|
1749
|
+
// like a bad answer rather than a wrong model. Only a model the
|
|
1750
|
+
// catalog documents as text-only is refused — a discovered endpoint
|
|
1751
|
+
// that published no capabilities at all has said nothing, and
|
|
1752
|
+
// refusing on a guess would break the common case.
|
|
1753
|
+
const spec = runner?.session.model;
|
|
1754
|
+
if (mediaType.startsWith("image/") &&
|
|
1755
|
+
spec &&
|
|
1756
|
+
!spec.supportsImages &&
|
|
1757
|
+
!spec.capabilitiesAssumed) {
|
|
1758
|
+
return send(res, 400, {
|
|
1759
|
+
error: `${spec.displayName ?? spec.id} cannot read images — switch models to attach one`,
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
const data = await readBody(req, MAX_UPLOAD_BYTES);
|
|
1763
|
+
const id = randomUUID();
|
|
1764
|
+
attachments.set(id, { name, mediaType, data, at: Date.now() });
|
|
1765
|
+
sweepAttachments();
|
|
1766
|
+
return send(res, 200, {
|
|
1767
|
+
id,
|
|
1768
|
+
name,
|
|
1769
|
+
mediaType,
|
|
1770
|
+
bytes: data.byteLength,
|
|
1771
|
+
kind: mediaType.startsWith("image/") ? "image" : "file",
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* A staged file the composer no longer points at.
|
|
1776
|
+
*
|
|
1777
|
+
* Best effort, and deliberately quiet about a miss: the page has already
|
|
1778
|
+
* dropped the chip, so nothing here can fail in a way the person could
|
|
1779
|
+
* act on. Without it the bytes would sit in memory until the sweep, and
|
|
1780
|
+
* removing a 12MB screenshot would free nothing for half an hour.
|
|
1781
|
+
*/
|
|
1782
|
+
case "POST /upload/drop": {
|
|
1783
|
+
const body = await readJson(req);
|
|
1784
|
+
if (typeof body.id === "string")
|
|
1785
|
+
attachments.delete(body.id);
|
|
1786
|
+
return send(res, 200, { ok: true });
|
|
1787
|
+
}
|
|
1788
|
+
case "POST /prompt": {
|
|
1789
|
+
const body = await readJson(req);
|
|
1790
|
+
const runner = runnerFor(body.session);
|
|
1791
|
+
if (!runner)
|
|
1792
|
+
return send(res, 404, { error: "no such session" });
|
|
1793
|
+
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
|
1794
|
+
const ids = Array.isArray(body.attachments) ? body.attachments : [];
|
|
1795
|
+
const references = readReferences(body.references);
|
|
1796
|
+
if (!prompt && !ids.length && !references.length) {
|
|
1797
|
+
return send(res, 400, { error: "prompt is required" });
|
|
1798
|
+
}
|
|
1799
|
+
// Per runner, not global: a turn in one tab must not block another.
|
|
1800
|
+
if (runner.running)
|
|
1801
|
+
return send(res, 409, { error: "a turn is already running" });
|
|
1802
|
+
const staged = ids
|
|
1803
|
+
.map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
|
|
1804
|
+
.filter((entry) => Boolean(entry));
|
|
1805
|
+
for (const id of ids)
|
|
1806
|
+
if (typeof id === "string")
|
|
1807
|
+
attachments.delete(id);
|
|
1808
|
+
// The other end of the staging map's lifecycle: sending is the moment
|
|
1809
|
+
// a composer is known to have moved on, so it is a good moment to ask
|
|
1810
|
+
// what else has been abandoned.
|
|
1811
|
+
sweepAttachments();
|
|
1812
|
+
void runTurn(runner, prompt, staged, references);
|
|
1813
|
+
return send(res, 202, { ok: true, attached: staged.length, referenced: references.length });
|
|
1814
|
+
}
|
|
1815
|
+
case "POST /permission": {
|
|
1816
|
+
const body = await readJson(req);
|
|
1817
|
+
const entry = typeof body.id === "string" ? pending.get(body.id) : undefined;
|
|
1818
|
+
if (!entry)
|
|
1819
|
+
return send(res, 404, { error: "no such pending request" });
|
|
1820
|
+
pending.delete(entry.id);
|
|
1821
|
+
entry.resolve({
|
|
1822
|
+
allow: body.allow === true,
|
|
1823
|
+
...(typeof body.remember === "string"
|
|
1824
|
+
? { remember: body.remember }
|
|
1825
|
+
: {}),
|
|
1826
|
+
});
|
|
1827
|
+
return send(res, 200, { ok: true });
|
|
1828
|
+
}
|
|
1829
|
+
case "POST /interrupt":
|
|
1830
|
+
{
|
|
1831
|
+
const body = await readJson(req);
|
|
1832
|
+
runnerFor(body.session)?.running?.abort();
|
|
1833
|
+
}
|
|
1834
|
+
return send(res, 200, { ok: true });
|
|
1835
|
+
default:
|
|
1836
|
+
return send(res, 404, { error: "not found" });
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* The browser coming back from the site, session in hand.
|
|
1841
|
+
*
|
|
1842
|
+
* Answers with a page rather than JSON: a human is looking at this tab, and
|
|
1843
|
+
* what they need to be told is that they can close it. The app itself hears
|
|
1844
|
+
* about the sign-in over the event stream, which is where it was waiting.
|
|
1845
|
+
*/
|
|
1846
|
+
async function browserSignInReturn(url, res) {
|
|
1847
|
+
const state = url.searchParams.get("state") ?? "";
|
|
1848
|
+
const pending = pendingBrowserSignIns.get(state);
|
|
1849
|
+
// Consumed on sight, whatever happens next. A nonce that survived a failed
|
|
1850
|
+
// attempt could be replayed by anything that saw it in the address bar.
|
|
1851
|
+
pendingBrowserSignIns.delete(state);
|
|
1852
|
+
if (!pending) {
|
|
1853
|
+
return closeTab(res, 400, "That sign-in has expired. Start it again from the app.");
|
|
1854
|
+
}
|
|
1855
|
+
const failure = url.searchParams.get("error");
|
|
1856
|
+
if (failure)
|
|
1857
|
+
return closeTab(res, 400, failure);
|
|
1858
|
+
const code = url.searchParams.get("code") ?? "";
|
|
1859
|
+
if (!code)
|
|
1860
|
+
return closeTab(res, 400, "The site sent no sign-in back.");
|
|
1861
|
+
const result = await exchangeHandoff(code, state);
|
|
1862
|
+
if (result.type !== "success") {
|
|
1863
|
+
const message = result.error ?? "that sign-in was not accepted";
|
|
1864
|
+
broadcast({ type: "notice", message: `sign-in failed: ${message}` });
|
|
1865
|
+
return closeTab(res, 400, message);
|
|
1866
|
+
}
|
|
1867
|
+
try {
|
|
1868
|
+
await current.harness.auth.complete(pending.provider, result);
|
|
1869
|
+
}
|
|
1870
|
+
catch (err) {
|
|
1871
|
+
return closeTab(res, 500, errorMessage(err));
|
|
1872
|
+
}
|
|
1873
|
+
closeTab(res, 200, `Signed in to ${pending.provider}.`);
|
|
1874
|
+
// Same as a saved key: the credential is stored, now go ask the gateway
|
|
1875
|
+
// what the account can actually call.
|
|
1876
|
+
void current.harness
|
|
1877
|
+
.refreshModels(pending.provider)
|
|
1878
|
+
.then((found) => {
|
|
1879
|
+
broadcast({ type: "models_changed", provider: pending.provider, discovered: found.length });
|
|
1880
|
+
})
|
|
1881
|
+
.catch((err) => {
|
|
1882
|
+
broadcast({
|
|
1883
|
+
type: "notice",
|
|
1884
|
+
message: `signed in to ${pending.provider}, but discovering its models failed: ${errorMessage(err)}`,
|
|
1885
|
+
});
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* The provider's sign-in method, if it has one.
|
|
1890
|
+
*
|
|
1891
|
+
* Reported by index because that is what `authorize` takes, and a provider
|
|
1892
|
+
* may declare several methods — the official plan takes either an account
|
|
1893
|
+
* sign-in or a key.
|
|
1894
|
+
*/
|
|
1895
|
+
function signInMethod(providerId) {
|
|
1896
|
+
const provider = current.harness.auth.get(providerId);
|
|
1897
|
+
if (!provider)
|
|
1898
|
+
return undefined;
|
|
1899
|
+
const index = provider.methods.findIndex((method) => method.type === "oauth");
|
|
1900
|
+
if (index === -1)
|
|
1901
|
+
return undefined;
|
|
1902
|
+
return { index, label: provider.methods[index].label };
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* The connectors and plugins this harness actually has.
|
|
1906
|
+
*
|
|
1907
|
+
* Read from the live harness rather than from configuration: a server that
|
|
1908
|
+
* is declared but failed to start is worth showing as exactly that, and a
|
|
1909
|
+
* menu built from the config file would claim it was there.
|
|
1910
|
+
*/
|
|
1911
|
+
function contextOffer() {
|
|
1912
|
+
const harness = current.harness;
|
|
1913
|
+
const connectors = (harness.mcpStatus?.() ?? []).map((status) => ({
|
|
1914
|
+
name: status.name,
|
|
1915
|
+
connected: status.connected,
|
|
1916
|
+
transport: status.transport,
|
|
1917
|
+
tools: status.toolCount,
|
|
1918
|
+
...(status.error ? { error: status.error } : {}),
|
|
1919
|
+
...(status.needsAuth ? { needsAuth: true } : {}),
|
|
1920
|
+
}));
|
|
1921
|
+
const plugins = (harness.plugins ?? []).map((plugin) => ({
|
|
1922
|
+
name: plugin.name,
|
|
1923
|
+
...(plugin.description ? { description: plugin.description } : {}),
|
|
1924
|
+
source: plugin.source,
|
|
1925
|
+
servers: Object.keys(plugin.mcpServers ?? {}),
|
|
1926
|
+
subagents: plugin.subagents?.length ?? 0,
|
|
1927
|
+
}));
|
|
1928
|
+
return { connectors, plugins };
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* What the composer attached to one message, as a block above the prompt.
|
|
1932
|
+
*
|
|
1933
|
+
* Paths are named, not inlined: the agent has tools for reading a file and a
|
|
1934
|
+
* folder, the user can watch it use them, and a folder someone picked out of
|
|
1935
|
+
* curiosity should not spend a hundred thousand tokens before the first
|
|
1936
|
+
* word of the answer. Connectors and plugins are described from live state
|
|
1937
|
+
* for the same reason the menu is.
|
|
1938
|
+
*/
|
|
1939
|
+
function referenceNote(references) {
|
|
1940
|
+
if (!references.length)
|
|
1941
|
+
return "";
|
|
1942
|
+
const offer = contextOffer();
|
|
1943
|
+
const lines = references.map((ref) => {
|
|
1944
|
+
if (ref.kind === "file")
|
|
1945
|
+
return `- file: ${ref.value}`;
|
|
1946
|
+
if (ref.kind === "folder")
|
|
1947
|
+
return `- folder: ${ref.value}`;
|
|
1948
|
+
if (ref.kind === "connector") {
|
|
1949
|
+
const found = offer.connectors.find((connector) => connector.name === ref.value);
|
|
1950
|
+
const detail = !found
|
|
1951
|
+
? "not connected"
|
|
1952
|
+
: found.connected
|
|
1953
|
+
? `${found.tools} tools, named mcp__${found.name}__*`
|
|
1954
|
+
: `not connected${found.error ? ` — ${found.error}` : ""}`;
|
|
1955
|
+
return `- MCP connector: ${ref.value} (${detail})`;
|
|
1956
|
+
}
|
|
1957
|
+
const plugin = offer.plugins.find((entry) => entry.name === ref.value);
|
|
1958
|
+
const detail = plugin?.description ? ` — ${plugin.description}` : "";
|
|
1959
|
+
return `- plugin: ${ref.value}${detail}`;
|
|
1960
|
+
});
|
|
1961
|
+
return [
|
|
1962
|
+
"<referenced>",
|
|
1963
|
+
"The user attached these to this message. Read what you need before answering.",
|
|
1964
|
+
...lines,
|
|
1965
|
+
"</referenced>",
|
|
1966
|
+
].join("\n");
|
|
1967
|
+
}
|
|
1968
|
+
function state(runner = runners.get(focused)) {
|
|
1969
|
+
const { session: sess } = runner;
|
|
1970
|
+
const model = sess.model;
|
|
1971
|
+
const used = sess.usage.inputTokens + sess.usage.outputTokens;
|
|
1972
|
+
return {
|
|
1973
|
+
sessionId: sess.id,
|
|
1974
|
+
// The page labels its tabs from this. Without it every tab read
|
|
1975
|
+
// "Untitled", including sessions the store had a title for all along.
|
|
1976
|
+
title: sess.title,
|
|
1977
|
+
cwd: sess.cwd,
|
|
1978
|
+
model: model.id,
|
|
1979
|
+
provider: model.provider,
|
|
1980
|
+
mode: sess.permissions.getMode(),
|
|
1981
|
+
costUsd: sess.costUsd,
|
|
1982
|
+
// Beside the cost, because the line under the composer says three things
|
|
1983
|
+
// about one turn: what it cost, how many tokens that took, and how much
|
|
1984
|
+
// of the prompt was served from cache — and the last is the only one of
|
|
1985
|
+
// the three that says whether the first could have been lower.
|
|
1986
|
+
usage: tokensOf(sess.usage),
|
|
1987
|
+
turns: sess.turns,
|
|
1988
|
+
contextWindow: model.contextWindow,
|
|
1989
|
+
contextUsed: model.contextWindow ? Math.min(1, used / model.contextWindow) : undefined,
|
|
1990
|
+
busy: Boolean(runner.running),
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* The transcript, flattened into what the page draws.
|
|
1995
|
+
*
|
|
1996
|
+
* Tool results are attached to the assistant turn that called them rather
|
|
1997
|
+
* than left as the user messages they are on the wire, because the page
|
|
1998
|
+
* shows a call and its result as one card.
|
|
1999
|
+
*/
|
|
2000
|
+
function history(runner = runners.get(focused)) {
|
|
2001
|
+
const entries = [];
|
|
2002
|
+
let index = 0;
|
|
2003
|
+
for (const message of runner.session.messages) {
|
|
2004
|
+
index++;
|
|
2005
|
+
const tools = message.content.filter((block) => block.type === "tool_call");
|
|
2006
|
+
const results = message.content.filter((block) => block.type === "tool_result");
|
|
2007
|
+
const text = messageText(message).trim();
|
|
2008
|
+
// The bytes stay where they are; what travels is where to find them.
|
|
2009
|
+
// Without this a reloaded conversation showed "[image: shot.png]" where
|
|
2010
|
+
// the screenshot had been — the model still saw it, the person did not.
|
|
2011
|
+
const images = message.content
|
|
2012
|
+
.map((block, position) => ({ block, position }))
|
|
2013
|
+
.filter((entry) => entry.block.type === "image")
|
|
2014
|
+
.map((entry) => ({
|
|
2015
|
+
src: `/attachment?session=${encodeURIComponent(runner.session.id)}` +
|
|
2016
|
+
`&message=${index}&block=${entry.position}`,
|
|
2017
|
+
}));
|
|
2018
|
+
// messageText drops thinking blocks by design, so they are read directly.
|
|
2019
|
+
const thinking = message.content
|
|
2020
|
+
.filter((block) => block.type === "thinking")
|
|
2021
|
+
.map((block) => block.thinking)
|
|
2022
|
+
.join("")
|
|
2023
|
+
.trim();
|
|
2024
|
+
if (results.length) {
|
|
2025
|
+
const target = entries.at(-1);
|
|
2026
|
+
if (target) {
|
|
2027
|
+
// The results arrived in a later message, so a fork "after this
|
|
2028
|
+
// reply" has to include it or the branch reopens with tool calls
|
|
2029
|
+
// that have no answers — a request every provider rejects.
|
|
2030
|
+
target.keepMessages = index;
|
|
2031
|
+
target.results = results.map((block) => ({
|
|
2032
|
+
id: block.toolCallId,
|
|
2033
|
+
text: block.content
|
|
2034
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
2035
|
+
.join("")
|
|
2036
|
+
.slice(0, 4000),
|
|
2037
|
+
...(block.isError ? { isError: true } : {}),
|
|
2038
|
+
}));
|
|
2039
|
+
}
|
|
2040
|
+
if (!text && !thinking && !images.length)
|
|
2041
|
+
continue;
|
|
2042
|
+
}
|
|
2043
|
+
if (!text && !tools.length && !thinking && !images.length)
|
|
2044
|
+
continue;
|
|
2045
|
+
entries.push({
|
|
2046
|
+
role: message.role === "assistant" ? "assistant" : "user",
|
|
2047
|
+
keepMessages: index,
|
|
2048
|
+
...(images.length ? { images } : {}),
|
|
2049
|
+
...(thinking ? { thinking } : {}),
|
|
2050
|
+
...(text ? { text } : {}),
|
|
2051
|
+
...(tools.length
|
|
2052
|
+
? { tools: tools.map((block) => ({ id: block.id, name: block.name, input: block.input })) }
|
|
2053
|
+
: {}),
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
return entries;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* The harness a directory's sessions run on, built the first time one asks.
|
|
2060
|
+
*
|
|
2061
|
+
* A session names the tree it works in, so starting or resuming one
|
|
2062
|
+
* elsewhere reaches for that project's harness rather than moving the
|
|
2063
|
+
* current one onto a new cwd: every runner already carries its own, and
|
|
2064
|
+
* rebuilding the open ones to follow would reconnect their MCP servers for
|
|
2065
|
+
* a change that is none of their business.
|
|
2066
|
+
*
|
|
2067
|
+
* Choosing a directory here is also a statement about what the user is
|
|
2068
|
+
* working on, so it is what the next launch opens.
|
|
2069
|
+
*/
|
|
2070
|
+
async function harnessFor(dir) {
|
|
2071
|
+
const project = await addProject(dir);
|
|
2072
|
+
void writePrefs({ project: project.path });
|
|
2073
|
+
const existing = harnesses.get(project.path);
|
|
2074
|
+
if (existing)
|
|
2075
|
+
return existing;
|
|
2076
|
+
const harness = await (opts.openProject
|
|
2077
|
+
? opts.openProject(project.path)
|
|
2078
|
+
: Harness.create({
|
|
2079
|
+
cwd: project.path,
|
|
2080
|
+
permissionPrompt: (request) => permissionPrompt(request),
|
|
2081
|
+
}));
|
|
2082
|
+
harnesses.set(project.path, harness);
|
|
2083
|
+
return harness;
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Open a different project.
|
|
2087
|
+
*
|
|
2088
|
+
* Everything scoped to a directory is rebuilt: the harness, its session, and
|
|
2089
|
+
* the change ledger. Carrying the ledger across would attribute one
|
|
2090
|
+
* project's edits to another.
|
|
2091
|
+
*/
|
|
2092
|
+
async function switchProject(dir, resume) {
|
|
2093
|
+
const harness = await harnessFor(dir);
|
|
2094
|
+
// Opening one of that project's own sessions is the common reason to
|
|
2095
|
+
// switch, and doing it in one step avoids stranding an empty session in
|
|
2096
|
+
// the list for every switch made on the way to an existing transcript.
|
|
2097
|
+
const resumed = resume ? await harness.resumeSession(resume) : undefined;
|
|
2098
|
+
const session = resumed ?? freshSession(harness);
|
|
2099
|
+
if (!resumed)
|
|
2100
|
+
await session.init();
|
|
2101
|
+
const runner = makeRunner(session, harness);
|
|
2102
|
+
runners.set(session.id, runner);
|
|
2103
|
+
focused = session.id;
|
|
2104
|
+
current = { session, agent: runner.agent, harness };
|
|
2105
|
+
const snapshot = state(runner);
|
|
2106
|
+
broadcast({
|
|
2107
|
+
type: "session_changed",
|
|
2108
|
+
session: session.id,
|
|
2109
|
+
state: snapshot,
|
|
2110
|
+
entries: history(runner),
|
|
2111
|
+
});
|
|
2112
|
+
return snapshot;
|
|
2113
|
+
}
|
|
2114
|
+
async function switchSession(body) {
|
|
2115
|
+
const action = String(body.action ?? "");
|
|
2116
|
+
let next;
|
|
2117
|
+
/**
|
|
2118
|
+
* The tree this session works in.
|
|
2119
|
+
*
|
|
2120
|
+
* Named by the caller, or the project already on screen. A session belongs
|
|
2121
|
+
* to one directory — the page asks which before starting one, and resuming
|
|
2122
|
+
* a transcript recorded elsewhere has to reach for the tree it mentions
|
|
2123
|
+
* rather than point its tools at whatever project is open now.
|
|
2124
|
+
*/
|
|
2125
|
+
const where = typeof body.cwd === "string" ? body.cwd.trim() : "";
|
|
2126
|
+
let harness = where && where !== current.session.cwd ? await harnessFor(where) : current.harness;
|
|
2127
|
+
if (action === "new") {
|
|
2128
|
+
next = freshSession(harness);
|
|
2129
|
+
await next.init();
|
|
2130
|
+
}
|
|
2131
|
+
else if (action === "resume" && typeof body.id === "string") {
|
|
2132
|
+
next = await harness.resumeSession(body.id);
|
|
2133
|
+
}
|
|
2134
|
+
else if (action === "fork") {
|
|
2135
|
+
harness = current.harness;
|
|
2136
|
+
const forked = await harness.forkSession(current.session.id, {
|
|
2137
|
+
...(typeof body.keepMessages === "number" ? { keepMessages: body.keepMessages } : {}),
|
|
2138
|
+
});
|
|
2139
|
+
next = forked;
|
|
2140
|
+
}
|
|
2141
|
+
else {
|
|
2142
|
+
return { error: `unknown action: ${action}` };
|
|
2143
|
+
}
|
|
2144
|
+
if (!next)
|
|
2145
|
+
return { error: "could not open that session" };
|
|
2146
|
+
// A tab per session, with its own agent and ledger. Reusing the previous
|
|
2147
|
+
// agent would append this session's turns to the old transcript, and
|
|
2148
|
+
// sharing a ledger would attribute one session's edits to another.
|
|
2149
|
+
let runner = runners.get(next.id);
|
|
2150
|
+
if (!runner) {
|
|
2151
|
+
runner = makeRunner(next, harness);
|
|
2152
|
+
runners.set(next.id, runner);
|
|
2153
|
+
}
|
|
2154
|
+
focused = next.id;
|
|
2155
|
+
// A reopened tab keeps the harness it was built on, which is the one its
|
|
2156
|
+
// own cwd belongs to.
|
|
2157
|
+
current = { session: next, agent: runner.agent, harness: runner.harness };
|
|
2158
|
+
const snapshot = state(runner);
|
|
2159
|
+
broadcast({
|
|
2160
|
+
type: "session_changed",
|
|
2161
|
+
session: next.id,
|
|
2162
|
+
state: snapshot,
|
|
2163
|
+
entries: history(runner),
|
|
2164
|
+
});
|
|
2165
|
+
return snapshot;
|
|
2166
|
+
}
|
|
2167
|
+
async function runTurn(runner, prompt, staged = [], references = []) {
|
|
2168
|
+
const controller = new AbortController();
|
|
2169
|
+
runner.running = controller;
|
|
2170
|
+
const id = runner.session.id;
|
|
2171
|
+
// Every event carries its session so the page can route it to the tab it
|
|
2172
|
+
// belongs to; without it a reply from a background tab would stream into
|
|
2173
|
+
// whichever one happens to be on screen.
|
|
2174
|
+
broadcast({
|
|
2175
|
+
type: "prompt",
|
|
2176
|
+
session: id,
|
|
2177
|
+
text: prompt,
|
|
2178
|
+
...(staged.length ? { attachments: staged.map((file) => file.name) } : {}),
|
|
2179
|
+
// The transcript shows what was referenced, not just what was typed:
|
|
2180
|
+
// otherwise a turn that reads three files looks like it went and found
|
|
2181
|
+
// them on its own.
|
|
2182
|
+
...(references.length ? { references } : {}),
|
|
2183
|
+
});
|
|
2184
|
+
// The note goes above the prompt rather than after it, so the model has
|
|
2185
|
+
// the context before it reads the request that depends on it.
|
|
2186
|
+
const note = referenceNote(references);
|
|
2187
|
+
const text = note ? `${note}\n\n${prompt}` : prompt;
|
|
2188
|
+
const input = staged.length ? buildMessage(text, staged) : text;
|
|
2189
|
+
try {
|
|
2190
|
+
for await (const event of runner.agent.run(input, { signal: controller.signal })) {
|
|
2191
|
+
broadcast({ ...event, session: id });
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
catch (err) {
|
|
2195
|
+
broadcast({
|
|
2196
|
+
type: "error",
|
|
2197
|
+
session: id,
|
|
2198
|
+
error: errorMessage(err),
|
|
2199
|
+
// Carried as a number rather than left for the page to read out of the
|
|
2200
|
+
// sentence. A 402 is not an error to print — it is a screen to open —
|
|
2201
|
+
// and a frontend that finds that out by matching "HTTP 402" in a
|
|
2202
|
+
// string is a frontend that breaks the day the string is reworded.
|
|
2203
|
+
...(typeof err?.status === "number"
|
|
2204
|
+
? { status: err.status }
|
|
2205
|
+
: {}),
|
|
2206
|
+
// Which vendor refused, for the same reason the status travels: a 401
|
|
2207
|
+
// is the one failure the user can fix, and fixing it means opening
|
|
2208
|
+
// that provider's dialog. The page knows the session's model but not
|
|
2209
|
+
// which provider a mid-turn fallback had ended up on.
|
|
2210
|
+
...(typeof err?.provider === "string"
|
|
2211
|
+
? { provider: err.provider }
|
|
2212
|
+
: {}),
|
|
2213
|
+
fatal: true,
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
finally {
|
|
2217
|
+
runner.running = undefined;
|
|
2218
|
+
broadcast({
|
|
2219
|
+
type: "idle",
|
|
2220
|
+
session: id,
|
|
2221
|
+
state: state(runner),
|
|
2222
|
+
changes: runner.ledger.list(),
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
function stream(res) {
|
|
2227
|
+
res.writeHead(200, {
|
|
2228
|
+
"content-type": "text/event-stream",
|
|
2229
|
+
"cache-control": "no-cache",
|
|
2230
|
+
connection: "keep-alive",
|
|
2231
|
+
// Proxies that buffer would hold the whole reply until the turn ends,
|
|
2232
|
+
// which defeats streaming entirely.
|
|
2233
|
+
"x-accel-buffering": "no",
|
|
2234
|
+
});
|
|
2235
|
+
res.write(": connected\n\n");
|
|
2236
|
+
clients.add(res);
|
|
2237
|
+
// A heartbeat keeps intermediaries from closing an idle stream while the
|
|
2238
|
+
// model is thinking.
|
|
2239
|
+
const beat = setInterval(() => res.write(": ping\n\n"), 15_000);
|
|
2240
|
+
beat.unref?.();
|
|
2241
|
+
res.on("close", () => {
|
|
2242
|
+
clearInterval(beat);
|
|
2243
|
+
clients.delete(res);
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* How many sessions one listing folds from disk.
|
|
2248
|
+
*
|
|
2249
|
+
* Bounded because the panel is opened casually and each session past this is
|
|
2250
|
+
* a file read. The newest are kept, and the response says how many were left
|
|
2251
|
+
* out rather than presenting a cut list as the whole set.
|
|
2252
|
+
*/
|
|
2253
|
+
const CATALOGUE_SESSIONS = 200;
|
|
2254
|
+
/**
|
|
2255
|
+
* How much of one artifact log a listing reads.
|
|
2256
|
+
*
|
|
2257
|
+
* The tail, because the newest revisions are the ones a listing shows. A
|
|
2258
|
+
* session that rewrote a page fifty times has fifty full copies in its log
|
|
2259
|
+
* and exactly one of them on screen.
|
|
2260
|
+
*/
|
|
2261
|
+
const CATALOGUE_TAIL_BYTES = 4 * 1024 * 1024;
|
|
2262
|
+
/**
|
|
2263
|
+
* How long a fold of the logs is reused.
|
|
2264
|
+
*
|
|
2265
|
+
* The catalogue is rebuilt on every artifact event — a model rewriting a
|
|
2266
|
+
* page does that a dozen times a turn — and the loaded half is recomputed
|
|
2267
|
+
* each time regardless, because it is a map lookup. The disk half is the
|
|
2268
|
+
* expensive one and it can only change under this process when *another*
|
|
2269
|
+
* process writes a session this one does not have open, so a few seconds of
|
|
2270
|
+
* staleness costs nothing anybody can see.
|
|
2271
|
+
*/
|
|
2272
|
+
const CATALOGUE_TTL_MS = 15_000;
|
|
2273
|
+
let cachedLogs;
|
|
2274
|
+
/**
|
|
2275
|
+
* Every artifact this machine holds, newest session first.
|
|
2276
|
+
*
|
|
2277
|
+
* Two sources, and the order between them matters. A loaded session's store
|
|
2278
|
+
* is authoritative for its own artifacts: it holds the canvas revision a
|
|
2279
|
+
* person made a second ago and the publication link, neither of which the
|
|
2280
|
+
* log carries. Everything else is folded from the revision logs, which is
|
|
2281
|
+
* what makes the panel's answer independent of which tabs are open.
|
|
2282
|
+
*/
|
|
2283
|
+
async function artifactCatalogue() {
|
|
2284
|
+
const groups = [];
|
|
2285
|
+
for (const runner of runners.values()) {
|
|
2286
|
+
const artifacts = describeArtifacts(getArtifactStore(runner.session.state));
|
|
2287
|
+
if (!artifacts.length)
|
|
2288
|
+
continue;
|
|
2289
|
+
groups.push({
|
|
2290
|
+
session: runner.session.id,
|
|
2291
|
+
title: runner.session.title,
|
|
2292
|
+
current: runner.session.id === focused,
|
|
2293
|
+
updatedAt: Math.max(...artifacts.map((artifact) => artifact.at), 0),
|
|
2294
|
+
artifacts,
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
const fromLogs = await foldedLogs();
|
|
2298
|
+
// A loaded session overrides its own log, so a session that appears in
|
|
2299
|
+
// both is only listed once — from memory, which is the newer of the two.
|
|
2300
|
+
for (const group of fromLogs.groups) {
|
|
2301
|
+
if (!runners.has(group.session))
|
|
2302
|
+
groups.push(group);
|
|
2303
|
+
}
|
|
2304
|
+
// The session in front of you first, whatever its dates say: its artifacts
|
|
2305
|
+
// are the ones this turn just made. The rest by when they were last
|
|
2306
|
+
// touched, which is the order the sidebar puts sessions in.
|
|
2307
|
+
groups.sort((a, b) => Number(b.current) - Number(a.current) || b.updatedAt - a.updatedAt);
|
|
2308
|
+
return { sessions: groups, ...(fromLogs.more ? { more: fromLogs.more } : {}) };
|
|
2309
|
+
}
|
|
2310
|
+
/** The durable half of the catalogue, folded from the logs and reused briefly. */
|
|
2311
|
+
async function foldedLogs() {
|
|
2312
|
+
if (cachedLogs && Date.now() - cachedLogs.at < CATALOGUE_TTL_MS)
|
|
2313
|
+
return cachedLogs;
|
|
2314
|
+
const store = current.harness.store;
|
|
2315
|
+
const logs = await store.artifactSessions();
|
|
2316
|
+
// Read for their titles and their dates, and to put the newest first — a
|
|
2317
|
+
// cap that kept an arbitrary two hundred would be worse than no cap. The
|
|
2318
|
+
// limit is on the listing, not on the read: it bounds the title backfill,
|
|
2319
|
+
// which is a transcript read for every session that never got one.
|
|
2320
|
+
const metas = new Map((await current.harness.listSessions(500)).map((meta) => [meta.id, meta]));
|
|
2321
|
+
const ordered = logs
|
|
2322
|
+
.map((id) => ({ id, updatedAt: metas.get(id)?.updatedAt ?? 0 }))
|
|
2323
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
2324
|
+
const groups = [];
|
|
2325
|
+
for (const { id, updatedAt } of ordered.slice(0, CATALOGUE_SESSIONS)) {
|
|
2326
|
+
const revisions = await store.artifactRevisions(id, CATALOGUE_TAIL_BYTES);
|
|
2327
|
+
if (!revisions.length)
|
|
2328
|
+
continue;
|
|
2329
|
+
const folded = new ArtifactStore();
|
|
2330
|
+
restoreArtifacts(folded, revisions);
|
|
2331
|
+
const artifacts = describeArtifacts(folded);
|
|
2332
|
+
if (!artifacts.length)
|
|
2333
|
+
continue;
|
|
2334
|
+
const meta = metas.get(id);
|
|
2335
|
+
groups.push({
|
|
2336
|
+
session: id,
|
|
2337
|
+
...(meta?.title ? { title: meta.title } : {}),
|
|
2338
|
+
current: false,
|
|
2339
|
+
updatedAt: updatedAt || Math.max(...artifacts.map((artifact) => artifact.at), 0),
|
|
2340
|
+
artifacts,
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
cachedLogs = {
|
|
2344
|
+
at: Date.now(),
|
|
2345
|
+
groups,
|
|
2346
|
+
more: Math.max(0, ordered.length - CATALOGUE_SESSIONS),
|
|
2347
|
+
};
|
|
2348
|
+
return cachedLogs;
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* A store's artifacts, each with the date of the revision on show.
|
|
2352
|
+
*
|
|
2353
|
+
* The timestamp is not on the descriptor — it belongs to a revision, not to
|
|
2354
|
+
* the artifact — but a list that spans weeks is unreadable without it.
|
|
2355
|
+
*/
|
|
2356
|
+
function describeArtifacts(store) {
|
|
2357
|
+
return store.list().map((artifact) => ({
|
|
2358
|
+
...artifact,
|
|
2359
|
+
at: store.get(artifact.id)?.revision.createdAt ?? 0,
|
|
2360
|
+
}));
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* The body of one artifact, isolated. Resolves against whichever session the
|
|
2364
|
+
* caller names, so an artifact stays viewable after the page switches tabs.
|
|
2365
|
+
*/
|
|
2366
|
+
function serveArtifactBody(url, res) {
|
|
2367
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
2368
|
+
if (!runner)
|
|
2369
|
+
return send(res, 404, { error: "no such session" });
|
|
2370
|
+
const id = url.searchParams.get("id") ?? "";
|
|
2371
|
+
if (!isValidArtifactId(id))
|
|
2372
|
+
return send(res, 400, { error: "bad artifact id" });
|
|
2373
|
+
const versionParam = url.searchParams.get("version");
|
|
2374
|
+
const found = getArtifactStore(runner.session.state).get(id, versionParam ? Number(versionParam) : undefined);
|
|
2375
|
+
if (!found)
|
|
2376
|
+
return send(res, 404, { error: "no such artifact" });
|
|
2377
|
+
sendArtifact(res, found.artifact, found.revision);
|
|
2378
|
+
}
|
|
2379
|
+
function constantTimeEqual(supplied, expected) {
|
|
2380
|
+
if (!supplied)
|
|
2381
|
+
return false;
|
|
2382
|
+
const a = Buffer.from(supplied);
|
|
2383
|
+
const b = Buffer.from(expected);
|
|
2384
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
2385
|
+
}
|
|
2386
|
+
function authorized(url, req) {
|
|
2387
|
+
const supplied = url.searchParams.get("token") ??
|
|
2388
|
+
(req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
|
|
2389
|
+
if (!supplied)
|
|
2390
|
+
return false;
|
|
2391
|
+
// Constant-time comparison: a plain !== leaks the token a character at a
|
|
2392
|
+
// time to anything that can measure the response.
|
|
2393
|
+
const a = Buffer.from(supplied);
|
|
2394
|
+
const b = Buffer.from(token);
|
|
2395
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
2396
|
+
}
|
|
2397
|
+
await listenPreferring(server, opts.port, host);
|
|
2398
|
+
const address = server.address();
|
|
2399
|
+
const port = typeof address === "object" && address ? address.port : opts.port;
|
|
2400
|
+
return {
|
|
2401
|
+
url: `http://${host}:${port}/?token=${token}`,
|
|
2402
|
+
token,
|
|
2403
|
+
permissionPrompt,
|
|
2404
|
+
async close() {
|
|
2405
|
+
for (const client of clients)
|
|
2406
|
+
client.end();
|
|
2407
|
+
clients.clear();
|
|
2408
|
+
// Any prompt still waiting has to be answered, or the agent's turn hangs
|
|
2409
|
+
// forever holding the process open. Shutting down denies.
|
|
2410
|
+
for (const entry of pending.values())
|
|
2411
|
+
entry.resolve({ allow: false });
|
|
2412
|
+
pending.clear();
|
|
2413
|
+
for (const runner of runners.values())
|
|
2414
|
+
runner.running?.abort();
|
|
2415
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
2416
|
+
},
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* The port used when nobody names one.
|
|
2421
|
+
*
|
|
2422
|
+
* A stable number so the URL is the same every run — bookmarkable, and the
|
|
2423
|
+
* same one a second terminal can be told about. It is not a secret and is not
|
|
2424
|
+
* asked to be: what protects this server is the loopback bind and the
|
|
2425
|
+
* per-run token, and the comment at the top of this file is careful to credit
|
|
2426
|
+
* those rather than an unguessable port.
|
|
2427
|
+
*/
|
|
2428
|
+
const DEFAULT_PORT = 4310;
|
|
2429
|
+
/**
|
|
2430
|
+
* Listen on the preferred port, or anywhere if it is taken.
|
|
2431
|
+
*
|
|
2432
|
+
* Two rules, because the two cases mean different things:
|
|
2433
|
+
*
|
|
2434
|
+
* - No `--port`: try DEFAULT_PORT, and on EADDRINUSE fall back to an
|
|
2435
|
+
* OS-assigned one. Running a second instance is ordinary — a second
|
|
2436
|
+
* project, a second terminal — and refusing to start would be a worse
|
|
2437
|
+
* answer than a different port. The URL is printed either way.
|
|
2438
|
+
* - An explicit `--port`: that number or nothing. Someone who names a port
|
|
2439
|
+
* has something pointed at it, and quietly listening somewhere else would
|
|
2440
|
+
* leave them staring at a dead bookmark with the server apparently up.
|
|
2441
|
+
*/
|
|
2442
|
+
async function listenPreferring(server, port, host) {
|
|
2443
|
+
if (port !== undefined)
|
|
2444
|
+
return listen(server, port, host);
|
|
2445
|
+
try {
|
|
2446
|
+
await listen(server, DEFAULT_PORT, host);
|
|
2447
|
+
}
|
|
2448
|
+
catch (err) {
|
|
2449
|
+
if (err.code !== "EADDRINUSE")
|
|
2450
|
+
throw err;
|
|
2451
|
+
await listen(server, 0, host);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
function listen(server, port, host) {
|
|
2455
|
+
return new Promise((resolve, reject) => {
|
|
2456
|
+
server.once("error", reject);
|
|
2457
|
+
server.listen(port, host, () => {
|
|
2458
|
+
server.off("error", reject);
|
|
2459
|
+
resolve();
|
|
2460
|
+
});
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
function send(res, status, body) {
|
|
2464
|
+
const payload = JSON.stringify(body);
|
|
2465
|
+
res.writeHead(status, {
|
|
2466
|
+
"content-type": "application/json; charset=utf-8",
|
|
2467
|
+
"content-length": Buffer.byteLength(payload),
|
|
2468
|
+
});
|
|
2469
|
+
res.end(payload);
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Who an access token says it belongs to, as far as it is worth displaying.
|
|
2473
|
+
*
|
|
2474
|
+
* Read out of the JWT's own payload rather than asked of the auth server: this
|
|
2475
|
+
* is display text beside a menu, and a panel repaint should not depend on a
|
|
2476
|
+
* third party being reachable. Nothing is trusted on the strength of it — the
|
|
2477
|
+
* signature is checked by whoever the token is sent to.
|
|
2478
|
+
*
|
|
2479
|
+
* The name and the picture live in `user_metadata`, which is where the site's
|
|
2480
|
+
* auth provider puts whatever the sign-in method knew: a Google sign-in fills
|
|
2481
|
+
* both, an email and password fills neither. Every field is optional here
|
|
2482
|
+
* because every one of them genuinely can be missing.
|
|
2483
|
+
*/
|
|
2484
|
+
function accountIdentity(token) {
|
|
2485
|
+
const payload = token.split(".")[1];
|
|
2486
|
+
if (!payload)
|
|
2487
|
+
return {};
|
|
2488
|
+
try {
|
|
2489
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
2490
|
+
const meta = claims.user_metadata ?? {};
|
|
2491
|
+
const text = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
2492
|
+
return {
|
|
2493
|
+
...(text(claims.email) ? { email: text(claims.email) } : {}),
|
|
2494
|
+
...(text(meta["full_name"]) ?? text(meta["name"])
|
|
2495
|
+
? { name: (text(meta["full_name"]) ?? text(meta["name"])) }
|
|
2496
|
+
: {}),
|
|
2497
|
+
// Only over https: this address is handed to an <img> in a page that
|
|
2498
|
+
// holds the session token, and a plaintext one would be a request any
|
|
2499
|
+
// network between here and there could answer.
|
|
2500
|
+
...(avatarUrl(text(meta["avatar_url"]) ?? text(meta["picture"]))
|
|
2501
|
+
? { avatar: avatarUrl(text(meta["avatar_url"]) ?? text(meta["picture"])) }
|
|
2502
|
+
: {}),
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
catch {
|
|
2506
|
+
// Not a JWT, or not one with a readable payload — an API key stored as an
|
|
2507
|
+
// OAuth credential by some other provider, most likely.
|
|
2508
|
+
return {};
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
/** The address of a picture, if it is one the page may safely be given. */
|
|
2512
|
+
function avatarUrl(value) {
|
|
2513
|
+
if (!value)
|
|
2514
|
+
return undefined;
|
|
2515
|
+
try {
|
|
2516
|
+
return new URL(value).protocol === "https:" ? value : undefined;
|
|
2517
|
+
}
|
|
2518
|
+
catch {
|
|
2519
|
+
return undefined;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
/** The address on an access token, if it says one. */
|
|
2523
|
+
function accountEmail(token) {
|
|
2524
|
+
return accountIdentity(token).email;
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* The last page of a sign-in that happened in a browser tab.
|
|
2528
|
+
*
|
|
2529
|
+
* Self-contained: one inline stylesheet, no script, no font and no image
|
|
2530
|
+
* fetched from anywhere. The tab has done its job and the only useful thing it
|
|
2531
|
+
* can say is whether that job worked — but it is also the last thing a new
|
|
2532
|
+
* account sees of us, and a line of unstyled 16px text in the corner of a
|
|
2533
|
+
* white page reads as something that crashed rather than something that
|
|
2534
|
+
* finished. The mark is drawn in SVG for the same reason the icons in the app
|
|
2535
|
+
* are: it is the one asset that cannot fail to load.
|
|
2536
|
+
*
|
|
2537
|
+
* Both outcomes use this. Success is stated plainly and failure keeps the
|
|
2538
|
+
* sentence it was given, in the colour that says which one happened — the tab
|
|
2539
|
+
* is not where anything can be fixed, so it explains rather than offers.
|
|
2540
|
+
*/
|
|
2541
|
+
/**
|
|
2542
|
+
* One call to the metering gateway, in the shape the caller needs back.
|
|
2543
|
+
*
|
|
2544
|
+
* A failure is a value rather than a throw: everything that calls this is
|
|
2545
|
+
* rendering a screen about money, and "the shop is closed" and "the shop did
|
|
2546
|
+
* not answer" are two different sentences to show a person who cannot pay.
|
|
2547
|
+
*/
|
|
2548
|
+
async function gatewayJson(path, opts = {}) {
|
|
2549
|
+
const base = officialBaseUrl();
|
|
2550
|
+
let response;
|
|
2551
|
+
try {
|
|
2552
|
+
response = await fetch(`${base}${path}`, {
|
|
2553
|
+
method: opts.body === undefined ? "GET" : "POST",
|
|
2554
|
+
headers: {
|
|
2555
|
+
accept: "application/json",
|
|
2556
|
+
...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),
|
|
2557
|
+
...(opts.body === undefined ? {} : { "content-type": "application/json" }),
|
|
2558
|
+
},
|
|
2559
|
+
...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }),
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
catch (err) {
|
|
2563
|
+
return { ok: false, status: 0, error: `could not reach ${base}: ${errorMessage(err)}` };
|
|
2564
|
+
}
|
|
2565
|
+
const text = await response.text();
|
|
2566
|
+
let parsed = {};
|
|
2567
|
+
try {
|
|
2568
|
+
parsed = text ? JSON.parse(text) : {};
|
|
2569
|
+
}
|
|
2570
|
+
catch {
|
|
2571
|
+
// A gateway answering HTML is a gateway behind something that is not the
|
|
2572
|
+
// gateway. The status is still worth reporting; the body is not.
|
|
2573
|
+
}
|
|
2574
|
+
if (!response.ok) {
|
|
2575
|
+
// The gateway nests its failures as { error: { code, message } }; other
|
|
2576
|
+
// things in front of it may not, so both shapes are read.
|
|
2577
|
+
const detail = parsed["error"];
|
|
2578
|
+
const message = detail && typeof detail === "object" && "message" in detail
|
|
2579
|
+
? String(detail.message)
|
|
2580
|
+
: typeof detail === "string"
|
|
2581
|
+
? detail
|
|
2582
|
+
: `HTTP ${response.status}`;
|
|
2583
|
+
return { ok: false, status: response.status, error: message };
|
|
2584
|
+
}
|
|
2585
|
+
return { ok: true, value: parsed };
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* Whether the browser could be sent back to this address with a session.
|
|
2589
|
+
*
|
|
2590
|
+
* The same question `loopbackTarget` asks on the website, kept deliberately as
|
|
2591
|
+
* strict: the two lists have to agree, and the one that matters is the site's.
|
|
2592
|
+
* A hostname that resolves to loopback but is not spelled as one — a tunnel, a
|
|
2593
|
+
* LAN name pointed here — is refused for exactly the reason the site refuses
|
|
2594
|
+
* it, and the emailed code is what serves that case.
|
|
2595
|
+
*/
|
|
2596
|
+
function isLoopbackHost(host) {
|
|
2597
|
+
let hostname;
|
|
2598
|
+
try {
|
|
2599
|
+
// The IPv6 literal keeps its brackets in `hostname`, which is what the URL
|
|
2600
|
+
// parser hands back and not what anyone writing this list expects.
|
|
2601
|
+
hostname = new URL(`http://${host}`).hostname;
|
|
2602
|
+
}
|
|
2603
|
+
catch {
|
|
2604
|
+
return false;
|
|
2605
|
+
}
|
|
2606
|
+
return hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "localhost";
|
|
2607
|
+
}
|
|
2608
|
+
function closeTab(res, status, message) {
|
|
2609
|
+
const escaped = message.replace(/[&<>]/g, (char) => char === "&" ? "&" : char === "<" ? "<" : ">");
|
|
2610
|
+
const ok = status < 400;
|
|
2611
|
+
const page = `<!doctype html><meta charset="utf-8"><title>OHarness</title>` +
|
|
2612
|
+
`<meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
2613
|
+
`<style>` +
|
|
2614
|
+
`:root{color-scheme:light dark;` +
|
|
2615
|
+
`--bg:#fff;--card:#fff;--fg:#1a1a1a;--dim:#6b7280;--line:#00000012;--mark:#111827;--bad:#b91c1c}` +
|
|
2616
|
+
`@media(prefers-color-scheme:dark){:root{` +
|
|
2617
|
+
`--bg:#0e1013;--card:#15171c;--fg:#e6e6e6;--dim:#8b93a1;--line:#ffffff14;--mark:#e5e7eb;--bad:#f87171}}` +
|
|
2618
|
+
`*{box-sizing:border-box}` +
|
|
2619
|
+
`body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;` +
|
|
2620
|
+
`background:var(--bg);color:var(--fg);` +
|
|
2621
|
+
`font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Inter,sans-serif;` +
|
|
2622
|
+
`-webkit-font-smoothing:antialiased}` +
|
|
2623
|
+
`main{width:100%;max-width:26rem;padding:28px;border-radius:16px;background:var(--card);` +
|
|
2624
|
+
`border:1px solid var(--line);box-shadow:0 1px 2px #0000000f,0 20px 60px #00000014;text-align:center}` +
|
|
2625
|
+
`svg{width:34px;height:34px;color:var(--mark)}` +
|
|
2626
|
+
`h1{margin:14px 0 0;font-size:16px;font-weight:600;letter-spacing:-.01em}` +
|
|
2627
|
+
`h1.bad{color:var(--bad)}` +
|
|
2628
|
+
`p{margin:8px 0 0;font-size:13px;color:var(--dim)}` +
|
|
2629
|
+
`</style>` +
|
|
2630
|
+
`<body><main>` +
|
|
2631
|
+
`<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.2" ` +
|
|
2632
|
+
`stroke-linecap="round" aria-hidden="true">` +
|
|
2633
|
+
`<path d="M20 6h-8a6 6 0 0 0-6 6v8"/><path d="M12 26h8a6 6 0 0 0 6-6v-8"/>` +
|
|
2634
|
+
`<circle cx="16" cy="16" r="3.5" fill="currentColor" stroke="none"/></svg>` +
|
|
2635
|
+
`<h1${ok ? "" : ' class="bad"'}>${escaped}</h1>` +
|
|
2636
|
+
(ok ? `<p>You can close this tab and go back to OHarness.</p>` : "") +
|
|
2637
|
+
`</main>`;
|
|
2638
|
+
res.writeHead(status, {
|
|
2639
|
+
"content-type": "text/html; charset=utf-8",
|
|
2640
|
+
"content-length": Buffer.byteLength(page),
|
|
2641
|
+
});
|
|
2642
|
+
res.end(page);
|
|
2643
|
+
}
|
|
2644
|
+
/** The extensions a picked file is carried as an image under. */
|
|
2645
|
+
const PICKED_IMAGE_TYPES = {
|
|
2646
|
+
".png": "image/png",
|
|
2647
|
+
".jpg": "image/jpeg",
|
|
2648
|
+
".jpeg": "image/jpeg",
|
|
2649
|
+
".gif": "image/gif",
|
|
2650
|
+
".webp": "image/webp",
|
|
2651
|
+
};
|
|
2652
|
+
/**
|
|
2653
|
+
* What `/attachment` will hand back to the page.
|
|
2654
|
+
*
|
|
2655
|
+
* Raster only: an `image/svg+xml` block would be a script running on the
|
|
2656
|
+
* frontend's own origin, and the frontend holds the session token.
|
|
2657
|
+
*/
|
|
2658
|
+
const VIEWABLE_IMAGE_TYPES = new Set([
|
|
2659
|
+
"image/png",
|
|
2660
|
+
"image/jpeg",
|
|
2661
|
+
"image/gif",
|
|
2662
|
+
"image/webp",
|
|
2663
|
+
]);
|
|
2664
|
+
/** Images are worth a few megabytes; anything larger is a mistake. */
|
|
2665
|
+
const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
|
2666
|
+
/** How long an unsent upload is kept before the sweep reclaims it. */
|
|
2667
|
+
const STAGED_TTL_MS = 30 * 60 * 1000;
|
|
2668
|
+
/** The ceiling on everything staged at once, across every tab. */
|
|
2669
|
+
const MAX_STAGED_BYTES = 64 * 1024 * 1024;
|
|
2670
|
+
/** How much of a text attachment is inlined into the prompt. */
|
|
2671
|
+
const MAX_INLINE_CHARS = 200_000;
|
|
2672
|
+
async function readBody(req, limit) {
|
|
2673
|
+
const chunks = [];
|
|
2674
|
+
let size = 0;
|
|
2675
|
+
for await (const chunk of req) {
|
|
2676
|
+
size += chunk.length;
|
|
2677
|
+
// Checked while reading, not after: buffering the whole body first is
|
|
2678
|
+
// exactly the thing the limit exists to prevent.
|
|
2679
|
+
if (size > limit)
|
|
2680
|
+
throw new Error("request body too large");
|
|
2681
|
+
chunks.push(chunk);
|
|
2682
|
+
}
|
|
2683
|
+
return Buffer.concat(chunks);
|
|
2684
|
+
}
|
|
2685
|
+
/**
|
|
2686
|
+
* A prompt carrying attachments.
|
|
2687
|
+
*
|
|
2688
|
+
* Images become image blocks, which every adapter renders into its vendor's
|
|
2689
|
+
* shape. Anything else is inlined as text under its filename — a base64 blob
|
|
2690
|
+
* of a zip would burn context and tell the model nothing.
|
|
2691
|
+
*/
|
|
2692
|
+
/** Whether a chosen path is a folder; anything unreadable is treated as a file. */
|
|
2693
|
+
async function isDirectory(target) {
|
|
2694
|
+
try {
|
|
2695
|
+
return (await stat(target)).isDirectory();
|
|
2696
|
+
}
|
|
2697
|
+
catch {
|
|
2698
|
+
return false;
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
const REFERENCE_KINDS = new Set(["file", "folder", "connector", "plugin"]);
|
|
2702
|
+
/** Enough to reference a directory of files, far short of a whole tree. */
|
|
2703
|
+
const MAX_REFERENCES = 32;
|
|
2704
|
+
/**
|
|
2705
|
+
* References as they arrive from the page.
|
|
2706
|
+
*
|
|
2707
|
+
* A reference ends up as a line inside a block the model reads, so a value
|
|
2708
|
+
* carrying a newline or a closing tag could forge the rest of that block — and
|
|
2709
|
+
* a file name is allowed to contain both. Flattening whitespace and dropping
|
|
2710
|
+
* angle brackets closes that; neither costs anything a real path needs, and
|
|
2711
|
+
* Windows forbids the brackets in a name outright.
|
|
2712
|
+
*/
|
|
2713
|
+
function readReferences(value) {
|
|
2714
|
+
if (!Array.isArray(value))
|
|
2715
|
+
return [];
|
|
2716
|
+
const seen = new Set();
|
|
2717
|
+
const out = [];
|
|
2718
|
+
for (const item of value) {
|
|
2719
|
+
if (!item || typeof item !== "object")
|
|
2720
|
+
continue;
|
|
2721
|
+
const kind = item["kind"];
|
|
2722
|
+
const raw = item["value"];
|
|
2723
|
+
if (typeof kind !== "string" || !REFERENCE_KINDS.has(kind))
|
|
2724
|
+
continue;
|
|
2725
|
+
if (typeof raw !== "string")
|
|
2726
|
+
continue;
|
|
2727
|
+
const cleaned = raw.replace(/[<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
2728
|
+
if (!cleaned)
|
|
2729
|
+
continue;
|
|
2730
|
+
const key = `${kind}:${cleaned}`;
|
|
2731
|
+
if (seen.has(key))
|
|
2732
|
+
continue;
|
|
2733
|
+
seen.add(key);
|
|
2734
|
+
out.push({ kind: kind, value: cleaned });
|
|
2735
|
+
if (out.length >= MAX_REFERENCES)
|
|
2736
|
+
break;
|
|
2737
|
+
}
|
|
2738
|
+
return out;
|
|
2739
|
+
}
|
|
2740
|
+
function buildMessage(prompt, staged) {
|
|
2741
|
+
const content = [];
|
|
2742
|
+
const notes = [];
|
|
2743
|
+
for (const file of staged) {
|
|
2744
|
+
if (file.mediaType.startsWith("image/")) {
|
|
2745
|
+
content.push({
|
|
2746
|
+
type: "image",
|
|
2747
|
+
mimeType: file.mediaType,
|
|
2748
|
+
data: file.data.toString("base64"),
|
|
2749
|
+
});
|
|
2750
|
+
notes.push(`[image: ${file.name}]`);
|
|
2751
|
+
continue;
|
|
2752
|
+
}
|
|
2753
|
+
const text = file.data.toString("utf8");
|
|
2754
|
+
// A NUL early on means binary, and pasting its bytes into the prompt
|
|
2755
|
+
// wastes context without conveying anything.
|
|
2756
|
+
if (file.data.subarray(0, 8000).includes(0)) {
|
|
2757
|
+
notes.push(`[skipped ${file.name}: binary, ${file.data.byteLength} bytes]`);
|
|
2758
|
+
continue;
|
|
2759
|
+
}
|
|
2760
|
+
const clipped = text.length > MAX_INLINE_CHARS
|
|
2761
|
+
? `${text.slice(0, MAX_INLINE_CHARS)}\n… truncated`
|
|
2762
|
+
: text;
|
|
2763
|
+
notes.push(`--- ${file.name} ---\n${clipped}`);
|
|
2764
|
+
}
|
|
2765
|
+
const body = [prompt, ...notes].filter(Boolean).join("\n\n");
|
|
2766
|
+
content.unshift({ type: "text", text: body });
|
|
2767
|
+
return [{ role: "user", content }];
|
|
2768
|
+
}
|
|
2769
|
+
async function readJson(req) {
|
|
2770
|
+
const chunks = [];
|
|
2771
|
+
let size = 0;
|
|
2772
|
+
for await (const chunk of req) {
|
|
2773
|
+
size += chunk.length;
|
|
2774
|
+
// A prompt is text; anything this large is a mistake or an attempt to
|
|
2775
|
+
// exhaust memory, and reading it all first would be the vulnerability.
|
|
2776
|
+
if (size > 1_000_000)
|
|
2777
|
+
throw new Error("request body too large");
|
|
2778
|
+
chunks.push(chunk);
|
|
2779
|
+
}
|
|
2780
|
+
if (!chunks.length)
|
|
2781
|
+
return {};
|
|
2782
|
+
try {
|
|
2783
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2784
|
+
}
|
|
2785
|
+
catch {
|
|
2786
|
+
throw new Error("invalid JSON body");
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
//# sourceMappingURL=server.js.map
|