@opengeni/api-router 0.5.7 → 0.7.3
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.
- package/dist/app.d.ts +21 -3
- package/dist/app.js +3 -1
- package/dist/{chunk-HBEJMWD3.js → chunk-EYYTFA7N.js} +2396 -676
- package/dist/chunk-EYYTFA7N.js.map +1 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/app.ts +47 -4
- package/src/github-access.ts +46 -0
- package/src/github-browser-flow.ts +83 -0
- package/src/http/auth.ts +12 -4
- package/src/http/sse.ts +526 -92
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +774 -146
- package/src/mcp/session-view.ts +622 -203
- package/src/mcp/toolspace.ts +110 -25
- package/src/routes/codex.ts +17 -14
- package/src/routes/enrollments.ts +2 -2
- package/src/routes/github.ts +63 -202
- package/src/routes/install.ts +1 -1
- package/src/routes/machines.ts +2 -2
- package/src/routes/sessions.ts +639 -76
- package/src/routes/workspace-capture.ts +56 -38
- package/src/routes/workspaces.ts +30 -11
- package/src/sandbox/access.ts +1 -1
- package/src/sandbox/auth-callout.ts +1 -1
- package/src/sandbox/channel-a.ts +14 -1
- package/src/sandbox/enrollment.ts +4 -4
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +1 -1
- package/src/sandbox/viewer.ts +1 -1
- package/dist/chunk-HBEJMWD3.js.map +0 -1
package/src/routes/sessions.ts
CHANGED
|
@@ -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,6 +23,12 @@ import {
|
|
|
22
23
|
PtyResizeRequest,
|
|
23
24
|
PtyWriteRequest,
|
|
24
25
|
SessionControlRequest,
|
|
26
|
+
SESSION_EVENT_RAW_DELTA_TYPES,
|
|
27
|
+
SessionEventPayloadMode,
|
|
28
|
+
SessionEventReadDirection,
|
|
29
|
+
SessionEventReadMode,
|
|
30
|
+
SessionEventSemanticClass,
|
|
31
|
+
SessionEventType,
|
|
25
32
|
SaveComposerDraftRequest,
|
|
26
33
|
SteerSessionQueueItemRequest,
|
|
27
34
|
SteerSessionMessageRequest,
|
|
@@ -30,8 +37,12 @@ import {
|
|
|
30
37
|
UpdateSessionGoalRequest,
|
|
31
38
|
UpdateSessionRequest,
|
|
32
39
|
ViewerHeartbeatRequest,
|
|
40
|
+
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
41
|
+
workspaceControlUtf8Bytes,
|
|
33
42
|
type SandboxBackend,
|
|
34
43
|
type Session,
|
|
44
|
+
type SessionAuthorizationOperation,
|
|
45
|
+
type SessionQueueSnapshot,
|
|
35
46
|
type TerminalPtyExitedPayload,
|
|
36
47
|
type TerminalPtyOutputDeltaPayload,
|
|
37
48
|
type TerminalPtyStartedPayload,
|
|
@@ -39,6 +50,7 @@ import {
|
|
|
39
50
|
import { streamTokenDegraded } from "@opengeni/config";
|
|
40
51
|
import {
|
|
41
52
|
acceptSessionApprovalDecision,
|
|
53
|
+
acceptSessionHumanInputResponse,
|
|
42
54
|
clearSessionGoal,
|
|
43
55
|
clearSessionContext,
|
|
44
56
|
closePtySession,
|
|
@@ -47,13 +59,17 @@ import {
|
|
|
47
59
|
getSession,
|
|
48
60
|
getSessionForSubject,
|
|
49
61
|
getSessionGoal,
|
|
62
|
+
getSessionHumanInputRequest,
|
|
50
63
|
getSessionQueueSnapshot,
|
|
51
64
|
getStreamAcknowledgment,
|
|
52
65
|
insertPtySession,
|
|
53
|
-
|
|
66
|
+
listSessionEventPage,
|
|
67
|
+
listSessionHumanInputRequests,
|
|
54
68
|
listSessionIdsInGroup,
|
|
55
69
|
listSessionsForSubject,
|
|
56
70
|
listSessionTurns,
|
|
71
|
+
projectEffectiveControlForRelatedAccess,
|
|
72
|
+
projectSessionForRelatedAccess,
|
|
57
73
|
recordStreamAcknowledgment,
|
|
58
74
|
requestSessionCompaction,
|
|
59
75
|
setSessionCodexPin,
|
|
@@ -71,21 +87,31 @@ import {
|
|
|
71
87
|
SessionCommandIdempotencyError,
|
|
72
88
|
SessionControlConflictError,
|
|
73
89
|
SessionContextBusyError,
|
|
90
|
+
HumanInputResponseValidationError,
|
|
74
91
|
latestWorkspaceCapture,
|
|
75
92
|
workspaceCaptureAtRevision,
|
|
76
93
|
type AppendEventInput,
|
|
77
94
|
} from "@opengeni/db";
|
|
78
95
|
import {
|
|
79
96
|
appendAndPublishEvents,
|
|
97
|
+
boundSessionEventHttpPage,
|
|
80
98
|
coalesceSessionEventDeltas,
|
|
81
99
|
publishDurableSessionEvents,
|
|
82
100
|
} from "@opengeni/events";
|
|
83
101
|
import { z } from "zod";
|
|
84
102
|
import { withChannelA } from "../sandbox/channel-a";
|
|
85
103
|
import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
|
|
86
|
-
import type { Context, Hono } from "hono";
|
|
104
|
+
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
87
105
|
import { HTTPException } from "hono/http-exception";
|
|
88
|
-
import {
|
|
106
|
+
import {
|
|
107
|
+
requireAccessGrant,
|
|
108
|
+
requireSessionAuthorization,
|
|
109
|
+
requireSessionAuthorizationListScope,
|
|
110
|
+
SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
|
|
111
|
+
SessionAuthorizationDeniedError,
|
|
112
|
+
SessionAuthorizationUnavailableError,
|
|
113
|
+
type ResolvedSessionAuthorization,
|
|
114
|
+
} from "@opengeni/core";
|
|
89
115
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
90
116
|
import {
|
|
91
117
|
attachViewer,
|
|
@@ -119,6 +145,60 @@ import { serveWorkspaceCapture, serveWorkspaceCaptureFile } from "./workspace-ca
|
|
|
119
145
|
|
|
120
146
|
export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
121
147
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
148
|
+
const requestSessionAuthorization = new WeakMap<Request, ResolvedSessionAuthorization>();
|
|
149
|
+
const relatedSessionAccessFor = (c: Context): "target" | "root" =>
|
|
150
|
+
requestSessionAuthorization.get(c.req.raw)?.relatedSessionAccess ?? "root";
|
|
151
|
+
const projectQueueSnapshot = (
|
|
152
|
+
snapshot: SessionQueueSnapshot,
|
|
153
|
+
sessionId: string,
|
|
154
|
+
access: "target" | "root",
|
|
155
|
+
): SessionQueueSnapshot => ({
|
|
156
|
+
...snapshot,
|
|
157
|
+
effectiveControl: projectEffectiveControlForRelatedAccess(
|
|
158
|
+
snapshot.effectiveControl,
|
|
159
|
+
sessionId,
|
|
160
|
+
access,
|
|
161
|
+
),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// A host-bound deployment has one fail-closed authorization seam for every
|
|
165
|
+
// HTTP session surface. Register it before the routes so a newly added path
|
|
166
|
+
// cannot accidentally inherit workspace access without an explicit operation
|
|
167
|
+
// classification. The long-lived event stream performs its own initial check
|
|
168
|
+
// and bounded reauthorization below.
|
|
169
|
+
const authorizeSessionHttp: MiddlewareHandler = async (c, next) => {
|
|
170
|
+
if (!deps.sessionAuthorization) {
|
|
171
|
+
await next();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
175
|
+
const sessionId = c.req.param("sessionId") ?? "";
|
|
176
|
+
const operation = sessionAuthorizationOperationForHttp(
|
|
177
|
+
c.req.method,
|
|
178
|
+
new URL(c.req.url).pathname,
|
|
179
|
+
sessionId,
|
|
180
|
+
);
|
|
181
|
+
if (operation === "session.stream.read") {
|
|
182
|
+
await next();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!operation) {
|
|
186
|
+
throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
|
|
187
|
+
}
|
|
188
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
189
|
+
try {
|
|
190
|
+
const authorization = await requireSessionAuthorization(deps, grant, {
|
|
191
|
+
sessionId,
|
|
192
|
+
operation,
|
|
193
|
+
surface: "http",
|
|
194
|
+
});
|
|
195
|
+
if (authorization) requestSessionAuthorization.set(c.req.raw, authorization);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
throw sessionAuthorizationHttpError(error);
|
|
198
|
+
}
|
|
199
|
+
await next();
|
|
200
|
+
};
|
|
201
|
+
app.use("/v1/workspaces/:workspaceId/sessions/:sessionId/*", authorizeSessionHttp);
|
|
122
202
|
|
|
123
203
|
app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
|
|
124
204
|
const workspaceId = c.req.param("workspaceId");
|
|
@@ -130,6 +210,12 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
130
210
|
app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
|
|
131
211
|
const workspaceId = c.req.param("workspaceId");
|
|
132
212
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
213
|
+
let authorizationScope;
|
|
214
|
+
try {
|
|
215
|
+
authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
|
|
216
|
+
} catch (error) {
|
|
217
|
+
throw sessionAuthorizationHttpError(error);
|
|
218
|
+
}
|
|
133
219
|
const pageView = c.req.query("view") === "page";
|
|
134
220
|
const query = sessionListQuery(c.req.query(), pageView);
|
|
135
221
|
let page: Awaited<ReturnType<typeof listSessionsForSubject>>;
|
|
@@ -140,6 +226,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
140
226
|
...(query.cursor ? { cursor: query.cursor } : {}),
|
|
141
227
|
...(query.search ? { search: query.search } : {}),
|
|
142
228
|
...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
|
|
229
|
+
...(authorizationScope ? { authorizationScope } : {}),
|
|
143
230
|
});
|
|
144
231
|
} catch (error) {
|
|
145
232
|
if (error instanceof SessionListAccessError) {
|
|
@@ -150,6 +237,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
150
237
|
}
|
|
151
238
|
throw error;
|
|
152
239
|
}
|
|
240
|
+
// The page body carries this fact directly. Preserve the historical array
|
|
241
|
+
// body for older clients while still making its older-pin omission visible
|
|
242
|
+
// to raw HTTP consumers without changing that response shape.
|
|
243
|
+
c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
|
|
153
244
|
if (pageView) {
|
|
154
245
|
return c.json(page);
|
|
155
246
|
}
|
|
@@ -168,7 +259,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
168
259
|
if (!z.string().uuid().safeParse(sessionId).success) {
|
|
169
260
|
throw new HTTPException(404, { message: "session not found" });
|
|
170
261
|
}
|
|
171
|
-
const session = await getSessionForSubject(
|
|
262
|
+
const session = await getSessionForSubject(
|
|
263
|
+
db,
|
|
264
|
+
workspaceId,
|
|
265
|
+
sessionId,
|
|
266
|
+
grant.subjectId,
|
|
267
|
+
relatedSessionAccessFor(c),
|
|
268
|
+
);
|
|
172
269
|
if (!session) {
|
|
173
270
|
throw new HTTPException(404, { message: "session not found" });
|
|
174
271
|
}
|
|
@@ -199,14 +296,17 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
199
296
|
if (!session) {
|
|
200
297
|
throw new HTTPException(404, { message: "session not found" });
|
|
201
298
|
}
|
|
202
|
-
return c.json(session);
|
|
299
|
+
return c.json(projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)));
|
|
203
300
|
} catch (error) {
|
|
204
301
|
if (error instanceof SessionPinAccessError) {
|
|
205
302
|
throw new HTTPException(403, { message: error.message });
|
|
206
303
|
}
|
|
207
304
|
if (error instanceof SessionPinVersionConflictError) {
|
|
208
305
|
return c.json(
|
|
209
|
-
{
|
|
306
|
+
{
|
|
307
|
+
message: "session pin changed in another client",
|
|
308
|
+
current: error.current,
|
|
309
|
+
},
|
|
210
310
|
409,
|
|
211
311
|
);
|
|
212
312
|
}
|
|
@@ -216,8 +316,8 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
216
316
|
|
|
217
317
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
|
|
218
318
|
const workspaceId = c.req.param("workspaceId");
|
|
219
|
-
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
220
|
-
return c.json(await readSessionLineage(
|
|
319
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
320
|
+
return c.json(await readSessionLineage(deps, grant, c.req.param("sessionId")));
|
|
221
321
|
});
|
|
222
322
|
|
|
223
323
|
// Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
|
|
@@ -232,7 +332,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
232
332
|
const body = (await c.req.json()) as { target?: string };
|
|
233
333
|
const target = typeof body.target === "string" ? body.target : "";
|
|
234
334
|
if (!target) {
|
|
235
|
-
throw new HTTPException(400, {
|
|
335
|
+
throw new HTTPException(400, {
|
|
336
|
+
message: 'target is required ("auto" or an account id)',
|
|
337
|
+
});
|
|
236
338
|
}
|
|
237
339
|
const pinned = target === "auto" ? null : target;
|
|
238
340
|
const mutation = await withCodexCapacityMutation(
|
|
@@ -245,7 +347,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
245
347
|
);
|
|
246
348
|
const ok = mutation.result;
|
|
247
349
|
if (!ok) {
|
|
248
|
-
throw new HTTPException(404, {
|
|
350
|
+
throw new HTTPException(404, {
|
|
351
|
+
message: "session or codex account not found",
|
|
352
|
+
});
|
|
249
353
|
}
|
|
250
354
|
await Promise.allSettled(
|
|
251
355
|
mutation.wakeTargets.map((wake) =>
|
|
@@ -279,11 +383,17 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
279
383
|
const sessionId = c.req.param("sessionId");
|
|
280
384
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
281
385
|
const payload = UpdateSessionRequest.parse(await c.req.json());
|
|
282
|
-
await updateSessionTitle(
|
|
386
|
+
const titleUpdate = await updateSessionTitle(deps, grant, sessionId, payload.title, "user");
|
|
283
387
|
// A session-returning member route must preserve the caller's private pin
|
|
284
388
|
// projection. Returning the generic mapSession() default here would reset a
|
|
285
389
|
// pinned React consumer to false/version 0 after a harmless rename.
|
|
286
|
-
const session = await getSessionForSubject(
|
|
390
|
+
const session = await getSessionForSubject(
|
|
391
|
+
db,
|
|
392
|
+
workspaceId,
|
|
393
|
+
sessionId,
|
|
394
|
+
grant.subjectId,
|
|
395
|
+
titleUpdate.relatedSessionAccess,
|
|
396
|
+
);
|
|
287
397
|
if (!session) {
|
|
288
398
|
throw new HTTPException(404, { message: "session not found" });
|
|
289
399
|
}
|
|
@@ -464,7 +574,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
464
574
|
workflowId: requested.temporalWorkflowId,
|
|
465
575
|
wakeRevision: requested.wakeRevision,
|
|
466
576
|
});
|
|
467
|
-
return c.json({
|
|
577
|
+
return c.json({
|
|
578
|
+
status: "pending",
|
|
579
|
+
message: "Compaction will run at the next safe boundary.",
|
|
580
|
+
});
|
|
468
581
|
});
|
|
469
582
|
|
|
470
583
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
|
|
@@ -472,22 +585,136 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
472
585
|
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
473
586
|
const sessionId = c.req.param("sessionId");
|
|
474
587
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
475
|
-
const
|
|
476
|
-
const
|
|
588
|
+
const rawAfter = c.req.query("after");
|
|
589
|
+
const rawBefore = c.req.query("before");
|
|
590
|
+
const after = eventSequence(rawAfter, 0);
|
|
591
|
+
const before = optionalEventSequence(rawBefore);
|
|
477
592
|
const compact = compactEvents(c.req.query("compact"));
|
|
478
|
-
const
|
|
479
|
-
const
|
|
593
|
+
const explicitReplay = rawAfter !== undefined || rawBefore !== undefined || compact;
|
|
594
|
+
const mode = eventEnumValue(
|
|
595
|
+
c.req.query("mode"),
|
|
596
|
+
SessionEventReadMode,
|
|
597
|
+
"mode",
|
|
598
|
+
explicitReplay ? "forensic" : "monitoring",
|
|
599
|
+
);
|
|
600
|
+
const latestClass = eventEnumValue(
|
|
601
|
+
c.req.query("latest"),
|
|
602
|
+
SessionEventSemanticClass,
|
|
603
|
+
"latest",
|
|
604
|
+
undefined,
|
|
605
|
+
);
|
|
606
|
+
if (
|
|
607
|
+
latestClass &&
|
|
608
|
+
["includeTypes", "excludeTypes", "includeClasses", "excludeClasses"].some(
|
|
609
|
+
(name) => c.req.query(name) !== undefined,
|
|
610
|
+
)
|
|
611
|
+
) {
|
|
612
|
+
throw new HTTPException(400, {
|
|
613
|
+
message: "latest cannot be combined with event filters",
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
const direction = latestClass
|
|
617
|
+
? "before"
|
|
618
|
+
: eventEnumValue(
|
|
619
|
+
c.req.query("direction"),
|
|
620
|
+
SessionEventReadDirection,
|
|
621
|
+
"direction",
|
|
622
|
+
before !== undefined
|
|
623
|
+
? "before"
|
|
624
|
+
: rawAfter !== undefined
|
|
625
|
+
? "after"
|
|
626
|
+
: mode === "monitoring"
|
|
627
|
+
? "before"
|
|
628
|
+
: "after",
|
|
629
|
+
);
|
|
630
|
+
const payloadMode = eventEnumValue(
|
|
631
|
+
c.req.query("payloadMode"),
|
|
632
|
+
SessionEventPayloadMode,
|
|
633
|
+
"payloadMode",
|
|
634
|
+
mode === "monitoring" ? "summary" : "full",
|
|
635
|
+
);
|
|
636
|
+
const includeTypes = eventEnumList(
|
|
637
|
+
c.req.query("includeTypes"),
|
|
638
|
+
SessionEventType,
|
|
639
|
+
"includeTypes",
|
|
640
|
+
);
|
|
641
|
+
const excludeTypes = eventEnumList(
|
|
642
|
+
c.req.query("excludeTypes"),
|
|
643
|
+
SessionEventType,
|
|
644
|
+
"excludeTypes",
|
|
645
|
+
);
|
|
646
|
+
const includeClasses = eventEnumList(
|
|
647
|
+
c.req.query("includeClasses"),
|
|
648
|
+
SessionEventSemanticClass,
|
|
649
|
+
"includeClasses",
|
|
650
|
+
);
|
|
651
|
+
const excludeClasses = eventEnumList(
|
|
652
|
+
c.req.query("excludeClasses"),
|
|
653
|
+
SessionEventSemanticClass,
|
|
654
|
+
"excludeClasses",
|
|
655
|
+
);
|
|
656
|
+
const limit = latestClass
|
|
657
|
+
? 1
|
|
658
|
+
: eventListLimit(
|
|
659
|
+
c.req.query("limit"),
|
|
660
|
+
compact ? 5000 : mode === "monitoring" ? 250 : 2000,
|
|
661
|
+
mode === "monitoring" ? 40 : 500,
|
|
662
|
+
);
|
|
663
|
+
const dbPage = await listSessionEventPage(db, workspaceId, sessionId, {
|
|
480
664
|
after,
|
|
481
665
|
...(before !== undefined ? { before } : {}),
|
|
482
666
|
limit,
|
|
667
|
+
direction,
|
|
668
|
+
payloadMode,
|
|
669
|
+
includeTypes,
|
|
670
|
+
excludeTypes,
|
|
671
|
+
includeClasses: latestClass ? [latestClass] : includeClasses,
|
|
672
|
+
excludeClasses,
|
|
673
|
+
...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
|
|
674
|
+
});
|
|
675
|
+
const events = dbPage.events;
|
|
676
|
+
const projected = compact ? coalesceSessionEventDeltas(events) : events;
|
|
677
|
+
const page = boundSessionEventHttpPage(projected, {
|
|
678
|
+
direction,
|
|
483
679
|
});
|
|
484
|
-
|
|
680
|
+
const hasMore = dbPage.hasMore || page.truncated;
|
|
681
|
+
c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
|
|
682
|
+
c.header("X-OpenGeni-Page-Max-Bytes", String(1024 * 1024));
|
|
683
|
+
c.header("X-OpenGeni-Page-Truncated", String(hasMore));
|
|
684
|
+
c.header("X-OpenGeni-Has-More", String(hasMore));
|
|
685
|
+
c.header("X-OpenGeni-Event-Mode", mode);
|
|
686
|
+
c.header("X-OpenGeni-Event-Direction", direction);
|
|
687
|
+
c.header("X-OpenGeni-Payload-Mode", payloadMode);
|
|
688
|
+
c.header("X-OpenGeni-Forensic-Exact", String(mode === "forensic" && payloadMode === "full"));
|
|
689
|
+
const coveredFirst = page.events[0]?.sequence;
|
|
690
|
+
const coveredLast = page.events.at(-1)?.sequence;
|
|
691
|
+
if (coveredFirst !== undefined) c.header("X-OpenGeni-Covered-First", String(coveredFirst));
|
|
692
|
+
if (coveredLast !== undefined) c.header("X-OpenGeni-Covered-Last", String(coveredLast));
|
|
693
|
+
const truncatedBy = page.truncated ? "http_bytes" : dbPage.truncatedBy;
|
|
694
|
+
if (truncatedBy) c.header("X-OpenGeni-Truncated-By", truncatedBy);
|
|
695
|
+
if (page.nextSequence !== null) {
|
|
696
|
+
c.header(
|
|
697
|
+
direction === "before" ? "X-OpenGeni-Next-Before" : "X-OpenGeni-Next-After",
|
|
698
|
+
String(page.nextSequence),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
return c.json(page.events);
|
|
485
702
|
});
|
|
486
703
|
|
|
487
704
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
|
|
488
705
|
const workspaceId = c.req.param("workspaceId");
|
|
489
|
-
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
706
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
490
707
|
const sessionId = c.req.param("sessionId");
|
|
708
|
+
let authorization;
|
|
709
|
+
try {
|
|
710
|
+
authorization = await requireSessionAuthorization(deps, grant, {
|
|
711
|
+
sessionId,
|
|
712
|
+
operation: "session.stream.read",
|
|
713
|
+
surface: "stream",
|
|
714
|
+
});
|
|
715
|
+
} catch (error) {
|
|
716
|
+
throw sessionAuthorizationHttpError(error);
|
|
717
|
+
}
|
|
491
718
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
492
719
|
const after = Number(c.req.query("after") ?? c.req.header("Last-Event-ID") ?? 0);
|
|
493
720
|
return sseSessionStream(
|
|
@@ -497,6 +724,22 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
497
724
|
sessionId,
|
|
498
725
|
Number.isFinite(after) ? after : 0,
|
|
499
726
|
c.req.raw.signal,
|
|
727
|
+
{
|
|
728
|
+
observability: deps.observability,
|
|
729
|
+
...(authorization
|
|
730
|
+
? {
|
|
731
|
+
reauthorizeAfterMs:
|
|
732
|
+
authorization.reauthorizeAfterMs ?? SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
|
|
733
|
+
reauthorize: async () => {
|
|
734
|
+
await requireSessionAuthorization(deps, grant, {
|
|
735
|
+
sessionId,
|
|
736
|
+
operation: "session.stream.read",
|
|
737
|
+
surface: "stream",
|
|
738
|
+
});
|
|
739
|
+
},
|
|
740
|
+
}
|
|
741
|
+
: {}),
|
|
742
|
+
},
|
|
500
743
|
);
|
|
501
744
|
});
|
|
502
745
|
|
|
@@ -516,7 +759,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
516
759
|
const sessionId = c.req.param("sessionId");
|
|
517
760
|
const snapshot = await getSessionQueueSnapshot(db, workspaceId, sessionId);
|
|
518
761
|
if (!snapshot) throw new HTTPException(404, { message: "session not found" });
|
|
519
|
-
return c.json(snapshot);
|
|
762
|
+
return c.json(projectQueueSnapshot(snapshot, sessionId, relatedSessionAccessFor(c)));
|
|
520
763
|
});
|
|
521
764
|
|
|
522
765
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/move", async (c) => {
|
|
@@ -526,14 +769,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
526
769
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
527
770
|
const payload = MoveSessionQueueItemRequest.parse(await c.req.json());
|
|
528
771
|
try {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
772
|
+
const response = await moveHumanQueuePrompt(
|
|
773
|
+
deps,
|
|
774
|
+
{
|
|
775
|
+
accountId: grant.accountId,
|
|
776
|
+
workspaceId,
|
|
777
|
+
sessionId,
|
|
778
|
+
subjectId: grant.subjectId,
|
|
779
|
+
},
|
|
780
|
+
c.req.param("turnId"),
|
|
781
|
+
payload,
|
|
536
782
|
);
|
|
783
|
+
return c.json({
|
|
784
|
+
...response,
|
|
785
|
+
snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
|
|
786
|
+
});
|
|
537
787
|
} catch (error) {
|
|
538
788
|
return commandConflictResponse(c, error);
|
|
539
789
|
}
|
|
@@ -546,14 +796,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
546
796
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
547
797
|
const payload = EditSessionQueueItemRequest.parse(await c.req.json());
|
|
548
798
|
try {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
799
|
+
const response = await editHumanQueuePrompt(
|
|
800
|
+
deps,
|
|
801
|
+
{
|
|
802
|
+
accountId: grant.accountId,
|
|
803
|
+
workspaceId,
|
|
804
|
+
sessionId,
|
|
805
|
+
subjectId: grant.subjectId,
|
|
806
|
+
},
|
|
807
|
+
c.req.param("turnId"),
|
|
808
|
+
payload,
|
|
556
809
|
);
|
|
810
|
+
return c.json({
|
|
811
|
+
...response,
|
|
812
|
+
snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
|
|
813
|
+
});
|
|
557
814
|
} catch (error) {
|
|
558
815
|
return commandConflictResponse(c, error);
|
|
559
816
|
}
|
|
@@ -566,14 +823,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
566
823
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
567
824
|
const payload = SteerSessionQueueItemRequest.parse(await c.req.json());
|
|
568
825
|
try {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
826
|
+
const response = await steerHumanQueuePrompt(
|
|
827
|
+
deps,
|
|
828
|
+
{
|
|
829
|
+
accountId: grant.accountId,
|
|
830
|
+
workspaceId,
|
|
831
|
+
sessionId,
|
|
832
|
+
subjectId: grant.subjectId,
|
|
833
|
+
},
|
|
834
|
+
c.req.param("turnId"),
|
|
835
|
+
payload,
|
|
576
836
|
);
|
|
837
|
+
return c.json({
|
|
838
|
+
...response,
|
|
839
|
+
snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
|
|
840
|
+
});
|
|
577
841
|
} catch (error) {
|
|
578
842
|
return commandConflictResponse(c, error);
|
|
579
843
|
}
|
|
@@ -586,14 +850,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
586
850
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
587
851
|
const payload = DeleteSessionQueueItemRequest.parse(await c.req.json());
|
|
588
852
|
try {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
853
|
+
const response = await deleteHumanQueuePrompt(
|
|
854
|
+
deps,
|
|
855
|
+
{
|
|
856
|
+
accountId: grant.accountId,
|
|
857
|
+
workspaceId,
|
|
858
|
+
sessionId,
|
|
859
|
+
subjectId: grant.subjectId,
|
|
860
|
+
},
|
|
861
|
+
c.req.param("turnId"),
|
|
862
|
+
payload,
|
|
596
863
|
);
|
|
864
|
+
return c.json({
|
|
865
|
+
...response,
|
|
866
|
+
snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
|
|
867
|
+
});
|
|
597
868
|
} catch (error) {
|
|
598
869
|
return commandConflictResponse(c, error);
|
|
599
870
|
}
|
|
@@ -604,7 +875,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
604
875
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
605
876
|
const sessionId = c.req.param("sessionId");
|
|
606
877
|
return c.json(
|
|
607
|
-
await getHumanComposerDraft(
|
|
878
|
+
await getHumanComposerDraft(deps, {
|
|
608
879
|
accountId: grant.accountId,
|
|
609
880
|
workspaceId,
|
|
610
881
|
sessionId,
|
|
@@ -621,8 +892,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
621
892
|
try {
|
|
622
893
|
return c.json(
|
|
623
894
|
await saveHumanComposerDraft(
|
|
624
|
-
|
|
625
|
-
{
|
|
895
|
+
deps,
|
|
896
|
+
{
|
|
897
|
+
accountId: grant.accountId,
|
|
898
|
+
workspaceId,
|
|
899
|
+
sessionId,
|
|
900
|
+
subjectId: grant.subjectId,
|
|
901
|
+
},
|
|
626
902
|
payload,
|
|
627
903
|
),
|
|
628
904
|
);
|
|
@@ -634,16 +910,33 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
634
910
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
|
|
635
911
|
const workspaceId = c.req.param("workspaceId");
|
|
636
912
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
913
|
+
if (workspaceControlUtf8Bytes(grant.subjectId) > WORKSPACE_CONTROL_ACTOR_MAX_BYTES) {
|
|
914
|
+
throw new HTTPException(400, { message: "workspace-control actor is too large" });
|
|
915
|
+
}
|
|
637
916
|
const sessionId = c.req.param("sessionId");
|
|
638
|
-
const
|
|
917
|
+
const parsed = SessionControlRequest.safeParse(await c.req.json().catch(() => null));
|
|
918
|
+
if (!parsed.success) {
|
|
919
|
+
throw new HTTPException(400, { message: "invalid session control request" });
|
|
920
|
+
}
|
|
639
921
|
try {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
922
|
+
const response = await controlHumanSessionWorkstream(
|
|
923
|
+
deps,
|
|
924
|
+
{
|
|
925
|
+
accountId: grant.accountId,
|
|
926
|
+
workspaceId,
|
|
927
|
+
sessionId,
|
|
928
|
+
subjectId: grant.subjectId,
|
|
929
|
+
},
|
|
930
|
+
parsed.data,
|
|
646
931
|
);
|
|
932
|
+
return c.json({
|
|
933
|
+
...response,
|
|
934
|
+
effectiveControl: projectEffectiveControlForRelatedAccess(
|
|
935
|
+
response.effectiveControl,
|
|
936
|
+
sessionId,
|
|
937
|
+
relatedSessionAccessFor(c),
|
|
938
|
+
),
|
|
939
|
+
});
|
|
647
940
|
} catch (error) {
|
|
648
941
|
return commandConflictResponse(c, error);
|
|
649
942
|
}
|
|
@@ -658,6 +951,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
658
951
|
const payload = SteerSessionMessageRequest.parse(raw);
|
|
659
952
|
const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
|
|
660
953
|
text: payload.text,
|
|
954
|
+
turnInstructions: payload.turnInstructions ?? null,
|
|
661
955
|
resources: payload.resources,
|
|
662
956
|
tools: payload.tools,
|
|
663
957
|
toolsProvided: userMessagePayloadHasOwnProperty({ payload: raw }, "tools"),
|
|
@@ -681,9 +975,27 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
681
975
|
const sessionId = c.req.param("sessionId");
|
|
682
976
|
const rawEvent = await c.req.json();
|
|
683
977
|
const event = ClientSessionEvent.parse(rawEvent);
|
|
978
|
+
const refinedOperation =
|
|
979
|
+
event.type === "user.approvalDecision"
|
|
980
|
+
? "session.approval.write"
|
|
981
|
+
: event.type === "user.humanInputResponse"
|
|
982
|
+
? "session.human_input.write"
|
|
983
|
+
: null;
|
|
984
|
+
if (refinedOperation) {
|
|
985
|
+
try {
|
|
986
|
+
await requireSessionAuthorization(deps, grant, {
|
|
987
|
+
sessionId,
|
|
988
|
+
operation: refinedOperation,
|
|
989
|
+
surface: "http",
|
|
990
|
+
});
|
|
991
|
+
} catch (error) {
|
|
992
|
+
throw sessionAuthorizationHttpError(error);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
684
995
|
if (event.type === "user.message") {
|
|
685
996
|
const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
|
|
686
997
|
text: event.payload.text,
|
|
998
|
+
turnInstructions: event.payload.turnInstructions ?? null,
|
|
687
999
|
resources: event.payload.resources ?? [],
|
|
688
1000
|
tools: event.payload.tools ?? [],
|
|
689
1001
|
toolsProvided: userMessagePayloadHasOwnProperty(rawEvent, "tools"),
|
|
@@ -726,8 +1038,83 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
726
1038
|
});
|
|
727
1039
|
return c.json(accepted.event, 202);
|
|
728
1040
|
}
|
|
1041
|
+
|
|
1042
|
+
if (event.type === "user.humanInputResponse") {
|
|
1043
|
+
let accepted;
|
|
1044
|
+
try {
|
|
1045
|
+
accepted = await acceptSessionHumanInputResponse(db, {
|
|
1046
|
+
accountId: grant.accountId,
|
|
1047
|
+
workspaceId,
|
|
1048
|
+
sessionId,
|
|
1049
|
+
requestId: event.payload.requestId,
|
|
1050
|
+
response: event.payload.response,
|
|
1051
|
+
respondedBy: grant.subjectId,
|
|
1052
|
+
clientEventId: event.clientEventId ?? null,
|
|
1053
|
+
});
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (error instanceof HumanInputResponseValidationError) {
|
|
1056
|
+
throw new HTTPException(error.code === "SKIP_NOT_ALLOWED" ? 409 : 422, {
|
|
1057
|
+
message: error.message,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
if (accepted.action === "not_found") {
|
|
1063
|
+
throw new HTTPException(404, { message: "human-input request not found" });
|
|
1064
|
+
}
|
|
1065
|
+
await publishDurableSessionEvents(bus, workspaceId, sessionId, accepted.events);
|
|
1066
|
+
if (accepted.workflowWakeRevision !== null) {
|
|
1067
|
+
await workflowClient.signalApprovalDecision({
|
|
1068
|
+
accountId: grant.accountId,
|
|
1069
|
+
workspaceId,
|
|
1070
|
+
sessionId,
|
|
1071
|
+
eventId: accepted.events[0]?.id ?? event.payload.requestId,
|
|
1072
|
+
workflowId: workflowIdForSession(sessionId),
|
|
1073
|
+
workflowWakeRevision: accepted.workflowWakeRevision,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
if (accepted.action === "conflict") {
|
|
1077
|
+
throw new HTTPException(409, {
|
|
1078
|
+
message: `human-input request is ${accepted.request.status}`,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
return c.json(accepted.event, 202);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/human-input-requests", async (c) => {
|
|
1086
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1087
|
+
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
1088
|
+
const sessionId = c.req.param("sessionId");
|
|
1089
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
1090
|
+
const rawStatus = c.req.query("status");
|
|
1091
|
+
const status = rawStatus ? HumanInputRequestStatus.safeParse(rawStatus) : null;
|
|
1092
|
+
if (status && !status.success) {
|
|
1093
|
+
throw new HTTPException(400, { message: "invalid human-input request status" });
|
|
1094
|
+
}
|
|
1095
|
+
const requests = await listSessionHumanInputRequests(db, workspaceId, sessionId, {
|
|
1096
|
+
...(status?.success ? { status: status.data } : {}),
|
|
1097
|
+
});
|
|
1098
|
+
return c.json({ requests });
|
|
729
1099
|
});
|
|
730
1100
|
|
|
1101
|
+
app.get(
|
|
1102
|
+
"/v1/workspaces/:workspaceId/sessions/:sessionId/human-input-requests/:requestId",
|
|
1103
|
+
async (c) => {
|
|
1104
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1105
|
+
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
1106
|
+
const sessionId = c.req.param("sessionId");
|
|
1107
|
+
const request = await getSessionHumanInputRequest(
|
|
1108
|
+
db,
|
|
1109
|
+
workspaceId,
|
|
1110
|
+
sessionId,
|
|
1111
|
+
c.req.param("requestId"),
|
|
1112
|
+
);
|
|
1113
|
+
if (!request) throw new HTTPException(404, { message: "human-input request not found" });
|
|
1114
|
+
return c.json(request);
|
|
1115
|
+
},
|
|
1116
|
+
);
|
|
1117
|
+
|
|
731
1118
|
// ── API-direct stream capabilities + viewer attach (P1.4) ─────────────────
|
|
732
1119
|
//
|
|
733
1120
|
// All IN-PROCESS: capability negotiation reads the descriptor + the group
|
|
@@ -781,6 +1168,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
781
1168
|
{ workspaceId, sandboxGroupId: session.sandboxGroupId },
|
|
782
1169
|
);
|
|
783
1170
|
const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
|
|
1171
|
+
const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
|
|
784
1172
|
// Per-principal acknowledgment: A acknowledging does not consent for B. The
|
|
785
1173
|
// un-redacted desktop stream ALWAYS requires the un-redacted ack; a shared box
|
|
786
1174
|
// ADDITIONALLY requires the shared-exposure ack. Both must match the POST
|
|
@@ -872,13 +1260,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
872
1260
|
// tracks the desktop tier + a desktop-capable backend.
|
|
873
1261
|
computerUseEnabled: settings.computerUseEnabled,
|
|
874
1262
|
computerUseReadOnly: settings.computerUseReadOnly,
|
|
875
|
-
// Graceful degrade (
|
|
1263
|
+
// Graceful degrade (stream-token availability contract): if desktop is enabled but no stream-token
|
|
876
1264
|
// secret is resolvable, the desktop cell reports transport:null rather
|
|
877
1265
|
// than advertising a plane we can never authorize.
|
|
878
1266
|
streamTokenSecretAvailable: !streamTokenDegraded(settings),
|
|
879
1267
|
desktopAcknowledged: acknowledged,
|
|
880
1268
|
shared,
|
|
881
|
-
sharedSessionIds,
|
|
1269
|
+
sharedSessionIds: visibleSharedSessionIds,
|
|
882
1270
|
// The minted live address (null when not unlocked/degraded). The resolver
|
|
883
1271
|
// only folds it in when the desktop gates pass + the ack is present.
|
|
884
1272
|
...(desktopStream
|
|
@@ -960,7 +1348,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
960
1348
|
}
|
|
961
1349
|
const parsed = AcknowledgeStreamRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
962
1350
|
if (!parsed.success) {
|
|
963
|
-
throw new HTTPException(400, {
|
|
1351
|
+
throw new HTTPException(400, {
|
|
1352
|
+
message: "invalid stream acknowledgment request",
|
|
1353
|
+
});
|
|
964
1354
|
}
|
|
965
1355
|
const recorded = await recordStreamAcknowledgment(db, {
|
|
966
1356
|
accountId: grant.accountId,
|
|
@@ -996,7 +1386,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
996
1386
|
}
|
|
997
1387
|
const parsed = AttachViewerRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
998
1388
|
if (!parsed.success) {
|
|
999
|
-
throw new HTTPException(400, {
|
|
1389
|
+
throw new HTTPException(400, {
|
|
1390
|
+
message: "invalid viewer attach request",
|
|
1391
|
+
});
|
|
1000
1392
|
}
|
|
1001
1393
|
// Consent gate (P3.2 / addendum E.1): ONLY the un-redacted DESKTOP pixel plane
|
|
1002
1394
|
// requires the calling principal's acknowledgment (recorded per group+subject;
|
|
@@ -1017,10 +1409,14 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1017
1409
|
subjectId: grant.subjectId,
|
|
1018
1410
|
});
|
|
1019
1411
|
if (!ack?.acknowledgedUnredacted) {
|
|
1020
|
-
throw new HTTPException(409, {
|
|
1412
|
+
throw new HTTPException(409, {
|
|
1413
|
+
message: "stream_acknowledgment_required",
|
|
1414
|
+
});
|
|
1021
1415
|
}
|
|
1022
1416
|
if (shared && !ack.acknowledgedShared) {
|
|
1023
|
-
throw new HTTPException(409, {
|
|
1417
|
+
throw new HTTPException(409, {
|
|
1418
|
+
message: "shared_acknowledgment_required",
|
|
1419
|
+
});
|
|
1024
1420
|
}
|
|
1025
1421
|
}
|
|
1026
1422
|
// SELFHOSTED ACTIVE: when the session's active sandbox is selfhosted, skip
|
|
@@ -1177,7 +1573,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1177
1573
|
}
|
|
1178
1574
|
const parsed = ViewerHeartbeatRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
1179
1575
|
if (!parsed.success) {
|
|
1180
|
-
throw new HTTPException(400, {
|
|
1576
|
+
throw new HTTPException(400, {
|
|
1577
|
+
message: "viewer heartbeat requires { leaseEpoch }",
|
|
1578
|
+
});
|
|
1181
1579
|
}
|
|
1182
1580
|
const alive = await heartbeatViewer(
|
|
1183
1581
|
{ db, settings },
|
|
@@ -1240,7 +1638,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1240
1638
|
idleGraceMs: settings.sandboxIdleGraceMs,
|
|
1241
1639
|
});
|
|
1242
1640
|
// null ⇒ the lease was already cold-and-reaped (revoke is an idempotent no-op).
|
|
1243
|
-
return c.json({
|
|
1641
|
+
return c.json({
|
|
1642
|
+
liveness: result?.liveness ?? null,
|
|
1643
|
+
refcount: result?.refcount ?? null,
|
|
1644
|
+
});
|
|
1244
1645
|
},
|
|
1245
1646
|
);
|
|
1246
1647
|
|
|
@@ -1281,12 +1682,19 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1281
1682
|
if (!session) {
|
|
1282
1683
|
throw new HTTPException(404, { message: "session not found" });
|
|
1283
1684
|
}
|
|
1284
|
-
return {
|
|
1685
|
+
return {
|
|
1686
|
+
accountId: grant.accountId,
|
|
1687
|
+
workspaceId,
|
|
1688
|
+
session,
|
|
1689
|
+
subjectId: grant.subjectId,
|
|
1690
|
+
};
|
|
1285
1691
|
}
|
|
1286
1692
|
|
|
1287
1693
|
async function parseChannelABody<T>(
|
|
1288
1694
|
c: Context,
|
|
1289
|
-
schema: {
|
|
1695
|
+
schema: {
|
|
1696
|
+
safeParse: (v: unknown) => { success: true; data: T } | { success: false };
|
|
1697
|
+
},
|
|
1290
1698
|
): Promise<T> {
|
|
1291
1699
|
const raw = await c.req.json().catch(() => undefined);
|
|
1292
1700
|
const result = schema.safeParse(raw ?? {});
|
|
@@ -1416,7 +1824,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1416
1824
|
const sessionId = c.req.param("sessionId") ?? "";
|
|
1417
1825
|
const path = c.req.query("path");
|
|
1418
1826
|
if (!path) {
|
|
1419
|
-
throw new HTTPException(400, {
|
|
1827
|
+
throw new HTTPException(400, {
|
|
1828
|
+
message: "path query parameter is required",
|
|
1829
|
+
});
|
|
1420
1830
|
}
|
|
1421
1831
|
const session = await getSession(db, workspaceId, sessionId);
|
|
1422
1832
|
if (!session) {
|
|
@@ -1431,7 +1841,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1431
1841
|
if (revisionParam !== undefined && revisionParam !== "") {
|
|
1432
1842
|
const revision = Number(revisionParam);
|
|
1433
1843
|
if (!Number.isInteger(revision) || revision < 0) {
|
|
1434
|
-
throw new HTTPException(400, {
|
|
1844
|
+
throw new HTTPException(400, {
|
|
1845
|
+
message: "revision must be a non-negative integer",
|
|
1846
|
+
});
|
|
1435
1847
|
}
|
|
1436
1848
|
row = await workspaceCaptureAtRevision(db, workspaceId, sessionId, revision);
|
|
1437
1849
|
} else {
|
|
@@ -1503,7 +1915,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1503
1915
|
throw new HTTPException(404, { message: "pty not found or closed" });
|
|
1504
1916
|
}
|
|
1505
1917
|
if (pty.execSessionId === null) {
|
|
1506
|
-
throw new HTTPException(409, {
|
|
1918
|
+
throw new HTTPException(409, {
|
|
1919
|
+
message: "interactive terminal unsupported on this backend",
|
|
1920
|
+
});
|
|
1507
1921
|
}
|
|
1508
1922
|
let seq = 1;
|
|
1509
1923
|
await withChannelA({ db, settings, bus }, ctx, async ({ service }) => {
|
|
@@ -1565,7 +1979,11 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1565
1979
|
workspaceId: ctx.workspaceId,
|
|
1566
1980
|
ptyId: req.ptyId,
|
|
1567
1981
|
});
|
|
1568
|
-
const exited: TerminalPtyExitedPayload = {
|
|
1982
|
+
const exited: TerminalPtyExitedPayload = {
|
|
1983
|
+
ptyId: req.ptyId,
|
|
1984
|
+
exitCode: 0,
|
|
1985
|
+
reason: "exit",
|
|
1986
|
+
};
|
|
1569
1987
|
await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
|
|
1570
1988
|
{ type: "terminal.pty.exited", payload: exited },
|
|
1571
1989
|
]);
|
|
@@ -1574,14 +1992,157 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1574
1992
|
});
|
|
1575
1993
|
}
|
|
1576
1994
|
|
|
1577
|
-
function eventListLimit(raw: string | undefined, max = 2000): number {
|
|
1578
|
-
const limit = Number(raw ??
|
|
1995
|
+
function eventListLimit(raw: string | undefined, max = 2000, fallback = 500): number {
|
|
1996
|
+
const limit = Number(raw ?? fallback);
|
|
1579
1997
|
if (!Number.isFinite(limit)) {
|
|
1580
|
-
return
|
|
1998
|
+
return fallback;
|
|
1581
1999
|
}
|
|
1582
2000
|
return Math.min(max, Math.max(1, Math.floor(limit)));
|
|
1583
2001
|
}
|
|
1584
2002
|
|
|
2003
|
+
/**
|
|
2004
|
+
* Map every mounted session-addressed HTTP path to the host-neutral operation
|
|
2005
|
+
* the embedding port authorizes. Returning null is deliberately fail-closed in
|
|
2006
|
+
* host-managed mode; standalone deployments never consult this classifier.
|
|
2007
|
+
*/
|
|
2008
|
+
export function sessionAuthorizationOperationForHttp(
|
|
2009
|
+
method: string,
|
|
2010
|
+
pathname: string,
|
|
2011
|
+
sessionId: string,
|
|
2012
|
+
): SessionAuthorizationOperation | null {
|
|
2013
|
+
const marker = `/sessions/${sessionId}`;
|
|
2014
|
+
const markerAt = pathname.indexOf(marker);
|
|
2015
|
+
if (markerAt < 0) return null;
|
|
2016
|
+
const suffix = pathname.slice(markerAt + marker.length);
|
|
2017
|
+
const verb = method.toUpperCase();
|
|
2018
|
+
|
|
2019
|
+
if (suffix === "") {
|
|
2020
|
+
if (verb === "GET") return "session.read";
|
|
2021
|
+
if (verb === "PATCH") return "session.title.write";
|
|
2022
|
+
return null;
|
|
2023
|
+
}
|
|
2024
|
+
if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
|
|
2025
|
+
if (suffix === "/lineage" && verb === "GET") return "session.lineage.read";
|
|
2026
|
+
if (suffix === "/codex-account" && verb === "POST") {
|
|
2027
|
+
return "session.codex_account.write";
|
|
2028
|
+
}
|
|
2029
|
+
if (suffix === "/goal") {
|
|
2030
|
+
return verb === "GET"
|
|
2031
|
+
? "session.goal.read"
|
|
2032
|
+
: ["PATCH", "DELETE"].includes(verb)
|
|
2033
|
+
? "session.goal.write"
|
|
2034
|
+
: null;
|
|
2035
|
+
}
|
|
2036
|
+
if (suffix === "/context/clear" || suffix === "/context/compact") {
|
|
2037
|
+
return verb === "POST" ? "session.context.write" : null;
|
|
2038
|
+
}
|
|
2039
|
+
if (suffix === "/events/stream" && verb === "GET") return "session.stream.read";
|
|
2040
|
+
if (suffix === "/events") {
|
|
2041
|
+
if (verb === "GET") return "session.events.read";
|
|
2042
|
+
if (verb === "POST") return "session.append";
|
|
2043
|
+
return null;
|
|
2044
|
+
}
|
|
2045
|
+
if (suffix === "/turns" && verb === "GET") return "session.turns.read";
|
|
2046
|
+
if (suffix === "/queue" && verb === "GET") return "session.queue.read";
|
|
2047
|
+
if (suffix.startsWith("/queue/") && verb === "POST") return "session.queue.control";
|
|
2048
|
+
if (suffix === "/composer-draft") {
|
|
2049
|
+
if (verb === "GET") return "session.composer.read";
|
|
2050
|
+
if (verb === "PUT") return "session.composer.write";
|
|
2051
|
+
return null;
|
|
2052
|
+
}
|
|
2053
|
+
if (suffix === "/control" && verb === "POST") return "session.control";
|
|
2054
|
+
if (suffix === "/steer" && verb === "POST") return "session.steer";
|
|
2055
|
+
if (suffix === "/human-input-requests" && verb === "GET") {
|
|
2056
|
+
return "session.human_input.read";
|
|
2057
|
+
}
|
|
2058
|
+
if (suffix.startsWith("/human-input-requests/") && verb === "GET") {
|
|
2059
|
+
return "session.human_input.read";
|
|
2060
|
+
}
|
|
2061
|
+
if (suffix === "/stream-capabilities" && verb === "GET") return "session.viewer.read";
|
|
2062
|
+
if (suffix === "/stream-capabilities/acknowledge" && verb === "POST") {
|
|
2063
|
+
return "session.stream.acknowledge";
|
|
2064
|
+
}
|
|
2065
|
+
if (suffix === "/viewers" && verb === "POST") return "session.viewer.control";
|
|
2066
|
+
if (suffix.startsWith("/viewers/") && ["POST", "DELETE"].includes(verb)) {
|
|
2067
|
+
return "session.viewer.control";
|
|
2068
|
+
}
|
|
2069
|
+
if (suffix === "/fs/list" || suffix === "/fs/read") {
|
|
2070
|
+
return verb === "POST" ? "session.files.read" : null;
|
|
2071
|
+
}
|
|
2072
|
+
if (["/fs/write", "/fs/delete", "/fs/move", "/fs/mkdir"].includes(suffix)) {
|
|
2073
|
+
return verb === "POST" ? "session.files.write" : null;
|
|
2074
|
+
}
|
|
2075
|
+
if (suffix.startsWith("/git/") && verb === "POST") return "session.git.read";
|
|
2076
|
+
if ((suffix === "/workspace/capture" || suffix === "/workspace/capture/file") && verb === "GET") {
|
|
2077
|
+
return "session.capture.read";
|
|
2078
|
+
}
|
|
2079
|
+
if (suffix === "/terminal/exec" && verb === "POST") return "session.terminal.control";
|
|
2080
|
+
if (suffix === "/terminal/pty" && verb === "POST") return "session.terminal.control";
|
|
2081
|
+
if (suffix.startsWith("/terminal/pty/") && verb === "POST") {
|
|
2082
|
+
return "session.terminal.control";
|
|
2083
|
+
}
|
|
2084
|
+
return null;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function sessionAuthorizationHttpError(error: unknown): HTTPException {
|
|
2088
|
+
if (error instanceof SessionAuthorizationDeniedError) {
|
|
2089
|
+
return new HTTPException(404, { message: "session not found" });
|
|
2090
|
+
}
|
|
2091
|
+
if (error instanceof SessionAuthorizationUnavailableError) {
|
|
2092
|
+
return new HTTPException(503, { message: "session authorization is unavailable" });
|
|
2093
|
+
}
|
|
2094
|
+
if (error instanceof HTTPException) return error;
|
|
2095
|
+
throw error;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function eventEnumValue<T extends string>(
|
|
2099
|
+
raw: string | undefined,
|
|
2100
|
+
schema: { safeParse(value: unknown): { success: boolean; data?: T } },
|
|
2101
|
+
name: string,
|
|
2102
|
+
fallback: T,
|
|
2103
|
+
): T;
|
|
2104
|
+
function eventEnumValue<T extends string>(
|
|
2105
|
+
raw: string | undefined,
|
|
2106
|
+
schema: { safeParse(value: unknown): { success: boolean; data?: T } },
|
|
2107
|
+
name: string,
|
|
2108
|
+
fallback: undefined,
|
|
2109
|
+
): T | undefined;
|
|
2110
|
+
function eventEnumValue<T extends string>(
|
|
2111
|
+
raw: string | undefined,
|
|
2112
|
+
schema: { safeParse(value: unknown): { success: boolean; data?: T } },
|
|
2113
|
+
name: string,
|
|
2114
|
+
fallback: T | undefined,
|
|
2115
|
+
): T | undefined {
|
|
2116
|
+
if (raw === undefined) return fallback;
|
|
2117
|
+
const parsed = schema.safeParse(raw);
|
|
2118
|
+
if (!parsed.success) {
|
|
2119
|
+
throw new HTTPException(400, { message: `${name} is invalid` });
|
|
2120
|
+
}
|
|
2121
|
+
return parsed.data as T;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function eventEnumList<T extends string>(
|
|
2125
|
+
raw: string | undefined,
|
|
2126
|
+
schema: { safeParse(value: unknown): { success: boolean; data?: T } },
|
|
2127
|
+
name: string,
|
|
2128
|
+
): T[] {
|
|
2129
|
+
if (raw === undefined || raw.trim() === "") return [];
|
|
2130
|
+
const values = raw
|
|
2131
|
+
.split(",")
|
|
2132
|
+
.map((value) => value.trim())
|
|
2133
|
+
.filter(Boolean);
|
|
2134
|
+
if (values.length > 100) {
|
|
2135
|
+
throw new HTTPException(400, { message: `${name} accepts at most 100 values` });
|
|
2136
|
+
}
|
|
2137
|
+
return values.map((value) => {
|
|
2138
|
+
const parsed = schema.safeParse(value);
|
|
2139
|
+
if (!parsed.success) {
|
|
2140
|
+
throw new HTTPException(400, { message: `${name} contains an invalid value` });
|
|
2141
|
+
}
|
|
2142
|
+
return parsed.data as T;
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
|
|
1585
2146
|
function sessionListQuery(
|
|
1586
2147
|
query: Record<string, string>,
|
|
1587
2148
|
allowCursor = true,
|
|
@@ -1611,7 +2172,9 @@ function sessionListQuery(
|
|
|
1611
2172
|
}
|
|
1612
2173
|
const search = query.search?.trim();
|
|
1613
2174
|
if (search && search.length > 200) {
|
|
1614
|
-
throw new HTTPException(400, {
|
|
2175
|
+
throw new HTTPException(400, {
|
|
2176
|
+
message: "search must be at most 200 characters",
|
|
2177
|
+
});
|
|
1615
2178
|
}
|
|
1616
2179
|
return {
|
|
1617
2180
|
limit: query.limit,
|