@vincemakes/kiso-web 0.0.0-stage → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/dist/main/agent.js +80 -0
  4. package/dist/main/approvals.js +68 -0
  5. package/dist/main/artifacts.js +166 -0
  6. package/dist/main/auth.js +109 -0
  7. package/dist/main/context.js +20 -0
  8. package/dist/main/desk-api.js +249 -0
  9. package/dist/main/env.js +104 -0
  10. package/dist/main/faux.js +12 -0
  11. package/dist/main/files.js +156 -0
  12. package/dist/main/folder-info.js +36 -0
  13. package/dist/main/folders.js +102 -0
  14. package/dist/main/fork.js +25 -0
  15. package/dist/main/fs-routes.js +69 -0
  16. package/dist/main/git.js +117 -0
  17. package/dist/main/headless.js +128 -0
  18. package/dist/main/history-worker.js +18 -0
  19. package/dist/main/history.js +151 -0
  20. package/dist/main/host.js +210 -0
  21. package/dist/main/login.js +27 -0
  22. package/dist/main/names.js +15 -0
  23. package/dist/main/observed-revision.js +69 -0
  24. package/dist/main/preview.js +141 -0
  25. package/dist/main/sanitize.js +28 -0
  26. package/dist/main/settings.js +242 -0
  27. package/dist/main/space.js +198 -0
  28. package/dist/main/tasks.js +92 -0
  29. package/dist/main/times.js +28 -0
  30. package/dist/main/usage.js +102 -0
  31. package/dist/main/works.js +21 -0
  32. package/dist/shared/files.js +2 -0
  33. package/dist/shared/frames.js +13 -0
  34. package/dist/shared/git.js +2 -0
  35. package/dist/shared/history.js +5 -0
  36. package/dist/shared/settings.js +2 -0
  37. package/out/renderer/assets/index-C3fCoBdq.css +1 -0
  38. package/out/renderer/assets/index-DiDTmq9b.js +45 -0
  39. package/out/renderer/favicon-32.png +0 -0
  40. package/out/renderer/favicon.svg +1 -0
  41. package/out/renderer/icon.png +0 -0
  42. package/out/renderer/index.html +18 -0
  43. package/out/renderer/manifest.webmanifest +10 -0
  44. package/package.json +64 -5
