@opengeni/api-router 0.5.7 → 0.9.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.
@@ -12,6 +12,7 @@ import {
12
12
  FsMoveRequest,
13
13
  FsReadRequest,
14
14
  FsWriteRequest,
15
+ HumanInputRequestStatus,
15
16
  GitDiffRequest,
16
17
  GitLogRequest,
17
18
  GitShowRequest,
@@ -22,16 +23,33 @@ import {
22
23
  PtyResizeRequest,
23
24
  PtyWriteRequest,
24
25
  SessionControlRequest,
26
+ SESSION_EVENT_RAW_DELTA_TYPES,
27
+ SessionEventPayloadMode,
28
+ SessionEventReadDirection,
29
+ SessionEventReadMode,
30
+ SessionEventLatestClass,
31
+ SessionEventResultMode,
32
+ SessionEventSemanticClass,
33
+ SessionEventType,
34
+ SessionMcpServerId,
35
+ compactSessionEventResult,
36
+ sessionEventLatestClassToSemanticClass,
25
37
  SaveComposerDraftRequest,
26
38
  SteerSessionQueueItemRequest,
27
39
  SteerSessionMessageRequest,
28
40
  TerminalExecRequest,
29
41
  UpdateSessionPinRequest,
30
42
  UpdateSessionGoalRequest,
43
+ UpdateSessionMcpApprovalPolicyRequest,
31
44
  UpdateSessionRequest,
32
45
  ViewerHeartbeatRequest,
46
+ WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
47
+ workspaceControlUtf8Bytes,
33
48
  type SandboxBackend,
49
+ type LineageNode,
34
50
  type Session,
51
+ type SessionAuthorizationOperation,
52
+ type SessionQueueSnapshot,
35
53
  type TerminalPtyExitedPayload,
36
54
  type TerminalPtyOutputDeltaPayload,
37
55
  type TerminalPtyStartedPayload,
@@ -39,6 +57,7 @@ import {
39
57
  import { streamTokenDegraded } from "@opengeni/config";
40
58
  import {
41
59
  acceptSessionApprovalDecision,
60
+ acceptSessionHumanInputResponse,
42
61
  clearSessionGoal,
43
62
  clearSessionContext,
44
63
  closePtySession,
@@ -47,13 +66,17 @@ import {
47
66
  getSession,
48
67
  getSessionForSubject,
49
68
  getSessionGoal,
69
+ getSessionHumanInputRequest,
50
70
  getSessionQueueSnapshot,
51
71
  getStreamAcknowledgment,
52
72
  insertPtySession,
53
- listSessionEvents,
73
+ listSessionEventPage,
74
+ listSessionHumanInputRequests,
54
75
  listSessionIdsInGroup,
55
76
  listSessionsForSubject,
56
77
  listSessionTurns,
78
+ projectEffectiveControlForRelatedAccess,
79
+ projectSessionForRelatedAccess,
57
80
  recordStreamAcknowledgment,
58
81
  requestSessionCompaction,
59
82
  setSessionCodexPin,
@@ -71,21 +94,31 @@ import {
71
94
  SessionCommandIdempotencyError,
72
95
  SessionControlConflictError,
73
96
  SessionContextBusyError,
97
+ HumanInputResponseValidationError,
74
98
  latestWorkspaceCapture,
75
99
  workspaceCaptureAtRevision,
76
100
  type AppendEventInput,
77
101
  } from "@opengeni/db";
78
102
  import {
79
103
  appendAndPublishEvents,
104
+ boundSessionEventHttpPage,
80
105
  coalesceSessionEventDeltas,
81
106
  publishDurableSessionEvents,
82
107
  } from "@opengeni/events";
83
108
  import { z } from "zod";
84
109
  import { withChannelA } from "../sandbox/channel-a";
85
110
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
86
- import type { Context, Hono } from "hono";
111
+ import type { Context, Hono, MiddlewareHandler } from "hono";
87
112
  import { HTTPException } from "hono/http-exception";
88
- import { requireAccessGrant } from "@opengeni/core";
113
+ import {
114
+ requireAccessGrant,
115
+ requireSessionAuthorization,
116
+ requireSessionAuthorizationListScope,
117
+ SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
118
+ SessionAuthorizationDeniedError,
119
+ SessionAuthorizationUnavailableError,
120
+ type ResolvedSessionAuthorization,
121
+ } from "@opengeni/core";
89
122
  import type { ApiRouteDeps } from "@opengeni/core";
90
123
  import {
91
124
  attachViewer,
@@ -110,8 +143,12 @@ import {
110
143
  readSessionLineage,
111
144
  saveHumanComposerDraft,
112
145
  steerHumanQueuePrompt,
146
+ updateSessionMcpApprovalPolicy,
113
147
  updateSessionTitle,
114
148
  workflowIdForSession,
149
+ sessionWithEffectiveToolPolicy,
150
+ workspaceSessionToolPolicyDefaultServerIds,
151
+ workspaceSessionToolPolicyServerIds,
115
152
  } from "@opengeni/core";
116
153
  import { assertSessionExists, boundedLimit } from "../http/common";
117
154
  import { sseSessionStream } from "../http/sse";
@@ -119,17 +156,77 @@ import { serveWorkspaceCapture, serveWorkspaceCaptureFile } from "./workspace-ca
119
156
 
120
157
  export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
121
158
  const { settings, db, bus, workflowClient, objectStorage } = deps;
159
+ const requestSessionAuthorization = new WeakMap<Request, ResolvedSessionAuthorization>();
160
+ const relatedSessionAccessFor = (c: Context): "target" | "root" =>
161
+ requestSessionAuthorization.get(c.req.raw)?.relatedSessionAccess ?? "root";
162
+ const projectQueueSnapshot = (
163
+ snapshot: SessionQueueSnapshot,
164
+ sessionId: string,
165
+ access: "target" | "root",
166
+ ): SessionQueueSnapshot => ({
167
+ ...snapshot,
168
+ effectiveControl: projectEffectiveControlForRelatedAccess(
169
+ snapshot.effectiveControl,
170
+ sessionId,
171
+ access,
172
+ ),
173
+ });
174
+
175
+ // A host-bound deployment has one fail-closed authorization seam for every
176
+ // HTTP session surface. Register it before the routes so a newly added path
177
+ // cannot accidentally inherit workspace access without an explicit operation
178
+ // classification. The long-lived event stream performs its own initial check
179
+ // and bounded reauthorization below.
180
+ const authorizeSessionHttp: MiddlewareHandler = async (c, next) => {
181
+ if (!deps.sessionAuthorization) {
182
+ await next();
183
+ return;
184
+ }
185
+ const workspaceId = c.req.param("workspaceId") ?? "";
186
+ const sessionId = c.req.param("sessionId") ?? "";
187
+ const operation = sessionAuthorizationOperationForHttp(
188
+ c.req.method,
189
+ new URL(c.req.url).pathname,
190
+ sessionId,
191
+ );
192
+ if (operation === "session.stream.read") {
193
+ await next();
194
+ return;
195
+ }
196
+ if (!operation) {
197
+ throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
198
+ }
199
+ const grant = await requireAccessGrant(c, deps, workspaceId);
200
+ try {
201
+ const authorization = await requireSessionAuthorization(deps, grant, {
202
+ sessionId,
203
+ operation,
204
+ surface: "http",
205
+ });
206
+ if (authorization) requestSessionAuthorization.set(c.req.raw, authorization);
207
+ } catch (error) {
208
+ throw sessionAuthorizationHttpError(error);
209
+ }
210
+ await next();
211
+ };
212
+ app.use("/v1/workspaces/:workspaceId/sessions/:sessionId/*", authorizeSessionHttp);
122
213
 
123
214
  app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
124
215
  const workspaceId = c.req.param("workspaceId");
125
216
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
126
217
  const session = await createSessionForRequest(deps, grant, workspaceId, await c.req.json());
127
- return c.json(session, 202);
218
+ return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
128
219
  });
129
220
 
130
221
  app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
131
222
  const workspaceId = c.req.param("workspaceId");
132
223
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
224
+ let authorizationScope;
225
+ try {
226
+ authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
227
+ } catch (error) {
228
+ throw sessionAuthorizationHttpError(error);
229
+ }
133
230
  const pageView = c.req.query("view") === "page";
134
231
  const query = sessionListQuery(c.req.query(), pageView);
135
232
  let page: Awaited<ReturnType<typeof listSessionsForSubject>>;
@@ -140,6 +237,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
140
237
  ...(query.cursor ? { cursor: query.cursor } : {}),
141
238
  ...(query.search ? { search: query.search } : {}),
142
239
  ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
240
+ ...(authorizationScope ? { authorizationScope } : {}),
143
241
  });
144
242
  } catch (error) {
145
243
  if (error instanceof SessionListAccessError) {
@@ -150,15 +248,30 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
150
248
  }
151
249
  throw error;
152
250
  }
251
+ // The page body carries this fact directly. Preserve the historical array
252
+ // body for older clients while still making its older-pin omission visible
253
+ // to raw HTTP consumers without changing that response shape.
254
+ c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
255
+ const policy = await loadEffectivePolicyContext(deps, workspaceId);
256
+ const decorate = (session: Session): Session =>
257
+ sessionWithEffectiveToolPolicy(
258
+ session,
259
+ policy.workspaceServerIds,
260
+ policy.workspaceDefaultServerIds,
261
+ );
153
262
  if (pageView) {
154
- return c.json(page);
263
+ return c.json({
264
+ ...page,
265
+ pinned: page.pinned.map(decorate),
266
+ sessions: page.sessions.map(decorate),
267
+ });
155
268
  }
156
269
  // Same-major compatibility: listSessions() has historically returned an
157
270
  // array. Preserve that wire shape while adding personal pin metadata/order;
158
271
  // cursor consumers opt into the additive page view. A query flag rather
159
272
  // than a /sessions/page path is deliberate: an older API safely ignores it
160
273
  // and returns its historical array instead of treating "page" as a UUID.
161
- return c.json([...page.pinned, ...page.sessions]);
274
+ return c.json([...page.pinned, ...page.sessions].map(decorate));
162
275
  });
