@opengeni/api-router 0.2.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 (41) hide show
  1. package/dist/app.d.ts +16 -0
  2. package/dist/app.js +35 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/chunk-XSYUDIX3.js +6331 -0
  5. package/dist/chunk-XSYUDIX3.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.js +567 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +74 -0
  10. package/src/app.ts +351 -0
  11. package/src/auth/managed-auth.ts +237 -0
  12. package/src/http/auth.ts +92 -0
  13. package/src/http/common.ts +16 -0
  14. package/src/http/sse.ts +89 -0
  15. package/src/index.ts +362 -0
  16. package/src/mcp/documents.ts +57 -0
  17. package/src/mcp/server.ts +961 -0
  18. package/src/mcp/session-view.ts +281 -0
  19. package/src/routes/api-keys.ts +65 -0
  20. package/src/routes/billing.ts +495 -0
  21. package/src/routes/capabilities.ts +80 -0
  22. package/src/routes/codex.ts +393 -0
  23. package/src/routes/documents.ts +185 -0
  24. package/src/routes/enrollments.ts +357 -0
  25. package/src/routes/environments.ts +175 -0
  26. package/src/routes/files.ts +148 -0
  27. package/src/routes/github.ts +341 -0
  28. package/src/routes/install.ts +218 -0
  29. package/src/routes/machines.ts +107 -0
  30. package/src/routes/packs.ts +241 -0
  31. package/src/routes/scheduled-tasks.ts +126 -0
  32. package/src/routes/sessions.ts +1083 -0
  33. package/src/routes/social.ts +119 -0
  34. package/src/routes/workspaces.ts +206 -0
  35. package/src/sandbox/access.ts +89 -0
  36. package/src/sandbox/auth-callout.ts +178 -0
  37. package/src/sandbox/channel-a.ts +265 -0
  38. package/src/sandbox/enrollment.ts +498 -0
  39. package/src/sandbox/machines.ts +255 -0
  40. package/src/sandbox/metrics-ingestion.ts +289 -0
  41. package/src/sandbox/viewer.ts +993 -0
