@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.1

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +988 -2
  3. package/THIRD_PARTY_NOTICES.md +2467 -0
  4. package/bin/vibe-os.mjs +13 -0
  5. package/dist/web/apple-touch-icon.png +0 -0
  6. package/dist/web/assets/index-B77Qk8B5.js +35 -0
  7. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  8. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  9. package/dist/web/assets/index-CH5Em-Mo.css +1 -0
  10. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  11. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-app.svg +19 -0
  15. package/dist/web/icon-maskable-512.png +0 -0
  16. package/dist/web/icon-maskable.svg +13 -0
  17. package/dist/web/icon.svg +15 -0
  18. package/dist/web/index.html +36 -0
  19. package/dist/web/ssh.wasm +0 -0
  20. package/dist/web/ssh.wasm.br +0 -0
  21. package/dist/web/ssh.wasm.gz +0 -0
  22. package/dist/web/sw.js +29 -0
  23. package/dist/web/wasm_exec.js +575 -0
  24. package/dist/web/wasm_exec.js.br +0 -0
  25. package/dist/web/wasm_exec.js.gz +0 -0
  26. package/package.json +82 -4
  27. package/scripts/compile.ts +62 -0
  28. package/scripts/compress.mjs +54 -0
  29. package/scripts/fetch-wasm.mjs +242 -0
  30. package/scripts/gen-assets.ts +76 -0
  31. package/scripts/lib/walk.ts +18 -0
  32. package/scripts/notices.ts +189 -0
  33. package/server/acme.ts +182 -0
  34. package/server/api.ts +515 -0
  35. package/server/assets.generated.ts +53 -0
  36. package/server/attach.ts +524 -0
  37. package/server/auth.ts +194 -0
  38. package/server/bridge.ts +145 -0
  39. package/server/browser.ts +519 -0
  40. package/server/cli.ts +857 -0
  41. package/server/config.ts +385 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +977 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +655 -0
  46. package/server/http.ts +53 -0
  47. package/server/index.ts +469 -0
  48. package/server/log.ts +40 -0
  49. package/server/main.ts +7 -0
  50. package/server/mcp.ts +291 -0
  51. package/server/preflight.ts +87 -0
  52. package/server/profiles.ts +238 -0
  53. package/server/projects.ts +721 -0
  54. package/server/reaper.ts +303 -0
  55. package/server/runtime.ts +64 -0
  56. package/server/session.ts +330 -0
  57. package/server/setup.ts +1151 -0
  58. package/server/ssh-ca.ts +368 -0
  59. package/server/static.ts +147 -0
  60. package/server/wallpapers.ts +248 -0
  61. package/server/windows.ts +237 -0
  62. package/shared/args.ts +91 -0
  63. package/shared/grid.ts +32 -0
  64. package/shared/wire.ts +226 -0