163
276
 
164
277
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
@@ -168,11 +281,17 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
168
281
  if (!z.string().uuid().safeParse(sessionId).success) {
169
282
  throw new HTTPException(404, { message: "session not found" });
170
283
  }
171
- const session = await getSessionForSubject(db, workspaceId, sessionId, grant.subjectId);
284
+ const session = await getSessionForSubject(
285
+ db,
286
+ workspaceId,
287
+ sessionId,
288
+ grant.subjectId,
289
+ relatedSessionAccessFor(c),
290
+ );
172
291
  if (!session) {
173
292
  throw new HTTPException(404, { message: "session not found" });
174
293
  }
175
- return c.json(session);
294
+ return c.json(await withEffectivePolicy(deps, workspaceId, session));
176
295
  });
177
296
 
178
297
  // Personal pin only: this is organization state for the authenticated member,
@@ -199,14 +318,23 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
199
318
  if (!session) {
200
319
  throw new HTTPException(404, { message: "session not found" });
201
320
  }
202
- return c.json(session);
321
+ return c.json(
322
+ await withEffectivePolicy(
323
+ deps,
324
+ workspaceId,
325
+ projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
326
+ ),
327
+ );
203
328
  } catch (error) {
204
329
  if (error instanceof SessionPinAccessError) {
205
330
  throw new HTTPException(403, { message: error.message });
206
331
  }
207
332
  if (error instanceof SessionPinVersionConflictError) {
208
333
  return c.json(
209
- { message: "session pin changed in another client", current: error.current },
334
+ {
335
+ message: "session pin changed in another client",
336
+ current: error.current,
337
+ },
210
338
  409,
211
339
  );
212
340
  }
@@ -216,8 +344,20 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
216
344
 
217
345
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
218
346
  const workspaceId = c.req.param("workspaceId");
219
- await requireAccessGrant(c, deps, workspaceId, "sessions:read");
220
- return c.json(await readSessionLineage(db, workspaceId, c.req.param("sessionId")));
347
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
348
+ const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
349
+ const policy = await loadEffectivePolicyContext(deps, workspaceId);
350
+ return c.json({
351
+ ...lineage,
352
+ ancestors: lineage.ancestors.map((session) =>
353
+ sessionWithEffectiveToolPolicy(
354
+ session,
355
+ policy.workspaceServerIds,
356
+ policy.workspaceDefaultServerIds,
357
+ ),
358
+ ),
359
+ children: mapLineageNodes(lineage.children, policy),
360
+ });
221
361
  });
222
362
 
223
363
  // Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
@@ -232,7 +372,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
232
372
  const body = (await c.req.json()) as { target?: string };
233
373
  const target = typeof body.target === "string" ? body.target : "";
234
374
  if (!target) {
235
- throw new HTTPException(400, { message: 'target is required ("auto" or an account id)' });
375
+ throw new HTTPException(400, {
376
+ message: 'target is required ("auto" or an account id)',
377
+ });
236
378
  }
237
379
  const pinned = target === "auto" ? null : target;
238
380
  const mutation = await withCodexCapacityMutation(
@@ -245,7 +387,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
245
387
  );
246
388
  const ok = mutation.result;
247
389
  if (!ok) {
248
- throw new HTTPException(404, { message: "session or codex account not found" });
390
+ throw new HTTPException(404, {
391
+ message: "session or codex account not found",
392
+ });
249
393
  }
