@roamcode.ai/server 1.0.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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth.d.ts +63 -0
  3. package/dist/auth.js +133 -0
  4. package/dist/claude-auth-service.d.ts +76 -0
  5. package/dist/claude-auth-service.js +217 -0
  6. package/dist/claude-latest-service.d.ts +34 -0
  7. package/dist/claude-latest-service.js +61 -0
  8. package/dist/config.d.ts +78 -0
  9. package/dist/config.js +59 -0
  10. package/dist/data-dir.d.ts +42 -0
  11. package/dist/data-dir.js +70 -0
  12. package/dist/diag.d.ts +43 -0
  13. package/dist/diag.js +83 -0
  14. package/dist/fs-service.d.ts +90 -0
  15. package/dist/fs-service.js +290 -0
  16. package/dist/index.d.ts +82 -0
  17. package/dist/index.js +44 -0
  18. package/dist/managed-runtime.d.ts +51 -0
  19. package/dist/managed-runtime.js +411 -0
  20. package/dist/managed-update-helper.d.ts +2 -0
  21. package/dist/managed-update-helper.js +34 -0
  22. package/dist/mcp-send.d.ts +33 -0
  23. package/dist/mcp-send.js +107 -0
  24. package/dist/origin-check.d.ts +37 -0
  25. package/dist/origin-check.js +101 -0
  26. package/dist/pane-status.d.ts +61 -0
  27. package/dist/pane-status.js +145 -0
  28. package/dist/providers/claude-metadata-service.d.ts +58 -0
  29. package/dist/providers/claude-metadata-service.js +352 -0
  30. package/dist/providers/claude-provider.d.ts +11 -0
  31. package/dist/providers/claude-provider.js +166 -0
  32. package/dist/providers/codex-activity.d.ts +21 -0
  33. package/dist/providers/codex-activity.js +122 -0
  34. package/dist/providers/codex-app-server-client.d.ts +90 -0
  35. package/dist/providers/codex-app-server-client.js +485 -0
  36. package/dist/providers/codex-latest-service.d.ts +50 -0
  37. package/dist/providers/codex-latest-service.js +174 -0
  38. package/dist/providers/codex-metadata-service.d.ts +161 -0
  39. package/dist/providers/codex-metadata-service.js +686 -0
  40. package/dist/providers/codex-profile-client.d.ts +16 -0
  41. package/dist/providers/codex-profile-client.js +52 -0
  42. package/dist/providers/codex-profile-security.d.ts +23 -0
  43. package/dist/providers/codex-profile-security.js +161 -0
  44. package/dist/providers/codex-provider.d.ts +15 -0
  45. package/dist/providers/codex-provider.js +174 -0
  46. package/dist/providers/codex-thread-coordinator.d.ts +18 -0
  47. package/dist/providers/codex-thread-coordinator.js +93 -0
  48. package/dist/providers/codex-thread-persistence.d.ts +9 -0
  49. package/dist/providers/codex-thread-persistence.js +45 -0
  50. package/dist/providers/codex-thread-resolver.d.ts +59 -0
  51. package/dist/providers/codex-thread-resolver.js +322 -0
  52. package/dist/providers/options.d.ts +7 -0
  53. package/dist/providers/options.js +155 -0
  54. package/dist/providers/provider-artifacts.d.ts +3 -0
  55. package/dist/providers/provider-artifacts.js +30 -0
  56. package/dist/providers/registry.d.ts +7 -0
  57. package/dist/providers/registry.js +23 -0
  58. package/dist/providers/types.d.ts +95 -0
  59. package/dist/providers/types.js +8 -0
  60. package/dist/push-dispatch.d.ts +81 -0
  61. package/dist/push-dispatch.js +100 -0
  62. package/dist/push-store.d.ts +25 -0
  63. package/dist/push-store.js +79 -0
  64. package/dist/rate-limit.d.ts +52 -0
  65. package/dist/rate-limit.js +72 -0
  66. package/dist/server-config.d.ts +60 -0
  67. package/dist/server-config.js +77 -0
  68. package/dist/service-install.d.ts +60 -0
  69. package/dist/service-install.js +221 -0
  70. package/dist/session-defaults.d.ts +26 -0
  71. package/dist/session-defaults.js +60 -0
  72. package/dist/session-store.d.ts +81 -0
  73. package/dist/session-store.js +654 -0
  74. package/dist/start.d.ts +31 -0
  75. package/dist/start.js +372 -0
  76. package/dist/static-routes.d.ts +101 -0
  77. package/dist/static-routes.js +188 -0
  78. package/dist/terminal-capability.d.ts +5 -0
  79. package/dist/terminal-capability.js +27 -0
  80. package/dist/terminal-manager.d.ts +224 -0
  81. package/dist/terminal-manager.js +917 -0
  82. package/dist/terminal-process.d.ts +85 -0
  83. package/dist/terminal-process.js +238 -0
  84. package/dist/terminal-shared.d.ts +36 -0
  85. package/dist/terminal-shared.js +43 -0
  86. package/dist/tmux-list.d.ts +11 -0
  87. package/dist/tmux-list.js +39 -0
  88. package/dist/transport.d.ts +123 -0
  89. package/dist/transport.js +1559 -0
  90. package/dist/updater.d.ts +161 -0
  91. package/dist/updater.js +451 -0
  92. package/dist/usage-service.d.ts +118 -0
  93. package/dist/usage-service.js +173 -0
  94. package/dist/vapid.d.ts +17 -0
  95. package/dist/vapid.js +31 -0
  96. package/dist/web-push-send.d.ts +20 -0
  97. package/dist/web-push-send.js +21 -0
  98. package/dist/ws-ticket.d.ts +47 -0
  99. package/dist/ws-ticket.js +62 -0
  100. package/package.json +55 -0