@@ -0,0 +1,1083 @@
1
+ import {
2
+ AcknowledgeStreamRequest,
3
+ AttachViewerRequest,
4
+ ClearSessionContextRequest,
5
+ ClientSessionEvent,
6
+ CompactSessionContextRequest,
7
+ FsDeleteRequest,
8
+ FsListRequest,
9
+ FsMkdirRequest,
10
+ FsMoveRequest,
11
+ FsReadRequest,
12
+ FsWriteRequest,
13
+ GitDiffRequest,
14
+ GitLogRequest,
15
+ GitShowRequest,
16
+ GitStatusRequest,
17
+ PtyCloseRequest,
18
+ PtyOpenRequest,
19
+ PtyResizeRequest,
20
+ PtyWriteRequest,
21
+ ReorderSessionTurnsRequest,
22
+ TerminalExecRequest,
23
+ UpdateSessionGoalRequest,
24
+ UpdateSessionRequest,
25
+ UpdateSessionTurnRequest,
26
+ ViewerHeartbeatRequest,
27
+ type SandboxBackend,
28
+ type Session,
29
+ type TerminalPtyExitedPayload,
30
+ type TerminalPtyOutputDeltaPayload,
31
+ type TerminalPtyStartedPayload,
32
+ } from "@opengeni/contracts";
33
+ import { resolveContextCompactionMode, streamTokenDegraded } from "@opengeni/config";
34
+ import {
35
+ cancelQueuedSessionTurn,
36
+ clearSessionContext,
37
+ closePtySession,
38
+ getOpenPtySession,
39
+ getSandbox,
40
+ getSession,
41
+ getSessionGoal,
42
+ getStreamAcknowledgment,
43
+ insertPtySession,
44
+ listSessionEvents,
45
+ listSessionIdsInGroup,
46
+ listSessions,
47
+ listSessionTurns,
48
+ recordStreamAcknowledgment,
49
+ reorderQueuedSessionTurns,
50
+ requestSessionCompaction,
51
+ requireSession,
52
+ setSessionCodexPin,
53
+ revokeViewer,
54
+ setSessionGoalStatus,
55
+ updatePtySessionActivity,
56
+ updateQueuedSessionTurn,
57
+ type AppendEventInput,
58
+ } from "@opengeni/db";
59
+ import { appendAndPublishEvents } from "@opengeni/events";
60
+ import { withChannelA } from "../sandbox/channel-a";
61
+ import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
62
+ import type { Context, Hono } from "hono";
63
+ import { HTTPException } from "hono/http-exception";
64
+ import { requireAccessGrant } from "@opengeni/core";
65
+ import type { ApiRouteDeps } from "@opengeni/core";
66
+ import { attachViewer, detachViewer, heartbeatViewer, mintDesktopStream, mintTerminalStream, readGroupLease, viewerHeartbeatIntervalMs, type DesktopStreamMint, type TerminalStreamMint } from "../sandbox/viewer";
67
+ import { settingsWithEnabledCapabilityMcpServers } from "@opengeni/core";
68
+ import {
69
+ normalizeResources,
70
+ validateFileResources,
71
+ validateGitHubRepositorySelection,
72
+ validateToolRefs,
73
+ } from "@opengeni/core";
74
+ import {
75
+ acceptSessionUserMessage,
76
+ assertConfiguredModel,
77
+ createSessionForRequest,
78
+ requireQueuedTurnForApi,
79
+ updateSessionTitle,
80
+ workflowIdForSession,
81
+ } from "@opengeni/core";
82
+ import { assertSessionExists, boundedLimit } from "../http/common";
83
+ import { sseSessionStream } from "../http/sse";
84
+
85
+ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
86
+ const { settings, db, bus, workflowClient, objectStorage } = deps;
87
+
88
+ app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
89
+ const workspaceId = c.req.param("workspaceId");
90
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
91
+ const session = await createSessionForRequest(deps, grant, workspaceId, await c.req.json());
92
+ return c.json(session, 202);
93
+ });
94
+
95
+ app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
96
+ const workspaceId = c.req.param("workspaceId");
97
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
98
+ return c.json(await listSessions(db, workspaceId, boundedLimit(c.req.query("limit"))));
99
+ });
100
+
101
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
102
+ const workspaceId = c.req.param("workspaceId");
103
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
104
+ const session = await getSession(db, workspaceId, c.req.param("sessionId"));
105
+ if (!session) {
106
+ throw new HTTPException(404, { message: "session not found" });
107
+ }
108
+ return c.json(session);
109
+ });
110
+
111
+ // Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
112
+ // "auto" clears the pin (the session follows the workspace active pointer); a
113
+ // uuid pins the session to that specific account. The pin applies to the NEXT
114
+ // turn (the worker reads it at turn start). 404 when the session or the target
115
+ // account id isn't in the workspace.
116
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/codex-account", async (c) => {
117
+ const workspaceId = c.req.param("workspaceId");
118
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
119
+ const sessionId = c.req.param("sessionId");
120
+ const body = (await c.req.json()) as { target?: string };
121
+ const target = typeof body.target === "string" ? body.target : "";
122
+ if (!target) {
123
+ throw new HTTPException(400, { message: "target is required (\"auto\" or an account id)" });
124
+ }
125
+ const pinned = target === "auto" ? null : target;
126
+ const ok = await setSessionCodexPin(db, workspaceId, sessionId, pinned);
127
+ if (!ok) {
128
+ throw new HTTPException(404, { message: "session or codex account not found" });
129
+ }
130
+ return c.json({ pinned: target === "auto" ? "auto" : target });
131
+ });
132
+
133
+ // Manual rename. A user-set title is permanent: the db write is
134
+ // unconditional (source='user'), so it always pins the session over later
135
+ // agent writes. Returns the refreshed session, mirroring GET detail.
136
+ app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
137
+ const workspaceId = c.req.param("workspaceId");
138
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
139
+ const sessionId = c.req.param("sessionId");
140
+ await assertSessionExists(db, workspaceId, sessionId);
141
+ const payload = UpdateSessionRequest.parse(await c.req.json());
142
+ await updateSessionTitle({ db, bus }, workspaceId, sessionId, payload.title, "user");
143
+ const session = await getSession(db, workspaceId, sessionId);
144
+ if (!session) {
145
+ throw new HTTPException(404, { message: "session not found" });
146
+ }
147
+ return c.json(session);
148
+ });
149
+
150
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
151
+ const workspaceId = c.req.param("workspaceId");
152
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
153
+ const sessionId = c.req.param("sessionId");
154
+ await assertSessionExists(db, workspaceId, sessionId);
155
+ const goal = await getSessionGoal(db, workspaceId, sessionId);
156
+ if (!goal) {
157
+ throw new HTTPException(404, { message: "session goal not found" });
158
+ }
159
+ return c.json(goal);
160
+ });
161
+
162
+ app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
163
+ const workspaceId = c.req.param("workspaceId");
164
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
165
+ const sessionId = c.req.param("sessionId");
166
+ await assertSessionExists(db, workspaceId, sessionId);
167
+ const payload = UpdateSessionGoalRequest.parse(await c.req.json());
168
+ const existing = await getSessionGoal(db, workspaceId, sessionId);
169
+ if (!existing) {
170
+ throw new HTTPException(404, { message: "session goal not found" });
171
+ }
172
+ if (existing.status === "completed") {
173
+ throw new HTTPException(409, { message: "session goal is completed; set a new goal instead" });
174
+ }
175
+ if (payload.status === "paused") {
176
+ const { goal, changed } = await setSessionGoalStatus(db, workspaceId, sessionId, {
177
+ status: "paused",
178
+ ...(payload.rationale ? { rationale: payload.rationale } : {}),
179
+ pausedReason: "api",
180
+ });
181
+ if (changed) {
182
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
183
+ type: "goal.paused",
184
+ payload: {
185
+ goalId: goal.id,
186
+ actor: "api",
187
+ reason: "api",
188
+ ...(payload.rationale ? { rationale: payload.rationale } : {}),
189
+ autoContinuations: goal.autoContinuations,
190
+ noProgressStreak: goal.noProgressStreak,
191
+ },
192
+ }]);
193
+ }
194
+ return c.json(goal);
195
+ }
196
+ // Resume: only valid from paused; resets counters and re-arms the loop.
197
+ if (existing.status !== "paused") {
198
+ throw new HTTPException(409, { message: `session goal is ${existing.status}; only paused goals can be resumed` });
199
+ }
200
+ const { goal, changed } = await setSessionGoalStatus(db, workspaceId, sessionId, { status: "active" });
201
+ // `changed` guards the racing-PATCH case: both requests can pass the
202
+ // status pre-check, but only the transition winner emits and wakes.
203
+ if (changed) {
204
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
205
+ type: "goal.resumed",
206
+ payload: {
207
+ goalId: goal.id,
208
+ text: goal.text,
209
+ ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
210
+ version: goal.version,
211
+ actor: "api",
212
+ },
213
+ }]);
214
+ // signalWithStart restarts a completed workflow whose first claim finds no
215
+ // queued turn, so maybeContinueGoal fires — resume works on an idle session.
216
+ await workflowClient.wakeSessionWorkflow({ accountId: grant.accountId, workspaceId, sessionId, workflowId: workflowIdForSession(sessionId) });
217
+ }
218
+ return c.json(goal);
219
+ });
220
+
221
+ // Operator context controls (slash-command palette: /clear, /compact). These
222
+ // are session/operator actions — NOT a structured channel to the agent. Both
223
+ // require sessions:control.
224
+
225
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/context/clear", async (c) => {
226
+ const workspaceId = c.req.param("workspaceId");
227
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
228
+ const sessionId = c.req.param("sessionId");
229
+ await assertSessionExists(db, workspaceId, sessionId);
230
+ // Explicit confirm on the wire (literal true) — an empty/accidental POST
231
+ // cannot wipe context. Mirrors the client-side confirm affordance. A
232
+ // missing/false confirm is a client error (400), not a server fault.
233
+ const clearBody = ClearSessionContextRequest.safeParse(await c.req.json().catch(() => ({})));
234
+ if (!clearBody.success) {
235
+ throw new HTTPException(400, { message: "context clear requires an explicit { confirm: true }" });
236
+ }
237
+ const session = await requireSession(db, workspaceId, sessionId);
238
+ // Clearing mid-turn would strand the in-flight RunState (and, in
239
+ // requires_action, an awaiting approval whose resume needs that blob).
240
+ // Refuse, mirroring the goal 409 guards.
241
+ if (session.status === "queued" || session.status === "running" || session.status === "requires_action") {
242
+ throw new HTTPException(409, { message: `session is ${session.status}; cannot clear context mid-turn — stop the turn first` });
243
+ }
244
+ const result = await clearSessionContext(db, { accountId: grant.accountId, workspaceId, sessionId });
245
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
246
+ type: "session.context.cleared",
247
+ payload: {
248
+ clearedBy: "api",
249
+ supersededItems: result.supersededItems,
250
+ markerPosition: result.markerPosition,
251
+ },
252
+ }]);
253
+ return c.body(null, 204);
254
+ });
255
+
256
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/context/compact", async (c) => {
257
+ const workspaceId = c.req.param("workspaceId");
258
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
259
+ const sessionId = c.req.param("sessionId");
260
+ await assertSessionExists(db, workspaceId, sessionId);
261
+ CompactSessionContextRequest.parse((await c.req.json().catch(() => ({}))) ?? {});
262
+ // /compact is only a TRIGGER — it never duplicates the compaction engine.
263
+ // Client-managed (Azure) path: set a durable request flag the worker honors
264
+ // before the next turn (forced compaction). Server-managed provider / off:
265
+ // compaction is automatic or disabled, so this is an honest no-op.
266
+ //
267
+ // Integration seam with provider-aware-compaction: when that work exposes a
268
+ // synchronous "compact now" entry callable from the API process, this route
269
+ // should call it directly and return its result; until then the flag +
270
+ // worker maybeCompactContext(force) is the minimal honored interface.
271
+ const mode = resolveContextCompactionMode(settings);
272
+ if (mode === "client") {
273
+ await requestSessionCompaction(db, workspaceId, sessionId);
274
+ return c.json({ status: "queued", message: "Compaction will run before the next turn." });
275
+ }
276
+ if (mode === "server") {
277
+ return c.json({ status: "noop", message: "This session's provider compacts context automatically; no manual compaction is needed." });
278
+ }
279
+ return c.json({ status: "noop", message: "Context compaction is disabled for this session." });
280
+ });
281
+
282
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
283
+ const workspaceId = c.req.param("workspaceId");
284
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
285
+ const sessionId = c.req.param("sessionId");
286
+ await assertSessionExists(db, workspaceId, sessionId);
287
+ const after = Number(c.req.query("after") ?? 0);
288
+ const limit = Number(c.req.query("limit") ?? 500);
289
+ return c.json(await listSessionEvents(db, workspaceId, sessionId, Number.isFinite(after) ? after : 0, Number.isFinite(limit) ? limit : 500));
290
+ });
291
+
292
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
293
+ const workspaceId = c.req.param("workspaceId");
294
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
295
+ const sessionId = c.req.param("sessionId");
296
+ await assertSessionExists(db, workspaceId, sessionId);
297
+ const after = Number(c.req.query("after") ?? c.req.header("Last-Event-ID") ?? 0);
298
+ return sseSessionStream(db, bus, workspaceId, sessionId, Number.isFinite(after) ? after : 0, c.req.raw.signal);
299
+ });
300
+
301
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/turns", async (c) => {
302
+ const workspaceId = c.req.param("workspaceId");
303
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
304
+ const sessionId = c.req.param("sessionId");
305
+ await assertSessionExists(db, workspaceId, sessionId);
306
+ return c.json(await listSessionTurns(db, workspaceId, sessionId, boundedLimit(c.req.query("limit"))));
307
+ });
308
+
309
+ app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/:turnId", async (c) => {
310
+ const workspaceId = c.req.param("workspaceId");
311
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
312
+ const sessionId = c.req.param("sessionId");
313
+ const turnId = c.req.param("turnId");
314
+ await assertSessionExists(db, workspaceId, sessionId);
315
+ const existing = await requireQueuedTurnForApi(db, workspaceId, sessionId, turnId);
316
+ const payload = UpdateSessionTurnRequest.parse(await c.req.json());
317
+ // A turn-update may switch the queued turn's model; reject one the host
318
+ // does not expose (omitted leaves the existing model unchanged).
319
+ assertConfiguredModel(settings, payload.model);
320
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
321
+ const resources = payload.resources !== undefined ? normalizeResources(payload.resources) : existing.resources;
322
+ const tools = payload.tools !== undefined ? validateToolRefs(payload.tools, runtimeSettings) : existing.tools;
323
+ if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
324
+ throw new HTTPException(503, { message: "object storage is not configured" });
325
+ }
326
+ await validateFileResources(db, workspaceId, resources);
327
+ const session = await requireSession(db, workspaceId, sessionId);
328
+ await validateGitHubRepositorySelection(db, workspaceId, [...session.resources, ...resources]);
329
+ const turn = await updateQueuedSessionTurn(db, workspaceId, turnId, {
330
+ ...(payload.prompt !== undefined ? { prompt: payload.prompt.trim() } : {}),
331
+ ...(payload.model !== undefined ? { model: payload.model } : {}),
332
+ ...(payload.reasoningEffort !== undefined ? { reasoningEffort: payload.reasoningEffort } : {}),
333
+ ...(payload.sandboxBackend !== undefined ? { sandboxBackend: payload.sandboxBackend } : {}),
334
+ ...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
335
+ resources,
336
+ tools,
337
+ });
338
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
339
+ type: "turn.updated",
340
+ turnId: turn.id,
341
+ payload: { turnId: turn.id },
342
+ }]);
343
+ return c.json(turn);
344
+ });
345
+
346
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/reorder", async (c) => {
347
+ const workspaceId = c.req.param("workspaceId");
348
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
349
+ const sessionId = c.req.param("sessionId");
350
+ await assertSessionExists(db, workspaceId, sessionId);
351
+ const payload = ReorderSessionTurnsRequest.parse(await c.req.json());
352
+ const turns = await reorderQueuedSessionTurns(db, workspaceId, sessionId, payload.turnIds);
353
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
354
+ type: "turn.updated",
355
+ payload: { reorderedTurnIds: payload.turnIds },
356
+ }]);
357
+ await workflowClient.wakeSessionWorkflow({ accountId: grant.accountId, workspaceId, sessionId, workflowId: workflowIdForSession(sessionId) });
358
+ return c.json(turns);
359
+ });
360
+
361
+ app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/:turnId", async (c) => {
362
+ const workspaceId = c.req.param("workspaceId");
363
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
364
+ const sessionId = c.req.param("sessionId");
365
+ const turnId = c.req.param("turnId");
366
+ await assertSessionExists(db, workspaceId, sessionId);
367
+ await requireQueuedTurnForApi(db, workspaceId, sessionId, turnId);
368
+ const turn = await cancelQueuedSessionTurn(db, workspaceId, turnId);
369
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
370
+ type: "turn.cancelled",
371
+ turnId: turn.id,
372
+ payload: { turnId: turn.id, triggerEventId: turn.triggerEventId },
373
+ }]);
374
+ return c.json(turn);
375
+ });
376
+
377
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
378
+ const workspaceId = c.req.param("workspaceId");
379
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
380
+ const sessionId = c.req.param("sessionId");
381
+ const rawEvent = await c.req.json();
382
+ const event = ClientSessionEvent.parse(rawEvent);
383
+ if (event.type === "user.message") {
384
+ const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
385
+ text: event.payload.text,
386
+ resources: event.payload.resources ?? [],
387
+ tools: event.payload.tools ?? [],
388
+ toolsProvided: userMessagePayloadHasOwnProperty(rawEvent, "tools"),
389
+ model: event.payload.model ?? null,
390
+ reasoningEffort: event.payload.reasoningEffort ?? null,
391
+ ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
392
+ });
393
+ return c.json(accepted, 202);
394
+ }
395
+
396
+ const session = await requireSession(db, workspaceId, sessionId);
397
+ if (event.type === "user.approvalDecision" && session.status !== "requires_action") {
398
+ throw new HTTPException(409, { message: `session is ${session.status}; no approval is pending` });
399
+ }
400
+ const eventsToAppend: AppendEventInput[] = [{
401
+ type: event.type,
402
+ payload: event.payload,
403
+ ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
404
+ }];
405
+ const appended = await appendAndPublishEvents(db, bus, workspaceId, sessionId, eventsToAppend);
406
+ const accepted = appended[0];
407
+ if (!accepted) {
408
+ throw new HTTPException(500, { message: "failed to append client event" });
409
+ }
410
+ const workflowId = workflowIdForSession(sessionId);
411
+ if (event.type === "user.approvalDecision") {
412
+ await workflowClient.signalApprovalDecision({ sessionId, eventId: accepted.id, workflowId });
413
+ } else {
414
+ await workflowClient.signalInterrupt({
415
+ accountId: grant.accountId,
416
+ workspaceId,
417
+ sessionId,
418
+ eventId: accepted.id,
419
+ workflowId,
420
+ });
421
+ }
422
+ return c.json(accepted, 202);
423
+ });
424
+
425
+ // ── API-direct stream capabilities + viewer attach (P1.4) ─────────────────
426
+ //
427
+ // All IN-PROCESS: capability negotiation reads the descriptor + the group
428
+ // lease (liveness/epoch); viewer attach acquires a holder on the group lease
429
+ // and (when cold) spins the box up via resume-by-id — NO worker, NO Temporal.
430
+ // Gated behind sandboxOwnershipEnabled (the lease is inert with the flag off).
431
+ //
432
+ // ROUTE DISCIPLINE: requireAccessGrant BEFORE any Zod parse; explicit
433
+ // HTTPException(400) on a parse failure (never a raw ZodError → 500);
434
+ // HTTPException(409) on an epoch fence.
435
+
436
+ function assertOwnershipEnabled(): void {
437
+ if (!settings.sandboxOwnershipEnabled) {
438
+ // The viewer-holder lifecycle rides the sandbox lease, which is dormant
439
+ // until the flag flips per-environment. A 404 (not 403) keeps the route
440
+ // invisible while disabled — it does not exist for this deployment yet.
441
+ throw new HTTPException(404, { message: "sandbox ownership is not enabled for this deployment" });
442
+ }
443
+ }
444
+
445
+ // Resolve the shared-exposure disclosure for a session's group: `shared` when
446
+ // the group has >1 session (addendum E.1), and the OTHER sessions' ids ONLY
447
+ // (never their conversation/metadata; the query selects only id — stress g).
448
+ async function resolveSharedExposure(
449
+ workspaceId: string,
450
+ session: { id: string; sandboxGroupId: string },
451
+ ): Promise<{ shared: boolean; sharedSessionIds: string[] }> {
452
+ const ids = await listSessionIdsInGroup(db, workspaceId, session.sandboxGroupId);
453
+ const others = ids.filter((id) => id !== session.id);
454
+ return { shared: others.length > 0, sharedSessionIds: others };
455
+ }
456
+
457
+ // GET .../stream-capabilities — the capability-negotiation read. Returns the
458
+ // SessionCapabilities doc (descriptor + lease liveness/epoch + os + the
459
+ // shared-exposure disclosure + the calling principal's acknowledgment state),
460
+ // API-direct. The desktop URL/token stay null until P4 mints them (gated by
461
+ // liveness=cold until a box is warm); the read is non-mutating.
462
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities", async (c) => {
463
+ const workspaceId = c.req.param("workspaceId");
464
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
465
+ assertOwnershipEnabled();
466
+ const sessionId = c.req.param("sessionId");
467
+ const session = await getSession(db, workspaceId, sessionId);
468
+ if (!session) {
469
+ throw new HTTPException(404, { message: "session not found" });
470
+ }
471
+ const lease = await readGroupLease({ db, settings }, { workspaceId, sandboxGroupId: session.sandboxGroupId });
472
+ const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
473
+ // Per-principal acknowledgment: A acknowledging does not consent for B. The
474
+ // un-redacted desktop stream ALWAYS requires the un-redacted ack; a shared box
475
+ // ADDITIONALLY requires the shared-exposure ack. Both must match the POST
476
+ // /viewers gate EXACTLY — otherwise a principal who recorded shared consent
477
+ // WITHOUT un-redacted consent could be handed a live VNC URL + scoped token
478
+ // from this read path while being correctly 409'd on attach (a consent-gate
479
+ // bypass of the un-redacted pixel plane).
480
+ const ack = await getStreamAcknowledgment(db, { workspaceId, sandboxGroupId: session.sandboxGroupId, subjectId: grant.subjectId });
481
+ const acknowledged = ack ? (ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)) : false;
482
+
483
+ // P4.2 — the pixel DATA PLANE, served API-direct. When the backend is
484
+ // desktop-capable AND sandboxDesktopEnabled AND the (shared, if shared)
485
+ // acknowledgment is present AND the box is WARM, mint the REAL DesktopStream
486
+ // cell IN-PROCESS: resume the box by id, ensureDisplayStack (idempotent),
487
+ // exposeStreamPort (resolve the 6080 tunnel + mint the scoped token), record
488
+ // data_plane_url under the epoch fence, and emit stream.url.rotated to other
489
+ // viewers on a box rollover. The handshake never SPINS UP a cold box (that is
490
+ // the viewer-attach path) — a cold lease stays lease_cold. A degraded mint
491
+ // (no secret / display-stack or tunnel failure) returns null → transport:null.
492
+ let desktopStream: DesktopStreamMint | null = null;
493
+ const desktopUnlocked =
494
+ settings.sandboxDesktopEnabled
495
+ && !streamTokenDegraded(settings)
496
+ && acknowledged
497
+ && (session.activeSandboxId != null || lease?.liveness === "warm" || lease?.liveness === "draining");
498
+ if (desktopUnlocked) {
499
+ desktopStream = await mintDesktopStream({ db, settings, bus }, {
500
+ accountId: grant.accountId,
501
+ workspaceId,
502
+ session,
503
+ // The handshake's token is scoped to the calling principal (it is a read,
504
+ // not a viewer-holder acquire); the per-holder token is re-minted on
505
+ // POST /viewers. A previousEpoch != current would have rotated already
506
+ // via the warming-commit; the read does not itself drive rotation.
507
+ viewerId: grant.subjectId,
508
+ ...(lease ? { lease } : {}),
509
+ });
510
+ }
511
+
512
+ // P5.t — the REAL PTY terminal cell, served API-DIRECT. Independent of the
513
+ // desktop: it gates ONLY on sandboxTerminalEnabled + a real-PTY backend + a
514
+ // WARM box (NO un-redacted ack — the terminal cell has no acknowledgment
515
+ // gate). A degraded mint (terminal off / no secret / ttyd or tunnel failure)
516
+ // returns null → the Terminal cell falls back to the sse-events firehose.
517
+ let terminalStream: TerminalStreamMint | null = null;
518
+ const terminalUnlocked =
519
+ settings.sandboxTerminalEnabled
520
+ && !streamTokenDegraded(settings)
521
+ && (session.activeSandboxId != null || lease?.liveness === "warm" || lease?.liveness === "draining");
522
+ if (terminalUnlocked) {
523
+ terminalStream = await mintTerminalStream({ db, settings, bus }, {
524
+ accountId: grant.accountId,
525
+ workspaceId,
526
+ session,
527
+ viewerId: grant.subjectId,
528
+ ...(lease ? { lease } : {}),
529
+ });
530
+ }
531
+
532
+ const capabilities = negotiateCapabilities({
533
+ sessionId,
534
+ backend: session.sandboxBackend as SandboxBackend,
535
+ os: session.sandboxOs,
536
+ liveness: lease?.liveness ?? "cold",
537
+ leaseEpoch: lease?.leaseEpoch ?? 0,
538
+ desktopEnabled: settings.sandboxDesktopEnabled,
539
+ // Human take-control: when the desktop is available + this policy is on
540
+ // (default), the cell is mode "interactive" — the noVNC viewer drives :0
541
+ // (x11vnc runs without -viewonly). Off → mode "read-only" (client disables
542
+ // take-control). Independent of the agent's computerUseReadOnly.
543
+ desktopInteractive: settings.sandboxDesktopInteractive,
544
+ // P4.3 computer-use: the agent drives :0 (xdotool/scrot); availability
545
+ // tracks the desktop tier + a desktop-capable backend.
546
+ computerUseEnabled: settings.computerUseEnabled,
547
+ computerUseReadOnly: settings.computerUseReadOnly,
548
+ // Graceful degrade (I8/OD-8): if desktop is enabled but no stream-token
549
+ // secret is resolvable, the desktop cell reports transport:null rather
550
+ // than advertising a plane we can never authorize.
551
+ streamTokenSecretAvailable: !streamTokenDegraded(settings),
552
+ desktopAcknowledged: acknowledged,
553
+ shared,
554
+ sharedSessionIds,
555
+ // The minted live address (null when not unlocked/degraded). The resolver
556
+ // only folds it in when the desktop gates pass + the ack is present.
557
+ ...(desktopStream
558
+ ? {
559
+ desktopStream: {
560
+ url: desktopStream.url,
561
+ token: desktopStream.token,
562
+ expiresAt: desktopStream.expiresAt,
563
+ resolution: desktopStream.resolution,
564
+ },
565
+ }
566
+ : {}),
567
+ // P5.t — the terminal policy toggle + the minted pty-ws address. The
568
+ // resolver advertises sse-events (firehose) on a cold/disabled terminal and
569
+ // folds the live pty-ws url/token in only when the gates passed + minted.
570
+ terminalEnabled: settings.sandboxTerminalEnabled,
571
+ ...(terminalStream
572
+ ? {
573
+ terminalStream: {
574
+ url: terminalStream.url,
575
+ token: terminalStream.token,
576
+ expiresAt: terminalStream.expiresAt,
577
+ },
578
+ }
579
+ : {}),
580
+ });
581
+
582
+ // SWAP-CASE desktop transport: the negotiation keyed on the HOME backend, but
583
+ // the desktop/terminal plane actually runs on the ACTIVE sandbox when one is
584
+ // pinned. If that active sandbox is a SELFHOSTED machine, its desktop is the
585
+ // RELAY framebuffer (PNG-per-frame) — the "relay-frames"/"frames" client, NOT
586
+ // the home box's noVNC. Override the cell so the viewer selects the frame
587
+ // renderer (the mint already served the machine's relay url). Machine-PRIMARY
588
+ // sessions (home backend already selfhosted) negotiate relay-frames directly,
589
+ // so the `=== "vnc-ws"` guard skips the extra lookup for them.
590
+ let responseCapabilities = capabilities;
591
+ if (session.activeSandboxId && capabilities.DesktopStream.transport === "vnc-ws") {
592
+ const activeSandbox = await getSandbox(db, workspaceId, session.activeSandboxId);
593
+ if (activeSandbox?.kind === "selfhosted") {
594
+ responseCapabilities = {
595
+ ...capabilities,
596
+ DesktopStream: {
597
+ ...capabilities.DesktopStream,
598
+ transport: "relay-frames",
599
+ client: "frames",
600
+ mode: "read-only",
601
+ },
602
+ };
603
+ }
604
+ }
605
+ return c.json(responseCapabilities);
606
+ });
607
+
608
+ // POST .../stream-capabilities/acknowledge — record the calling principal's
609
+ // acknowledgment of the un-redacted pixel plane (and, when shared, the
610
+ // shared-exposure disclosure). Reuses the acknowledgment machinery — gated on
611
+ // stream:acknowledge, no new permission. Until this is recorded the
612
+ // desktop-stream (viewer attach) path returns 409 (P3.2 consent gate).
613
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities/acknowledge", async (c) => {
614
+ const workspaceId = c.req.param("workspaceId");
615
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:acknowledge");
616
+ assertOwnershipEnabled();
617
+ const sessionId = c.req.param("sessionId");
618
+ const session = await getSession(db, workspaceId, sessionId);
619
+ if (!session) {
620
+ throw new HTTPException(404, { message: "session not found" });
621
+ }
622
+ const parsed = AcknowledgeStreamRequest.safeParse(await c.req.json().catch(() => ({})));
623
+ if (!parsed.success) {
624
+ throw new HTTPException(400, { message: "invalid stream acknowledgment request" });
625
+ }
626
+ const recorded = await recordStreamAcknowledgment(db, {
627
+ accountId: grant.accountId,
628
+ workspaceId,
629
+ sandboxGroupId: session.sandboxGroupId,
630
+ subjectId: grant.subjectId,
631
+ acknowledgeUnredacted: parsed.data.acknowledgeUnredacted,
632
+ acknowledgeShared: parsed.data.acknowledgeShared,
633
+ });
634
+ return c.json({ acknowledged: recorded.acknowledgedUnredacted, acknowledgedShared: recorded.acknowledgedShared });
635
+ });
636
+
637
+ // POST .../viewers — acquire a viewer holder on the desktop-stream (un-redacted
638
+ // pixel) path. Gated on stream:view (strictly broader than sessions:read: the
639
+ // pixel plane is un-redacted). THE CONSENT GATE: until the calling principal
640
+ // has acknowledged the un-redacted plane this returns 409
641
+ // stream_acknowledgment_required; when the box is shared and the shared-exposure
642
+ // disclosure is not acknowledged it returns 409 shared_acknowledgment_required.
643
+ // Only after consent does it acquire the holder (spinning the box up in-process
644
+ // when cold).
645
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers", async (c) => {
646
+ const workspaceId = c.req.param("workspaceId");
647
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
648
+ assertOwnershipEnabled();
649
+ const sessionId = c.req.param("sessionId");
650
+ const session = await getSession(db, workspaceId, sessionId);
651
+ if (!session) {
652
+ throw new HTTPException(404, { message: "session not found" });
653
+ }
654
+ const parsed = AttachViewerRequest.safeParse(await c.req.json().catch(() => ({})));
655
+ if (!parsed.success) {
656
+ throw new HTTPException(400, { message: "invalid viewer attach request" });
657
+ }
658
+ // Consent gate (P3.2 / addendum E.1): ONLY the un-redacted DESKTOP pixel plane
659
+ // requires the calling principal's acknowledgment (recorded per group+subject;
660
+ // a shared box additionally needs the shared-exposure consent). A TERMINAL-ONLY
661
+ // warm attach (`desktop:false`, the default) carries NO consent gate — a shell
662
+ // is interactive by nature and the gate is the scoped tunnel URL + stream token
663
+ // — so it warms the box and mints the pty-ws terminal cell without a 409. Gating
664
+ // the terminal attach behind the desktop ack (the bug this fixes) dead-ended the
665
+ // interactive terminal: the box never warmed → the Terminal cell stayed on the
666
+ // read-only sse-events firehose forever ("read only"), and with the desktop tier
667
+ // off by default there was no consent flow to ever clear the gate.
668
+ const wantDesktop = parsed.data.desktop ?? false;
669
+ const { shared } = await resolveSharedExposure(workspaceId, session);
670
+ if (wantDesktop) {
671
+ const ack = await getStreamAcknowledgment(db, { workspaceId, sandboxGroupId: session.sandboxGroupId, subjectId: grant.subjectId });
672
+ if (!ack?.acknowledgedUnredacted) {
673
+ throw new HTTPException(409, { message: "stream_acknowledgment_required" });
674
+ }
675
+ if (shared && !ack.acknowledgedShared) {
676
+ throw new HTTPException(409, { message: "shared_acknowledgment_required" });
677
+ }
678
+ }
679
+ // SELFHOSTED ACTIVE: when the session's active sandbox is selfhosted, skip
680
+ // attachViewer (it warms the Modal group box — the wrong target). Synthesize a
681
+ // result shaped like ViewerAttachResult and mint relay cells directly.
682
+ const activeSandbox = session.activeSandboxId ? await getSandbox(db, workspaceId, session.activeSandboxId) : null;
683
+ const selfhostedActive = activeSandbox?.kind === "selfhosted";
684
+
685
+ let stream: DesktopStreamMint | null = null;
686
+ let terminal: TerminalStreamMint | null = null;
687
+
688
+ let result: Awaited<ReturnType<typeof attachViewer>>;
689
+ if (selfhostedActive) {
690
+ const viewerId = parsed.data.viewerId ?? crypto.randomUUID();
691
+ result = {
692
+ viewerId,
693
+ liveness: "warm",
694
+ leaseEpoch: session.activeEpoch,
695
+ sandboxGroupId: session.sandboxGroupId,
696
+ viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
697
+ dataPlaneUrl: null,
698
+ };
699
+ if (
700
+ (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled)
701
+ && !streamTokenDegraded(settings)
702
+ ) {
703
+ if (wantDesktop && settings.sandboxDesktopEnabled) {
704
+ stream = await mintDesktopStream({ db, settings, bus }, {
705
+ accountId: grant.accountId,
706
+ workspaceId,
707
+ session,
708
+ viewerId,
709
+ // No Modal lease for selfhosted-active; the mint routes to the relay.
710
+ });
711
+ }
712
+ if (settings.sandboxTerminalEnabled) {
713
+ terminal = await mintTerminalStream({ db, settings, bus }, {
714
+ accountId: grant.accountId,
715
+ workspaceId,
716
+ session,
717
+ viewerId,
718
+ // No Modal lease for selfhosted-active; the mint routes to the relay.
719
+ });
720
+ }
721
+ }
722
+ } else {
723
+ result = await attachViewer({ db, settings }, {
724
+ accountId: grant.accountId,
725
+ workspaceId,
726
+ session,
727
+ ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
728
+ });
729
+
730
+ // P4.2 — the viewer now holds a WARM box; mint the real pixel cell IN-PROCESS
731
+ // (resume by id → ensureDisplayStack → exposeStreamPort) scoped to THIS
732
+ // viewer holder, record data_plane_url, and fold the live address into the
733
+ // response. A degraded mint (no secret / headless / display-stack or tunnel
734
+ // failure) leaves dataPlaneUrl null — the client falls back to Channel-A. The
735
+ // box is warm here (attachViewer spun it up or attached), so the handshake's
736
+ // never-spin-up rule does not apply.
737
+ if (
738
+ (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled)
739
+ && !streamTokenDegraded(settings)
740
+ ) {
741
+ const lease = await readGroupLease({ db, settings }, { workspaceId, sandboxGroupId: session.sandboxGroupId });
742
+ if (lease) {
743
+ // The pixel cell is minted only when the caller asked for the desktop plane
744
+ // (and consented above). A terminal-only attach skips it — the box is warm,
745
+ // the terminal mint below still runs.
746
+ if (wantDesktop && settings.sandboxDesktopEnabled) {
747
+ stream = await mintDesktopStream({ db, settings, bus }, {
748
+ accountId: grant.accountId,
749
+ workspaceId,
750
+ session,
751
+ viewerId: result.viewerId,
752
+ lease,
753
+ });
754
+ }
755
+ // P5.t — the same warm-box viewer attach also mints the REAL PTY terminal
756
+ // address (independent of the desktop toggle). A degraded mint leaves the
757
+ // terminal fields null → the client falls back to the sse-events firehose.
758
+ if (settings.sandboxTerminalEnabled) {
759
+ terminal = await mintTerminalStream({ db, settings, bus }, {
760
+ accountId: grant.accountId,
761
+ workspaceId,
762
+ session,
763
+ viewerId: result.viewerId,
764
+ lease,
765
+ });
766
+ }
767
+ }
768
+ }
769
+ }
770
+ return c.json(
771
+ {
772
+ ...result,
773
+ dataPlaneUrl: stream?.url ?? result.dataPlaneUrl,
774
+ streamToken: stream?.token ?? null,
775
+ streamExpiresAt: stream?.expiresAt ?? null,
776
+ resolution: stream?.resolution ?? null,
777
+ transport: stream ? ("vnc-ws" as const) : null,
778
+ client: stream ? ("novnc" as const) : null,
779
+ // The REAL PTY terminal address (pty-ws), null when degraded.
780
+ terminalUrl: terminal?.url ?? null,
781
+ terminalToken: terminal?.token ?? null,
782
+ terminalExpiresAt: terminal?.expiresAt ?? null,
783
+ terminalTransport: terminal ? ("pty-ws" as const) : null,
784
+ },
785
+ 201,
786
+ );
787
+ });
788
+
789
+ // POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
790
+ // The desktop-stream lifecycle is gated on stream:view (the un-redacted plane).
791
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat", async (c) => {
792
+ const workspaceId = c.req.param("workspaceId");
793
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
794
+ assertOwnershipEnabled();
795
+ const sessionId = c.req.param("sessionId");
796
+ const session = await getSession(db, workspaceId, sessionId);
797
+ if (!session) {
798
+ throw new HTTPException(404, { message: "session not found" });
799
+ }
800
+ const parsed = ViewerHeartbeatRequest.safeParse(await c.req.json().catch(() => ({})));
801
+ if (!parsed.success) {
802
+ throw new HTTPException(400, { message: "viewer heartbeat requires { leaseEpoch }" });
803
+ }
804
+ const alive = await heartbeatViewer({ db, settings }, {
805
+ accountId: grant.accountId,
806
+ workspaceId,
807
+ sandboxGroupId: session.sandboxGroupId,
808
+ viewerId: c.req.param("viewerId"),
809
+ expectedEpoch: parsed.data.leaseEpoch,
810
+ });
811
+ return c.json({ alive });
812
+ });
813
+
814
+ // DELETE .../viewers/:viewerId — release the holder (idempotent).
815
+ app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
816
+ const workspaceId = c.req.param("workspaceId");
817
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
818
+ assertOwnershipEnabled();
819
+ const sessionId = c.req.param("sessionId");
820
+ const session = await getSession(db, workspaceId, sessionId);
821
+ if (!session) {
822
+ throw new HTTPException(404, { message: "session not found" });
823
+ }
824
+ await detachViewer({ db, settings }, {
825
+ accountId: grant.accountId,
826
+ workspaceId,
827
+ sandboxGroupId: session.sandboxGroupId,
828
+ viewerId: c.req.param("viewerId"),
829
+ });
830
+ return c.body(null, 204);
831
+ });
832
+
833
+ // POST .../viewers/:viewerId/revoke — OD-6 v1 revocation. Drops the named
834
+ // viewer's holder from the GROUP lease so refcount recomputes; the box drains
835
+ // iff nothing else holds it (a turn-held or other-viewer-held box survives —
836
+ // group-refcount liveness). Gated on stream:view (no new permission). The
837
+ // live-RFB force-disconnect of an already-open socket is a P4 follow-up; the
838
+ // holder-drop (so the box can drain) is the v1 deliverable.
839
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/revoke", async (c) => {
840
+ const workspaceId = c.req.param("workspaceId");
841
+ const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
842
+ assertOwnershipEnabled();
843
+ const sessionId = c.req.param("sessionId");
844
+ const session = await getSession(db, workspaceId, sessionId);
845
+ if (!session) {
846
+ throw new HTTPException(404, { message: "session not found" });
847
+ }
848
+ const result = await revokeViewer(db, {
849
+ accountId: grant.accountId,
850
+ workspaceId,
851
+ sandboxGroupId: session.sandboxGroupId,
852
+ viewerId: c.req.param("viewerId"),
853
+ idleGraceMs: settings.sandboxIdleGraceMs,
854
+ });
855
+ // null ⇒ the lease was already cold-and-reaped (revoke is an idempotent no-op).
856
+ return c.json({ liveness: result?.liveness ?? null, refcount: result?.refcount ?? null });
857
+ });
858
+
859
+ // ══════════════════════ Channel-A structured services (P4.4) ══════════════
860
+ //
861
+ // FileSystem (list/read/write/delete) + Git (status/diff/log/show) + Terminal
862
+ // (exec + interactive PTY), all served API-DIRECT: each route does
863
+ // requireAccessGrant BEFORE Zod parse -> resume the box by id in-process
864
+ // (cold->warming CAS + viewer holder) -> SandboxChannelAService method
865
+ // -> inline JSON -> release holder + drop handle.
866
+ // NO Temporal, NO worker RPC, NO NATS round-trip — reads never ride the bus
867
+ // (which would corrupt SSE gap-fill). The notifications (fs.changed/git.changed
868
+ // /terminal.pty.*) ride A1 via appendAndPublishEvents. Gated behind
869
+ // sandboxOwnershipEnabled (the lease is dormant otherwise). Explicit
870
+ // HTTPException(400/404/409) — never a raw ZodError -> 500.
871
+
872
+ // FS uses files:read for reads, files:write for mutations; Git is read-only
873
+ // (rides files:read); Terminal exec + PTY ride terminal:attach.
874
+
875
+ type ChannelARouteCtx = {
876
+ accountId: string;
877
+ workspaceId: string;
878
+ session: Session;
879
+ subjectId: string;
880
+ };
881
+
882
+ // Shared preamble: grant BEFORE parse, ownership gate, session lookup. Returns
883
+ // the resolved context the channel-a seam needs (session narrowed non-null).
884
+ async function channelAPreamble(
885
+ c: Context,
886
+ permission: "files:read" | "files:write" | "terminal:attach",
887
+ ): Promise<ChannelARouteCtx> {
888
+ const workspaceId = c.req.param("workspaceId") ?? "";
889
+ const grant = await requireAccessGrant(c, deps, workspaceId, permission);
890
+ assertOwnershipEnabled();
891
+ const sessionId = c.req.param("sessionId") ?? "";
892
+ const session = await getSession(db, workspaceId, sessionId);
893
+ if (!session) {
894
+ throw new HTTPException(404, { message: "session not found" });
895
+ }
896
+ return { accountId: grant.accountId, workspaceId, session, subjectId: grant.subjectId };
897
+ }
898
+
899
+ async function parseChannelABody<T>(c: Context, schema: { safeParse: (v: unknown) => { success: true; data: T } | { success: false } }): Promise<T> {
900
+ const raw = await c.req.json().catch(() => undefined);
901
+ const result = schema.safeParse(raw ?? {});
902
+ if (!result.success) {
903
+ throw new HTTPException(400, { message: "invalid request body" });
904
+ }
905
+ return result.data;
906
+ }
907
+
908
+ // ── FileSystem ──────────────────────────────────────────────────────────
909
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
910
+ const ctx = await channelAPreamble(c, "files:read");
911
+ const req = await parseChannelABody(c, FsListRequest);
912
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsList(req));
913
+ return c.json(out);
914
+ });
915
+
916
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
917
+ const ctx = await channelAPreamble(c, "files:read");
918
+ const req = await parseChannelABody(c, FsReadRequest);
919
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsRead(req));
920
+ return c.json(out);
921
+ });
922
+
923
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
924
+ const ctx = await channelAPreamble(c, "files:write");
925
+ const req = await parseChannelABody(c, FsWriteRequest);
926
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsWrite(req));
927
+ return c.json(out);
928
+ });
929
+
930
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
931
+ const ctx = await channelAPreamble(c, "files:write");
932
+ const req = await parseChannelABody(c, FsDeleteRequest);
933
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsDelete(req));
934
+ return c.json(out);
935
+ });
936
+
937
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
938
+ const ctx = await channelAPreamble(c, "files:write");
939
+ const req = await parseChannelABody(c, FsMoveRequest);
940
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsMove(req));
941
+ return c.json(out);
942
+ });
943
+
944
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
945
+ const ctx = await channelAPreamble(c, "files:write");
946
+ const req = await parseChannelABody(c, FsMkdirRequest);
947
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.fsMkdir(req));
948
+ return c.json(out);
949
+ });
950
+
951
+ // ── Git (read-only) ─────────────────────────────────────────────────────
952
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
953
+ const ctx = await channelAPreamble(c, "files:read");
954
+ const req = await parseChannelABody(c, GitStatusRequest);
955
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitStatus(req));
956
+ return c.json(out);
957
+ });
958
+
959
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
960
+ const ctx = await channelAPreamble(c, "files:read");
961
+ const req = await parseChannelABody(c, GitDiffRequest);
962
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitDiff(req));
963
+ return c.json(out);
964
+ });
965
+
966
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
967
+ const ctx = await channelAPreamble(c, "files:read");
968
+ const req = await parseChannelABody(c, GitLogRequest);
969
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitLog(req));
970
+ return c.json(out);
971
+ });
972
+
973
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
974
+ const ctx = await channelAPreamble(c, "files:read");
975
+ const req = await parseChannelABody(c, GitShowRequest);
976
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.gitShow(req));
977
+ return c.json(out);
978
+ });
979
+
980
+ // ── Terminal: synchronous exec ────────────────────────────────────────────
981
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
982
+ const ctx = await channelAPreamble(c, "terminal:attach");
983
+ const req = await parseChannelABody(c, TerminalExecRequest);
984
+ const out = await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.terminalExec(req));
985
+ return c.json(out);
986
+ });
987
+
988
+ // ── Terminal: interactive PTY control (output rides A1) ───────────────────
989
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
990
+ const ctx = await channelAPreamble(c, "terminal:attach");
991
+ const req = await parseChannelABody(c, PtyOpenRequest);
992
+ const ptyId = crypto.randomUUID();
993
+ const out = await withChannelA({ db, settings, bus }, ctx, async ({ service, lease }) => {
994
+ const opened = await service.ptyOpen(req, ptyId);
995
+ // Persist the ptyId<->exec-session map fenced to the box's epoch.
996
+ await insertPtySession(db, {
997
+ id: ptyId,
998
+ accountId: ctx.accountId,
999
+ workspaceId: ctx.workspaceId,
1000
+ sessionId: ctx.session.id,
1001
+ execSessionId: opened.execSessionId,
1002
+ leaseEpoch: lease.leaseEpoch,
1003
+ cols: req.cols,
1004
+ rows: req.rows,
1005
+ shell: opened.shell,
1006
+ cwd: req.cwd,
1007
+ openedBy: ctx.subjectId,
1008
+ });
1009
+ // Emit terminal.pty.started + any initial banner output on A1.
1010
+ const started: TerminalPtyStartedPayload = { ptyId, cols: req.cols, rows: req.rows, shell: opened.shell, cwd: req.cwd };
1011
+ const events: AppendEventInput[] = [{ type: "terminal.pty.started", payload: started }];
1012
+ if (opened.initialOutput) {
1013
+ const delta: TerminalPtyOutputDeltaPayload = { ptyId, stream: "stdout", chunk: opened.initialOutput, seq: 0 };
1014
+ events.push({ type: "terminal.pty.output.delta", payload: delta });
1015
+ }
1016
+ await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, events);
1017
+ return opened.response;
1018
+ });
1019
+ return c.json(out, 201);
1020
+ });
1021
+
1022
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
1023
+ const ctx = await channelAPreamble(c, "terminal:attach");
1024
+ const req = await parseChannelABody(c, PtyWriteRequest);
1025
+ const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
1026
+ if (!pty) {
1027
+ throw new HTTPException(404, { message: "pty not found or closed" });
1028
+ }
1029
+ if (pty.execSessionId === null) {
1030
+ throw new HTTPException(409, { message: "interactive terminal unsupported on this backend" });
1031
+ }
1032
+ let seq = 1;
1033
+ await withChannelA({ db, settings, bus }, ctx, async ({ service }) => {
1034
+ const output = await service.ptyWrite(req, pty.execSessionId!, req.data);
1035
+ await updatePtySessionActivity(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId, execSessionId: pty.execSessionId });
1036
+ if (output) {
1037
+ const delta: TerminalPtyOutputDeltaPayload = { ptyId: req.ptyId, stream: "stdout", chunk: output, seq: seq++ };
1038
+ await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [{ type: "terminal.pty.output.delta", payload: delta }]);
1039
+ }
1040
+ });
1041
+ return c.body(null, 204);
1042
+ });
1043
+
1044
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
1045
+ const ctx = await channelAPreamble(c, "terminal:attach");
1046
+ const req = await parseChannelABody(c, PtyResizeRequest);
1047
+ const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
1048
+ if (!pty) {
1049
+ throw new HTTPException(404, { message: "pty not found or closed" });
1050
+ }
1051
+ if (pty.execSessionId !== null) {
1052
+ await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.ptyResize(req, pty.execSessionId!));
1053
+ }
1054
+ await updatePtySessionActivity(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId, cols: req.cols, rows: req.rows });
1055
+ return c.body(null, 204);
1056
+ });
1057
+
1058
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
1059
+ const ctx = await channelAPreamble(c, "terminal:attach");
1060
+ const req = await parseChannelABody(c, PtyCloseRequest);
1061
+ const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
1062
+ // Idempotent: closing an already-closed/absent PTY is a 204 no-op.
1063
+ if (pty) {
1064
+ await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.ptyClose(req, pty.execSessionId));
1065
+ await closePtySession(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId });
1066
+ const exited: TerminalPtyExitedPayload = { ptyId: req.ptyId, exitCode: 0, reason: "exit" };
1067
+ await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [{ type: "terminal.pty.exited", payload: exited }]);
1068
+ }
1069
+ return c.body(null, 204);
1070
+ });
1071
+ }
1072
+
1073
+ function hasOwnProperty(value: unknown, key: string): boolean {
1074
+ return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
1075
+ }
1076
+
1077
+ function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean {
1078
+ if (!value || typeof value !== "object") {
1079
+ return false;
1080
+ }
1081
+ const payload = (value as { payload?: unknown }).payload;
1082
+ return hasOwnProperty(payload, key);
1083
+ }