250
394
  await Promise.allSettled(
251
395
  mutation.wakeTargets.map((wake) =>
@@ -279,17 +423,49 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
279
423
  const sessionId = c.req.param("sessionId");
280
424
  await assertSessionExists(db, workspaceId, sessionId);
281
425
  const payload = UpdateSessionRequest.parse(await c.req.json());
282
- await updateSessionTitle({ db, bus }, workspaceId, sessionId, payload.title, "user");
426
+ const titleUpdate = await updateSessionTitle(deps, grant, sessionId, payload.title, "user");
283
427
  // A session-returning member route must preserve the caller's private pin
284
428
  // projection. Returning the generic mapSession() default here would reset a
285
429
  // pinned React consumer to false/version 0 after a harmless rename.
286
- const session = await getSessionForSubject(db, workspaceId, sessionId, grant.subjectId);
430
+ const session = await getSessionForSubject(
431
+ db,
432
+ workspaceId,
433
+ sessionId,
434
+ grant.subjectId,
435
+ titleUpdate.relatedSessionAccess,
436
+ );
287
437
  if (!session) {
288
438
  throw new HTTPException(404, { message: "session not found" });
289
439
  }
290
- return c.json(session);
440
+ return c.json(await withEffectivePolicy(deps, workspaceId, session));
291
441
  });
292
442
 
443
+ app.patch(
444
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/mcp-servers/:serverId/approval-policy",
445
+ async (c) => {
446
+ const workspaceId = c.req.param("workspaceId");
447
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
448
+ const sessionId = c.req.param("sessionId");
449
+ const parsedServerId = SessionMcpServerId.safeParse(c.req.param("serverId"));
450
+ const payload = UpdateSessionMcpApprovalPolicyRequest.safeParse(
451
+ await c.req.json().catch(() => null),
452
+ );
453
+ if (!parsedServerId.success || !payload.success) {
454
+ throw new HTTPException(400, { message: "invalid MCP approval-policy request" });
455
+ }
456
+ await assertSessionExists(db, workspaceId, sessionId);
457
+ return c.json(
458
+ await updateSessionMcpApprovalPolicy(
459
+ deps,
460
+ grant,
461
+ sessionId,
462
+ parsedServerId.data,
463
+ payload.data.requireApproval,
464
+ ),
465
+ );
466
+ },
467
+ );
468
+
293
469
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
294
470
  const workspaceId = c.req.param("workspaceId");
295
471
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -464,7 +640,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
464
640
  workflowId: requested.temporalWorkflowId,
465
641
  wakeRevision: requested.wakeRevision,
466
642
  });
467
- return c.json({ status: "pending", message: "Compaction will run at the next safe boundary." });
643
+ return c.json({
644
+ status: "pending",
645
+ message: "Compaction will run at the next safe boundary.",
646
+ });
468
647
  });
469
648
 
470
649
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
@@ -472,22 +651,171 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
472
651
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
473
652
  const sessionId = c.req.param("sessionId");
474
653
  await assertSessionExists(db, workspaceId, sessionId);
475
- const after = eventSequence(c.req.query("after"), 0);
476
- const before = optionalEventSequence(c.req.query("before"));
654
+ const rawAfter = c.req.query("after");
655
+ const rawBefore = c.req.query("before");
656
+ const after = eventSequence(rawAfter, 0);
657
+ const before = optionalEventSequence(rawBefore);
477
658
  const compact = compactEvents(c.req.query("compact"));
478
- const limit = eventListLimit(c.req.query("limit"), compact ? 5000 : 2000);
479
- const events = await listSessionEvents(db, workspaceId, sessionId, {
659
+ const explicitReplay = rawAfter !== undefined || rawBefore !== undefined || compact;
660
+ const mode = eventEnumValue(
661
+ c.req.query("mode"),
662
+ SessionEventReadMode,
663
+ "mode",
664
+ explicitReplay ? "forensic" : "monitoring",
665
+ );
666
+ const latestRequested = eventEnumValue(
667
+ c.req.query("latest"),
668
+ SessionEventLatestClass,
669
+ "latest",
670
+ undefined,
671
+ );
672
+ const latestClass =
673
+ latestRequested === undefined
674
+ ? undefined
675
+ : sessionEventLatestClassToSemanticClass(latestRequested);
676
+ const resultMode = eventEnumValue(
677
+ c.req.query("resultMode") ?? c.req.query("result"),
678
+ SessionEventResultMode,
679
+ "resultMode",
680
+ "events",
681
+ );
682
+ if (resultMode === "compact" && latestClass === undefined) {
683
+ throw new HTTPException(400, {
684
+ message: "resultMode=compact requires latest",
685
+ });
686
+ }
687
+ if (
688
+ latestClass &&
689
+ ["includeTypes", "excludeTypes", "includeClasses", "excludeClasses"].some(
690
+ (name) => c.req.query(name) !== undefined,
691
+ )
692
+ ) {
693
+ throw new HTTPException(400, {
694
+ message: "latest cannot be combined with event filters",
695
+ });
696
+ }
697
+ const direction = latestClass
698
+ ? "before"
699
+ : eventEnumValue(
700
+ c.req.query("direction"),
701
+ SessionEventReadDirection,
702
+ "direction",
703
+ before !== undefined
704
+ ? "before"
705
+ : rawAfter !== undefined
706
+ ? "after"
707
+ : mode === "monitoring"
708
+ ? "before"
709
+ : "after",
710
+ );
711
+ const payloadMode = eventEnumValue(
712
+ c.req.query("payloadMode"),
713
+ SessionEventPayloadMode,
714
+ "payloadMode",
715
+ mode === "monitoring" ? "summary" : "full",
716
+ );
717
+ const includeTypes = eventEnumList(
718
+ c.req.query("includeTypes"),
719
+ SessionEventType,
720
+ "includeTypes",
721
+ );
722
+ const excludeTypes = eventEnumList(
723
+ c.req.query("excludeTypes"),
724
+ SessionEventType,
725
+ "excludeTypes",
726
+ );
727
+ const includeClasses = eventEnumList(
728
+ c.req.query("includeClasses"),
729
+ SessionEventSemanticClass,
730
+ "includeClasses",
731
+ );
732
+ const excludeClasses = eventEnumList(
733
+ c.req.query("excludeClasses"),
734
+ SessionEventSemanticClass,
735
+ "excludeClasses",
736
+ );
737
+ const limit = latestClass
738
+ ? 1
739
+ : eventListLimit(
740
+ c.req.query("limit"),
741
+ compact ? 5000 : mode === "monitoring" ? 250 : 2000,
742
+ mode === "monitoring" ? 40 : 500,
743
+ );
744
+ const dbPayloadMode = resultMode === "compact" ? ("full" as const) : payloadMode;
745
+ const dbPage = await listSessionEventPage(db, workspaceId, sessionId, {
480
746
  after,
481
747
  ...(before !== undefined ? { before } : {}),
482
748
  limit,
749
+ direction,
750
+ payloadMode: dbPayloadMode,
751
+ includeTypes,
752
+ excludeTypes,
753
+ includeClasses: latestClass ? [latestClass] : includeClasses,
754
+ excludeClasses,
755
+ ...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
756
+ ...(latestClass ? { authoritativeLatest: true } : {}),
483
757
  });
484
- return c.json(compact ? coalesceSessionEventDeltas(events) : events);
758
+ const events = dbPage.events;
759
+ if (resultMode === "compact") {
760
+ const event = events[0];
761
+ c.header("X-OpenGeni-Event-Result-Mode", "compact");
762
+ c.header("X-OpenGeni-Event-Result", event ? "found" : "not_found");
763
+ c.header("X-OpenGeni-Event-Mode", mode);
764
+ c.header("X-OpenGeni-Event-Direction", direction);
765
+ c.header("X-OpenGeni-Payload-Mode", "full");
766
+ c.header("X-OpenGeni-Forensic-Exact", "false");
767
+ if (!event) return c.json(null, 200);
768
+ const result = compactSessionEventResult(
769
+ event,
770
+ latestClass!,
771
+ dbPage.coveredSequence ?? { first: event.sequence, last: event.sequence },
772
+ );
773
+ c.header("X-OpenGeni-Covered-First", String(result.coveredSequence.first));
774
+ c.header("X-OpenGeni-Covered-Last", String(result.coveredSequence.last));
775
+ return c.json(result);
776
+ }
777
+ const projected = compact ? coalesceSessionEventDeltas(events) : events;
778
+ const page = boundSessionEventHttpPage(projected, {
779
+ direction,
780
+ });
781
+ const hasMore = dbPage.hasMore || page.truncated;
782
+ c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
783
+ c.header("X-OpenGeni-Page-Max-Bytes", String(1024 * 1024));
784
+ c.header("X-OpenGeni-Page-Truncated", String(hasMore));
785
+ c.header("X-OpenGeni-Has-More", String(hasMore));
786
+ c.header("X-OpenGeni-Event-Mode", mode);
787
+ c.header("X-OpenGeni-Event-Direction", direction);
788
+ c.header("X-OpenGeni-Payload-Mode", payloadMode);
789
+ c.header("X-OpenGeni-Forensic-Exact", String(mode === "forensic" && payloadMode === "full"));
790
+ const coveredFirst = page.events[0]?.sequence;
791
+ const coveredLast = page.events.at(-1)?.sequence;
792
+ if (coveredFirst !== undefined) c.header("X-OpenGeni-Covered-First", String(coveredFirst));
793
+ if (coveredLast !== undefined) c.header("X-OpenGeni-Covered-Last", String(coveredLast));
794
+ const truncatedBy = page.truncated ? "http_bytes" : dbPage.truncatedBy;
795
+ if (truncatedBy) c.header("X-OpenGeni-Truncated-By", truncatedBy);
796
+ if (page.nextSequence !== null) {
797
+ c.header(
798
+ direction === "before" ? "X-OpenGeni-Next-Before" : "X-OpenGeni-Next-After",
799
+ String(page.nextSequence),
800
+ );
801
+ }
802
+ return c.json(page.events);
485
803
  });