@@ -0,0 +1,1559 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { basename as pathBasename } from "node:path";
3
+ import Fastify from "fastify";
4
+ import websocket from "@fastify/websocket";
5
+ import multipart from "@fastify/multipart";
6
+ import { FsService, FsError } from "./fs-service.js";
7
+ import { AuthGate, extractBearerToken } from "./auth.js";
8
+ import { isOriginAllowed } from "./origin-check.js";
9
+ import { RateLimiter } from "./rate-limit.js";
10
+ import { generateAccessToken, persistAccessToken } from "./data-dir.js";
11
+ import { registerStatic, isPublicPath, isShellPath, pathForGate, hasEncodedSep } from "./static-routes.js";
12
+ import { WsTicketStore } from "./ws-ticket.js";
13
+ import { stat } from "node:fs/promises";
14
+ import { TERMINAL_FILE_TTL_MS, TERMINAL_SWEEP_INTERVAL_MS, terminalSharedBase, terminalSharedDir, } from "./terminal-shared.js";
15
+ import { createUpdater, RUNNING_VERSION } from "./updater.js";
16
+ import { createClaudeVersionProbe, defaultRunClaudeVersion, normalizeProviderAvailability } from "./diag.js";
17
+ import { TerminalManager } from "./terminal-manager.js";
18
+ import { detectTerminalSupport } from "./terminal-capability.js";
19
+ import { listTmuxSessions } from "./tmux-list.js";
20
+ import { openSessionStore } from "./session-store.js";
21
+ import { parseProviderOptions, ProviderOptionsError } from "./providers/options.js";
22
+ import { ProviderError } from "./providers/types.js";
23
+ import { ProviderRegistry } from "./providers/registry.js";
24
+ import { createClaudeProvider } from "./providers/claude-provider.js";
25
+ import { createCodexProvider } from "./providers/codex-provider.js";
26
+ import { normalizeSessionDefaults, SessionDefaultsConflictError } from "./session-defaults.js";
27
+ /** Terminal WS guards. Input: cap a single frame so a client can't force a huge alloc / flood the pty (1MB
28
+ * still allows large pastes). Output: if the client buffers more than this undrained, close (it reconnects
29
+ * and tmux redraws) rather than grow Node's heap unbounded on a slow link. */
30
+ const MAX_TERMINAL_INPUT_BYTES = 1_000_000;
31
+ const MAX_PENDING_TERMINAL_INPUT_FRAMES = 64;
32
+ const MAX_PENDING_TERMINAL_INPUT_BYTES = 1_000_000;
33
+ const MAX_TERMINAL_WS_BUFFER = 16_000_000;
34
+ /** Server→client WS ping cadence. An idle terminal (no output, no keystrokes) carries zero WS traffic, so
35
+ * a fronting proxy (e.g. Cloudflare Tunnel's ~100s idle cap) would drop the connection and force the client
36
+ * to flap through a reconnect. A periodic ping keeps the link warm (the browser auto-pongs). Well under any
37
+ * common proxy idle timeout. */
38
+ const TERMINAL_WS_PING_MS = 25_000;
39
+ /**
40
+ * SSRF guard for a Web-Push endpoint the server will later POST to: reject loopback / private / link-local
41
+ * hosts (incl. the cloud metadata address 169.254.169.254) so an authed client can't point delivery at an
42
+ * internal service. Real push services (FCM / Apple / Mozilla) are public HTTPS hosts, so this never blocks a
43
+ * legitimate subscription.
44
+ */
45
+ function isDisallowedPushHost(hostname) {
46
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
47
+ if (host === "localhost" || host.endsWith(".localhost"))
48
+ return true;
49
+ if (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd"))
50
+ return true;
51
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
52
+ if (!v4)
53
+ return false;
54
+ const a = Number(v4[1]);
55
+ const b = Number(v4[2]);
56
+ return (a === 127 || a === 10 || a === 0 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254) // prettier-ignore
57
+ );
58
+ }
59
+ export function createServer(config, deps = {}) {
60
+ // Cached best-effort `claude --version`. Used by the authed GET /diag and by GET /claude/version (the
61
+ // update-awareness signal). Injected in tests; a real probe over the configured claude bin + process env.
62
+ const claudeVersionProbe = deps.claudeVersionProbe ??
63
+ createClaudeVersionProbe({ run: defaultRunClaudeVersion(config.claude.claudeBin, process.env) });
64
+ const terminalAvailable = deps.terminalAvailable ?? detectTerminalSupport();
65
+ const providers = deps.providers ??
66
+ new ProviderRegistry([
67
+ createClaudeProvider({ claudeBin: config.claude.claudeBin }),
68
+ createCodexProvider({ codexBin: config.codexBin ?? "codex" }),
69
+ ]);
70
+ const store = deps.store ?? openSessionStore({ dbPath: ":memory:" });
71
+ const terminalManager = deps.terminalManager ??
72
+ new TerminalManager({
73
+ store,
74
+ providers,
75
+ ...(deps.codexThreadResolver ? { codexThreadResolver: deps.codexThreadResolver } : {}),
76
+ now: () => Date.now(),
77
+ // Away-from-desk pushes: a session going quiet with nobody watching → "claude is waiting" (the manager
78
+ // only fires this when the last client walks away while awaiting); claude exiting with NOBODY watching →
79
+ // "session ended" (an attached client already sees the WS close, so skip the redundant push). Both go
80
+ // through dispatchPush so they carry the awaiting-session count as the badge. Fire-and-forget.
81
+ onAwaiting: (id) => dispatchPush({ kind: "awaiting", sessionId: id }),
82
+ onFinished: (id, wasAttached) => {
83
+ if (!wasAttached)
84
+ dispatchPush({ kind: "finished", sessionId: id });
85
+ },
86
+ });
87
+ /**
88
+ * Fire an away-from-desk push, always stamping the CURRENT awaiting-session count as `badgeCount` so the
89
+ * service worker can set the home-screen app badge to "how many sessions need you". Fire-and-forget — the
90
+ * dispatcher never throws / never blocks, and it's a no-op when push isn't configured.
91
+ */
92
+ const dispatchPush = (event) => {
93
+ const meta = event.sessionId ? terminalManager.get(event.sessionId) : undefined;
94
+ const label = meta ? meta.name?.trim() || pathBasename(meta.cwd) : undefined;
95
+ void deps.pushDispatcher?.dispatch({
96
+ ...event,
97
+ ...(meta ? { provider: meta.provider, label } : {}),
98
+ badgeCount: terminalManager.awaitingCount(),
99
+ });
100
+ };
101
+ if (terminalAvailable) {
102
+ // Only rehydrate (which prunes store rows for dead sessions) when we have a DEFINITIVE live-session
103
+ // list. `undefined` = the tmux probe failed transiently → skip, so a flaky probe never wipes the
104
+ // user's resumable terminal sessions.
105
+ // Retry a transiently-failed probe a couple of times before giving up: skipping rehydrate leaves the
106
+ // user's previously-running sessions unadopted (invisible + leaked) until a later restart.
107
+ let liveTmuxNames = listTmuxSessions();
108
+ for (let i = 0; liveTmuxNames === undefined && i < 2; i += 1)
109
+ liveTmuxNames = listTmuxSessions();
110
+ if (liveTmuxNames)
111
+ terminalManager.rehydrate({ liveTmuxNames });
112
+ }
113
+ const authGate = deps.authGate ?? new AuthGate({ token: config.accessToken });
114
+ // Global per-client rate limiter (token bucket). A real one is built from the configured rpm/burst; a
115
+ // rpm of 0 DISABLES it (enabled:false). Injected in tests for a deterministic clock + a tiny limit.
116
+ const rateLimiter = deps.rateLimiter ??
117
+ new RateLimiter({
118
+ capacity: config.rateLimitRpm,
119
+ windowMs: 60_000,
120
+ burst: config.rateLimitBurst,
121
+ enabled: config.rateLimitRpm > 0,
122
+ });
123
+ const fsService = new FsService({ root: config.fsRoot });
124
+ // Terminal uploads live under the app data dir (outside any project repo — see terminal-shared.ts), one
125
+ // folder per session. Bound their lifetime: prune files past the TTL across EVERY session folder under the
126
+ // shared base — once at boot (catches files that aged out while the server was down, and orphaned folders
127
+ // whose session is gone) and on a periodic timer. (Also pruned on each upload.) unref() so the timer never
128
+ // keeps the process alive.
129
+ const terminalSharedRoot = terminalSharedBase({ dataDir: config.dataDir, fsRoot: config.fsRoot });
130
+ const sweepSharedFiles = () => {
131
+ void fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
132
+ };
133
+ sweepSharedFiles();
134
+ const sharedSweepTimer = setInterval(sweepSharedFiles, TERMINAL_SWEEP_INTERVAL_MS);
135
+ if (typeof sharedSweepTimer.unref === "function")
136
+ sharedSweepTimer.unref();
137
+ // OPT-IN idle-session reaper (SESSION_IDLE_TTL_MS; 0 = off, the default so detached sessions survive for
138
+ // later reattach). When enabled, periodically kill running terminals with no attached client idle past the
139
+ // TTL, bounding detached claude+tmux accumulation. unref() so it never keeps the process alive.
140
+ const idleTtlMs = config.sessionIdleTtlMs ?? 0;
141
+ if (idleTtlMs > 0) {
142
+ const reapEvery = Math.max(30_000, Math.min(idleTtlMs, 5 * 60_000));
143
+ const idleTimer = setInterval(() => {
144
+ const n = terminalManager.reapIdle(idleTtlMs);
145
+ if (n > 0)
146
+ console.log(`reaped ${n} idle terminal session(s) (SESSION_IDLE_TTL_MS=${idleTtlMs})`);
147
+ }, reapEvery);
148
+ if (typeof idleTimer.unref === "function")
149
+ idleTimer.unref();
150
+ }
151
+ // CONCURRENCY CAP: refuse a new spawn once `config.maxSessions` live terminal sessions exist (0 disables
152
+ // it). Only running sessions count, so dormant/errored records don't and reopening within the cap is
153
+ // unaffected. The message names the env var so an operator can lift it.
154
+ const sessionCapMessage = `live session cap reached (${config.maxSessions}); close a session or raise ROAMCODE_MAX_SESSIONS`;
155
+ // OTA self-update. The real updater keeps its release cache/status in the data dir and activates an
156
+ // exact npm version; tests inject a fixture release feed with no network or service mutation.
157
+ const updater = deps.updater ?? createUpdater({ dataDir: config.dataDir });
158
+ const storeMode = deps.storeMode ?? "sqlite";
159
+ // Single-use WS tickets (POST /ws-ticket) — the preferred terminal-WS credential; see ws-ticket.ts.
160
+ const wsTickets = deps.wsTickets ?? new WsTicketStore();
161
+ // trustProxy makes request.ip honour X-Forwarded-For behind a reverse proxy, so the
162
+ // per-client auth lockout keys on the real client IP (see Task 4's proxy caveat).
163
+ const app = Fastify({ logger: false, trustProxy: config.trustProxy ?? false });
164
+ // Multipart uploads, capped at the configured size.
165
+ app.register(multipart, { limits: { fileSize: config.maxUploadBytes } });
166
+ // Global token gate — applies to BOTH REST routes AND the WebSocket upgrade request
167
+ // (a Fastify global preHandler runs for the WS route's GET upgrade and a 401 there
168
+ // aborts the upgrade — verified). The token for a WS upgrade may arrive in the
169
+ // Authorization header, a single-use `?ticket=`, or the (deprecated) `?token=` query param.
170
+ app.addHook("preHandler", async (request, reply) => {
171
+ // DEFAULT-DENY: every route is token-gated unless EXPLICITLY allowlisted here. Only two things are
172
+ // public: (1) the static PWA shell/assets (the login screen must render before a token exists), and
173
+ // (2) /health (below). CRITICAL: gate on the DECODED path (and reject encoded separators) so this
174
+ // matches the path Fastify's router actually routes — otherwise `GET /%73essions` (=/sessions) would
175
+ // look public here yet reach the protected handler, bypassing the token check.
176
+ const path = pathForGate(request.url);
177
+ const isGetLike = request.method === "GET" || request.method === "HEAD";
178
+ if (isGetLike && !hasEncodedSep(request.url)) {
179
+ // (1a) The explicit shell allowlist: `/`, `/assets/*`, and top-level bundle files. Only static
180
+ // handlers exist at these shapes (every API route is extensionless + prefixed), so a token
181
+ // can never be required to boot the login screen.
182
+ if (isShellPath(path))
183
+ return;
184
+ // (1b) SPA navigation fallback (`/login`, any client route on a hard refresh): allowed WITHOUT a
185
+ // token ONLY when the request matched NO registered route (fastify's is404) — then the sole
186
+ // reachable handler is the notFound handler (the SPA shell or a JSON 404), never an API
187
+ // handler. A REGISTERED route can never take this branch, so a NEW route someone forgets to
188
+ // think about is token-gated by default instead of silently public (the old denylist's trap).
189
+ if (request.is404 && isPublicPath(path))
190
+ return;
191
+ }
192
+ // /health is an unauthenticated liveness probe (a launchd/cloudflared/uptime check can't present a
193
+ // token). It returns only { ok: true } — no sensitive data — so it's safe to leave open.
194
+ if (path === "/health")
195
+ return;
196
+ // No token configured (loopback dev): allow. Non-loopback w/o token is blocked at startup.
197
+ if (!config.accessToken)
198
+ return;
199
+ // `?token=a&token=b` parses to an array — only a single string is a usable token.
200
+ // Anything else (array, missing) becomes undefined so the auth path can't be fed a non-string.
201
+ const q = request.query;
202
+ const queryToken = typeof q?.token === "string" ? q.token : undefined;
203
+ const queryTicket = typeof q?.ticket === "string" ? q.ticket : undefined;
204
+ const isWsUpgradePath = path.endsWith("/ws") || path.endsWith("/terminal");
205
+ // PREFERRED WS auth: a single-use short-TTL ticket from POST /ws-ticket. Consuming it here means a
206
+ // WS URL that lands in a proxy/access log carries an already-spent, ~30s credential instead of the
207
+ // long-lived token. Origin + rate-limit checks below still apply to a ticket-authed upgrade.
208
+ const ticketOk = isWsUpgradePath && queryTicket !== undefined && wsTickets.consume(queryTicket);
209
+ if (!ticketOk) {
210
+ // Accept the token from `?token=` ONLY on routes a browser genuinely can't send an Authorization
211
+ // header on: the WS upgrade (`/sessions/:id/ws|/terminal` — DEPRECATED, kept so bundles from before
212
+ // the ticket flow keep reconnecting; new clients use ?ticket=), <img> media GETs (`/images/*`), and
213
+ // file downloads (`/fs/download`). Every other route uses the header — so the access token isn't
214
+ // written into proxy / access logs (query strings are routinely logged), which would otherwise leak
215
+ // a full-access credential.
216
+ const queryTokenAllowed = isWsUpgradePath || path.startsWith("/images/") || path === "/fs/download";
217
+ const token = extractBearerToken(request.headers.authorization) ?? (queryTokenAllowed ? queryToken : undefined);
218
+ const result = authGate.check(token, request.ip);
219
+ if (!result.ok) {
220
+ reply.code(401).send({ error: "unauthorized" });
221
+ return;
222
+ }
223
+ }
224
+ // ORIGIN / CSWSH GUARD (runs AFTER the token gate, for authenticated requests — incl. the WS upgrade).
225
+ // The token can leak into a URL; this stops a malicious cross-origin BROWSER page that holds it from
226
+ // puppeting the host. SAFE DEFAULT: allow absent / same-origin / loopback / public-URL / allow-listed
227
+ // origins (the real PWA is always one of these); reject only a PRESENT, cross-origin, non-allow-listed
228
+ // Origin. The page cannot forge its Origin header, so this can never reject the genuine app.
229
+ const originAllowed = isOriginAllowed(request.headers.origin, request.headers.host, {
230
+ publicUrl: config.publicUrl,
231
+ allowedOrigins: config.allowedOrigins,
232
+ });
233
+ if (!originAllowed) {
234
+ reply.code(403).send({ error: "forbidden origin" });
235
+ return;
236
+ }
237
+ // GLOBAL RATE LIMIT (runs LAST, for authenticated requests). Keyed by the same clientKey as the auth
238
+ // lockout (request.ip, honoring trustProxy). Generous by default (way above the app's poll cadence) and
239
+ // disable-able; a flood gets 429 + Retry-After. /health was already exempted above (it never reaches
240
+ // here), so liveness probes are never throttled. The WS is ONE upgrade then long-lived, so the limit is
241
+ // for HTTP/API volume, not the WS data path.
242
+ // EXEMPTION: cacheable image thumbnails (GET /images/<ref>) skip the VOLUME limiter — they are
243
+ // content-addressed/immutable and still passed the auth + origin checks above (the token is required),
244
+ // so excluding them is safe and avoids 429-ing legit thumbnails when a fast scroll of an image-dense
245
+ // transcript fires many parallel <img> GETs. Auth/origin are NOT bypassed — only the rate-limit step.
246
+ const imageGetExempt = request.method === "GET" && path.startsWith("/images/");
247
+ if (!imageGetExempt) {
248
+ const limit = rateLimiter.take(request.ip);
249
+ if (!limit.allowed) {
250
+ reply.header("retry-after", String(limit.retryAfterSeconds)).code(429).send({ error: "rate limited" });
251
+ return;
252
+ }
253
+ }
254
+ });
255
+ // WebSocket support. Registered synchronously; routes are added below.
256
+ app.register(websocket);
257
+ // Handshake auth is handled by the GLOBAL preHandler (it runs for the upgrade GET and
258
+ // reads ?token= too). By the time this handler runs, the token is already validated;
259
+ // we only reject an unknown session here.
260
+ app.register(async (wsScope) => {
261
+ wsScope.get("/sessions/:id/terminal", { websocket: true }, (socket, request) => {
262
+ const id = request.params.id;
263
+ if (!terminalManager.get(id)) {
264
+ socket.close(4404, "terminal session not found");
265
+ return;
266
+ }
267
+ // The client fits its xterm BEFORE connecting and passes the size as `?cols=&rows=`, so the pty/tmux
268
+ // is born at the real viewport (no spawn-at-80×24-then-reflow). Parsed defensively; absent → defaults.
269
+ const c = Number(request.query.cols);
270
+ const r = Number(request.query.rows);
271
+ const size = Number.isInteger(c) && c > 0 && Number.isInteger(r) && r > 0 ? { cols: c, rows: r } : undefined;
272
+ // `?respawn=continue`: when THIS connect respawns an ENDED session, the fresh claude gets
273
+ // `--continue` (resume the previous conversation) for that spawn only. Absent / `fresh` /
274
+ // any other value = today's blank-slate respawn. Ignored entirely on a live reattach.
275
+ const respawn = request.query.respawn === "continue" ? "continue" : "fresh";
276
+ let sub;
277
+ let closed = false;
278
+ let pingTimer;
279
+ let pendingFrames = [];
280
+ let pendingBytes = 0;
281
+ const attachAbort = new AbortController();
282
+ const detach = () => {
283
+ if (closed)
284
+ return;
285
+ closed = true;
286
+ attachAbort.abort();
287
+ pendingFrames = [];
288
+ pendingBytes = 0;
289
+ if (pingTimer)
290
+ clearInterval(pingTimer);
291
+ sub?.unsubscribe();
292
+ sub = undefined;
293
+ };
294
+ const closeSafely = (code, reason) => {
295
+ detach();
296
+ try {
297
+ socket.close(code, reason);
298
+ }
299
+ catch {
300
+ /* already gone */
301
+ }
302
+ };
303
+ const dispatchInput = (raw) => {
304
+ if (raw.length > MAX_TERMINAL_INPUT_BYTES)
305
+ return;
306
+ let msg;
307
+ try {
308
+ msg = JSON.parse(raw.toString());
309
+ }
310
+ catch {
311
+ return;
312
+ }
313
+ try {
314
+ if (msg.t === "i" && typeof msg.d === "string")
315
+ terminalManager.write(id, msg.d);
316
+ else if (msg.t === "r" && typeof msg.c === "number" && typeof msg.r === "number")
317
+ terminalManager.resize(id, msg.c, msg.r);
318
+ }
319
+ catch {
320
+ closeSafely(4400, "terminal input failed");
321
+ }
322
+ };
323
+ socket.on("message", (raw) => {
324
+ if (closed)
325
+ return;
326
+ const frame = Buffer.from(raw);
327
+ if (sub) {
328
+ dispatchInput(frame);
329
+ return;
330
+ }
331
+ if (frame.length > MAX_TERMINAL_INPUT_BYTES ||
332
+ pendingFrames.length >= MAX_PENDING_TERMINAL_INPUT_FRAMES ||
333
+ pendingBytes + frame.length > MAX_PENDING_TERMINAL_INPUT_BYTES) {
334
+ closeSafely(4400, "terminal input overflow");
335
+ return;
336
+ }
337
+ pendingFrames.push(frame);
338
+ pendingBytes += frame.length;
339
+ });
340
+ socket.on("close", detach);
341
+ socket.on("error", detach);
342
+ void terminalManager
343
+ .attach(id, {
344
+ onData: (chunk) => {
345
+ if (socket.readyState !== socket.OPEN)
346
+ return;
347
+ // Backpressure: if the client can't drain (slow link, backgrounded tab) and we've buffered a
348
+ // runaway amount of pty output, close rather than grow Node's heap unbounded. The client
349
+ // reconnects and tmux redraws a clean screen, so no state is lost.
350
+ if (socket.bufferedAmount > MAX_TERMINAL_WS_BUFFER) {
351
+ try {
352
+ socket.close(4400, "terminal backpressure");
353
+ }
354
+ catch {
355
+ /* already gone */
356
+ }
357
+ return;
358
+ }
359
+ try {
360
+ socket.send(Buffer.from(chunk, "utf8")); // binary frame
361
+ }
362
+ catch {
363
+ sub?.unsubscribe();
364
+ try {
365
+ socket.close();
366
+ }
367
+ catch {
368
+ /* already gone */
369
+ }
370
+ }
371
+ },
372
+ // claude exited (the manager ended the session) → tell the client so it shows Restart/Close
373
+ // instead of a frozen screen. 4410 = "ended" (do NOT auto-reconnect on this code).
374
+ onExit: () => {
375
+ try {
376
+ socket.close(4410, "session ended");
377
+ }
378
+ catch {
379
+ /* already gone */
380
+ }
381
+ },
382
+ // Out-of-band control (file/image attachments claude sent) → a TEXT frame, so the client can
383
+ // split it from the BINARY pty stream. Skipped under backpressure like the data path.
384
+ onControl: (json) => {
385
+ if (socket.readyState !== socket.OPEN || socket.bufferedAmount > MAX_TERMINAL_WS_BUFFER)
386
+ return;
387
+ try {
388
+ socket.send(json);
389
+ }
390
+ catch {
391
+ /* already gone */
392
+ }
393
+ },
394
+ }, size, { respawn, signal: attachAbort.signal })
395
+ .then((attached) => {
396
+ if (!attached) {
397
+ if (!closed)
398
+ closeSafely(4404, "terminal session not found");
399
+ return;
400
+ }
401
+ sub = attached;
402
+ const liveSub = attached;
403
+ if (closed || socket.readyState !== socket.OPEN) {
404
+ liveSub.unsubscribe();
405
+ sub = undefined;
406
+ return;
407
+ }
408
+ // KEEPALIVE: ping the (possibly idle) client so a fronting proxy doesn't drop the connection out
409
+ // from under a live terminal. .unref() so the timer never keeps the process alive; cleared below.
410
+ pingTimer = setInterval(() => {
411
+ if (socket.readyState === socket.OPEN) {
412
+ try {
413
+ socket.ping();
414
+ }
415
+ catch {
416
+ /* socket dying — the close handler cleans up */
417
+ }
418
+ }
419
+ }, TERMINAL_WS_PING_MS);
420
+ pingTimer.unref?.();
421
+ const replay = pendingFrames;
422
+ pendingFrames = [];
423
+ pendingBytes = 0;
424
+ for (const frame of replay) {
425
+ if (closed || socket.readyState !== socket.OPEN || sub !== liveSub)
426
+ break;
427
+ dispatchInput(frame);
428
+ }
429
+ })
430
+ .catch(() => {
431
+ if (!closed && socket.readyState === socket.OPEN)
432
+ closeSafely(4404, "terminal attach failed");
433
+ });
434
+ });
435
+ });
436
+ app.post("/sessions", async (request, reply) => {
437
+ const body = request.body;
438
+ if (!body || typeof body.cwd !== "string") {
439
+ reply.code(400).send({ error: "cwd is required" });
440
+ return;
441
+ }
442
+ const requestedProvider = body.provider === undefined ? "claude" : body.provider;
443
+ if (requestedProvider !== "claude" && requestedProvider !== "codex") {
444
+ reply.code(400).send({ code: "INVALID_PROVIDER", error: "Invalid provider" });
445
+ return;
446
+ }
447
+ const provider = requestedProvider;
448
+ // Terminal is the only mode: spawn a pty-backed tmux session.
449
+ if (!terminalAvailable) {
450
+ reply
451
+ .code(400)
452
+ .send({ error: "terminal mode unavailable", hint: "install tmux on the host (and ensure node-pty loads)" });
453
+ return;
454
+ }
455
+ try {
456
+ const selectedProvider = providers.get(provider);
457
+ const availability = await selectedProvider.probe();
458
+ if (!availability.terminalAvailable)
459
+ throw new Error("unavailable");
460
+ }
461
+ catch {
462
+ reply.code(503).send({ code: "PROVIDER_UNAVAILABLE", error: "Provider terminal unavailable" });
463
+ return;
464
+ }
465
+ // CONCURRENCY CAP (host DoS): bound the number of LIVE terminal sessions. Only running sessions count,
466
+ // so dormant/errored records don't and reopening within the cap is unaffected.
467
+ const liveTerminals = terminalManager.list().filter((t) => t.status === "running").length;
468
+ if (config.maxSessions > 0 && liveTerminals >= config.maxSessions) {
469
+ reply.code(429).send({ error: sessionCapMessage });
470
+ return;
471
+ }
472
+ // Validate the cwd up-front (it's a real directory) so a bad path fails the CREATE with a clear error
473
+ // instead of silently failing later when the pty lazily spawns on first attach.
474
+ try {
475
+ const s = await stat(body.cwd);
476
+ if (!s.isDirectory()) {
477
+ reply.code(400).send({ error: `cwd is not a directory: ${body.cwd}` });
478
+ return;
479
+ }
480
+ }
481
+ catch {
482
+ reply.code(400).send({ error: `cwd does not exist: ${body.cwd}` });
483
+ return;
484
+ }
485
+ const id = randomUUID();
486
+ const rawOptions = body.options ??
487
+ (provider === "claude"
488
+ ? {
489
+ ...(typeof body.model === "string" ? { model: body.model } : {}),
490
+ ...(typeof body.effort === "string" ? { effort: body.effort } : {}),
491
+ ...(Array.isArray(body.addDirs) ? { addDirs: body.addDirs } : {}),
492
+ ...(typeof body.dangerouslySkip === "boolean" ? { dangerouslySkip: body.dangerouslySkip } : {}),
493
+ ...(typeof body.permissionMode === "string" ? { permissionMode: body.permissionMode } : {}),
494
+ }
495
+ : {});
496
+ let options;
497
+ const warnings = [];
498
+ try {
499
+ options = parseProviderOptions(provider, rawOptions);
500
+ for (const dir of options.addDirs ?? []) {
501
+ const dirStat = await stat(dir);
502
+ if (!dirStat.isDirectory())
503
+ throw new ProviderOptionsError("Invalid provider options: addDirs must be directories");
504
+ }
505
+ }
506
+ catch (error) {
507
+ const code = error instanceof ProviderError && error.code === "PROVIDER_UNAVAILABLE" ? 503 : 400;
508
+ reply.code(code).send({
509
+ code: error instanceof ProviderError ? error.code : "INVALID_PROVIDER_OPTIONS",
510
+ error: error instanceof ProviderError ? error.message : "Invalid provider options",
511
+ });
512
+ return;
513
+ }
514
+ if (options.provider === "codex" && options.model) {
515
+ if (deps.codexMetadata) {
516
+ try {
517
+ await deps.codexMetadata.validateModelSelection(options.model, options.reasoningEffort);
518
+ }
519
+ catch (error) {
520
+ if (error instanceof ProviderError && error.code === "INVALID_PROVIDER_OPTIONS") {
521
+ reply.code(400).send({
522
+ code: "INVALID_PROVIDER_OPTIONS",
523
+ error: "Invalid Codex model or reasoning selection",
524
+ });
525
+ return;
526
+ }
527
+ warnings.push({
528
+ code: "PROVIDER_METADATA_UNAVAILABLE",
529
+ message: "Codex model compatibility could not be verified",
530
+ });
531
+ }
532
+ }
533
+ else {
534
+ warnings.push({
535
+ code: "PROVIDER_METADATA_UNAVAILABLE",
536
+ message: "Codex model compatibility could not be verified",
537
+ });
538
+ }
539
+ }
540
+ if (options.provider === "claude" && options.model && deps.claudeMetadata) {
541
+ try {
542
+ await deps.claudeMetadata.validateModelSelection(options.model, options.effort);
543
+ }
544
+ catch (error) {
545
+ if (error instanceof ProviderError && error.code === "INVALID_PROVIDER_OPTIONS") {
546
+ reply.code(400).send({
547
+ code: "INVALID_PROVIDER_OPTIONS",
548
+ error: "Invalid Claude model or effort selection",
549
+ });
550
+ return;
551
+ }
552
+ warnings.push({
553
+ code: "PROVIDER_METADATA_UNAVAILABLE",
554
+ message: "Claude model compatibility could not be verified",
555
+ });
556
+ }
557
+ }
558
+ // TOCTOU: the cap was checked before the `await stat` above, which yields — re-check right before the
559
+ // (synchronous) create so two concurrent POSTs can't both pass the cap and exceed maxSessions.
560
+ if (config.maxSessions > 0 &&
561
+ terminalManager.list().filter((t) => t.status === "running").length >= config.maxSessions) {
562
+ reply.code(429).send({ error: sessionCapMessage });
563
+ return;
564
+ }
565
+ let meta;
566
+ try {
567
+ meta =
568
+ options.provider === "claude"
569
+ ? terminalManager.create({ id, cwd: body.cwd, provider: "claude", options })
570
+ : terminalManager.create({ id, cwd: body.cwd, provider: "codex", options });
571
+ }
572
+ catch (error) {
573
+ if (error instanceof ProviderError) {
574
+ reply
575
+ .code(error.code === "PROVIDER_UNAVAILABLE" ? 503 : 400)
576
+ .send({ code: error.code, error: "Provider session could not be created" });
577
+ }
578
+ else {
579
+ reply.code(500).send({ code: "SESSION_CREATE_FAILED", error: "Session could not be created" });
580
+ }
581
+ return;
582
+ }
583
+ // Return `{ session }` (not a flat body). The web client does `return (await res.json()).session`.
584
+ // Shape the session like a SessionMeta (mode:"terminal" so the client routes to TerminalView). Echo the
585
+ // derived dangerouslySkip so the rail badges an RCE-skip session from the moment it's created.
586
+ reply.code(201).send({
587
+ session: {
588
+ id: meta.id,
589
+ provider: meta.provider,
590
+ cwd: meta.cwd,
591
+ mode: "terminal",
592
+ status: meta.status,
593
+ createdAt: meta.createdAt,
594
+ lastActivityAt: meta.lastActivityAt,
595
+ dangerouslySkip: meta.dangerouslySkip,
596
+ // Echo the runtime flags so the chat header shows what's actually running from the first render.
597
+ model: meta.model,
598
+ effort: meta.effort,
599
+ permissionMode: meta.permissionMode,
600
+ sandbox: meta.sandbox,
601
+ approvalPolicy: meta.approvalPolicy,
602
+ identityState: meta.identityState,
603
+ },
604
+ ...(warnings.length > 0 ? { warnings } : {}),
605
+ });
606
+ });
607
+ // Unauthenticated liveness probe (the preHandler lets /health through). Returns only { ok: true }.
608
+ app.get("/health", async () => ({ ok: true }));
609
+ const unsetSessionDefaults = { defaults: null, revision: 0 };
610
+ const sessionDefaultsEnvelope = (stored) => stored
611
+ ? {
612
+ defaults: normalizeSessionDefaults(stored.defaults),
613
+ revision: stored.revision,
614
+ updatedAt: stored.updatedAt,
615
+ }
616
+ : unsetSessionDefaults;
617
+ // One authoritative defaults document shared by every browser connected to this server. These routes
618
+ // are authenticated by the global default-deny preHandler. PUT is compare-and-swap so a stale browser
619
+ // never silently overwrites a newer save from another device.
620
+ app.get("/settings/session-defaults", async () => sessionDefaultsEnvelope(store.getSessionDefaults()));
621
+ app.put("/settings/session-defaults", { bodyLimit: 256 * 1024 }, async (request, reply) => {
622
+ const body = request.body;
623
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
624
+ reply.code(400).send({ error: "invalid session defaults payload" });
625
+ return;
626
+ }
627
+ const record = body;
628
+ const keys = Object.keys(record);
629
+ const expectedRevision = record.expectedRevision;
630
+ if (keys.some((key) => key !== "defaults" && key !== "expectedRevision") ||
631
+ !Object.prototype.hasOwnProperty.call(record, "defaults") ||
632
+ !Number.isSafeInteger(expectedRevision) ||
633
+ expectedRevision < 0) {
634
+ reply.code(400).send({ error: "invalid session defaults payload" });
635
+ return;
636
+ }
637
+ let defaults;
638
+ try {
639
+ defaults = normalizeSessionDefaults(record.defaults);
640
+ }
641
+ catch {
642
+ reply.code(400).send({ error: "invalid session defaults payload" });
643
+ return;
644
+ }
645
+ try {
646
+ const stored = store.putSessionDefaults(defaults, expectedRevision, Date.now());
647
+ return sessionDefaultsEnvelope(stored);
648
+ }
649
+ catch (error) {
650
+ if (error instanceof SessionDefaultsConflictError) {
651
+ reply.code(409).send({
652
+ code: "SETTINGS_CONFLICT",
653
+ error: error.message,
654
+ current: sessionDefaultsEnvelope(error.current),
655
+ });
656
+ return;
657
+ }
658
+ throw error;
659
+ }
660
+ });
661
+ app.get("/sessions", async () => {
662
+ const sessions = terminalManager.list().map((t) => ({
663
+ id: t.id,
664
+ provider: t.provider,
665
+ cwd: t.cwd,
666
+ mode: "terminal",
667
+ status: t.status,
668
+ createdAt: t.createdAt,
669
+ lastActivityAt: t.lastActivityAt,
670
+ // Live activity from the capture-pane monitor (working | blocked | idle) — the rail's per-session status.
671
+ activity: t.activity,
672
+ // Loud "needs you" flag = activity==="blocked" (claude waiting on YOUR decision). The SessionList badge +
673
+ // count + away push key off this; a merely-idle or still-working session is NOT awaiting.
674
+ awaiting: t.awaiting,
675
+ // Whether this session runs with --dangerously-skip-permissions, so the rail can badge the RCE-skip risk.
676
+ dangerouslySkip: t.dangerouslySkip,
677
+ // Effective runtime metadata. Launch options seed it; the read-only pane monitor updates providers that
678
+ // expose live model/effort chrome, so in-session changes reach the header on the next sessions poll.
679
+ model: t.model,
680
+ effort: t.effort,
681
+ permissionMode: t.permissionMode,
682
+ sandbox: t.sandbox,
683
+ approvalPolicy: t.approvalPolicy,
684
+ // User-set display name (PATCH /sessions/:id). `undefined` serializes to ABSENT, so the field only
685
+ // appears when a name is actually set — clients `?? cwd` for the label.
686
+ name: t.name,
687
+ identityState: t.identityState,
688
+ providerSessionId: t.providerSessionId,
689
+ }));
690
+ return { sessions };
691
+ });
692
+ // Rename a session (server-side, so the name shows on EVERY device and survives restarts). Contract:
693
+ // {name: string} trims + sets; an empty/whitespace-only string, null, or an absent field CLEARS back to
694
+ // unnamed. 204 on success, 404 for an unknown id, 400 for a non-string/oversized name. Token-gated by
695
+ // the global default-deny preHandler.
696
+ app.patch("/sessions/:id", async (request, reply) => {
697
+ const { id } = request.params;
698
+ if (!terminalManager.get(id)) {
699
+ reply.code(404).send({ error: "session not found" });
700
+ return;
701
+ }
702
+ const raw = request.body?.name;
703
+ if (raw !== undefined && raw !== null && typeof raw !== "string") {
704
+ reply.code(400).send({ error: "name must be a string or null" });
705
+ return;
706
+ }
707
+ const trimmed = typeof raw === "string" ? raw.trim() : "";
708
+ // A UI label, not a document — cap it so a runaway client can't bloat every GET /sessions response.
709
+ if (trimmed.length > 120) {
710
+ reply.code(400).send({ error: "name too long (max 120 characters)" });
711
+ return;
712
+ }
713
+ terminalManager.setName(id, trimmed.length > 0 ? trimmed : undefined);
714
+ reply.code(204).send();
715
+ });
716
+ // Close a session: stop its live process AND remove it from the list + store. Idempotent — deleting an
717
+ // unknown id is a 204 no-op, not a 404 — so a double-close / a stale client both succeed.
718
+ app.delete("/sessions/:id", async (request, reply) => {
719
+ const { id } = request.params;
720
+ if (terminalManager.get(id))
721
+ terminalManager.stop(id);
722
+ reply.code(204).send();
723
+ });
724
+ // Legacy stop endpoint — kept working, converges on full removal (stop + delete). 404 only when the
725
+ // session is already gone, preserving the old "stop a known session" contract.
726
+ app.post("/sessions/:id/stop", async (request, reply) => {
727
+ const { id } = request.params;
728
+ if (!terminalManager.get(id)) {
729
+ reply.code(404).send({ error: "session not found" });
730
+ return;
731
+ }
732
+ terminalManager.stop(id);
733
+ return { ok: true };
734
+ });
735
+ // Claude sends a file/image to the terminal: the mcp-send stdio server (spawned as claude's subprocess)
736
+ // POSTs here on a send_image/send_file tool call. The path is fsRoot+realpath-validated (no traversal,
737
+ // no symlink escape — same defense as /fs/download); on success a control frame is pushed to the
738
+ // terminal session over the existing WS. Token-gated by the global preHandler.
739
+ app.post("/sessions/:id/attach", async (request, reply) => {
740
+ const sessionId = request.params.id;
741
+ if (!terminalManager.get(sessionId)) {
742
+ reply.code(404).send({ error: "session not found" });
743
+ return;
744
+ }
745
+ const body = request.body;
746
+ if (!body || typeof body.path !== "string") {
747
+ reply.code(400).send({ error: "path is required" });
748
+ return;
749
+ }
750
+ const caption = typeof body.caption === "string" ? body.caption : undefined;
751
+ let described;
752
+ try {
753
+ described = await fsService.describeForAttachment(body.path);
754
+ }
755
+ catch (err) {
756
+ if (err instanceof FsError) {
757
+ reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
758
+ }
759
+ else {
760
+ reply.code(404).send({ error: err.message });
761
+ }
762
+ return;
763
+ }
764
+ // kind=image forces inline image rendering even for an unknown extension; kind=file forces a
765
+ // download chip. Absent → infer from the extension (describeForAttachment.isImage).
766
+ const isImage = body.kind === "image" ? true : body.kind === "file" ? false : described.isImage;
767
+ const id = randomUUID();
768
+ // Push a control frame over the terminal WS (the client renders it in the Files panel). The manager
769
+ // also BUFFERS this frame so a client that (re)connects later still sees the file (replay on attach).
770
+ terminalManager.pushControl(sessionId, {
771
+ t: "attach",
772
+ id,
773
+ path: body.path,
774
+ name: described.name,
775
+ caption,
776
+ isImage,
777
+ });
778
+ // Away-from-desk: ping the phone that a file arrived. Fire-and-forget (dispatch never throws/blocks).
779
+ dispatchPush({ kind: "file", sessionId, detail: described.name });
780
+ reply.code(200).send({ ok: true, id });
781
+ });
782
+ // Deterministic "needs you" via claude's OWN hooks (per-session settings written by the spawn layer — see
783
+ // config.buildHooksSettingsDocument). claude's `Stop` hook POSTs ?event=stop when it finishes a turn and is
784
+ // now waiting on the user; `UserPromptSubmit` POSTs ?event=submit when you send a prompt. This REPLACES the
785
+ // old terminal-output scraping, which couldn't tell "still working / waiting on a background agent" from
786
+ // "waiting for you" and fired false positives. Token-gated by the global preHandler. The away-from-desk PUSH
787
+ // fires only when nobody is watching (you're right there otherwise), and works even with the app CLOSED —
788
+ // the hook runs inside claude regardless of any browser attachment.
789
+ app.post("/sessions/:id/hook", async (request, reply) => {
790
+ const sessionId = request.params.id;
791
+ if (!terminalManager.get(sessionId)) {
792
+ reply.code(404).send({ error: "session not found" });
793
+ return;
794
+ }
795
+ // NOTE: these hooks NO LONGER drive `awaiting`. A `Stop` (a TURN finished) now means the session is
796
+ // IDLE — a calm "your turn whenever" — NOT the loud "needs you", which is reserved for claude actually
797
+ // BLOCKING on a decision (a permission or plan prompt). The capture-pane activity monitor
798
+ // (TerminalManager.refreshActivity) is the sole authority for working/blocked/idle, so it can tell those
799
+ // apart (incl. "main loop done but background agents still developing" = working). The route is kept so
800
+ // existing sessions' hooks don't 404; it just validates the event.
801
+ if (request.query.event !== "submit" && request.query.event !== "stop") {
802
+ reply.code(400).send({ error: "unknown event" });
803
+ return;
804
+ }
805
+ reply.code(200).send({ ok: true });
806
+ });
807
+ // Web Push opt-in routes (spec §1). The whole `/push/*` namespace is token-gated by the global
808
+ // preHandler (it is in API_PATH_DENYLIST), including GET /push/vapid — the PWA already holds the
809
+ // token by the time it opts into push, so no special-casing is needed.
810
+ app.get("/push/vapid", async (_request, reply) => {
811
+ if (!deps.vapidPublicKey) {
812
+ reply.code(404).send({ error: "push not configured" });
813
+ return;
814
+ }
815
+ // SECURITY: return ONLY the public key. NEVER serialize the whole VapidKeys (the private key
816
+ // must never reach a client).
817
+ return { publicKey: deps.vapidPublicKey };
818
+ });
819
+ app.post("/push/subscribe", async (request, reply) => {
820
+ if (!deps.pushStore) {
821
+ reply.code(404).send({ error: "push not configured" });
822
+ return;
823
+ }
824
+ const b = request.body;
825
+ if (!b ||
826
+ typeof b.endpoint !== "string" ||
827
+ typeof b.keys?.p256dh !== "string" ||
828
+ typeof b.keys?.auth !== "string") {
829
+ reply.code(400).send({ error: "endpoint + keys.p256dh + keys.auth are required" });
830
+ return;
831
+ }
832
+ // SSRF guard: the server later POSTs to this endpoint (web-push). Require a well-formed HTTPS URL
833
+ // so a client can't register an arbitrary/loopback target to make the server issue requests to it.
834
+ let endpointUrl;
835
+ try {
836
+ endpointUrl = new URL(b.endpoint);
837
+ }
838
+ catch {
839
+ reply.code(400).send({ error: "endpoint must be a valid URL" });
840
+ return;
841
+ }
842
+ if (endpointUrl.protocol !== "https:") {
843
+ reply.code(400).send({ error: "endpoint must be an https: URL" });
844
+ return;
845
+ }
846
+ if (isDisallowedPushHost(endpointUrl.hostname)) {
847
+ reply.code(400).send({ error: "endpoint host is not allowed" });
848
+ return;
849
+ }
850
+ deps.pushStore.upsert({
851
+ endpoint: b.endpoint,
852
+ p256dh: b.keys.p256dh,
853
+ auth: b.keys.auth,
854
+ sessionId: typeof b.sessionId === "string" ? b.sessionId : undefined,
855
+ createdAt: Date.now(),
856
+ });
857
+ reply.code(201).send({ ok: true });
858
+ });
859
+ app.post("/push/unsubscribe", async (request, reply) => {
860
+ if (!deps.pushStore) {
861
+ reply.code(404).send({ error: "push not configured" });
862
+ return;
863
+ }
864
+ const endpoint = request.body?.endpoint;
865
+ if (typeof endpoint !== "string") {
866
+ reply.code(400).send({ error: "endpoint is required" });
867
+ return;
868
+ }
869
+ deps.pushStore.remove(endpoint);
870
+ return { ok: true };
871
+ });
872
+ // POST /push/test → send a harmless "notifications are working ✓" ping to EVERY stored subscription (the
873
+ // dispatcher fans a session-less "test" event to all subs). Powers the web Settings "Send test
874
+ // notification" button so a user can confirm delivery end-to-end. Always 200; the body's `ok` says whether
875
+ // it went out. Reasons: push isn't configured (no dispatcher/store) or there are no subscriptions yet.
876
+ // Token-gated by the global preHandler (the whole /push/* namespace is in API_PATH_DENYLIST).
877
+ app.post("/push/test", async (_request, reply) => {
878
+ const { pushDispatcher, pushStore } = deps;
879
+ if (!pushDispatcher || !pushStore) {
880
+ reply.code(200).send({ ok: false, reason: "push not configured" });
881
+ return;
882
+ }
883
+ // No subscriptions → nothing to deliver to; tell the client so it can prompt the user to enable push.
884
+ let subCount = 0;
885
+ try {
886
+ subCount = pushStore.list().length;
887
+ }
888
+ catch {
889
+ // a store read failure is treated as "no subs" — never 500 a diagnostic button
890
+ }
891
+ if (subCount === 0) {
892
+ reply.code(200).send({ ok: false, reason: "no push subscriptions" });
893
+ return;
894
+ }
895
+ // Fire-and-forget-ish: dispatch never throws (dead subs are pruned on 404/410). We don't inspect
896
+ // per-endpoint results — a 200 { ok:true } means "we attempted delivery to your subscriptions".
897
+ await pushDispatcher.dispatch({ kind: "test" });
898
+ reply.code(200).send({ ok: true });
899
+ });
900
+ // OTA self-update (token-gated by the global preHandler).
901
+ // GET /version → the cached check {current,latest,behind,updatable,updateAvailable,changelog}.
902
+ app.get("/version", async (request, reply) => {
903
+ try {
904
+ // `?force=1` bypasses the cached GitHub Releases check.
905
+ const force = request.query?.force === "1";
906
+ const version = await updater.getVersion(force);
907
+ return { ...version, terminalAvailable };
908
+ }
909
+ catch (err) {
910
+ // A feed/spawn failure must not 500 the open-on-load probe; expose a degraded version snapshot.
911
+ reply.code(200).send({
912
+ current: "—",
913
+ latest: "—",
914
+ behind: 0,
915
+ updatable: false,
916
+ updateAvailable: false,
917
+ changelog: [],
918
+ runningVersion: RUNNING_VERSION,
919
+ runningBuild: RUNNING_VERSION,
920
+ buildDrift: false,
921
+ installDrift: false,
922
+ releaseCount: 0,
923
+ updateAction: "none",
924
+ installation: "unmanaged",
925
+ rollbackAvailable: false,
926
+ checkStatus: "error",
927
+ terminalAvailable,
928
+ error: err.message,
929
+ });
930
+ }
931
+ });
932
+ // POST /update {confirm:true,target?} → verify and install the exact stable version. The confirm flag
933
+ // is a deliberate double-gate (alongside the token) for a server-restarting action.
934
+ app.post("/update", async (request, reply) => {
935
+ if (request.body?.confirm !== true) {
936
+ reply.code(400).send({ error: "confirm:true is required to apply an update" });
937
+ return;
938
+ }
939
+ let result;
940
+ try {
941
+ result = await updater.startUpdate({ targetVersion: request.body?.target });
942
+ }
943
+ catch (err) {
944
+ reply.code(409).send({ error: err.message });
945
+ return;
946
+ }
947
+ if (!result.started) {
948
+ reply.code(409).send({ error: result.reason ?? "update not available" });
949
+ return;
950
+ }
951
+ reply.code(202).send({ ok: true, state: "starting", operationId: result.operationId, target: result.target });
952
+ });
953
+ // GET /update/status → the detached updater's status file {state,phase,error?,target?,log?}.
954
+ app.get("/update/status", async () => {
955
+ return updater.readStatus();
956
+ });
957
+ // POST /update/rollback swaps the managed runtime to the previously verified release. No git state is
958
+ // touched; the same boot-smoke + atomic pointer + restart pipeline is used.
959
+ app.post("/update/rollback", async (request, reply) => {
960
+ if (request.body?.confirm !== true) {
961
+ reply.code(400).send({ error: "confirm:true is required to roll back" });
962
+ return;
963
+ }
964
+ const targetVersion = updater.readLastGoodVersion();
965
+ if (!targetVersion) {
966
+ reply.code(409).send({ error: "no previous managed version is available" });
967
+ return;
968
+ }
969
+ let result;
970
+ try {
971
+ result = await updater.startUpdate({ rollback: true });
972
+ }
973
+ catch (err) {
974
+ reply.code(409).send({ error: err.message });
975
+ return;
976
+ }
977
+ if (!result.started) {
978
+ reply.code(409).send({ error: result.reason ?? "rollback not available" });
979
+ return;
980
+ }
981
+ reply.code(202).send({
982
+ ok: true,
983
+ state: "starting",
984
+ operationId: result.operationId,
985
+ target: result.target ?? targetVersion,
986
+ });
987
+ });
988
+ // GET /diag → authed fleet-observability snapshot (token-gated by the global preHandler; distinct from
989
+ // the minimal unauthenticated /health). Reports the running/active version relationship,
990
+ // storeMode (sqlite vs the non-durable memory fallback), best-effort claude availability+version
991
+ // (cached; never blocks long), node version, and the last update state. Never 500s — each field degrades
992
+ // independently so one failing probe can't take down the whole diagnostic.
993
+ app.get("/diag", async () => {
994
+ let installDrift = false;
995
+ let current = "—";
996
+ try {
997
+ const v = await updater.getVersion();
998
+ installDrift = v.installDrift;
999
+ current = v.current;
1000
+ }
1001
+ catch {
1002
+ // a release-feed failure must not 500 /diag — leave the defaults
1003
+ }
1004
+ let claude;
1005
+ try {
1006
+ claude = await claudeVersionProbe.get();
1007
+ }
1008
+ catch {
1009
+ claude = { available: false };
1010
+ }
1011
+ return {
1012
+ current,
1013
+ runningVersion: RUNNING_VERSION,
1014
+ runningBuild: RUNNING_VERSION,
1015
+ installDrift,
1016
+ buildDrift: installDrift,
1017
+ storeMode,
1018
+ claude,
1019
+ providers: await readProviderAvailability(),
1020
+ node: process.version,
1021
+ update: updater.readStatus(),
1022
+ };
1023
+ });
1024
+ // POST /token/rotate → rotate the single access token (authed; token-gated by the global preHandler,
1025
+ // and in API_PATH_DENYLIST). Generates a fresh CSPRNG token (data-dir.ts's generator), persists it to
1026
+ // the same 0600 token file, atomically swaps it into the live AuthGate (the OLD token is rejected the
1027
+ // instant this returns — every later request must present the new one), and returns it ONCE in the body
1028
+ // so the client can re-store it.
1029
+ // NOTE: rotation requires a persistable token file — it's unavailable in tokenless (NO_TOKEN) loopback
1030
+ // dev (no token is configured); a rotate there is a 409. There's no in-memory rotate of a config-injected
1031
+ // ACCESS_TOKEN: an env-set token reappears on restart, so we persist + swap and report that caveat.
1032
+ app.post("/token/rotate", async (_request, reply) => {
1033
+ if (!config.accessToken) {
1034
+ reply.code(409).send({ error: "token rotation is unavailable when no access token is configured" });
1035
+ return;
1036
+ }
1037
+ // Generate a fresh CSPRNG token (injectable for tests) and persist it to the same 0600 token file so
1038
+ // the on-disk secret stays authoritative across a restart.
1039
+ let next;
1040
+ try {
1041
+ next = (deps.generateToken ?? generateAccessToken)();
1042
+ persistAccessToken(config.dataDir, next);
1043
+ }
1044
+ catch (err) {
1045
+ reply.code(500).send({ error: `failed to persist rotated token: ${err.message}` });
1046
+ return;
1047
+ }
1048
+ // Swap into the live gate; the OLD token is rejected from here on. Keep `config.accessToken` coherent
1049
+ // so anything that re-reads it sees the new secret. CAVEAT (inherent to the single-token model): an
1050
+ // mcp-send subprocess ALREADY running holds the old token in its per-session 0600 config, so its next
1051
+ // callback would 401 until the session respawns; new spawns pick up the persisted token. The client
1052
+ // must re-store the returned token (the web side updates token-store on a rotate response).
1053
+ authGate.rotateToken(next);
1054
+ config.accessToken = next;
1055
+ reply.code(200).send({ token: next });
1056
+ });
1057
+ // POST /ws-ticket → { ticket, expiresInMs }: a single-use, ~30s credential for the terminal WS URL
1058
+ // (`?ticket=<t>`), so the LONG-LIVED token never has to ride in a WS query string (query strings are
1059
+ // routinely written into proxy/access logs). Token-gated by the global default-deny preHandler — only
1060
+ // a client that already holds the real token can mint tickets. Consumed (and thus dead) by the very
1061
+ // upgrade that presents it; see the preHandler + ws-ticket.ts.
1062
+ app.post("/ws-ticket", async () => wsTickets.issue());
1063
+ const providerFrom = (raw) => (raw === "claude" || raw === "codex" ? raw : undefined);
1064
+ const unknownProvider = (reply) => {
1065
+ reply.code(404).send({ code: "PROVIDER_NOT_FOUND", error: "Provider not found" });
1066
+ };
1067
+ const metadataUnavailable = (reply) => {
1068
+ reply.code(503).send({ code: "PROVIDER_METADATA_UNAVAILABLE", error: "Provider metadata is unavailable" });
1069
+ };
1070
+ const claudeVersion = async () => {
1071
+ const [installed, latest] = await Promise.all([
1072
+ claudeVersionProbe
1073
+ .get()
1074
+ .then((v) => v.version ?? null)
1075
+ .catch(() => null),
1076
+ deps.claudeLatest ? deps.claudeLatest.getLatest().then((v) => v ?? null) : Promise.resolve(null),
1077
+ ]);
1078
+ return { installed, latest };
1079
+ };
1080
+ const claudeUsage = async () => ({ usage: deps.usage ? await deps.usage.getUsage() : null });
1081
+ const claudeAuthStatus = async (reply) => {
1082
+ if (!deps.claudeAuth)
1083
+ return { available: false };
1084
+ try {
1085
+ return { available: true, ...(await deps.claudeAuth.status()) };
1086
+ }
1087
+ catch {
1088
+ if (reply)
1089
+ return metadataUnavailable(reply);
1090
+ return { available: false };
1091
+ }
1092
+ };
1093
+ const startClaudeLogin = async (reply) => {
1094
+ if (!deps.claudeAuth)
1095
+ return metadataUnavailable(reply);
1096
+ try {
1097
+ return await deps.claudeAuth.startLogin();
1098
+ }
1099
+ catch {
1100
+ return metadataUnavailable(reply);
1101
+ }
1102
+ };
1103
+ const cancelClaudeLogin = () => {
1104
+ deps.claudeAuth?.cancel();
1105
+ return { ok: true };
1106
+ };
1107
+ const readProviderAvailability = async () => {
1108
+ const capabilityByProvider = {};
1109
+ const registered = providers.list();
1110
+ await Promise.all(registered.map(async (provider) => {
1111
+ let availability;
1112
+ try {
1113
+ availability = await provider.probe();
1114
+ }
1115
+ catch {
1116
+ availability = { terminalAvailable: false, metadataAvailable: false };
1117
+ }
1118
+ availability = normalizeProviderAvailability(terminalAvailable, availability);
1119
+ if (provider.id === "codex") {
1120
+ let metadataAvailable = false;
1121
+ try {
1122
+ metadataAvailable = Boolean(deps.codexMetadata && deps.codexCapabilityProbe && (await deps.codexCapabilityProbe.get()));
1123
+ }
1124
+ catch {
1125
+ metadataAvailable = false;
1126
+ }
1127
+ availability = normalizeProviderAvailability(terminalAvailable, availability, metadataAvailable);
1128
+ }
1129
+ capabilityByProvider[provider.id] = availability;
1130
+ }));
1131
+ return capabilityByProvider;
1132
+ };
1133
+ /** Provider capability discovery is independent per provider and per capability. */
1134
+ app.get("/providers", async () => {
1135
+ return { providers: await readProviderAvailability() };
1136
+ });
1137
+ app.get("/providers/:provider/auth/status", async (request, reply) => {
1138
+ const provider = providerFrom(request.params.provider);
1139
+ if (!provider)
1140
+ return unknownProvider(reply);
1141
+ if (provider === "claude") {
1142
+ return claudeAuthStatus(reply);
1143
+ }
1144
+ if (!deps.codexMetadata)
1145
+ return { available: false };
1146
+ try {
1147
+ return { available: true, ...(await deps.codexMetadata.getAccount()) };
1148
+ }
1149
+ catch {
1150
+ return metadataUnavailable(reply);
1151
+ }
1152
+ });
1153
+ app.post("/providers/:provider/auth/login/start", async (request, reply) => {
1154
+ const provider = providerFrom(request.params.provider);
1155
+ if (!provider)
1156
+ return unknownProvider(reply);
1157
+ try {
1158
+ if (provider === "claude") {
1159
+ return startClaudeLogin(reply);
1160
+ }
1161
+ if (!deps.codexMetadata)
1162
+ return metadataUnavailable(reply);
1163
+ const login = await deps.codexMetadata.startDeviceLogin();
1164
+ return {
1165
+ loginId: login.loginId,
1166
+ userCode: login.userCode,
1167
+ verificationUrl: login.verificationUrl,
1168
+ expiresAt: login.expiresAt,
1169
+ };
1170
+ }
1171
+ catch {
1172
+ return metadataUnavailable(reply);
1173
+ }
1174
+ });
1175
+ app.get("/providers/:provider/auth/login/status", async (request, reply) => {
1176
+ const provider = providerFrom(request.params.provider);
1177
+ if (!provider)
1178
+ return unknownProvider(reply);
1179
+ if (provider !== "codex") {
1180
+ reply.code(404).send({ code: "LOGIN_STATUS_UNAVAILABLE", error: "Login status is unavailable" });
1181
+ return;
1182
+ }
1183
+ const loginId = request.query?.loginId;
1184
+ if (typeof loginId !== "string" ||
1185
+ loginId.length === 0 ||
1186
+ loginId.length > 256 ||
1187
+ /[\p{Cc}\p{Zl}\p{Zp}]/u.test(loginId)) {
1188
+ reply.code(400).send({ code: "INVALID_LOGIN", error: "loginId is required" });
1189
+ return;
1190
+ }
1191
+ if (!deps.codexMetadata)
1192
+ return metadataUnavailable(reply);
1193
+ try {
1194
+ return deps.codexMetadata.getLoginStatus(loginId);
1195
+ }
1196
+ catch {
1197
+ return metadataUnavailable(reply);
1198
+ }
1199
+ });
1200
+ app.post("/providers/:provider/auth/login/cancel", async (request, reply) => {
1201
+ const provider = providerFrom(request.params.provider);
1202
+ if (!provider)
1203
+ return unknownProvider(reply);
1204
+ if (provider === "claude") {
1205
+ return cancelClaudeLogin();
1206
+ }
1207
+ const loginId = request.body?.loginId;
1208
+ if (typeof loginId !== "string") {
1209
+ reply.code(400).send({ code: "INVALID_LOGIN", error: "loginId is required" });
1210
+ return;
1211
+ }
1212
+ if (!deps.codexMetadata)
1213
+ return metadataUnavailable(reply);
1214
+ try {
1215
+ return await deps.codexMetadata.cancelLogin(loginId);
1216
+ }
1217
+ catch {
1218
+ return metadataUnavailable(reply);
1219
+ }
1220
+ });
1221
+ app.get("/providers/:provider/models", async (request, reply) => {
1222
+ const provider = providerFrom(request.params.provider);
1223
+ if (!provider)
1224
+ return unknownProvider(reply);
1225
+ if (provider === "claude") {
1226
+ if (!deps.claudeMetadata)
1227
+ return metadataUnavailable(reply);
1228
+ try {
1229
+ return { models: await deps.claudeMetadata.getModels() };
1230
+ }
1231
+ catch {
1232
+ return metadataUnavailable(reply);
1233
+ }
1234
+ }
1235
+ if (!deps.codexMetadata)
1236
+ return metadataUnavailable(reply);
1237
+ try {
1238
+ return { models: await deps.codexMetadata.getModels() };
1239
+ }
1240
+ catch {
1241
+ return metadataUnavailable(reply);
1242
+ }
1243
+ });
1244
+ app.get("/providers/:provider/profiles", async (request, reply) => {
1245
+ const provider = providerFrom(request.params.provider);
1246
+ if (!provider)
1247
+ return unknownProvider(reply);
1248
+ if (provider === "claude")
1249
+ return { profiles: [] };
1250
+ if (!deps.codexMetadata)
1251
+ return metadataUnavailable(reply);
1252
+ try {
1253
+ return { profiles: await deps.codexMetadata.listProfiles() };
1254
+ }
1255
+ catch {
1256
+ return metadataUnavailable(reply);
1257
+ }
1258
+ });
1259
+ app.get("/providers/:provider/usage", async (request, reply) => {
1260
+ const provider = providerFrom(request.params.provider);
1261
+ if (!provider)
1262
+ return unknownProvider(reply);
1263
+ if (provider === "claude")
1264
+ return claudeUsage();
1265
+ if (!deps.codexMetadata)
1266
+ return metadataUnavailable(reply);
1267
+ try {
1268
+ return { usage: await deps.codexMetadata.getUsage() };
1269
+ }
1270
+ catch {
1271
+ return metadataUnavailable(reply);
1272
+ }
1273
+ });
1274
+ app.get("/providers/:provider/version", async (request, reply) => {
1275
+ const provider = providerFrom(request.params.provider);
1276
+ if (!provider)
1277
+ return unknownProvider(reply);
1278
+ if (provider === "claude")
1279
+ return claudeVersion();
1280
+ if (!deps.codexLatest)
1281
+ return metadataUnavailable(reply);
1282
+ try {
1283
+ return await deps.codexLatest.getVersion();
1284
+ }
1285
+ catch {
1286
+ return metadataUnavailable(reply);
1287
+ }
1288
+ });
1289
+ // GET /usage → the Claude usage bars {usage: UsageInfo | null} (token-gated by the global preHandler).
1290
+ // The UsageService caches with a TTL so this poll is cheap; a spawn/parse failure degrades to
1291
+ // `usage:null` (the UI hides the bars) and never 500s. Absent dep (tests / no claude) → null.
1292
+ app.get("/usage", async () => {
1293
+ return claudeUsage();
1294
+ });
1295
+ // In-app Claude re-authentication (token-gated by the global preHandler). Lets a user whose server-side
1296
+ // Claude login expired sign in again from the app: start → returns the authorize URL; the user authorizes
1297
+ // in any browser + pastes the code back; code → finishes the exchange (fresh creds, no restart needed).
1298
+ // GET /auth/status → which account is signed in (or {available:false} when the feature is off).
1299
+ app.get("/auth/status", async () => {
1300
+ return claudeAuthStatus();
1301
+ });
1302
+ // POST /auth/login/start → { loginId, url } (503 if the feature is off / the URL never appears).
1303
+ app.post("/auth/login/start", async (_request, reply) => {
1304
+ return startClaudeLogin(reply);
1305
+ });
1306
+ // POST /auth/login/code { loginId, code } → { ok, message? }.
1307
+ app.post("/auth/login/code", async (request, reply) => {
1308
+ if (!deps.claudeAuth) {
1309
+ reply.code(503).send({ error: "Claude sign-in is not available on this server." });
1310
+ return;
1311
+ }
1312
+ const { loginId, code } = request.body ?? {};
1313
+ if (typeof loginId !== "string" || typeof code !== "string") {
1314
+ reply.code(400).send({ error: "loginId and code are required" });
1315
+ return;
1316
+ }
1317
+ return await deps.claudeAuth.submitCode(loginId, code);
1318
+ });
1319
+ // POST /auth/login/cancel → abandon an in-flight sign-in.
1320
+ app.post("/auth/login/cancel", async () => {
1321
+ return cancelClaudeLogin();
1322
+ });
1323
+ // GET /claude/version → { installed, latest } (token-gated). `installed` is the server's `claude --version`;
1324
+ // `latest` is the newest published version (null when unknown). The UI compares a session's claudeVersion
1325
+ // against `latest` to show a subtle "update available" hint. Never 500s — both degrade to null.
1326
+ app.get("/claude/version", async () => {
1327
+ return claudeVersion();
1328
+ });
1329
+ app.get("/fs/list", async (request, reply) => {
1330
+ try {
1331
+ const target = request.query.path ?? config.fsRoot;
1332
+ return await fsService.listDirectory(target);
1333
+ }
1334
+ catch (err) {
1335
+ if (err instanceof FsError) {
1336
+ reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
1337
+ }
1338
+ else {
1339
+ reply.code(400).send({ error: err.message });
1340
+ }
1341
+ }
1342
+ });
1343
+ // POST /fs/mkdir {path} → 201 { path }: create ONE directory for the picker's "new folder" flow.
1344
+ // Non-recursive by design (the parent must already exist — 404 otherwise); 409 when the path is taken;
1345
+ // fsRoot-confined exactly like /fs/list (403 on any escape). Token-gated by the global preHandler.
1346
+ app.post("/fs/mkdir", async (request, reply) => {
1347
+ const target = request.body?.path;
1348
+ if (typeof target !== "string" || target.trim().length === 0) {
1349
+ reply.code(400).send({ error: "path is required" });
1350
+ return;
1351
+ }
1352
+ try {
1353
+ const created = await fsService.makeDirectory(target);
1354
+ reply.code(201).send({ path: created.path });
1355
+ }
1356
+ catch (err) {
1357
+ if (err instanceof FsError) {
1358
+ reply.code(err.code === "forbidden" ? 403 : err.code === "exists" ? 409 : 404).send({ error: err.message });
1359
+ }
1360
+ else {
1361
+ reply.code(400).send({ error: err.message });
1362
+ }
1363
+ }
1364
+ });
1365
+ // GET /fs/search?q=<substr>&base=<abs dir, default fsRoot> → { results: [{path,name,isGitRepo}] }:
1366
+ // case-insensitive substring match on DIRECTORY names for the picker's "type to find your repo" flow.
1367
+ // Bounded walk (depth ≤5, ≤400 dirs, ≤30 results, shallowest-first; dot-dirs + node_modules skipped) —
1368
+ // see FsService.searchDirectories. fsRoot-confined; token-gated by the global preHandler.
1369
+ app.get("/fs/search", async (request, reply) => {
1370
+ const q = request.query.q;
1371
+ if (typeof q !== "string" || q.trim().length === 0) {
1372
+ reply.code(400).send({ error: "q is required" });
1373
+ return;
1374
+ }
1375
+ try {
1376
+ const results = await fsService.searchDirectories(q.trim(), request.query.base);
1377
+ return { results };
1378
+ }
1379
+ catch (err) {
1380
+ if (err instanceof FsError) {
1381
+ reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
1382
+ }
1383
+ else {
1384
+ reply.code(400).send({ error: err.message });
1385
+ }
1386
+ }
1387
+ });
1388
+ app.get("/fs/download", async (request, reply) => {
1389
+ if (!request.query.path) {
1390
+ reply.code(400).send({ error: "path is required" });
1391
+ return;
1392
+ }
1393
+ try {
1394
+ const file = await fsService.readFileForDownload(request.query.path);
1395
+ reply
1396
+ .header("content-disposition", contentDisposition(file.filename))
1397
+ .header("content-type", "application/octet-stream")
1398
+ .send(file.data);
1399
+ }
1400
+ catch (err) {
1401
+ if (err instanceof FsError) {
1402
+ reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
1403
+ }
1404
+ else {
1405
+ reply.code(404).send({ error: err.message });
1406
+ }
1407
+ }
1408
+ });
1409
+ app.post("/fs/upload", async (request, reply) => {
1410
+ const targetDir = request.query.dir ?? config.fsRoot;
1411
+ let data;
1412
+ try {
1413
+ data = await request.file();
1414
+ }
1415
+ catch (err) {
1416
+ reply.code(400).send({ error: err.message });
1417
+ return;
1418
+ }
1419
+ if (!data) {
1420
+ reply.code(400).send({ error: "no file field in the upload" });
1421
+ return;
1422
+ }
1423
+ let buffer;
1424
+ try {
1425
+ buffer = await data.toBuffer();
1426
+ }
1427
+ catch (err) {
1428
+ // @fastify/multipart throws when the per-file limit is exceeded.
1429
+ reply.code(413).send({ error: err.message });
1430
+ return;
1431
+ }
1432
+ if (data.file.truncated) {
1433
+ reply.code(413).send({ error: "file exceeds the upload size limit" });
1434
+ return;
1435
+ }
1436
+ try {
1437
+ const written = await fsService.writeUploadedFile(targetDir, data.filename, buffer);
1438
+ reply.code(201).send({ path: written.path });
1439
+ }
1440
+ catch (err) {
1441
+ reply.code(400).send({ error: err.message });
1442
+ }
1443
+ });
1444
+ // Serve the built PWA same-origin when a webDir was provided. Registered LAST so it never
1445
+ // Terminal upload (user → claude): save the file under the session's shared-files folder in the app DATA
1446
+ // dir (NOT the project tree — it is operational state, not application source; see
1447
+ // terminal-shared.ts), prune anything past the 7-day TTL, and return the absolute path (the client hands
1448
+ // it to the terminal so claude can read it). Server owns the location so the client can't target an
1449
+ // arbitrary dir. Token-gated by the global preHandler.
1450
+ app.post("/sessions/:id/upload", async (request, reply) => {
1451
+ const meta = terminalManager.get(request.params.id);
1452
+ if (!meta) {
1453
+ reply.code(404).send({ error: "terminal session not found" });
1454
+ return;
1455
+ }
1456
+ let dir;
1457
+ try {
1458
+ dir = await fsService.ensureDirWithinRoot(terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: meta.id }));
1459
+ }
1460
+ catch (err) {
1461
+ const code = err instanceof FsError && err.code === "forbidden" ? 403 : 400;
1462
+ reply.code(code).send({ error: err.message });
1463
+ return;
1464
+ }
1465
+ await fsService.pruneOlderThan(dir, TERMINAL_FILE_TTL_MS).catch(() => 0); // best-effort, before saving
1466
+ let data;
1467
+ try {
1468
+ data = await request.file();
1469
+ }
1470
+ catch (err) {
1471
+ reply.code(400).send({ error: err.message });
1472
+ return;
1473
+ }
1474
+ if (!data) {
1475
+ reply.code(400).send({ error: "no file field in the upload" });
1476
+ return;
1477
+ }
1478
+ let buffer;
1479
+ try {
1480
+ buffer = await data.toBuffer();
1481
+ }
1482
+ catch (err) {
1483
+ reply.code(413).send({ error: err.message });
1484
+ return;
1485
+ }
1486
+ if (data.file.truncated) {
1487
+ reply.code(413).send({ error: "file exceeds the upload size limit" });
1488
+ return;
1489
+ }
1490
+ try {
1491
+ const written = await fsService.writeUploadedFile(dir, data.filename, buffer);
1492
+ reply.code(201).send({ path: written.path });
1493
+ }
1494
+ catch (err) {
1495
+ reply.code(400).send({ error: err.message });
1496
+ }
1497
+ });
1498
+ // shadows the API/WS routes above (the SPA fallback is scoped by isPublicPath).
1499
+ if (deps.webDir)
1500
+ registerStatic(app, { webDir: deps.webDir });
1501
+ // Graceful shutdown: app.close() stops the file-sweep timer and closes the SQLite-backed stores opened
1502
+ // by startServer (session, push) so their DB handles are released — they're opened once at boot and never
1503
+ // reopened, so closing them on shutdown is safe. Terminal sessions live in tmux (detached from this
1504
+ // process), so they intentionally SURVIVE a server restart (rehydrate reattaches them on the next boot).
1505
+ app.addHook("onClose", async () => {
1506
+ clearInterval(sharedSweepTimer);
1507
+ try {
1508
+ if (typeof deps.codexMetadata?.dispose === "function")
1509
+ deps.codexMetadata.dispose();
1510
+ }
1511
+ catch {
1512
+ /* provider metadata teardown is best-effort; continue closing all other resources */
1513
+ }
1514
+ try {
1515
+ if (typeof deps.claudeMetadata?.dispose === "function")
1516
+ await deps.claudeMetadata.dispose();
1517
+ }
1518
+ catch {
1519
+ /* provider metadata teardown is best-effort; continue closing all other resources */
1520
+ }
1521
+ try {
1522
+ deps.claudeAuth?.cancel();
1523
+ }
1524
+ catch {
1525
+ /* continue closing */
1526
+ }
1527
+ try {
1528
+ await deps.disposeProviders?.();
1529
+ }
1530
+ catch {
1531
+ /* continue closing */
1532
+ }
1533
+ try {
1534
+ deps.store?.close();
1535
+ }
1536
+ catch {
1537
+ /* continue closing */
1538
+ }
1539
+ try {
1540
+ deps.pushStore?.close();
1541
+ }
1542
+ catch {
1543
+ /* every owned resource gets an independent teardown attempt */
1544
+ }
1545
+ });
1546
+ return { app, authGate, terminalManager, terminalAvailable };
1547
+ }
1548
+ /**
1549
+ * Build a safe `Content-Disposition` value for a download. A filename containing `"`, `\`, or a
1550
+ * CR/LF could break out of the header (header injection) or corrupt the quoted-string. We strip
1551
+ * control chars for the ASCII `filename=` fallback (quotes/backslashes escaped) and carry the full
1552
+ * UTF-8 name via RFC 5987 `filename*=` (percent-encoded), which modern clients prefer.
1553
+ */
1554
+ function contentDisposition(filename) {
1555
+ // Drop control chars (incl. CR/LF) from the ASCII fallback, then escape `\` and `"`.
1556
+ const ascii = filename.replace(/[\x00-\x1f\x7f"\\]/g, "_");
1557
+ const encoded = encodeURIComponent(filename);
1558
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
1559
+ }