package/server/acme.ts ADDED
@@ -0,0 +1,182 @@
1
+ // Let's Encrypt via HTTP-01.
2
+ //
3
+ // We already own port 80, which is exactly what the HTTP-01 challenge needs, so
4
+ // there is no separate listener and nothing to coordinate: the challenge
5
+ // responder is just another route on the plain-HTTP server, checked before the
6
+ // redirect-to-HTTPS rule.
7
+
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import path from "node:path";
10
+
11
+ import { writeAtomic } from "./fsx.ts";
12
+ import { describeError, log } from "./log.ts";
13
+
14
+ const CHALLENGE_PREFIX = "/.well-known/acme-challenge/";
15
+ /** Renew this far ahead of expiry. Let's Encrypt certs last 90 days. */
16
+ const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
17
+ /**
18
+ * How often to re-check expiry once running.
19
+ *
20
+ * The renewal logic below only ever ran at startup, and the documented
21
+ * deployment is a systemd unit with `Restart=on-failure` plus a compose file
22
+ * with `restart: unless-stopped` — neither of which restarts a *healthy*
23
+ * process. A box left alone therefore served an expired certificate on day 90.
24
+ * Twice a day is far more often than a 30-day window needs, and costs one
25
+ * `readCertificateInfo` per tick when there is nothing to do.
26
+ */
27
+ const RENEW_CHECK_MS = 12 * 60 * 60 * 1000;
28
+
29
+ export interface TlsMaterial {
30
+ key: string;
31
+ cert: string;
32
+ }
33
+
34
+ export interface AcmeOptions {
35
+ domain: string;
36
+ email?: string;
37
+ staging: boolean;
38
+ stateDir: string;
39
+ }
40
+
41
+ export class Acme {
42
+ private challenges = new Map<string, string>();
43
+ private readonly dir: string;
44
+
45
+ constructor(private readonly options: AcmeOptions) {
46
+ this.dir = path.join(options.stateDir, "tls");
47
+ }
48
+
49
+ /** Serves the HTTP-01 challenge. Must run before any HTTPS redirect. */
50
+ handleChallenge(url: URL): Response | null {
51
+ if (!url.pathname.startsWith(CHALLENGE_PREFIX)) return null;
52
+ const token = url.pathname.slice(CHALLENGE_PREFIX.length);
53
+ const value = this.challenges.get(token);
54
+ if (!value)
55
+ return new Response("not found\n", {
56
+ status: 404,
57
+ headers: { "content-type": "text/plain" },
58
+ });
59
+ return new Response(value, {
60
+ status: 200,
61
+ headers: { "content-type": "application/octet-stream" },
62
+ });
63
+ }
64
+
65
+ private paths() {
66
+ return {
67
+ key: path.join(this.dir, `${this.options.domain}.key`),
68
+ cert: path.join(this.dir, `${this.options.domain}.crt`),
69
+ account: path.join(this.dir, "account.key"),
70
+ };
71
+ }
72
+
73
+ private async loadExisting(): Promise<TlsMaterial | null> {
74
+ const { key, cert } = this.paths();
75
+ try {
76
+ const [keyPem, certPem] = await Promise.all([
77
+ readFile(key, "utf8"),
78
+ readFile(cert, "utf8"),
79
+ ]);
80
+ const acme = await import("acme-client");
81
+ const info = acme.crypto.readCertificateInfo(certPem);
82
+ const remaining = info.notAfter.getTime() - Date.now();
83
+ if (remaining > RENEW_BEFORE_MS) {
84
+ log.ok(
85
+ `reusing TLS certificate for ${this.options.domain} (expires ${info.notAfter.toISOString().slice(0, 10)})`,
86
+ );
87
+ return { key: keyPem, cert: certPem };
88
+ }
89
+ log.info(
90
+ `TLS certificate for ${this.options.domain} expires soon — renewing`,
91
+ );
92
+ return null;
93
+ } catch (err) {
94
+ // ENOENT is the ordinary "no certificate yet" path. Anything else — a
95
+ // permission problem, a truncated file, a corrupt PEM — must not silently
96
+ // become a reissue: that burns Let's Encrypt's duplicate-certificate rate
97
+ // limit, and under a restart loop it burns it fast.
98
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /** Returns usable key/cert material, ordering a new certificate if needed. */
104
+ async obtain(): Promise<TlsMaterial> {
105
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
106
+
107
+ const existing = await this.loadExisting();
108
+ if (existing) return existing;
109
+
110
+ const acme = await import("acme-client");
111
+ const { key: keyPath, cert: certPath, account: accountPath } = this.paths();
112
+
113
+ // Same reasoning as loadExisting, but the stakes are higher: overwriting
114
+ // this key discards the Let's Encrypt registration itself.
115
+ let accountKey: Buffer;
116
+ try {
117
+ accountKey = await readFile(accountPath);
118
+ } catch (err) {
119
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
120
+ accountKey = await acme.crypto.createPrivateKey();
121
+ await writeFile(accountPath, accountKey, { mode: 0o600 });
122
+ }
123
+
124
+ const client = new acme.Client({
125
+ directoryUrl: this.options.staging
126
+ ? acme.directory.letsencrypt.staging
127
+ : acme.directory.letsencrypt.production,
128
+ accountKey,
129
+ });
130
+
131
+ log.info(`requesting a TLS certificate for ${this.options.domain}…`);
132
+ const [privateKey, csr] = await acme.crypto.createCsr({
133
+ commonName: this.options.domain,
134
+ });
135
+
136
+ const cert = await client.auto({
137
+ csr,
138
+ email: this.options.email,
139
+ termsOfServiceAgreed: true,
140
+ challengePriority: ["http-01"],
141
+ challengeCreateFn: async (_authz, challenge, keyAuthorization) => {
142
+ if (challenge.type !== "http-01") return;
143
+ this.challenges.set(challenge.token, keyAuthorization);
144
+ },
145
+ challengeRemoveFn: async (_authz, challenge) => {
146
+ this.challenges.delete(challenge.token);
147
+ },
148
+ });
149
+
150
+ const keyPem = privateKey.toString();
151
+ const certPem = cert.toString();
152
+ await writeAtomic(keyPath, keyPem, 0o600);
153
+ await writeAtomic(certPath, certPem, 0o644);
154
+ log.ok(`TLS certificate issued for ${this.options.domain}`);
155
+
156
+ return { key: keyPem, cert: certPem };
157
+ }
158
+
159
+ /**
160
+ * Re-checks expiry on a timer and hands back fresh material when it rolls.
161
+ *
162
+ * Returns a stop function. The caller owns swapping the running HTTPS server
163
+ * over; this only decides when there is something to swap to.
164
+ */
165
+ watch(onRenewed: (material: TlsMaterial) => void): () => void {
166
+ const timer = setInterval(() => {
167
+ void (async () => {
168
+ try {
169
+ if (await this.loadExisting()) return; // still good
170
+ onRenewed(await this.obtain());
171
+ } catch (err) {
172
+ // A failed renewal must not take the process down: the current
173
+ // certificate is still being served and there are 30 days of runway.
174
+ log.warn(`TLS renewal check failed: ${describeError(err)}`);
175
+ }
176
+ })();
177
+ }, RENEW_CHECK_MS);
178
+ // Do not hold the event loop open just for this.
179
+ timer.unref?.();
180
+ return () => clearInterval(timer);
181
+ }
182
+ }
package/server/api.ts ADDED
@@ -0,0 +1,515 @@
1
+ import os from "node:os";
2
+ import pkg from "../package.json" with { type: "json" };
3
+ import type { ClientConfig } from "../shared/wire.ts";
4
+ import {
5
+ attachInfo,
6
+ killSession,
7
+ publicHost,
8
+ reapStaleHandoffs,
9
+ setHandoff,
10
+ } from "./attach.ts";
11
+ import type { Config } from "./config.ts";
12
+ import type { Db } from "./db.ts";
13
+ import { ID_PATTERN } from "./db.ts";
14
+ import { discoverClaude, discoverCursor, discoverHermes } from "./harness.ts";
15
+ import {
16
+ badId,
17
+ IMMUTABLE_CACHE_CONTROL,
18
+ json,
19
+ methodNotAllowed,
20
+ readJson,
21
+ } from "./http.ts";
22
+ import { describeError, log } from "./log.ts";
23
+ import {
24
+ discoverMcp,
25
+ discoverMcpWithDefs,
26
+ type McpScan,
27
+ syncMcpMirrors,
28
+ } from "./mcp.ts";
29
+ import {
30
+ createProfile,
31
+ deleteProfile,
32
+ getProfile,
33
+ listProfiles,
34
+ PALETTE,
35
+ updateProfile,
36
+ } from "./profiles.ts";
37
+ import {
38
+ createWorkspace,
39
+ getWorkspace,
40
+ listProjects,
41
+ listWorkspaces,
42
+ reconcileWorkspaces,
43
+ removeWorkspace,
44
+ scanProjects,
45
+ touchWorkspace,
46
+ } from "./projects.ts";
47
+ import { windowCommand } from "./session.ts";
48
+ import type { SshCa } from "./ssh-ca.ts";
49
+ import { fingerprint, MAX_PUBKEY_BYTES } from "./ssh-ca.ts";
50
+ import {
51
+ type DesktopPrefs,
52
+ MAX_WALLPAPER_BYTES,
53
+ type WallpaperStore,
54
+ } from "./wallpapers.ts";
55
+ import {
56
+ createWindow,
57
+ deleteWindow,
58
+ getWindow,
59
+ listWindows,
60
+ sessionNameFor,
61
+ updateWindow,
62
+ } from "./windows.ts";
63
+
64
+ /**
65
+ * Where to look for MCP servers, for one project or for all of them.
66
+ *
67
+ * A worktree is a copy of the repo, so its `.mcp.json` is the project's and is
68
+ * read once from the root. Local-scope servers are the opposite — they are
69
+ * keyed by the exact directory `claude mcp add` ran in, so every worktree is
70
+ * its own place to look.
71
+ */
72
+ function mcpScan(db: Db, projectId?: string): McpScan {
73
+ const roots: string[] = [];
74
+ const dirs: string[] = [];
75
+ for (const project of listProjects(db)) {
76
+ if (projectId !== undefined && project.id !== projectId) continue;
77
+ roots.push(project.path);
78
+ dirs.push(
79
+ project.path,
80
+ ...listWorkspaces(db, project.id).map((w) => w.path),
81
+ );
82
+ }
83
+ return { roots, dirs };
84
+ }
85
+
86
+ /**
87
+ * Brings the generated per-server files back in step with the box.
88
+ *
89
+ * Always over every project, never just the one being asked about: the sync
90
+ * deletes files that no longer have a server behind them, and a partial view
91
+ * would read another project's servers as gone.
92
+ */
93
+ /**
94
+ * Re-scans every project and rewrites the mirror files.
95
+ *
96
+ * Returns nothing: both call sites discarded the list, which made the discovery
97
+ * work look reusable when it was not.
98
+ */
99
+ async function refreshMcpMirrors(db: Db, stateDir: string): Promise<void> {
100
+ await syncMcpMirrors(
101
+ stateDir,
102
+ await discoverMcpWithDefs(stateDir, mcpScan(db)),
103
+ );
104
+ }
105
+
106
+ export interface ApiDeps {
107
+ config: Config;
108
+ ca: SshCa;
109
+ hostKey: string | null;
110
+ wallpapers: WallpaperStore;
111
+ db: Db;
112
+ }
113
+
114
+ export function createApi(deps: ApiDeps) {
115
+ const { ca, config, db, hostKey, wallpapers } = deps;
116
+
117
+ return async function handleApi(
118
+ req: Request,
119
+ url: URL,
120
+ ): Promise<Response | null> {
121
+ const p = url.pathname;
122
+ if (!p.startsWith("/api/")) return null;
123
+
124
+ if (p === "/api/health") return json({ ok: true });
125
+
126
+ // What the installed Claude CLI accepts, so the editor can offer it as
127
+ // dropdowns rather than asking people to remember flag spellings.
128
+ if (p === "/api/harness/claude" && req.method === "GET") {
129
+ return json(await discoverClaude());
130
+ }
131
+
132
+ // The same question for Hermes, which answers a narrower version of it:
133
+ // configured providers, its own defaults, and where its browser tools land.
134
+ if (p === "/api/harness/hermes" && req.method === "GET") {
135
+ return json(await discoverHermes());
136
+ }
137
+
138
+ // And for Cursor: the account's model list, and whether anyone is logged in.
139
+ if (p === "/api/harness/cursor" && req.method === "GET") {
140
+ return json(await discoverCursor());
141
+ }
142
+
143
+ if (p === "/api/config" && req.method === "GET") {
144
+ const body: ClientConfig = {
145
+ version: pkg.version,
146
+ hostname: os.hostname(),
147
+ user: config.user,
148
+ workspaceRoot: config.workspace,
149
+ sessions: config.sessions,
150
+ authRequired: config.token !== null,
151
+ endpoint: { name: "local", url: "./websocket" },
152
+ hostKey,
153
+ hostKeyFingerprint: hostKey ? fingerprint(hostKey) : null,
154
+ certificateEndpoint: "/api/ssh/certificate",
155
+ maxWallpaperBytes: MAX_WALLPAPER_BYTES,
156
+ themeColor: config.themeColor,
157
+ palette: PALETTE,
158
+ };
159
+ return json(body);
160
+ }
161
+
162
+ // ── projects ─────────────────────────────────────────────────────────────
163
+ if (p === "/api/projects" && req.method === "GET") {
164
+ // Reads the table only. Finding repos means walking the disk, which is
165
+ // far too expensive to do on a poll — that is what the refresh button
166
+ // (POST /api/projects/scan) is for, plus one scan at startup.
167
+ return json(listProjects(db));
168
+ }
169
+
170
+ if (p === "/api/projects/scan" && req.method === "POST") {
171
+ await scanProjects(db, config);
172
+ await reconcileWorkspaces(db, config);
173
+ return json(listProjects(db));
174
+ }
175
+
176
+ // The MCP servers a profile in this project could be given. Read from the
177
+ // box's own config rather than a list of our own, so `claude mcp add` is
178
+ // all it takes for one to appear here.
179
+ const mcpMatch = /^\/api\/projects\/([^/]+)\/mcp$/.exec(p);
180
+ if (mcpMatch && req.method === "GET") {
181
+ const projectId = decodeURIComponent(mcpMatch[1]);
182
+ if (!ID_PATTERN.test(projectId)) return badId("project");
183
+ // Writes the files as a side effect of listing them, so that anything
184
+ // offered in the editor is something a launch can actually point at.
185
+ await refreshMcpMirrors(db, config.stateDir);
186
+ return json(await discoverMcp(config.stateDir, mcpScan(db, projectId)));
187
+ }
188
+
189
+ const workspacesMatch = /^\/api\/projects\/([^/]+)\/workspaces$/.exec(p);
190
+ if (workspacesMatch) {
191
+ const projectId = decodeURIComponent(workspacesMatch[1]);
192
+ if (!ID_PATTERN.test(projectId)) return badId("project");
193
+
194
+ if (req.method === "GET") return json(listWorkspaces(db, projectId));
195
+
196
+ if (req.method === "POST") {
197
+ try {
198
+ const body = await readJson<{ name?: string }>(req);
199
+ const created = await createWorkspace(
200
+ db,
201
+ config.workspace,
202
+ projectId,
203
+ body.name,
204
+ );
205
+ return json(created, 201);
206
+ } catch (err) {
207
+ const message = describeError(err);
208
+ log.warn(`workspace creation failed: ${message}`);
209
+ return json({ error: message }, 400);
210
+ }
211
+ }
212
+ return methodNotAllowed();
213
+ }
214
+
215
+ // ── profiles ─────────────────────────────────────────────────────────────
216
+ const profilesMatch = /^\/api\/projects\/([^/]+)\/profiles$/.exec(p);
217
+ if (profilesMatch) {
218
+ const projectId = decodeURIComponent(profilesMatch[1]);
219
+ if (!ID_PATTERN.test(projectId)) return badId("project");
220
+
221
+ if (req.method === "GET") return json(listProfiles(db, projectId));
222
+
223
+ if (req.method === "POST") {
224
+ try {
225
+ const body = await readJson<Record<string, unknown>>(req);
226
+ return json(createProfile(db, projectId, body), 201);
227
+ } catch (err) {
228
+ return json({ error: describeError(err) }, 400);
229
+ }
230
+ }
231
+ return methodNotAllowed();
232
+ }
233
+
234
+ const profileMatch = /^\/api\/profiles\/([^/]+)$/.exec(p);
235
+ if (profileMatch) {
236
+ const id = decodeURIComponent(profileMatch[1]);
237
+ if (!ID_PATTERN.test(id)) return badId("profile");
238
+
239
+ if (req.method === "PATCH") {
240
+ try {
241
+ const body = await readJson<Record<string, unknown>>(req);
242
+ const updated = updateProfile(db, id, body);
243
+ return updated
244
+ ? json(updated)
245
+ : json({ error: "unknown profile" }, 404);
246
+ } catch (err) {
247
+ return json({ error: describeError(err) }, 400);
248
+ }
249
+ }
250
+
251
+ if (req.method === "DELETE") {
252
+ // Windows opened as this profile keep running and become plain
253
+ // terminals; ending live sessions is what the close button is for.
254
+ return deleteProfile(db, id)
255
+ ? json({ ok: true })
256
+ : json({ error: "unknown profile" }, 404);
257
+ }
258
+ return methodNotAllowed();
259
+ }
260
+
261
+ const workspaceMatch = /^\/api\/workspaces\/([^/]+)$/.exec(p);
262
+ if (workspaceMatch) {
263
+ const id = decodeURIComponent(workspaceMatch[1]);
264
+ if (!ID_PATTERN.test(id)) return badId("workspace");
265
+
266
+ if (req.method === "DELETE") {
267
+ // Every window here has a live session; removing the worktree
268
+ // without ending them leaves shells sitting in a deleted directory.
269
+ for (const win of listWindows(db, id)) {
270
+ const target = sessionNameFor(db, win.id);
271
+ if (target && config.sessions)
272
+ await killSession(config, target.session).catch(() => {});
273
+ }
274
+ try {
275
+ await removeWorkspace(db, config, id);
276
+ return json({ ok: true });
277
+ } catch (err) {
278
+ return json({ error: describeError(err) }, 400);
279
+ }
280
+ }
281
+
282
+ if (req.method === "POST") {
283
+ touchWorkspace(db, id);
284
+ return json({ ok: true });
285
+ }
286
+ return methodNotAllowed();
287
+ }
288
+
289
+ // ── windows ──────────────────────────────────────────────────────────────
290
+ const windowsMatch = /^\/api\/workspaces\/([^/]+)\/windows$/.exec(p);
291
+ if (windowsMatch) {
292
+ const workspaceId = decodeURIComponent(windowsMatch[1]);
293
+ if (!ID_PATTERN.test(workspaceId)) return badId("workspace");
294
+ const workspace = getWorkspace(db, workspaceId);
295
+ if (!workspace) return json({ error: "unknown workspace" }, 404);
296
+
297
+ if (req.method === "GET") {
298
+ // Cheap unless something is actually handed off, and it is what makes
299
+ // closing an ssh session give the window back on its own.
300
+ await reapStaleHandoffs(db, config).catch((err: unknown) => {
301
+ log.warn(`could not check handoffs: ${describeError(err)}`);
302
+ });
303
+ return json(listWindows(db, workspaceId));
304
+ }
305
+ if (req.method === "POST") {
306
+ const body = await readJson<{ profileId?: unknown }>(req);
307
+ let profileId: string | null = null;
308
+ if (typeof body.profileId === "string" && body.profileId !== "") {
309
+ const profile = getProfile(db, body.profileId);
310
+ // A profile belongs to a project, so it may only open windows in that
311
+ // project's workspaces — otherwise one project's flags and prompt
312
+ // could be launched inside another project's worktree.
313
+ if (!profile || profile.projectId !== workspace.projectId) {
314
+ return json({ error: "unknown profile for this workspace" }, 400);
315
+ }
316
+ profileId = profile.id;
317
+ }
318
+ return json(createWindow(db, workspaceId, profileId), 201);
319
+ }
320
+ return methodNotAllowed();
321
+ }
322
+
323
+ // How to reach this window from a real terminal. Read-only: it composes
324
+ // strings a person can copy, and never runs anything itself.
325
+ const attachMatch = /^\/api\/windows\/([^/]+)\/attach$/.exec(p);
326
+ if (attachMatch && req.method === "GET") {
327
+ const id = decodeURIComponent(attachMatch[1]);
328
+ if (!ID_PATTERN.test(id)) return badId("window");
329
+ if (!config.sessions)
330
+ return json(
331
+ { error: "this server runs plain login shells (--no-sessions)" },
332
+ 409,
333
+ );
334
+ const info = attachInfo(db, config, id, publicHost(req, url));
335
+ return info ? json(info) : json({ error: "unknown window" }, 404);
336
+ }
337
+
338
+ // Hands the window's terminal to an ssh client, or takes it back — which
339
+ // detaches whoever is attached, so there is never a second client.
340
+ const handoffMatch = /^\/api\/windows\/([^/]+)\/handoff$/.exec(p);
341
+ if (handoffMatch && req.method === "POST") {
342
+ const id = decodeURIComponent(handoffMatch[1]);
343
+ if (!ID_PATTERN.test(id)) return badId("window");
344
+ const body = await readJson<{ mode?: unknown }>(req);
345
+ if (body.mode !== "ssh" && body.mode !== null)
346
+ return json({ error: 'mode must be "ssh" or null' }, 400);
347
+ if (body.mode === "ssh" && !config.sessions) {
348
+ return json(
349
+ { error: "this server runs plain login shells (--no-sessions)" },
350
+ 409,
351
+ );
352
+ }
353
+ const ok = await setHandoff(db, config, id, body.mode);
354
+ if (!ok) return json({ error: "unknown window" }, 404);
355
+ return json(getWindow(db, id) ?? { ok: true });
356
+ }
357
+
358
+ const windowMatch = /^\/api\/windows\/([^/]+)$/.exec(p);
359
+ if (windowMatch) {
360
+ const id = decodeURIComponent(windowMatch[1]);
361
+ if (!ID_PATTERN.test(id)) return badId("window");
362
+
363
+ if (req.method === "PATCH") {
364
+ const body = await readJson<Record<string, unknown>>(req);
365
+ const updated = updateWindow(db, id, body);
366
+ return updated ? json(updated) : json({ error: "unknown window" }, 404);
367
+ }
368
+
369
+ if (req.method === "DELETE") {
370
+ // Closing a window is an explicit "I am done with this", so the session
371
+ // goes too. Window indices are reused and `new-session -A` attaches, so
372
+ // leaving it alive would silently resurrect it in the next window.
373
+ const target = sessionNameFor(db, id);
374
+ if (target && config.sessions) {
375
+ await killSession(config, target.session)
376
+ .then(() => log.info(`ended session ${target.session}`))
377
+ .catch(() => {});
378
+ }
379
+ return deleteWindow(db, id)
380
+ ? json({ ok: true })
381
+ : json({ error: "unknown window" }, 404);
382
+ }
383
+ return methodNotAllowed();
384
+ }
385
+
386
+ // ── certificates ─────────────────────────────────────────────────────────
387
+ if (p === "/api/ssh/certificate") {
388
+ if (req.method !== "POST") return methodNotAllowed();
389
+
390
+ const windowId = url.searchParams.get("window") ?? "";
391
+ if (!ID_PATTERN.test(windowId)) return badId("window");
392
+
393
+ const target = sessionNameFor(db, windowId);
394
+ if (!target) return json({ error: "unknown window" }, 404);
395
+
396
+ if (Number(req.headers.get("content-length") ?? 0) > MAX_PUBKEY_BYTES) {
397
+ return json({ error: "public key too large" }, 413);
398
+ }
399
+
400
+ try {
401
+ const publicKey = await req.text();
402
+ if (publicKey.length > MAX_PUBKEY_BYTES)
403
+ throw new Error("public key too large");
404
+
405
+ // Read from the window row, never from the request: the browser asks
406
+ // for a window, and the server decides what that window runs.
407
+ const profile = target.profileId
408
+ ? getProfile(db, target.profileId)
409
+ : undefined;
410
+
411
+ // A profile's `--mcp-config` paths are only as good as the files behind
412
+ // them, and those are regenerated from the box's config. Doing it here
413
+ // means a server edited with `claude mcp add` takes effect on the next
414
+ // window rather than whenever the editor was last opened.
415
+ if (profile?.harness === "claude") {
416
+ await refreshMcpMirrors(db, config.stateDir).catch((err: unknown) => {
417
+ log.warn(`could not refresh mcp configs: ${describeError(err)}`);
418
+ });
419
+ }
420
+
421
+ const forceCommand = windowCommand(
422
+ target.session,
423
+ target.cwd,
424
+ config,
425
+ profile,
426
+ );
427
+ const cert = await ca.signUserCert({
428
+ publicKey,
429
+ principal: config.user,
430
+ identity: `vibe-os/${target.session}`,
431
+ forceCommand,
432
+ ttlSeconds: config.certTtlSeconds,
433
+ });
434
+
435
+ log.info(
436
+ `issued certificate for ${target.session} in ${target.cwd}${profile ? ` as ${profile.name}` : ""}`,
437
+ );
438
+
439
+ // sshterm compares this header for exact equality against "text/plain";
440
+ // a "; charset=utf-8" suffix makes it reject the certificate.
441
+ return new Response(cert, {
442
+ status: 200,
443
+ headers: {
444
+ "content-type": "text/plain",
445
+ "cache-control": "no-store",
446
+ },
447
+ });
448
+ } catch (err) {
449
+ const message = describeError(err);
450
+ log.warn(`certificate request rejected: ${message}`);
451
+ return json({ error: message }, 400);
452
+ }
453
+ }
454
+
455
+ // ── desktop preferences ──────────────────────────────────────────────────
456
+ if (p === "/api/desktop") {
457
+ if (req.method === "GET") return json(await wallpapers.prefs());
458
+ if (req.method === "PUT") {
459
+ try {
460
+ const body = (await req.json()) as DesktopPrefs;
461
+ return json(await wallpapers.setPrefs(body));
462
+ } catch (err) {
463
+ return json({ error: describeError(err) }, 400);
464
+ }
465
+ }
466
+ return methodNotAllowed();
467
+ }
468
+
469
+ // ── wallpapers ───────────────────────────────────────────────────────────
470
+ if (p === "/api/wallpapers") {
471
+ if (req.method === "GET") return json(await wallpapers.list());
472
+ if (req.method === "POST") {
473
+ if (
474
+ Number(req.headers.get("content-length") ?? 0) > MAX_WALLPAPER_BYTES
475
+ ) {
476
+ return json({ error: "image too large" }, 413);
477
+ }
478
+ try {
479
+ const name = url.searchParams.get("name") ?? "wallpaper";
480
+ const bytes = new Uint8Array(await req.arrayBuffer());
481
+ return json(await wallpapers.save(bytes, name), 201);
482
+ } catch (err) {
483
+ return json({ error: describeError(err) }, 400);
484
+ }
485
+ }
486
+ return methodNotAllowed();
487
+ }
488
+
489
+ if (p.startsWith("/api/wallpapers/")) {
490
+ const id = p.slice("/api/wallpapers/".length);
491
+ if (req.method === "GET") {
492
+ const found = await wallpapers.read(id);
493
+ if (!found) return json({ error: "not found" }, 404);
494
+ return new Response(found.file, {
495
+ headers: {
496
+ "content-type": found.mime,
497
+ "cache-control": IMMUTABLE_CACHE_CONTROL,
498
+ // Defence in depth: even if a hostile file slipped past the magic
499
+ // byte check, the browser must not be talked into running it.
500
+ "content-security-policy": "default-src 'none'; sandbox",
501
+ "x-content-type-options": "nosniff",
502
+ },
503
+ });
504
+ }
505
+ if (req.method === "DELETE") {
506
+ return (await wallpapers.remove(id))
507
+ ? json({ ok: true })
508
+ : json({ error: "not found" }, 404);
509
+ }
510
+ return methodNotAllowed();
511
+ }
512
+
513
+ return json({ error: "not found" }, 404);
514
+ };
515
+ }