486
804
 
487
805
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
488
806
  const workspaceId = c.req.param("workspaceId");
489
- await requireAccessGrant(c, deps, workspaceId, "sessions:read");
807
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
490
808
  const sessionId = c.req.param("sessionId");
809
+ let authorization;
810
+ try {
811
+ authorization = await requireSessionAuthorization(deps, grant, {
812
+ sessionId,
813
+ operation: "session.stream.read",
814
+ surface: "stream",
815
+ });
816
+ } catch (error) {
817
+ throw sessionAuthorizationHttpError(error);
818
+ }
491
819
  await assertSessionExists(db, workspaceId, sessionId);
492
820
  const after = Number(c.req.query("after") ?? c.req.header("Last-Event-ID") ?? 0);
493
821
  return sseSessionStream(
@@ -497,6 +825,22 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
497
825
  sessionId,
498
826
  Number.isFinite(after) ? after : 0,
499
827
  c.req.raw.signal,
828
+ {
829
+ observability: deps.observability,
830
+ ...(authorization
831
+ ? {
832
+ reauthorizeAfterMs:
833
+ authorization.reauthorizeAfterMs ?? SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
834
+ reauthorize: async () => {
835
+ await requireSessionAuthorization(deps, grant, {
836
+ sessionId,
837
+ operation: "session.stream.read",
838
+ surface: "stream",
839
+ });
840
+ },
841
+ }
842
+ : {}),
843
+ },
500
844
  );
501
845
  });
502
846
 
@@ -516,7 +860,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
516
860
  const sessionId = c.req.param("sessionId");
517
861
  const snapshot = await getSessionQueueSnapshot(db, workspaceId, sessionId);
518
862
  if (!snapshot) throw new HTTPException(404, { message: "session not found" });
519
- return c.json(snapshot);
863
+ return c.json(projectQueueSnapshot(snapshot, sessionId, relatedSessionAccessFor(c)));
520
864
  });
521
865
 
522
866
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/move", async (c) => {
@@ -526,14 +870,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
526
870
  await assertSessionExists(db, workspaceId, sessionId);
527
871
  const payload = MoveSessionQueueItemRequest.parse(await c.req.json());