@@ -0,0 +1,210 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { appendFileSync, readFileSync } from "node:fs";
4
+ import { basename, extname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { canonicalPath, claimProjectDir, projectDirFor, sessionFolders } from "@vincemakes/kiso-code/config";
7
+ import { kisoHome } from "@vincemakes/kiso-runtime";
8
+ import { Auth } from "./auth.js";
9
+ import { ApprovalWatch } from "./approvals.js";
10
+ import { deskApi } from "./desk-api.js";
11
+ import { History } from "./history.js";
12
+ import { Previews } from "./preview.js";
13
+ import { createSpace } from "./space.js";
14
+ export async function startHost(options) {
15
+ const token = options.token ?? randomBytes(32).toString("hex");
16
+ const home = options.home ?? kisoHome();
17
+ const spaces = new Map();
18
+ const spaceOptions = (key, workspace, dir, prefix) => ({
19
+ key,
20
+ workspace,
21
+ dir,
22
+ prefix,
23
+ model: (ctx) => options.settings.model(ctx),
24
+ tierOf: (sessionId) => options.settings.tierOf(sessionId),
25
+ checkRunners: () => options.settings.checks(),
26
+ secretEnvNames: options.settings.secretEnvNames(),
27
+ exposeThinking: options.exposeThinking ?? true,
28
+ sanitizer: options.sanitizer ?? "web",
29
+ ...(options.keepaliveMs !== undefined ? { keepaliveMs: options.keepaliveMs } : {}),
30
+ ...(options.toolLimits !== undefined ? { toolLimits: options.toolLimits } : {}),
31
+ followUps: options.followUps ?? true,
32
+ titles: options.titles ?? true,
33
+ });
34
+ // the folder in use: its directory is claimed (named after its path) the way the terminal claims it
35
+ const workspace = canonicalPath(options.workspace);
36
+ const currentDir = projectDirFor(home, workspace);
37
+ claimProjectDir(currentDir, workspace);
38
+ const current = createSpace(spaceOptions(basename(currentDir), workspace, currentDir, "/v1/sessions"));
39
+ spaces.set(current.key, current);
40
+ /** Another folder's space, made on first use; only a folder the listing names (a key is never a path). */
41
+ const spaceFor = (key) => {
42
+ const held = spaces.get(key);
43
+ if (held !== undefined)
44
+ return held;
45
+ const folder = sessionFolders(home).find((f) => f.kind === "project" && f.workspace !== null && basename(f.dir) === key);
46
+ if (folder === undefined || folder.workspace === null)
47
+ return null;
48
+ const space = createSpace(spaceOptions(key, folder.workspace, folder.dir, `/w/${key}/v1/sessions`));
49
+ spaces.set(key, space);
50
+ return space;
51
+ };
52
+ const history = new History(home, async (key, sessionId) => {
53
+ const space = spaces.get(key);
54
+ if (space === undefined || !space.service.isRunning(sessionId))
55
+ return "idle";
56
+ return (await space.service.pendingApprovals(sessionId)).length > 0 ? "waiting" : "running";
57
+ }, (id) => options.settings.marksOf(id));
58
+ const rendererDir = fileURLToPath(new URL("../../out/renderer/", import.meta.url));
59
+ const tokenBytes = Buffer.from(token);
60
+ let auth;
61
+ let presence = null;
62
+ const previews = new Previews(options.previewLog);
63
+ const requestLog = options.requestLog;
64
+ const watch = new ApprovalWatch(() => spaces.values(), options.notify, () => presence);
65
+ watch.start();
66
+ const desk = { home, settings: options.settings, current, history, spaceFor, setPresence: (p) => (presence = p), notices: (seq) => watch.since(seq), previews };
67
+ let origins = new Set();
68
+ let hosts = new Set();
69
+ const refuse = (res, status, message) => {
70
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
71
+ res.end(JSON.stringify({ code: "forbidden", message }));
72
+ };
73
+ const answer = (res, status, body, headers = {}) => {
74
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
75
+ res.end(JSON.stringify(body));
76
+ };
77
+ /** The page's cookie, sent from the page itself: a request another page made (another port of 127.0.0.1 is the same site) says so in Sec-Fetch-Site. */
78
+ const cookieOk = (req) => {
79
+ const site = req.headers["sec-fetch-site"];
80
+ if (site !== undefined && site !== "same-origin" && site !== "none")
81
+ return false;
82
+ return auth?.signedIn(req) ?? false;
83
+ };
84
+ /** The sign-in doors: status (open; it also tells another `kiso-web` that this port is ours), connect (the page trades its code), code (scripts, by token). */
85
+ const authRoute = async (req, res, path) => {
86
+ if (path === "/auth/status" && req.method === "GET")
87
+ return answer(res, 200, { app: "kiso-web", signedIn: cookieOk(req) });
88
+ if (path === "/auth/connect" && req.method === "POST") {
89
+ // only our own page may trade a code: a browser always names the origin of a POST
90
+ if (!origins.has(req.headers.origin ?? ""))
91
+ return refuse(res, 403, "Origin not allowed");
92
+ let text = "";
93
+ for await (const chunk of req)
94
+ if ((text += chunk).length > 1024)
95
+ return refuse(res, 413, "too large");
96
+ let code;
97
+ try {
98
+ code = JSON.parse(text).code;
99
+ }
100
+ catch {
101
+ code = undefined;
102
+ }
103
+ const cookie = auth?.connect(code) ?? null;
104
+ return cookie === null ? refuse(res, 401, "the connect code is unknown, used or expired: run kiso web") : answer(res, 200, { ok: true }, { "set-cookie": cookie });
105
+ }
106
+ if (path === "/auth/code" && req.method === "POST")
107
+ return bearerOk(req) ? answer(res, 200, { code: auth.code() }) : refuse(res, 401, "missing or wrong token");
108
+ res.writeHead(404);
109
+ res.end();
110
+ };
111
+ const bearerOk = (req) => {
112
+ const header = req.headers.authorization ?? "";
113
+ if (!header.startsWith("Bearer "))
114
+ return false;
115
+ const given = Buffer.from(header.slice(7));
116
+ return given.length === tokenBytes.length && timingSafeEqual(given, tokenBytes);
117
+ };
118
+ const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".png": "image/png", ".webmanifest": "application/manifest+json" };
119
+ /** A file of the bundled renderer, by URL path; nothing outside its folder is served. */
120
+ const serveStatic = (res, pathname) => {
121
+ const full = resolve(rendererDir, pathname === "/" ? "index.html" : `.${pathname}`);
122
+ const type = TYPES[extname(full)];
123
+ try {
124
+ if (type === undefined || !full.startsWith(rendererDir))
125
+ throw new Error("not served");
126
+ const body = readFileSync(full);
127
+ // the page runs its own bundle and talks to its own origin, nothing else
128
+ res.writeHead(200, {
129
+ "content-type": type,
130
+ "cache-control": "no-store",
131
+ "x-content-type-options": "nosniff",
132
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https:; media-src http://localhost:* http://127.0.0.1:*; manifest-src 'self'; frame-src http://localhost:* http://127.0.0.1:*; base-uri 'none'; form-action 'none'",
133
+ });
134
+ res.end(body);
135
+ }
136
+ catch {
137
+ res.writeHead(404);
138
+ res.end();
139
+ }
140
+ };
141
+ const server = createServer((req, res) => {
142
+ void (async () => {
143
+ // DNS rebinding: a name that resolves to 127.0.0.1 but is not ours
144
+ if (!hosts.has(req.headers.host ?? ""))
145
+ return refuse(res, 403, "unknown Host");
146
+ // the proofs read which requests reached the host, and from where
147
+ if (requestLog !== undefined)
148
+ appendFileSync(requestLog, `${req.method} ${req.url} site=${req.headers["sec-fetch-site"] ?? "-"} origin=${req.headers.origin ?? "-"} cookie=${(req.headers.cookie ?? "").includes("kiso_web_") ? "yes" : "no"}\n`);
149
+ const url = new URL(req.url ?? "/", "http://localhost");
150
+ const origin = req.headers.origin;
151
+ if (origin !== undefined && !origins.has(origin))
152
+ return refuse(res, 403, "Origin not allowed");
153
+ if (url.pathname.startsWith("/auth/"))
154
+ return authRoute(req, res, url.pathname);
155
+ const other = /^\/w\/([A-Za-z0-9._-]+)\/v1\//.exec(url.pathname);
156
+ if (!url.pathname.startsWith("/v1/") && other === null && !url.pathname.startsWith("/desk/")) {
157
+ if (req.method !== "GET") {
158
+ res.writeHead(405);
159
+ return void res.end();
160
+ }
161
+ return serveStatic(res, url.pathname);
162
+ }
163
+ if (!bearerOk(req) && !cookieOk(req))
164
+ return refuse(res, 401, "not signed in: run kiso web");
165
+ if (url.pathname.startsWith("/desk/")) {
166
+ const answer = await deskApi(desk, req, url);
167
+ if (Buffer.isBuffer(answer.body) || answer.body === null) {
168
+ res.writeHead(answer.status, answer.headers);
169
+ return void res.end(answer.body ?? undefined);
170
+ }
171
+ res.writeHead(answer.status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
172
+ return void res.end(JSON.stringify(answer.body));
173
+ }
174
+ const space = other === null ? current : spaceFor(other[1]);
175
+ if (space === null || !(await space.handler.handle(req, res))) {
176
+ res.writeHead(404);
177
+ res.end();
178
+ }
179
+ })().catch(() => {
180
+ if (!res.headersSent)
181
+ res.writeHead(500);
182
+ res.end();
183
+ });
184
+ });
185
+ await new Promise((resolve, reject) => {
186
+ server.once("error", reject);
187
+ server.listen(options.port ?? 0, "127.0.0.1", resolve);
188
+ });
189
+ const port = server.address().port;
190
+ origins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
191
+ hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
192
+ auth = new Auth(home, port);
193
+ let closed = false;
194
+ const close = async (graceMs = 5_000) => {
195
+ if (closed)
196
+ return;
197
+ closed = true;
198
+ // the order that keeps every run resumable (packages/server README):
199
+ // drain, stop the listener, close the stores
200
+ await Promise.all([...spaces.values()].map((space) => space.drain(graceMs)));
201
+ server.closeAllConnections();
202
+ await new Promise((resolve) => server.close(() => resolve()));
203
+ watch.stop();
204
+ previews.closeAll();
205
+ history.close();
206
+ for (const space of spaces.values())
207
+ await space.close();
208
+ };
209
+ return { origin: `http://127.0.0.1:${port}`, port, token, connectCode: () => auth.code(), service: current.service, store: current.store, tasks: current.tasks, current: { key: current.key, workspace }, close };
210
+ }
@@ -0,0 +1,27 @@
1
+ import { KNOWN_PROVIDERS, endpointCredentialId, providerIdOf, setCredential } from "@vincemakes/kiso-code/config";
2
+ import { Refusal } from "./files.js";
3
+ /**
4
+ * Storing a key for a profile, through the terminal's own credential door (`setCredential`: the same `auth.json`, the same single write
5
+ * path). The key arrives from the page once, is written, and is never answered, logged or put in an error: every message here is fixed text.
6
+ */
7
+ const MIN_KEY = 8;
8
+ const MAX_KEY = 4096;
9
+ /** The store id a profile's key belongs under: its vendor, else its gateway's origin; null for a profile that has no place for a key. */
10
+ export function credentialIdFor(profile) {
11
+ return providerIdOf(profile.kind, profile.baseUrl) ?? endpointCredentialId(profile.baseUrl);
12
+ }
13
+ export function storeKey(profile, key) {
14
+ if (profile === undefined)
15
+ throw new Refusal(404, "no such profile");
16
+ if (typeof key !== "string" || key.length < MIN_KEY || key.length > MAX_KEY || !/^\S+$/.test(key))
17
+ throw new Refusal(400, "that does not look like a key");
18
+ const id = credentialIdFor(profile);
19
+ if (id === null)
20
+ throw new Refusal(400, "this profile has no place for a key");
21
+ // a ChatGPT sign-in is an OAuth flow with a browser: it stays in the terminal for now
22
+ if (id === "chatgpt")
23
+ throw new Refusal(400, "this sign-in is done in the terminal: kiso login chatgpt");
24
+ if (!id.startsWith("endpoint:") && !KNOWN_PROVIDERS.includes(id))
25
+ throw new Refusal(400, "unknown provider");
26
+ setCredential(id, { type: "api-key", key, savedAt: Date.now() });
27
+ }
@@ -0,0 +1,15 @@
1
+ import { readSessionName, writeSessionName } from "@vincemakes/kiso-runtime/internal";
2
+ /** A Work's name: the runtime's own session-name store, the one the terminal's rename writes (a file beside the log, never the log).
3
+ * The runtime's titles read it (the listing re-reads a folder whose name file changed); empty clears it; at most 120 characters. */
4
+ export function rename(dir, sessionId, name) {
5
+ const clean = name.replace(/\s+/g, " ").trim().slice(0, 120);
6
+ writeSessionName(dir, sessionId, clean === "" ? null : clean);
7
+ return clean === "" ? null : clean;
8
+ }
9
+ /** Whether a Work has a name: the person's, or a title written before. */
10
+ export const named = (dir, sessionId) => readSessionName(dir, sessionId) !== null;
11
+ /** A title made from the Work's first message, kept only while the Work is still unnamed: the person's name wins. */
12
+ export function titleIfUnnamed(dir, sessionId, title) {
13
+ if (title !== "" && !named(dir, sessionId))
14
+ writeSessionName(dir, sessionId, title);
15
+ }
@@ -0,0 +1,69 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { projectMessages } from "@vincemakes/kiso-core";
4
+ import { readShowsWholeFile } from "@vincemakes/kiso-tools-node";
5
+ /**
6
+ * The hidden observed revision: with it, edit_file and write_file take no
7
+ * expectedRevision and the file's latest `[rev:…]` is read off the
8
+ * session's own log — what the model last observed of that file. The same
9
+ * rule the terminal product applies (apps/cli state.ts, observedRevisionFromLog),
10
+ * rewritten over the public store and projection instead of the log file.
11
+ */
12
+ function real(p) {
13
+ try {
14
+ return realpathSync(p);
15
+ }
16
+ catch {
17
+ return p;
18
+ }
19
+ }
20
+ export function observedRevisionFrom(store, workspace) {
21
+ return (sessionId, full, kind = "edit") => {
22
+ if (sessionId === undefined)
23
+ return undefined;
24
+ let messages;
25
+ try {
26
+ messages = projectMessages(store.load(sessionId).map((r) => r.event));
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ const target = real(full);
32
+ const calls = new Map();
33
+ let rev;
34
+ let wholeKnown = false;
35
+ for (const m of messages) {
36
+ if (m.role === "assistant") {
37
+ for (const b of m.blocks) {
38
+ if (b.type === "tool_use" && typeof b.input.path === "string") {
39
+ calls.set(b.callId, { name: b.name, full: real(resolve(workspace, b.input.path)), input: b.input });
40
+ }
41
+ }
42
+ }
43
+ else if (m.role === "tool") {
44
+ const c = calls.get(m.callId);
45
+ if (c === undefined || c.full !== target)
46
+ continue;
47
+ const body = typeof m.content === "string" ? m.content : m.content.map((x) => (x.type === "text" ? x.text : "")).join("");
48
+ const hit = [...body.matchAll(/\[(rev:[0-9a-f]{16})\]/g)].pop();
49
+ if (hit === undefined)
50
+ continue;
51
+ if (!m.isError && (c.name === "read_file" || c.name === "edit_file" || c.name === "write_file")) {
52
+ const sameRevision = hit[1] === rev;
53
+ rev = hit[1];
54
+ if (c.name === "read_file")
55
+ wholeKnown = readShowsWholeFile(c.input, body) || (wholeKnown && sameRevision);
56
+ else if (c.name === "write_file")
57
+ wholeKnown = true;
58
+ }
59
+ else if (m.isError && c.name === "edit_file" && body.includes(" changed since ")) {
60
+ rev = hit[1];
61
+ wholeKnown = false;
62
+ }
63
+ }
64
+ }
65
+ if (kind === "write" && !wholeKnown)
66
+ return undefined;
67
+ return rev;
68
+ };
69
+ }
@@ -0,0 +1,141 @@
1
+ import { appendFileSync, createReadStream, realpathSync, statSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { basename, dirname, extname, join, sep } from "node:path";
4
+ /**
5
+ * Page previews. A page the model wrote is untrusted: it must reach neither the host (which can run commands) nor the folder's
6
+ * other files, nor services on this machine. So each preview gets its own server, on a random port under the OTHER loopback name
7
+ * than the page's (the page on 127.0.0.1: the preview on localhost), so the browser never sends it the kiso cookie and it is
8
+ * another origin. Its root is the entry file's own directory. It refuses anything outside it, every dot-file and dot-directory,
9
+ * key-like names and node_modules; GET and HEAD only, no listings. Every response carries a CSP sandbox (an opaque origin that
10
+ * may run scripts) that lets the page load https and its own files and reach no http address. It closes ten minutes after its
11
+ * last request, or when the panel closes it. The panel frames it with sandbox="allow-scripts" only.
12
+ */
13
+ const IDLE_MS = 10 * 60_000;
14
+ const KEYLIKE = /\.(pem|key|p12|pfx|crt|cer|der|asc|gpg|kdbx|keystore|jks)$|^id_|^credentials|^secrets?(\.|$)/i;
15
+ const TYPES = {
16
+ ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
17
+ ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".wasm": "application/wasm",
19
+ ".pdf": "application/pdf", ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".wav": "audio/wav",
20
+ ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
21
+ };
22
+ /** Why a path inside the root is refused, or null. */
23
+ export function refusedName(rel) {
24
+ const parts = rel.split("/").filter((p) => p !== "");
25
+ if (parts.some((p) => p.startsWith(".")))
26
+ return "dot-file";
27
+ if (parts.includes("node_modules"))
28
+ return "node_modules";
29
+ if (KEYLIKE.test(parts.at(-1) ?? ""))
30
+ return "key-like name";
31
+ return null;
32
+ }
33
+ export class Previews {
34
+ #open = new Map();
35
+ /** a file each request is written to, with its answer (the proofs read it) */
36
+ #log;
37
+ constructor(log) {
38
+ this.#log = log;
39
+ }
40
+ /** The address of `entry` (a file's real path) on its directory's preview server, started if there is none. `ancestor`: the page's origin, the one that may frame it. */
41
+ async open(entry, pageHost, ancestor) {
42
+ const name = pageHost.startsWith("localhost") ? "127.0.0.1" : "localhost";
43
+ const root = dirname(entry);
44
+ const key = `${name} ${ancestor} ${root}`;
45
+ const preview = this.#open.get(key) ?? (await this.#start(key, root, name, ancestor));
46
+ this.#touch(key, preview);
47
+ return `http://${name}:${preview.port}/${encodeURIComponent(basename(entry))}`;
48
+ }
49
+ /** Close the preview that serves `url` (its panel closed). */
50
+ close(url) {
51
+ for (const [key, p] of this.#open)
52
+ if (url.startsWith(`http://${p.name}:${p.port}/`))
53
+ this.#stop(key, p);
54
+ }
55
+ closeAll() {
56
+ for (const [key, p] of this.#open)
57
+ this.#stop(key, p);
58
+ }
59
+ #stop(key, p) {
60
+ if (p.timer !== null)
61
+ clearTimeout(p.timer);
62
+ this.#open.delete(key);
63
+ p.server.closeAllConnections();
64
+ p.server.close();
65
+ }
66
+ #touch(key, p) {
67
+ if (p.timer !== null)
68
+ clearTimeout(p.timer);
69
+ p.timer = setTimeout(() => this.#stop(key, p), IDLE_MS);
70
+ p.timer.unref();
71
+ }
72
+ async #start(key, root, name, ancestor) {
73
+ const headers = {
74
+ "content-security-policy": `sandbox allow-scripts; default-src 'self' 'unsafe-inline' https: data: blob:; connect-src 'self' https:; frame-ancestors ${ancestor}`,
75
+ "x-content-type-options": "nosniff",
76
+ "access-control-allow-origin": "*",
77
+ "cache-control": "no-store",
78
+ "referrer-policy": "no-referrer",
79
+ };
80
+ const server = createServer((req, res) => {
81
+ this.#touch(key, preview);
82
+ const end = (status, why) => {
83
+ if (this.#log !== undefined)
84
+ appendFileSync(this.#log, `${req.method} ${req.url} ${status} ${why}\n`);
85
+ res.writeHead(status, headers);
86
+ res.end();
87
+ };
88
+ if (req.headers.host !== `${name}:${preview.port}`)
89
+ return end(403, "unknown Host");
90
+ if (req.method !== "GET" && req.method !== "HEAD")
91
+ return end(405, "GET and HEAD only");
92
+ let rel;
93
+ try {
94
+ rel = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
95
+ }
96
+ catch {
97
+ return end(400, "bad path");
98
+ }
99
+ if (rel.split("/").every((p) => p === ""))
100
+ return end(404, "no listing");
101
+ const why = refusedName(rel);
102
+ if (why !== null)
103
+ return end(403, why);
104
+ let real;
105
+ try {
106
+ real = realpathSync(join(root, rel));
107
+ }
108
+ catch {
109
+ return end(404, "no such file");
110
+ }
111
+ // a link may lead out of the root, or into a dot-directory inside it
112
+ if (!real.startsWith(root + sep))
113
+ return end(403, "outside the root");
114
+ const inner = refusedName(real.slice(root.length + 1));
115
+ if (inner !== null)
116
+ return end(403, inner);
117
+ const stat = statSync(real);
118
+ if (!stat.isFile())
119
+ return end(404, "no listing");
120
+ const type = TYPES[extname(real).toLowerCase()] ?? "application/octet-stream";
121
+ // a range, for media (Safari plays video only by ranges)
122
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
123
+ const first = range === null ? 0 : range[1] === "" ? Math.max(0, stat.size - Number(range[2])) : Number(range[1]);
124
+ const last = range === null || range[2] === "" || range[1] === "" ? stat.size - 1 : Math.min(stat.size - 1, Number(range[2]));
125
+ if (this.#log !== undefined)
126
+ appendFileSync(this.#log, `${req.method} ${req.url} ${range === null ? 200 : 206} served\n`);
127
+ res.writeHead(range === null ? 200 : 206, { ...headers, "content-type": type, "content-length": String(Math.max(0, last - first + 1)), "accept-ranges": "bytes", ...(range === null ? {} : { "content-range": `bytes ${first}-${last}/${stat.size}` }) });
128
+ if (req.method === "HEAD" || last < first)
129
+ return void res.end();
130
+ createReadStream(real, { start: first, end: last }).pipe(res);
131
+ });
132
+ const preview = { root, name, server, port: 0, timer: null };
133
+ await new Promise((resolve, reject) => {
134
+ server.once("error", reject);
135
+ server.listen(0, name, resolve);
136
+ });
137
+ preview.port = server.address().port;
138
+ this.#open.set(key, preview);
139
+ return preview;
140
+ }
141
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The web app's own tool-argument sanitizer. The default one is a filter
3
+ * for a hosted product's tool card (it drops prose keys and cuts strings at
4
+ * 200 characters); the person on the other end of this stream is the
5
+ * person whose files these are, and a diff needs the whole of oldText and
6
+ * newText. So keys stay and strings are only capped at a size no edit
7
+ * comes near, to keep one frame from carrying a whole generated file.
8
+ *
9
+ * The projection hands the sanitizer the arguments alone, never the tool's
10
+ * name, so it cannot treat `edit_file` differently from `shell`: the cap is
11
+ * the same for every tool.
12
+ */
13
+ export const WEB_MAX_ARG_CHARS = 32_768;
14
+ const MAX_DEPTH = 8;
15
+ export function webSanitize(input, depth = 0) {
16
+ if (typeof input === "string")
17
+ return input.length > WEB_MAX_ARG_CHARS ? `${input.slice(0, WEB_MAX_ARG_CHARS)}…[${input.length - WEB_MAX_ARG_CHARS} more chars]` : input;
18
+ if (input === null || typeof input !== "object")
19
+ return input;
20
+ if (depth >= MAX_DEPTH)
21
+ return Array.isArray(input) ? "[…]" : "{…}";
22
+ if (Array.isArray(input))
23
+ return input.map((v) => webSanitize(v, depth + 1));
24
+ const out = {};
25
+ for (const [key, value] of Object.entries(input))
26
+ out[key] = webSanitize(value, depth + 1);
27
+ return out;
28
+ }