528
872
  try {
529
- return c.json(
530
- await moveHumanQueuePrompt(
531
- { db, bus },
532
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
533
- c.req.param("turnId"),
534
- payload,
535
- ),
873
+ const response = await moveHumanQueuePrompt(
874
+ deps,
875
+ {
876
+ accountId: grant.accountId,
877
+ workspaceId,
878
+ sessionId,
879
+ subjectId: grant.subjectId,
880
+ },
881
+ c.req.param("turnId"),
882
+ payload,
536
883
  );
884
+ return c.json({
885
+ ...response,
886
+ snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
887
+ });
537
888
  } catch (error) {
538
889
  return commandConflictResponse(c, error);
539
890
  }
@@ -546,14 +897,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
546
897
  await assertSessionExists(db, workspaceId, sessionId);
547
898
  const payload = EditSessionQueueItemRequest.parse(await c.req.json());
548
899
  try {
549
- return c.json(
550
- await editHumanQueuePrompt(
551
- { db, bus },
552
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
553
- c.req.param("turnId"),
554
- payload,
555
- ),
900
+ const response = await editHumanQueuePrompt(
901
+ deps,
902
+ {
903
+ accountId: grant.accountId,
904
+ workspaceId,
905
+ sessionId,
906
+ subjectId: grant.subjectId,
907
+ },
908
+ c.req.param("turnId"),
909
+ payload,
556
910
  );
911
+ return c.json({
912
+ ...response,
913
+ snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
914
+ });
557
915
  } catch (error) {
558
916
  return commandConflictResponse(c, error);
559
917
  }
@@ -566,14 +924,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
566
924
  await assertSessionExists(db, workspaceId, sessionId);
567
925
  const payload = SteerSessionQueueItemRequest.parse(await c.req.json());
568
926
  try {
569
- return c.json(
570
- await steerHumanQueuePrompt(
571
- { db, bus },
572
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
573
- c.req.param("turnId"),
574
- payload,
575
- ),
927
+ const response = await steerHumanQueuePrompt(
928
+ deps,
929
+ {
930
+ accountId: grant.accountId,
931
+ workspaceId,
932
+ sessionId,
933
+ subjectId: grant.subjectId,
934
+ },
935
+ c.req.param("turnId"),
936
+ payload,
576
937
  );
938
+ return c.json({
939
+ ...response,
940
+ snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
941
+ });
577
942
  } catch (error) {
578
943
  return commandConflictResponse(c, error);
579
944
  }
@@ -586,14 +951,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
586
951
  await assertSessionExists(db, workspaceId, sessionId);
587
952
  const payload = DeleteSessionQueueItemRequest.parse(await c.req.json());
588
953
  try {
589
- return c.json(
590
- await deleteHumanQueuePrompt(
591
- { db, bus },
592
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
593
- c.req.param("turnId"),
594
- payload,
595
- ),
954
+ const response = await deleteHumanQueuePrompt(
955
+ deps,
956
+ {
957
+ accountId: grant.accountId,
958
+ workspaceId,
959
+ sessionId,
960
+ subjectId: grant.subjectId,
961
+ },
962
+ c.req.param("turnId"),
963
+ payload,
596
964
  );
965
+ return c.json({
966
+ ...response,
967
+ snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
968
+ });
597
969
  } catch (error) {
598
970
  return commandConflictResponse(c, error);
599
971
  }
@@ -604,7 +976,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
604
976
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
605
977
  const sessionId = c.req.param("sessionId");
606
978
  return c.json(
607
- await getHumanComposerDraft(db, {
979
+ await getHumanComposerDraft(deps, {
608
980
  accountId: grant.accountId,
609
981
  workspaceId,
610
982
  sessionId,
@@ -621,8 +993,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
621
993
  try {
622
994
  return c.json(
623
995
  await saveHumanComposerDraft(
624
- db,
625
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
996
+ deps,
997
+ {
998
+ accountId: grant.accountId,
999
+ workspaceId,
1000
+ sessionId,
1001
+ subjectId: grant.subjectId,
1002
+ },
626
1003
  payload,
627
1004
  ),
628
1005
  );
@@ -634,16 +1011,33 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
634
1011
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
635
1012
  const workspaceId = c.req.param("workspaceId");
636
1013
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1014
+ if (workspaceControlUtf8Bytes(grant.subjectId) > WORKSPACE_CONTROL_ACTOR_MAX_BYTES) {
1015
+ throw new HTTPException(400, { message: "workspace-control actor is too large" });
1016
+ }
637
1017
  const sessionId = c.req.param("sessionId");
638
- const payload = SessionControlRequest.parse(await c.req.json());
1018
+ const parsed = SessionControlRequest.safeParse(await c.req.json().catch(() => null));
1019
+ if (!parsed.success) {
1020
+ throw new HTTPException(400, { message: "invalid session control request" });
1021
+ }
639
1022
  try {
640
- return c.json(
641
- await controlHumanSessionWorkstream(
642
- { db, bus, workflowClient },
643
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
644
- payload,
645
- ),
1023
+ const response = await controlHumanSessionWorkstream(
1024
+ deps,
1025
+ {
1026
+ accountId: grant.accountId,
1027
+ workspaceId,
1028
+ sessionId,
1029
+ subjectId: grant.subjectId,
1030
+ },
1031
+ parsed.data,
646
1032
  );
1033
+ return c.json({
1034
+ ...response,
1035
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1036
+ response.effectiveControl,
1037
+ sessionId,
1038
+ relatedSessionAccessFor(c),
1039
+ ),
1040
+ });
647
1041
  } catch (error) {
648
1042
  return commandConflictResponse(c, error);
649
1043
  }
@@ -658,6 +1052,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
658
1052
  const payload = SteerSessionMessageRequest.parse(raw);
659
1053
  const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
660
1054
  text: payload.text,
1055
+ turnInstructions: payload.turnInstructions ?? null,
661
1056
  resources: payload.resources,
662
1057
  tools: payload.tools,
663
1058
  toolsProvided: userMessagePayloadHasOwnProperty({ payload: raw }, "tools"),
@@ -681,9 +1076,27 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
681
1076
  const sessionId = c.req.param("sessionId");
682
1077
  const rawEvent = await c.req.json();
683
1078
  const event = ClientSessionEvent.parse(rawEvent);
1079
+ const refinedOperation =
1080
+ event.type === "user.approvalDecision"
1081
+ ? "session.approval.write"
1082
+ : event.type === "user.humanInputResponse"
1083
+ ? "session.human_input.write"
1084
+ : null;
1085
+ if (refinedOperation) {
1086
+ try {
1087
+ await requireSessionAuthorization(deps, grant, {
1088
+ sessionId,
1089
+ operation: refinedOperation,
1090
+ surface: "http",
1091
+ });
1092
+ } catch (error) {
1093
+ throw sessionAuthorizationHttpError(error);
1094
+ }
1095
+ }
684
1096
  if (event.type === "user.message") {
685
1097
  const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
686
1098
  text: event.payload.text,
1099
+ turnInstructions: event.payload.turnInstructions ?? null,
687
1100
  resources: event.payload.resources ?? [],
688
1101
  tools: event.payload.tools ?? [],
689
1102
  toolsProvided: userMessagePayloadHasOwnProperty(rawEvent, "tools"),
@@ -726,8 +1139,83 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
726
1139
  });
727
1140
  return c.json(accepted.event, 202);
728
1141
  }
1142
+
1143
+ if (event.type === "user.humanInputResponse") {
1144
+ let accepted;
1145
+ try {
1146
+ accepted = await acceptSessionHumanInputResponse(db, {
1147
+ accountId: grant.accountId,
1148
+ workspaceId,
1149
+ sessionId,
1150
+ requestId: event.payload.requestId,
1151
+ response: event.payload.response,
1152
+ respondedBy: grant.subjectId,
1153
+ clientEventId: event.clientEventId ?? null,
1154
+ });
1155
+ } catch (error) {
1156
+ if (error instanceof HumanInputResponseValidationError) {
1157
+ throw new HTTPException(error.code === "SKIP_NOT_ALLOWED" ? 409 : 422, {
1158
+ message: error.message,
1159
+ });
1160
+ }
1161
+ throw error;
1162
+ }
1163
+ if (accepted.action === "not_found") {
1164
+ throw new HTTPException(404, { message: "human-input request not found" });
1165
+ }
1166
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, accepted.events);
1167
+ if (accepted.workflowWakeRevision !== null) {
1168
+ await workflowClient.signalApprovalDecision({
1169
+ accountId: grant.accountId,
1170
+ workspaceId,
1171
+ sessionId,
1172
+ eventId: accepted.events[0]?.id ?? event.payload.requestId,
1173
+ workflowId: workflowIdForSession(sessionId),
1174
+ workflowWakeRevision: accepted.workflowWakeRevision,
1175
+ });
1176
+ }
1177
+ if (accepted.action === "conflict") {
1178
+ throw new HTTPException(409, {
1179
+ message: `human-input request is ${accepted.request.status}`,
1180
+ });
1181
+ }
1182
+ return c.json(accepted.event, 202);
1183
+ }
1184
+ });
1185
+
1186
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/human-input-requests", async (c) => {
1187
+ const workspaceId = c.req.param("workspaceId");
1188
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1189
+ const sessionId = c.req.param("sessionId");
1190
+ await assertSessionExists(db, workspaceId, sessionId);
1191
+ const rawStatus = c.req.query("status");
1192
+ const status = rawStatus ? HumanInputRequestStatus.safeParse(rawStatus) : null;
1193
+ if (status && !status.success) {
1194
+ throw new HTTPException(400, { message: "invalid human-input request status" });
1195
+ }
1196
+ const requests = await listSessionHumanInputRequests(db, workspaceId, sessionId, {
1197
+ ...(status?.success ? { status: status.data } : {}),
1198
+ });
1199
+ return c.json({ requests });
729
1200
  });
730
1201
 
1202
+ app.get(
1203
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/human-input-requests/:requestId",
1204
+ async (c) => {
1205
+ const workspaceId = c.req.param("workspaceId");
1206
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1207
+ const sessionId = c.req.param("sessionId");
1208
+ const request = await getSessionHumanInputRequest(
1209
+ db,
1210
+ workspaceId,
1211
+ sessionId,
1212
+ c.req.param("requestId"),
1213
+ );
1214
+ if (!request) throw new HTTPException(404, { message: "human-input request not found" });
1215
+ return c.json(request);
1216
+ },
1217
+ );
1218
+
731
1219
  // ── API-direct stream capabilities + viewer attach (P1.4) ─────────────────
732
1220
  //
733
1221
  // All IN-PROCESS: capability negotiation reads the descriptor + the group
@@ -781,6 +1269,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
781
1269
  { workspaceId, sandboxGroupId: session.sandboxGroupId },
782
1270
  );
783
1271
  const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
1272
+ const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
784
1273
  // Per-principal acknowledgment: A acknowledging does not consent for B. The
785
1274
  // un-redacted desktop stream ALWAYS requires the un-redacted ack; a shared box
786
1275
  // ADDITIONALLY requires the shared-exposure ack. Both must match the POST
@@ -872,13 +1361,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
872
1361
  // tracks the desktop tier + a desktop-capable backend.
873
1362
  computerUseEnabled: settings.computerUseEnabled,
874
1363
  computerUseReadOnly: settings.computerUseReadOnly,
875
- // Graceful degrade (I8/OD-8): if desktop is enabled but no stream-token
1364
+ // Graceful degrade (stream-token availability contract): if desktop is enabled but no stream-token
876
1365
  // secret is resolvable, the desktop cell reports transport:null rather
877
1366
  // than advertising a plane we can never authorize.
878
1367
  streamTokenSecretAvailable: !streamTokenDegraded(settings),
879
1368
  desktopAcknowledged: acknowledged,
880
1369
  shared,
881
- sharedSessionIds,
1370
+ sharedSessionIds: visibleSharedSessionIds,
882
1371
  // The minted live address (null when not unlocked/degraded). The resolver
883
1372
  // only folds it in when the desktop gates pass + the ack is present.
884
1373
  ...(desktopStream
@@ -960,7 +1449,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
960
1449
  }
961
1450
  const parsed = AcknowledgeStreamRequest.safeParse(await c.req.json().catch(() => ({})));
962
1451
  if (!parsed.success) {
963
- throw new HTTPException(400, { message: "invalid stream acknowledgment request" });
1452
+ throw new HTTPException(400, {
1453
+ message: "invalid stream acknowledgment request",
1454
+ });
964
1455
  }
965
1456
  const recorded = await recordStreamAcknowledgment(db, {
966
1457
  accountId: grant.accountId,
@@ -996,7 +1487,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
996
1487
  }
997
1488
  const parsed = AttachViewerRequest.safeParse(await c.req.json().catch(() => ({})));
998
1489
  if (!parsed.success) {
999
- throw new HTTPException(400, { message: "invalid viewer attach request" });
1490
+ throw new HTTPException(400, {
1491
+ message: "invalid viewer attach request",
1492
+ });
1000
1493
  }
1001
1494
  // Consent gate (P3.2 / addendum E.1): ONLY the un-redacted DESKTOP pixel plane
1002
1495
  // requires the calling principal's acknowledgment (recorded per group+subject;
@@ -1017,10 +1510,14 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1017
1510
  subjectId: grant.subjectId,
1018
1511
  });
1019
1512
  if (!ack?.acknowledgedUnredacted) {
1020
- throw new HTTPException(409, { message: "stream_acknowledgment_required" });
1513
+ throw new HTTPException(409, {
1514
+ message: "stream_acknowledgment_required",
1515
+ });
1021
1516
  }
1022
1517
  if (shared && !ack.acknowledgedShared) {
1023
- throw new HTTPException(409, { message: "shared_acknowledgment_required" });
1518
+ throw new HTTPException(409, {
1519
+ message: "shared_acknowledgment_required",
1520
+ });
1024
1521
  }
1025
1522
  }
1026
1523
  // SELFHOSTED ACTIVE: when the session's active sandbox is selfhosted, skip
@@ -1177,7 +1674,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1177
1674
  }
1178
1675
  const parsed = ViewerHeartbeatRequest.safeParse(await c.req.json().catch(() => ({})));
1179
1676
  if (!parsed.success) {
1180
- throw new HTTPException(400, { message: "viewer heartbeat requires { leaseEpoch }" });
1677
+ throw new HTTPException(400, {
1678
+ message: "viewer heartbeat requires { leaseEpoch }",
1679
+ });
1181
1680
  }
1182
1681
  const alive = await heartbeatViewer(
1183
1682
  { db, settings },
@@ -1240,7 +1739,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1240
1739
  idleGraceMs: settings.sandboxIdleGraceMs,
1241
1740
  });
1242
1741
  // null ⇒ the lease was already cold-and-reaped (revoke is an idempotent no-op).
1243
- return c.json({ liveness: result?.liveness ?? null, refcount: result?.refcount ?? null });
1742
+ return c.json({
1743
+ liveness: result?.liveness ?? null,
1744
+ refcount: result?.refcount ?? null,
1745
+ });
1244
1746
  },
1245
1747
  );
1246
1748
 
@@ -1281,12 +1783,19 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1281
1783
  if (!session) {
1282
1784
  throw new HTTPException(404, { message: "session not found" });
1283
1785
  }
1284
- return { accountId: grant.accountId, workspaceId, session, subjectId: grant.subjectId };
1786
+ return {
1787
+ accountId: grant.accountId,
1788
+ workspaceId,
1789
+ session,
1790
+ subjectId: grant.subjectId,
1791
+ };
1285
1792
  }
1286
1793
 
1287
1794
  async function parseChannelABody<T>(
1288
1795
  c: Context,
1289
- schema: { safeParse: (v: unknown) => { success: true; data: T } | { success: false } },
1796
+ schema: {
1797
+ safeParse: (v: unknown) => { success: true; data: T } | { success: false };
1798
+ },
1290
1799
  ): Promise<T> {
1291
1800
  const raw = await c.req.json().catch(() => undefined);
1292
1801
  const result = schema.safeParse(raw ?? {});
@@ -1416,7 +1925,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1416
1925
  const sessionId = c.req.param("sessionId") ?? "";
1417
1926
  const path = c.req.query("path");
1418
1927
  if (!path) {
1419
- throw new HTTPException(400, { message: "path query parameter is required" });
1928
+ throw new HTTPException(400, {
1929
+ message: "path query parameter is required",
1930
+ });
1420
1931
  }
1421
1932
  const session = await getSession(db, workspaceId, sessionId);
1422
1933
  if (!session) {
@@ -1431,7 +1942,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1431
1942
  if (revisionParam !== undefined && revisionParam !== "") {
1432
1943
  const revision = Number(revisionParam);
1433
1944
  if (!Number.isInteger(revision) || revision < 0) {
1434
- throw new HTTPException(400, { message: "revision must be a non-negative integer" });
1945
+ throw new HTTPException(400, {
1946
+ message: "revision must be a non-negative integer",
1947
+ });
1435
1948
  }
1436
1949
  row = await workspaceCaptureAtRevision(db, workspaceId, sessionId, revision);
1437
1950
  } else {
@@ -1503,7 +2016,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1503
2016
  throw new HTTPException(404, { message: "pty not found or closed" });
1504
2017
  }
1505
2018
  if (pty.execSessionId === null) {
1506
- throw new HTTPException(409, { message: "interactive terminal unsupported on this backend" });
2019
+ throw new HTTPException(409, {
2020
+ message: "interactive terminal unsupported on this backend",
2021
+ });
1507
2022
  }
1508
2023
  let seq = 1;
1509
2024
  await withChannelA({ db, settings, bus }, ctx, async ({ service }) => {
@@ -1565,7 +2080,11 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1565
2080
  workspaceId: ctx.workspaceId,
1566
2081
  ptyId: req.ptyId,
1567
2082
  });
1568
- const exited: TerminalPtyExitedPayload = { ptyId: req.ptyId, exitCode: 0, reason: "exit" };
2083
+ const exited: TerminalPtyExitedPayload = {
2084
+ ptyId: req.ptyId,
2085
+ exitCode: 0,
2086
+ reason: "exit",
2087
+ };
1569
2088
  await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
1570
2089
  { type: "terminal.pty.exited", payload: exited },
1571
2090
  ]);
@@ -1574,14 +2093,160 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1574
2093
  });
1575
2094
  }
1576
2095
 
1577
- function eventListLimit(raw: string | undefined, max = 2000): number {
1578
- const limit = Number(raw ?? 500);
2096
+ function eventListLimit(raw: string | undefined, max = 2000, fallback = 500): number {
2097
+ const limit = Number(raw ?? fallback);
1579
2098
  if (!Number.isFinite(limit)) {
1580
- return 500;
2099
+ return fallback;
1581
2100
  }
1582
2101
  return Math.min(max, Math.max(1, Math.floor(limit)));
1583
2102
  }
1584
2103
 
2104
+ /**
2105
+ * Map every mounted session-addressed HTTP path to the host-neutral operation
2106
+ * the embedding port authorizes. Returning null is deliberately fail-closed in
2107
+ * host-managed mode; standalone deployments never consult this classifier.
2108
+ */
2109
+ export function sessionAuthorizationOperationForHttp(
2110
+ method: string,
2111
+ pathname: string,
2112
+ sessionId: string,
2113
+ ): SessionAuthorizationOperation | null {
2114
+ const marker = `/sessions/${sessionId}`;
2115
+ const markerAt = pathname.indexOf(marker);
2116
+ if (markerAt < 0) return null;
2117
+ const suffix = pathname.slice(markerAt + marker.length);
2118
+ const verb = method.toUpperCase();
2119
+
2120
+ if (suffix === "") {
2121
+ if (verb === "GET") return "session.read";
2122
+ if (verb === "PATCH") return "session.title.write";
2123
+ return null;
2124
+ }
2125
+ if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
2126
+ if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
2127
+ return "session.mcp.approval_policy.write";
2128
+ }
2129
+ if (suffix === "/lineage" && verb === "GET") return "session.lineage.read";
2130
+ if (suffix === "/codex-account" && verb === "POST") {
2131
+ return "session.codex_account.write";
2132
+ }
2133
+ if (suffix === "/goal") {
2134
+ return verb === "GET"
2135
+ ? "session.goal.read"
2136
+ : ["PATCH", "DELETE"].includes(verb)
2137
+ ? "session.goal.write"
2138
+ : null;
2139
+ }
2140
+ if (suffix === "/context/clear" || suffix === "/context/compact") {
2141
+ return verb === "POST" ? "session.context.write" : null;
2142
+ }
2143
+ if (suffix === "/events/stream" && verb === "GET") return "session.stream.read";
2144
+ if (suffix === "/events") {
2145
+ if (verb === "GET") return "session.events.read";
2146
+ if (verb === "POST") return "session.append";
2147
+ return null;
2148
+ }
2149
+ if (suffix === "/turns" && verb === "GET") return "session.turns.read";
2150
+ if (suffix === "/queue" && verb === "GET") return "session.queue.read";
2151
+ if (suffix.startsWith("/queue/") && verb === "POST") return "session.queue.control";
2152
+ if (suffix === "/composer-draft") {
2153
+ if (verb === "GET") return "session.composer.read";
2154
+ if (verb === "PUT") return "session.composer.write";
2155
+ return null;
2156
+ }
2157
+ if (suffix === "/control" && verb === "POST") return "session.control";
2158
+ if (suffix === "/steer" && verb === "POST") return "session.steer";
2159
+ if (suffix === "/human-input-requests" && verb === "GET") {
2160
+ return "session.human_input.read";
2161
+ }
2162
+ if (suffix.startsWith("/human-input-requests/") && verb === "GET") {
2163
+ return "session.human_input.read";
2164
+ }
2165
+ if (suffix === "/stream-capabilities" && verb === "GET") return "session.viewer.read";
2166
+ if (suffix === "/stream-capabilities/acknowledge" && verb === "POST") {
2167
+ return "session.stream.acknowledge";
2168
+ }
2169
+ if (suffix === "/viewers" && verb === "POST") return "session.viewer.control";
2170
+ if (suffix.startsWith("/viewers/") && ["POST", "DELETE"].includes(verb)) {
2171
+ return "session.viewer.control";
2172
+ }
2173
+ if (suffix === "/fs/list" || suffix === "/fs/read") {
2174
+ return verb === "POST" ? "session.files.read" : null;
2175
+ }
2176
+ if (["/fs/write", "/fs/delete", "/fs/move", "/fs/mkdir"].includes(suffix)) {
2177
+ return verb === "POST" ? "session.files.write" : null;
2178
+ }
2179
+ if (suffix.startsWith("/git/") && verb === "POST") return "session.git.read";
2180
+ if ((suffix === "/workspace/capture" || suffix === "/workspace/capture/file") && verb === "GET") {
2181
+ return "session.capture.read";
2182
+ }
2183
+ if (suffix === "/terminal/exec" && verb === "POST") return "session.terminal.control";
2184
+ if (suffix === "/terminal/pty" && verb === "POST") return "session.terminal.control";
2185
+ if (suffix.startsWith("/terminal/pty/") && verb === "POST") {
2186
+ return "session.terminal.control";
2187
+ }
2188
+ return null;
2189
+ }
2190
+
2191
+ function sessionAuthorizationHttpError(error: unknown): HTTPException {
2192
+ if (error instanceof SessionAuthorizationDeniedError) {
2193
+ return new HTTPException(404, { message: "session not found" });
2194
+ }
2195
+ if (error instanceof SessionAuthorizationUnavailableError) {
2196
+ return new HTTPException(503, { message: "session authorization is unavailable" });
2197
+ }
2198
+ if (error instanceof HTTPException) return error;
2199
+ throw error;
2200
+ }
2201
+
2202
+ function eventEnumValue<T extends string>(
2203
+ raw: string | undefined,
2204
+ schema: { safeParse(value: unknown): { success: boolean; data?: T } },
2205
+ name: string,
2206
+ fallback: T,
2207
+ ): T;
2208
+ function eventEnumValue<T extends string>(
2209
+ raw: string | undefined,
2210
+ schema: { safeParse(value: unknown): { success: boolean; data?: T } },
2211
+ name: string,
2212
+ fallback: undefined,
2213
+ ): T | undefined;
2214
+ function eventEnumValue<T extends string>(
2215
+ raw: string | undefined,
2216
+ schema: { safeParse(value: unknown): { success: boolean; data?: T } },
2217
+ name: string,
2218
+ fallback: T | undefined,
2219
+ ): T | undefined {
2220
+ if (raw === undefined) return fallback;
2221
+ const parsed = schema.safeParse(raw);
2222
+ if (!parsed.success) {
2223
+ throw new HTTPException(400, { message: `${name} is invalid` });
2224
+ }
2225
+ return parsed.data as T;
2226
+ }
2227
+
2228
+ function eventEnumList<T extends string>(
2229
+ raw: string | undefined,
2230
+ schema: { safeParse(value: unknown): { success: boolean; data?: T } },
2231
+ name: string,
2232
+ ): T[] {
2233
+ if (raw === undefined || raw.trim() === "") return [];
2234
+ const values = raw
2235
+ .split(",")
2236
+ .map((value) => value.trim())
2237
+ .filter(Boolean);
2238
+ if (values.length > 100) {
2239
+ throw new HTTPException(400, { message: `${name} accepts at most 100 values` });
2240
+ }
2241
+ return values.map((value) => {
2242
+ const parsed = schema.safeParse(value);
2243
+ if (!parsed.success) {
2244
+ throw new HTTPException(400, { message: `${name} contains an invalid value` });
2245
+ }
2246
+ return parsed.data as T;
2247
+ });
2248
+ }
2249
+
1585
2250
  function sessionListQuery(
1586
2251
  query: Record<string, string>,
1587
2252
  allowCursor = true,
@@ -1611,7 +2276,9 @@ function sessionListQuery(
1611
2276
  }
1612
2277
  const search = query.search?.trim();
1613
2278
  if (search && search.length > 200) {
1614
- throw new HTTPException(400, { message: "search must be at most 200 characters" });
2279
+ throw new HTTPException(400, {
2280
+ message: "search must be at most 200 characters",
2281
+ });
1615
2282
  }
1616
2283
  return {
1617
2284
  limit: query.limit,
@@ -1675,3 +2342,44 @@ function commandConflictResponse(c: Context, error: unknown): Response {
1675
2342
  }
1676
2343
  throw error;
1677
2344
  }
2345
+
2346
+ type EffectivePolicyContext = {
2347
+ workspaceServerIds: string[];
2348
+ workspaceDefaultServerIds: string[];
2349
+ };
2350
+
2351
+ async function loadEffectivePolicyContext(
2352
+ deps: ApiRouteDeps,
2353
+ workspaceId: string,
2354
+ ): Promise<EffectivePolicyContext> {
2355
+ const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
2356
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
2357
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
2358
+ ]);
2359
+ return { workspaceServerIds, workspaceDefaultServerIds };
2360
+ }
2361
+
2362
+ async function withEffectivePolicy(
2363
+ deps: ApiRouteDeps,
2364
+ workspaceId: string,
2365
+ session: Session,
2366
+ ): Promise<Session> {
2367
+ const policy = await loadEffectivePolicyContext(deps, workspaceId);
2368
+ return sessionWithEffectiveToolPolicy(
2369
+ session,
2370
+ policy.workspaceServerIds,
2371
+ policy.workspaceDefaultServerIds,
2372
+ );
2373
+ }
2374
+
2375
+ function mapLineageNodes(nodes: LineageNode[], policy: EffectivePolicyContext): LineageNode[] {
2376
+ return nodes.map((node) => ({
2377
+ ...node,
2378
+ session: sessionWithEffectiveToolPolicy(
2379
+ node.session as Session,
2380
+ policy.workspaceServerIds,
2381
+ policy.workspaceDefaultServerIds,
2382
+ ),
2383
+ children: mapLineageNodes(node.children, policy),
2384
+ }));
2385
+ }