@opengeni/api-router 0.22.2 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +0 -30
- package/dist/browser-controller-authority.d.ts +43 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
- package/dist/chunk-JIKNR5YL.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/editable-artifact-live-hints.d.ts +11 -0
- package/dist/editable-artifact-native-kernel.d.ts +37 -0
- package/dist/editable-artifact-office-import.d.ts +22 -0
- package/dist/editable-artifact-production.d.ts +29 -0
- package/dist/editable-artifact-websocket.d.ts +49 -0
- package/dist/editable-artifact-workspace-files.d.ts +23 -0
- package/dist/github-browser-flow.d.ts +6 -0
- package/dist/http/cors.d.ts +1 -0
- package/dist/http/sse.d.ts +2 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1824 -30
- package/dist/index.js.map +1 -1
- package/dist/integrations/api-integrations.d.ts +24 -0
- package/dist/integrations/atlassian.d.ts +176 -0
- package/dist/integrations/github-skill-source.d.ts +5 -0
- package/dist/integrations/google-drive.d.ts +85 -0
- package/dist/integrations/oauth-client.d.ts +30 -1
- package/dist/integrations/provider-oauth.d.ts +19 -0
- package/dist/integrations/slack-bot.d.ts +4 -0
- package/dist/integrations/slack-interactions.d.ts +14 -2
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
- package/dist/mcp/editable-artifacts.d.ts +13 -0
- package/dist/mcp/receipts.d.ts +28 -0
- package/dist/mcp/scheduled-task-view.d.ts +518 -0
- package/dist/mcp/server.d.ts +14 -3
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/routes/api-integrations.d.ts +8 -0
- package/dist/routes/browser-identities.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +6 -0
- package/dist/routes/company-profile.d.ts +3 -0
- package/dist/routes/computer-sessions.d.ts +6 -0
- package/dist/routes/editable-artifacts.d.ts +44 -0
- package/dist/routes/integration-features.d.ts +3 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +17 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/auth-callout.d.ts +2 -0
- package/dist/sandbox/channel-a.d.ts +59 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +4 -2
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/package.json +19 -14
- package/src/app.ts +277 -41
- package/src/auth/managed-auth.ts +0 -16
- package/src/browser-controller-authority.ts +137 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/editable-artifact-live-hints.ts +64 -0
- package/src/editable-artifact-native-kernel.ts +659 -0
- package/src/editable-artifact-office-import.ts +230 -0
- package/src/editable-artifact-production.ts +419 -0
- package/src/editable-artifact-websocket.ts +311 -0
- package/src/editable-artifact-workspace-files.ts +186 -0
- package/src/github-browser-flow.ts +35 -6
- package/src/http/auth.ts +2 -0
- package/src/http/cors.ts +3 -0
- package/src/http/sse.ts +101 -6
- package/src/index.ts +147 -23
- package/src/integrations/api-integrations.ts +350 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1000 -64
- package/src/integrations/oauth-client.ts +159 -89
- package/src/integrations/provider-oauth.ts +777 -0
- package/src/integrations/slack-bot.ts +31 -2
- package/src/integrations/slack-interactions.ts +610 -42
- package/src/integrations/social-api.ts +11 -0
- package/src/mcp/documents.ts +74 -26
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/receipts.ts +95 -0
- package/src/mcp/scheduled-task-view.ts +642 -0
- package/src/mcp/server.ts +1718 -310
- package/src/memory-slack-delivery.ts +209 -0
- package/src/observability.ts +3 -3
- package/src/routes/api-integrations.ts +407 -0
- package/src/routes/api-keys.ts +7 -1
- package/src/routes/browser-identities.ts +136 -0
- package/src/routes/browser-sessions.ts +2543 -0
- package/src/routes/codex.ts +7 -4
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1247 -0
- package/src/routes/connections.ts +358 -102
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/enrollments.ts +54 -12
- package/src/routes/environments.ts +60 -11
- package/src/routes/files.ts +277 -65
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +38 -2
- package/src/routes/integration-features.ts +258 -0
- package/src/routes/machines.ts +1 -1
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +751 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +94 -42
- package/src/routes/sessions.ts +475 -234
- package/src/routes/skills.ts +174 -0
- package/src/routes/transcription-recordings.ts +65 -33
- package/src/routes/video-generation.ts +132 -0
- package/src/routes/workspaces.ts +46 -24
- package/src/sandbox/auth-callout.ts +16 -4
- package/src/sandbox/channel-a.ts +809 -85
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +58 -29
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/dist/chunk-HWXJW5C7.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -62
- package/src/mcp/toolspace.ts +0 -1186
package/src/routes/sessions.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
EditSessionQueueItemRequest,
|
|
13
13
|
EndSessionRealtimeRequest,
|
|
14
14
|
FsDeleteRequest,
|
|
15
|
+
FsListBatchRequest,
|
|
15
16
|
FsListRequest,
|
|
16
17
|
FsMkdirRequest,
|
|
17
18
|
FsMoveRequest,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
FsWriteRequest,
|
|
20
21
|
HumanInputRequestStatus,
|
|
21
22
|
GitDiffRequest,
|
|
23
|
+
GitReadBatchRequest,
|
|
22
24
|
GitLogRequest,
|
|
23
25
|
GitShowRequest,
|
|
24
26
|
GitStatusRequest,
|
|
@@ -53,9 +55,12 @@ import {
|
|
|
53
55
|
ViewerHeartbeatRequest,
|
|
54
56
|
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
55
57
|
workspaceControlUtf8Bytes,
|
|
58
|
+
type AccessGrant,
|
|
59
|
+
type AttachViewerResponse,
|
|
56
60
|
type SandboxBackend,
|
|
57
61
|
type LineageNode,
|
|
58
62
|
type Session,
|
|
63
|
+
type AgentTopologyPageResponse,
|
|
59
64
|
type ErrorCode,
|
|
60
65
|
type SessionAuthorizationOperation,
|
|
61
66
|
type SessionQueueSnapshot,
|
|
@@ -84,6 +89,8 @@ import {
|
|
|
84
89
|
listSessionEventPage,
|
|
85
90
|
listSessionHumanInputRequests,
|
|
86
91
|
listSessionIdsInGroup,
|
|
92
|
+
listSessionDiscoverySummaries,
|
|
93
|
+
listSessionDiscoveryAncestorPaths,
|
|
87
94
|
listSessionsForSubject,
|
|
88
95
|
getLatestStartedSessionTurn,
|
|
89
96
|
listSessionTurns,
|
|
@@ -91,8 +98,8 @@ import {
|
|
|
91
98
|
projectSessionForRelatedAccess,
|
|
92
99
|
recordStreamAcknowledgment,
|
|
93
100
|
requestSessionCompaction,
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
setSessionCodexPinInTransaction,
|
|
102
|
+
withSessionCodexCapacityMutation,
|
|
96
103
|
setSessionPin,
|
|
97
104
|
SessionPinVersionConflictError,
|
|
98
105
|
SessionPinAccessError,
|
|
@@ -122,6 +129,7 @@ import {
|
|
|
122
129
|
sessionLatestWorkspaceCapture,
|
|
123
130
|
renewSessionRealtimeInTransaction,
|
|
124
131
|
syncSessionRealtimeLedgerInTransaction,
|
|
132
|
+
withWorkspaceSessionActivityRls,
|
|
125
133
|
withWorkspaceRls,
|
|
126
134
|
workspaceCaptureAtRevision,
|
|
127
135
|
type AppendEventInput,
|
|
@@ -129,6 +137,8 @@ import {
|
|
|
129
137
|
type SandboxPtyProcessIdentity,
|
|
130
138
|
type SandboxRetainedProcess,
|
|
131
139
|
type Database,
|
|
140
|
+
type SessionDiscoveryCursor,
|
|
141
|
+
type SessionDiscoveryAncestor,
|
|
132
142
|
} from "@opengeni/db";
|
|
133
143
|
import {
|
|
134
144
|
appendAndPublishEvents,
|
|
@@ -141,13 +151,22 @@ import {
|
|
|
141
151
|
GatewayRealtimeBrokerError,
|
|
142
152
|
} from "../gateway-realtime";
|
|
143
153
|
import { z, ZodError } from "zod";
|
|
144
|
-
import {
|
|
154
|
+
import {
|
|
155
|
+
runConcurrentChannelAReads,
|
|
156
|
+
withChannelA,
|
|
157
|
+
withChannelARead,
|
|
158
|
+
type ChannelAContext,
|
|
159
|
+
type ChannelAHandle,
|
|
160
|
+
type ChannelAOperation,
|
|
161
|
+
} from "../sandbox/channel-a";
|
|
145
162
|
import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
|
|
146
163
|
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
147
164
|
import { HTTPException } from "hono/http-exception";
|
|
148
165
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
149
166
|
import {
|
|
167
|
+
hasPermission,
|
|
150
168
|
requireAccessGrant,
|
|
169
|
+
requirePermission,
|
|
151
170
|
requireSessionAuthorization,
|
|
152
171
|
requireSessionAuthorizationListScope,
|
|
153
172
|
SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
|
|
@@ -203,6 +222,19 @@ import {
|
|
|
203
222
|
|
|
204
223
|
type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
|
|
205
224
|
|
|
225
|
+
const VIEWER_LIFECYCLE_PERMISSIONS = ["stream:view", "terminal:attach", "files:write"] as const;
|
|
226
|
+
|
|
227
|
+
function requireViewerLifecyclePermission(grant: AccessGrant): void {
|
|
228
|
+
if (
|
|
229
|
+
VIEWER_LIFECYCLE_PERMISSIONS.some((permission) => hasPermission(grant.permissions, permission))
|
|
230
|
+
) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
throw new HTTPException(403, {
|
|
234
|
+
message: `missing permission: ${VIEWER_LIFECYCLE_PERMISSIONS.join(" or ")}`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
206
238
|
export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
207
239
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
208
240
|
const channelAServices = { db, settings, bus, observability: deps.observability };
|
|
@@ -333,6 +365,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
333
365
|
const authorizeSessionHttp: MiddlewareHandler = async (c, next) => {
|
|
334
366
|
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
335
367
|
const sessionId = c.req.param("sessionId") ?? "";
|
|
368
|
+
// Reject malformed route identifiers before the authorization resolver
|
|
369
|
+
// reaches UUID-typed persistence queries. Besides avoiding a needless DB
|
|
370
|
+
// round trip, this preserves the session surface's non-enumerating 404
|
|
371
|
+
// contract instead of leaking a driver-level 500.
|
|
372
|
+
if (!z.string().uuid().safeParse(sessionId).success) {
|
|
373
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
374
|
+
}
|
|
336
375
|
const operation = sessionAuthorizationOperationForHttp(
|
|
337
376
|
c.req.method,
|
|
338
377
|
new URL(c.req.url).pathname,
|
|
@@ -397,7 +436,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
397
436
|
// Creation has committed by this point. Keep response projection outside
|
|
398
437
|
// the create-rejection boundary so a post-commit policy read cannot be
|
|
399
438
|
// misreported as though the session itself was rejected.
|
|
400
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
|
|
439
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session), 202);
|
|
401
440
|
});
|
|
402
441
|
|
|
403
442
|
app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
|
|
@@ -501,7 +540,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
501
540
|
// body for older clients while still making its older-pin omission visible
|
|
502
541
|
// to raw HTTP consumers without changing that response shape.
|
|
503
542
|
c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
|
|
504
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
543
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
|
|
505
544
|
const decorate = (session: Session): Session =>
|
|
506
545
|
sessionWithEffectiveToolPolicy(
|
|
507
546
|
session,
|
|
@@ -523,6 +562,83 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
523
562
|
return c.json([...page.pinned, ...page.sessions].map(decorate));
|
|
524
563
|
});
|
|
525
564
|
|
|
565
|
+
app.get("/v1/workspaces/:workspaceId/agent-topology", async (c) => {
|
|
566
|
+
const workspaceId = c.req.param("workspaceId");
|
|
567
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
568
|
+
let authorizationScope;
|
|
569
|
+
try {
|
|
570
|
+
authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
|
|
571
|
+
} catch (error) {
|
|
572
|
+
throw sessionAuthorizationHttpError(error);
|
|
573
|
+
}
|
|
574
|
+
const query = agentTopologyQuery(c.req.query());
|
|
575
|
+
const page = await listSessionDiscoverySummaries(db, workspaceId, {
|
|
576
|
+
limit: query.limit,
|
|
577
|
+
orderBy: "updatedAt",
|
|
578
|
+
subjectId: grant.subjectId,
|
|
579
|
+
...(query.cursor ? { cursor: query.cursor } : {}),
|
|
580
|
+
...(query.search ? { search: query.search } : { parentSessionId: query.parentSessionId }),
|
|
581
|
+
...(authorizationScope ? { authorizationScope } : {}),
|
|
582
|
+
});
|
|
583
|
+
const ancestorPaths = query.search
|
|
584
|
+
? await listSessionDiscoveryAncestorPaths(
|
|
585
|
+
db,
|
|
586
|
+
workspaceId,
|
|
587
|
+
page.sessions.map((session) => session.id),
|
|
588
|
+
authorizationScope ?? undefined,
|
|
589
|
+
)
|
|
590
|
+
: new Map<string, SessionDiscoveryAncestor[]>();
|
|
591
|
+
const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
|
|
592
|
+
const blocker = session.effectiveControl.primaryBlocker;
|
|
593
|
+
return {
|
|
594
|
+
id: session.id,
|
|
595
|
+
title: session.title,
|
|
596
|
+
titleTruncated:
|
|
597
|
+
session.titleOriginalChars !== null &&
|
|
598
|
+
session.titleOriginalChars > Array.from(session.title ?? "").length,
|
|
599
|
+
parentSessionId: session.parentSessionId,
|
|
600
|
+
rootSessionId: session.rootSessionId,
|
|
601
|
+
nestedAgentDepth: session.nestedAgentDepth,
|
|
602
|
+
ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
|
|
603
|
+
id: ancestor.id,
|
|
604
|
+
title: ancestor.title,
|
|
605
|
+
titleTruncated:
|
|
606
|
+
ancestor.titleOriginalChars !== null &&
|
|
607
|
+
ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
|
|
608
|
+
})),
|
|
609
|
+
status: session.status,
|
|
610
|
+
pause: {
|
|
611
|
+
state: session.effectiveControl.state,
|
|
612
|
+
additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
|
|
613
|
+
source: blocker
|
|
614
|
+
? {
|
|
615
|
+
kind: blocker.kind,
|
|
616
|
+
...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
|
|
617
|
+
displayName: blocker.displayName,
|
|
618
|
+
displayNameTruncated:
|
|
619
|
+
blocker.displayNameOriginalChars > Array.from(blocker.displayName).length,
|
|
620
|
+
}
|
|
621
|
+
: null,
|
|
622
|
+
},
|
|
623
|
+
children: session.treeStats,
|
|
624
|
+
createdAt: session.createdAt,
|
|
625
|
+
updatedAt: session.updatedAt,
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
return c.json({
|
|
629
|
+
sessions,
|
|
630
|
+
total: page.total,
|
|
631
|
+
hasMore: page.hasMore,
|
|
632
|
+
nextCursor: page.nextCursor
|
|
633
|
+
? encodeAgentTopologyCursor({
|
|
634
|
+
cursor: page.nextCursor,
|
|
635
|
+
parentSessionId: query.parentSessionId,
|
|
636
|
+
search: query.search ?? null,
|
|
637
|
+
})
|
|
638
|
+
: null,
|
|
639
|
+
} satisfies AgentTopologyPageResponse);
|
|
640
|
+
});
|
|
641
|
+
|
|
526
642
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
|
|
527
643
|
const workspaceId = c.req.param("workspaceId");
|
|
528
644
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -540,7 +656,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
540
656
|
if (!session) {
|
|
541
657
|
throw new HTTPException(404, { message: "session not found" });
|
|
542
658
|
}
|
|
543
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
659
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
544
660
|
});
|
|
545
661
|
|
|
546
662
|
const publishRealtimeMutation = async (
|
|
@@ -579,16 +695,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
579
695
|
throw new HTTPException(400, { message: "invalid session realtime request" });
|
|
580
696
|
}
|
|
581
697
|
try {
|
|
582
|
-
const result = await
|
|
583
|
-
scopedDb
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}),
|
|
591
|
-
),
|
|
698
|
+
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
699
|
+
beginSessionRealtimeInTransaction(scopedDb, {
|
|
700
|
+
accountId: grant.accountId,
|
|
701
|
+
workspaceId,
|
|
702
|
+
sessionId,
|
|
703
|
+
ownerSubjectId: grant.subjectId,
|
|
704
|
+
...parsed.data,
|
|
705
|
+
}),
|
|
592
706
|
);
|
|
593
707
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
594
708
|
c.header("cache-control", "private, no-store");
|
|
@@ -616,16 +730,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
616
730
|
throw new HTTPException(400, { message: "invalid realtime heartbeat request" });
|
|
617
731
|
}
|
|
618
732
|
try {
|
|
619
|
-
const result = await
|
|
620
|
-
scopedDb
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}),
|
|
628
|
-
),
|
|
733
|
+
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
734
|
+
renewSessionRealtimeInTransaction(scopedDb, {
|
|
735
|
+
workspaceId,
|
|
736
|
+
sessionId,
|
|
737
|
+
realtimeId,
|
|
738
|
+
ownerSubjectId: grant.subjectId,
|
|
739
|
+
...parsed.data,
|
|
740
|
+
}),
|
|
629
741
|
);
|
|
630
742
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
631
743
|
c.header("cache-control", "private, no-store");
|
|
@@ -652,16 +764,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
652
764
|
throw new HTTPException(400, { message: "invalid realtime end request" });
|
|
653
765
|
}
|
|
654
766
|
try {
|
|
655
|
-
const result = await
|
|
656
|
-
scopedDb
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}),
|
|
664
|
-
),
|
|
767
|
+
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
768
|
+
endSessionRealtimeInTransaction(scopedDb, {
|
|
769
|
+
workspaceId,
|
|
770
|
+
sessionId,
|
|
771
|
+
realtimeId,
|
|
772
|
+
ownerSubjectId: grant.subjectId,
|
|
773
|
+
...parsed.data,
|
|
774
|
+
}),
|
|
665
775
|
);
|
|
666
776
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
667
777
|
c.header("cache-control", "private, no-store");
|
|
@@ -1024,16 +1134,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1024
1134
|
throw new HTTPException(422, { message: "invalid realtime ledger sync request" });
|
|
1025
1135
|
}
|
|
1026
1136
|
try {
|
|
1027
|
-
const result = await
|
|
1028
|
-
scopedDb
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
}),
|
|
1036
|
-
),
|
|
1137
|
+
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
1138
|
+
syncSessionRealtimeLedgerInTransaction(scopedDb, {
|
|
1139
|
+
workspaceId,
|
|
1140
|
+
sessionId,
|
|
1141
|
+
realtimeId,
|
|
1142
|
+
ownerSubjectId: grant.subjectId,
|
|
1143
|
+
...parsed.data,
|
|
1144
|
+
}),
|
|
1037
1145
|
);
|
|
1038
1146
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
1039
1147
|
c.header("cache-control", "private, no-store");
|
|
@@ -1072,6 +1180,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1072
1180
|
await withEffectivePolicy(
|
|
1073
1181
|
deps,
|
|
1074
1182
|
workspaceId,
|
|
1183
|
+
grant.subjectId,
|
|
1075
1184
|
projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
|
|
1076
1185
|
),
|
|
1077
1186
|
);
|
|
@@ -1096,7 +1205,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1096
1205
|
const workspaceId = c.req.param("workspaceId");
|
|
1097
1206
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
1098
1207
|
const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
|
|
1099
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
1208
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
|
|
1100
1209
|
return c.json({
|
|
1101
1210
|
...lineage,
|
|
1102
1211
|
ancestors: lineage.ancestors.map((session) =>
|
|
@@ -1138,11 +1247,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1138
1247
|
}
|
|
1139
1248
|
}
|
|
1140
1249
|
const pinned = target === "auto" ? null : target;
|
|
1141
|
-
const mutation = await
|
|
1250
|
+
const mutation = await withSessionCodexCapacityMutation(
|
|
1142
1251
|
db,
|
|
1143
1252
|
{ workspaceId, reason: "codex_manual_session_pin_changed" },
|
|
1144
1253
|
async (tx) => {
|
|
1145
|
-
const changed = await
|
|
1254
|
+
const changed = await setSessionCodexPinInTransaction(tx, workspaceId, sessionId, pinned);
|
|
1146
1255
|
return { result: changed, changed };
|
|
1147
1256
|
},
|
|
1148
1257
|
);
|
|
@@ -1198,7 +1307,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1198
1307
|
if (!session) {
|
|
1199
1308
|
throw new HTTPException(404, { message: "session not found" });
|
|
1200
1309
|
}
|
|
1201
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
1310
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
1202
1311
|
});
|
|
1203
1312
|
|
|
1204
1313
|
app.patch(
|
|
@@ -1236,7 +1345,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1236
1345
|
const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
|
|
1237
1346
|
try {
|
|
1238
1347
|
const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
|
|
1239
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
1348
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
1240
1349
|
} catch (error) {
|
|
1241
1350
|
if (error instanceof SessionToolPolicyVersionConflictError) {
|
|
1242
1351
|
return c.json(
|
|
@@ -1342,11 +1451,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1342
1451
|
if (event) {
|
|
1343
1452
|
try {
|
|
1344
1453
|
await bus.publish(workspaceId, sessionId, [event]);
|
|
1345
|
-
} catch
|
|
1346
|
-
console.warn(
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1454
|
+
} catch {
|
|
1455
|
+
console.warn("[api] cleared-goal live publish failed; durable event reconciles on replay", {
|
|
1456
|
+
errorClass: "EventPublishOperationError",
|
|
1457
|
+
errorCode: "cleared_goal_live_publish_failed",
|
|
1458
|
+
origin: "api",
|
|
1459
|
+
});
|
|
1350
1460
|
}
|
|
1351
1461
|
}
|
|
1352
1462
|
return c.body(null, 204);
|
|
@@ -1552,6 +1662,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1552
1662
|
const projected = compact ? coalesceSessionEventDeltas(events) : events;
|
|
1553
1663
|
const page = boundSessionEventHttpPage(projected, {
|
|
1554
1664
|
direction,
|
|
1665
|
+
eventProjection: mode === "forensic" && payloadMode === "full" ? "exact" : "bounded",
|
|
1555
1666
|
});
|
|
1556
1667
|
const hasMore = dbPage.hasMore || page.truncated;
|
|
1557
1668
|
c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
|
|
@@ -1834,10 +1945,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1834
1945
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1835
1946
|
const sessionId = c.req.param("sessionId");
|
|
1836
1947
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
1837
|
-
const
|
|
1838
|
-
const payload = SteerSessionMessageRequest.parse(raw);
|
|
1948
|
+
const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
|
|
1839
1949
|
const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
|
|
1840
1950
|
text: payload.text,
|
|
1951
|
+
annotations: payload.annotations,
|
|
1841
1952
|
turnInstructions: payload.turnInstructions ?? null,
|
|
1842
1953
|
resources: payload.resources,
|
|
1843
1954
|
model: payload.model ?? null,
|
|
@@ -1859,8 +1970,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1859
1970
|
const workspaceId = c.req.param("workspaceId");
|
|
1860
1971
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1861
1972
|
const sessionId = c.req.param("sessionId");
|
|
1862
|
-
const
|
|
1863
|
-
const event = ClientSessionEvent.parse(rawEvent);
|
|
1973
|
+
const event = parseSessionEventAdmission(await c.req.json().catch(() => null));
|
|
1864
1974
|
const refinedOperation =
|
|
1865
1975
|
event.type === "user.approvalDecision"
|
|
1866
1976
|
? "session.approval.write"
|
|
@@ -1881,6 +1991,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1881
1991
|
if (event.type === "user.message") {
|
|
1882
1992
|
const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
|
|
1883
1993
|
text: event.payload.text,
|
|
1994
|
+
annotations: event.payload.annotations,
|
|
1884
1995
|
turnInstructions: event.payload.turnInstructions ?? null,
|
|
1885
1996
|
resources: event.payload.resources ?? [],
|
|
1886
1997
|
model: event.payload.model ?? null,
|
|
@@ -2045,8 +2156,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2045
2156
|
// GET .../stream-capabilities — the capability-negotiation read. Returns the
|
|
2046
2157
|
// SessionCapabilities doc (descriptor + lease liveness/epoch + os + the
|
|
2047
2158
|
// shared-exposure disclosure + the calling principal's acknowledgment state),
|
|
2048
|
-
// API-direct.
|
|
2049
|
-
//
|
|
2159
|
+
// API-direct. It is a pure descriptor read: every URL/token stays null until an
|
|
2160
|
+
// exact, permission-checked POST /viewers grant mints it just in time.
|
|
2050
2161
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities", async (c) => {
|
|
2051
2162
|
const workspaceId = c.req.param("workspaceId");
|
|
2052
2163
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -2063,12 +2174,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2063
2174
|
const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
|
|
2064
2175
|
const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
|
|
2065
2176
|
// Per-principal acknowledgment: A acknowledging does not consent for B. The
|
|
2066
|
-
//
|
|
2067
|
-
//
|
|
2068
|
-
// /viewers
|
|
2069
|
-
// WITHOUT un-redacted consent could be handed a live VNC URL + scoped token
|
|
2070
|
-
// from this read path while being correctly 409'd on attach (a consent-gate
|
|
2071
|
-
// bypass of the un-redacted pixel plane).
|
|
2177
|
+
// Surface consent state so the UI can decide whether an explicit desktop
|
|
2178
|
+
// grant may be requested. This descriptor read never mints a credential; the
|
|
2179
|
+
// POST /viewers grant below re-checks both consent bits and stream:view.
|
|
2072
2180
|
const ack = await getStreamAcknowledgment(db, {
|
|
2073
2181
|
workspaceId,
|
|
2074
2182
|
sandboxGroupId: session.sandboxGroupId,
|
|
@@ -2078,65 +2186,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2078
2186
|
? ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)
|
|
2079
2187
|
: false;
|
|
2080
2188
|
|
|
2081
|
-
//
|
|
2082
|
-
//
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2085
|
-
// exposeStreamPort (resolve the 6080 tunnel + mint the scoped token), record
|
|
2086
|
-
// data_plane_url under the epoch fence, and emit stream.url.rotated to other
|
|
2087
|
-
// viewers on a box rollover. The handshake never SPINS UP a cold box (that is
|
|
2088
|
-
// the viewer-attach path) — a cold lease stays lease_cold. A degraded mint
|
|
2089
|
-
// (no secret / display-stack or tunnel failure) returns null → transport:null.
|
|
2090
|
-
let desktopStream: DesktopStreamMint | null = null;
|
|
2091
|
-
const desktopUnlocked =
|
|
2092
|
-
settings.sandboxDesktopEnabled &&
|
|
2093
|
-
!streamTokenDegraded(settings) &&
|
|
2094
|
-
acknowledged &&
|
|
2095
|
-
(session.activeSandboxId != null ||
|
|
2096
|
-
lease?.liveness === "warm" ||
|
|
2097
|
-
lease?.liveness === "draining");
|
|
2098
|
-
if (desktopUnlocked) {
|
|
2099
|
-
desktopStream = await mintDesktopStream(
|
|
2100
|
-
{ db, settings, bus },
|
|
2101
|
-
{
|
|
2102
|
-
accountId: grant.accountId,
|
|
2103
|
-
workspaceId,
|
|
2104
|
-
session,
|
|
2105
|
-
// The handshake's token is scoped to the calling principal (it is a read,
|
|
2106
|
-
// not a viewer-holder acquire); the per-holder token is re-minted on
|
|
2107
|
-
// POST /viewers. A previousEpoch != current would have rotated already
|
|
2108
|
-
// via the warming-commit; the read does not itself drive rotation.
|
|
2109
|
-
viewerId: grant.subjectId,
|
|
2110
|
-
...(lease ? { lease } : {}),
|
|
2111
|
-
},
|
|
2112
|
-
);
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
// P5.t — the REAL PTY terminal cell, served API-DIRECT. Independent of the
|
|
2116
|
-
// desktop: it gates ONLY on sandboxTerminalEnabled + a real-PTY backend + a
|
|
2117
|
-
// WARM box (NO un-redacted ack — the terminal cell has no acknowledgment
|
|
2118
|
-
// gate). A degraded mint (terminal off / no secret / ttyd or tunnel failure)
|
|
2119
|
-
// returns null → the Terminal cell falls back to the sse-events firehose.
|
|
2120
|
-
let terminalStream: TerminalStreamMint | null = null;
|
|
2121
|
-
const terminalUnlocked =
|
|
2122
|
-
settings.sandboxTerminalEnabled &&
|
|
2123
|
-
!streamTokenDegraded(settings) &&
|
|
2124
|
-
(session.activeSandboxId != null ||
|
|
2125
|
-
lease?.liveness === "warm" ||
|
|
2126
|
-
lease?.liveness === "draining");
|
|
2127
|
-
if (terminalUnlocked) {
|
|
2128
|
-
terminalStream = await mintTerminalStream(
|
|
2129
|
-
{ db, settings, bus },
|
|
2130
|
-
{
|
|
2131
|
-
accountId: grant.accountId,
|
|
2132
|
-
workspaceId,
|
|
2133
|
-
session,
|
|
2134
|
-
viewerId: grant.subjectId,
|
|
2135
|
-
...(lease ? { lease } : {}),
|
|
2136
|
-
},
|
|
2137
|
-
);
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2189
|
+
// This GET is deliberately descriptor-only: no provider resume, display/ttyd
|
|
2190
|
+
// startup, port exposure, or short-lived bearer mint. Besides enforcing least
|
|
2191
|
+
// privilege, that keeps the 120-second stream credentials from aging before a
|
|
2192
|
+
// user opens their surface. POST /viewers is the sole credential grant.
|
|
2140
2193
|
const capabilities = negotiateCapabilities({
|
|
2141
2194
|
sessionId,
|
|
2142
2195
|
backend: session.sandboxBackend as SandboxBackend,
|
|
@@ -2163,33 +2216,21 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2163
2216
|
desktopAcknowledged: acknowledged,
|
|
2164
2217
|
shared,
|
|
2165
2218
|
sharedSessionIds: visibleSharedSessionIds,
|
|
2166
|
-
//
|
|
2167
|
-
//
|
|
2168
|
-
...(desktopStream
|
|
2169
|
-
? {
|
|
2170
|
-
desktopStream: {
|
|
2171
|
-
url: desktopStream.url,
|
|
2172
|
-
token: desktopStream.token,
|
|
2173
|
-
expiresAt: desktopStream.expiresAt,
|
|
2174
|
-
resolution: desktopStream.resolution,
|
|
2175
|
-
},
|
|
2176
|
-
}
|
|
2177
|
-
: {}),
|
|
2178
|
-
// P5.t — the terminal policy toggle + the minted pty-ws address. The
|
|
2179
|
-
// resolver advertises sse-events (firehose) on a cold/disabled terminal and
|
|
2180
|
-
// folds the live pty-ws url/token in only when the gates passed + minted.
|
|
2219
|
+
// Plane policy only. Live addresses are intentionally absent on a
|
|
2220
|
+
// descriptor read and arrive from an authorized viewer grant.
|
|
2181
2221
|
terminalEnabled: settings.sandboxTerminalEnabled,
|
|
2182
|
-
...(terminalStream
|
|
2183
|
-
? {
|
|
2184
|
-
terminalStream: {
|
|
2185
|
-
url: terminalStream.url,
|
|
2186
|
-
token: terminalStream.token,
|
|
2187
|
-
expiresAt: terminalStream.expiresAt,
|
|
2188
|
-
},
|
|
2189
|
-
}
|
|
2190
|
-
: {}),
|
|
2191
2222
|
});
|
|
2192
2223
|
|
|
2224
|
+
const repositoryRoots = [
|
|
2225
|
+
...new Set(
|
|
2226
|
+
session.resources.flatMap((resource) =>
|
|
2227
|
+
resource.kind === "repository" && typeof resource.mountPath === "string"
|
|
2228
|
+
? [resource.mountPath.replace(/^\/+|\/+$/g, "")]
|
|
2229
|
+
: [],
|
|
2230
|
+
),
|
|
2231
|
+
),
|
|
2232
|
+
].filter(Boolean);
|
|
2233
|
+
|
|
2193
2234
|
// SWAP-CASE desktop transport (BOTH directions): negotiateCapabilities keyed on
|
|
2194
2235
|
// the HOME backend, but the pixel plane actually runs on the ACTIVE sandbox — and
|
|
2195
2236
|
// the two backends use DIFFERENT wire transports. The advertised transport MUST
|
|
@@ -2209,7 +2250,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2209
2250
|
// active sandbox kind is "selfhosted") — EXACTLY mintDesktopStream's routing. When
|
|
2210
2251
|
// the desktop is available we set the transport from the ACTIVE sandbox in one
|
|
2211
2252
|
// place (resolveActiveDesktopTransport), covering BOTH swap directions.
|
|
2212
|
-
let responseCapabilities =
|
|
2253
|
+
let responseCapabilities = {
|
|
2254
|
+
...capabilities,
|
|
2255
|
+
Git: {
|
|
2256
|
+
...capabilities.Git,
|
|
2257
|
+
repos: capabilities.Git.available ? repositoryRoots : [],
|
|
2258
|
+
},
|
|
2259
|
+
};
|
|
2213
2260
|
if (capabilities.DesktopStream.transport !== null) {
|
|
2214
2261
|
const activeSandbox = session.activeSandboxId
|
|
2215
2262
|
? await getSandbox(db, workspaceId, session.activeSandboxId)
|
|
@@ -2219,7 +2266,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2219
2266
|
settings.sandboxDesktopInteractive !== false,
|
|
2220
2267
|
);
|
|
2221
2268
|
responseCapabilities = {
|
|
2222
|
-
...
|
|
2269
|
+
...responseCapabilities,
|
|
2223
2270
|
DesktopStream: { ...capabilities.DesktopStream, ...wire },
|
|
2224
2271
|
};
|
|
2225
2272
|
}
|
|
@@ -2273,7 +2320,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2273
2320
|
// when cold).
|
|
2274
2321
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers", async (c) => {
|
|
2275
2322
|
const workspaceId = c.req.param("workspaceId");
|
|
2276
|
-
|
|
2323
|
+
// Authenticate and bind the workspace before parsing. The requested plane
|
|
2324
|
+
// determines the narrower permission below: terminal-only holders must not
|
|
2325
|
+
// require the strictly broader un-redacted Desktop permission.
|
|
2326
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
2277
2327
|
assertOwnershipEnabled();
|
|
2278
2328
|
const sessionId = c.req.param("sessionId");
|
|
2279
2329
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2286,19 +2336,29 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2286
2336
|
message: "invalid viewer attach request",
|
|
2287
2337
|
});
|
|
2288
2338
|
}
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
//
|
|
2292
|
-
//
|
|
2293
|
-
// is interactive by nature and the gate is the scoped tunnel URL + stream token
|
|
2294
|
-
// — so it warms the box and mints the pty-ws terminal cell without a 409. Gating
|
|
2295
|
-
// the terminal attach behind the desktop ack (the bug this fixes) dead-ended the
|
|
2296
|
-
// interactive terminal: the box never warmed → the Terminal cell stayed on the
|
|
2297
|
-
// read-only sse-events firehose forever ("read only"), and with the desktop tier
|
|
2298
|
-
// off by default there was no consent flow to ever clear the gate.
|
|
2339
|
+
// Resolve exact requested planes before authorization. Empty and
|
|
2340
|
+
// `desktop:false` v1 bodies remain terminal-only during rolling upgrades;
|
|
2341
|
+
// either new plane flag selects exact semantics. This closes desktop→terminal
|
|
2342
|
+
// privilege bleed and lets file edits avoid unrelated terminal bearers.
|
|
2299
2343
|
const wantDesktop = parsed.data.desktop ?? false;
|
|
2300
|
-
const
|
|
2344
|
+
const wantFiles = parsed.data.files ?? false;
|
|
2345
|
+
const hasExactPlaneSet = parsed.data.terminal !== undefined || parsed.data.files !== undefined;
|
|
2346
|
+
// v1 clients sent only `desktop:false` for both terminal and file warming;
|
|
2347
|
+
// retain its terminal-only grant during rolling upgrades. Presence of either
|
|
2348
|
+
// v2 flag switches to exact-plane semantics.
|
|
2349
|
+
const wantTerminal = parsed.data.terminal ?? (!hasExactPlaneSet && !wantDesktop);
|
|
2350
|
+
if (!wantDesktop && !wantTerminal && !wantFiles) {
|
|
2351
|
+
throw new HTTPException(400, { message: "viewer attach requires a live plane" });
|
|
2352
|
+
}
|
|
2353
|
+
if (wantDesktop) requirePermission(grant, "stream:view");
|
|
2354
|
+
if (wantTerminal) requirePermission(grant, "terminal:attach");
|
|
2355
|
+
if (wantFiles) requirePermission(grant, "files:write");
|
|
2356
|
+
|
|
2357
|
+
// Consent gate (P3.2 / addendum E.1): only the explicitly requested,
|
|
2358
|
+
// un-redacted desktop plane needs acknowledgment. Terminal and files retain
|
|
2359
|
+
// their independent permission boundaries.
|
|
2301
2360
|
if (wantDesktop) {
|
|
2361
|
+
const { shared } = await resolveSharedExposure(workspaceId, session);
|
|
2302
2362
|
const ack = await getStreamAcknowledgment(db, {
|
|
2303
2363
|
workspaceId,
|
|
2304
2364
|
sandboxGroupId: session.sandboxGroupId,
|
|
@@ -2341,7 +2401,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2341
2401
|
dataPlaneUrl: null,
|
|
2342
2402
|
};
|
|
2343
2403
|
if (
|
|
2344
|
-
(
|
|
2404
|
+
((wantDesktop && settings.sandboxDesktopEnabled) ||
|
|
2405
|
+
(wantTerminal && settings.sandboxTerminalEnabled)) &&
|
|
2345
2406
|
!streamTokenDegraded(settings)
|
|
2346
2407
|
) {
|
|
2347
2408
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
@@ -2353,7 +2414,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2353
2414
|
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
2354
2415
|
});
|
|
2355
2416
|
}
|
|
2356
|
-
if (settings.sandboxTerminalEnabled) {
|
|
2417
|
+
if (wantTerminal && settings.sandboxTerminalEnabled) {
|
|
2357
2418
|
terminal = await mintTerminalStream(viewerServices, {
|
|
2358
2419
|
accountId: grant.accountId,
|
|
2359
2420
|
workspaceId,
|
|
@@ -2368,6 +2429,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2368
2429
|
accountId: grant.accountId,
|
|
2369
2430
|
workspaceId,
|
|
2370
2431
|
session,
|
|
2432
|
+
waitSignal: c.req.raw.signal,
|
|
2371
2433
|
...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
|
|
2372
2434
|
});
|
|
2373
2435
|
|
|
@@ -2379,7 +2441,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2379
2441
|
// box is warm here (attachViewer spun it up or attached), so the handshake's
|
|
2380
2442
|
// never-spin-up rule does not apply.
|
|
2381
2443
|
if (
|
|
2382
|
-
(
|
|
2444
|
+
((wantDesktop && settings.sandboxDesktopEnabled) ||
|
|
2445
|
+
(wantTerminal && settings.sandboxTerminalEnabled)) &&
|
|
2383
2446
|
!streamTokenDegraded(settings)
|
|
2384
2447
|
) {
|
|
2385
2448
|
const lease = await readGroupLease(
|
|
@@ -2387,9 +2450,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2387
2450
|
{ workspaceId, sandboxGroupId: session.sandboxGroupId },
|
|
2388
2451
|
);
|
|
2389
2452
|
if (lease) {
|
|
2390
|
-
//
|
|
2391
|
-
//
|
|
2392
|
-
// the terminal mint below still runs.
|
|
2453
|
+
// Mint only explicitly authorized plane credentials. The shared holder
|
|
2454
|
+
// supplies liveness; it is not itself authority for another plane.
|
|
2393
2455
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
2394
2456
|
stream = await mintDesktopStream(viewerServices, {
|
|
2395
2457
|
accountId: grant.accountId,
|
|
@@ -2399,10 +2461,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2399
2461
|
lease,
|
|
2400
2462
|
});
|
|
2401
2463
|
}
|
|
2402
|
-
|
|
2403
|
-
// address (independent of the desktop toggle). A degraded mint leaves the
|
|
2404
|
-
// terminal fields null → the client falls back to the sse-events firehose.
|
|
2405
|
-
if (settings.sandboxTerminalEnabled) {
|
|
2464
|
+
if (wantTerminal && settings.sandboxTerminalEnabled) {
|
|
2406
2465
|
terminal = await mintTerminalStream(viewerServices, {
|
|
2407
2466
|
accountId: grant.accountId,
|
|
2408
2467
|
workspaceId,
|
|
@@ -2414,41 +2473,42 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2414
2473
|
}
|
|
2415
2474
|
}
|
|
2416
2475
|
}
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
?
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
201,
|
|
2442
|
-
);
|
|
2476
|
+
const response = {
|
|
2477
|
+
...result,
|
|
2478
|
+
dataPlaneUrl: stream?.url ?? null,
|
|
2479
|
+
streamToken: stream?.token ?? null,
|
|
2480
|
+
streamExpiresAt: stream?.expiresAt ?? null,
|
|
2481
|
+
resolution: stream?.resolution ?? null,
|
|
2482
|
+
// Transport MUST match where the pixels were minted: a selfhosted-active box
|
|
2483
|
+
// serves the RELAY framebuffer (relay-frames/frames), a Modal box serves noVNC
|
|
2484
|
+
// (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
|
|
2485
|
+
// noVNC renderer (and vice-versa on the swap-away case) → "closed before it
|
|
2486
|
+
// opened". Key off the SAME selfhostedActive the mint routed on.
|
|
2487
|
+
transport: stream
|
|
2488
|
+
? selfhostedActive
|
|
2489
|
+
? ("relay-frames" as const)
|
|
2490
|
+
: ("vnc-ws" as const)
|
|
2491
|
+
: null,
|
|
2492
|
+
client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
|
|
2493
|
+
// The REAL PTY terminal address (pty-ws), null when degraded.
|
|
2494
|
+
terminalUrl: terminal?.url ?? null,
|
|
2495
|
+
terminalToken: terminal?.token ?? null,
|
|
2496
|
+
terminalExpiresAt: terminal?.expiresAt ?? null,
|
|
2497
|
+
terminalTransport: terminal ? ("pty-ws" as const) : null,
|
|
2498
|
+
} satisfies AttachViewerResponse;
|
|
2499
|
+
return c.json(response, 201);
|
|
2443
2500
|
});
|
|
2444
2501
|
|
|
2445
2502
|
// POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
|
|
2446
|
-
//
|
|
2503
|
+
// A holder may belong to any exact live plane. Lifecycle control accepts any
|
|
2504
|
+
// permission that could have minted it; the unguessable holder id and workspace
|
|
2505
|
+
// grant remain the ownership boundary.
|
|
2447
2506
|
app.post(
|
|
2448
2507
|
"/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
|
|
2449
2508
|
async (c) => {
|
|
2450
2509
|
const workspaceId = c.req.param("workspaceId");
|
|
2451
|
-
const grant = await requireAccessGrant(c, deps, workspaceId
|
|
2510
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
2511
|
+
requireViewerLifecyclePermission(grant);
|
|
2452
2512
|
assertOwnershipEnabled();
|
|
2453
2513
|
const sessionId = c.req.param("sessionId");
|
|
2454
2514
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2478,7 +2538,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2478
2538
|
// DELETE .../viewers/:viewerId — release the holder (idempotent).
|
|
2479
2539
|
app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
|
|
2480
2540
|
const workspaceId = c.req.param("workspaceId");
|
|
2481
|
-
const grant = await requireAccessGrant(c, deps, workspaceId
|
|
2541
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
2542
|
+
requireViewerLifecyclePermission(grant);
|
|
2482
2543
|
assertOwnershipEnabled();
|
|
2483
2544
|
const sessionId = c.req.param("sessionId");
|
|
2484
2545
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2545,11 +2606,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2545
2606
|
// FS uses files:read for reads, files:write for mutations; Git is read-only
|
|
2546
2607
|
// (rides files:read); Terminal exec + PTY ride terminal:attach.
|
|
2547
2608
|
|
|
2548
|
-
type ChannelARouteCtx = {
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
session: Session;
|
|
2552
|
-
subjectId: string;
|
|
2609
|
+
type ChannelARouteCtx = ChannelAContext & {
|
|
2610
|
+
waitSignal: AbortSignal;
|
|
2611
|
+
operation: ChannelAOperation;
|
|
2553
2612
|
};
|
|
2554
2613
|
|
|
2555
2614
|
// Shared preamble: grant BEFORE parse, ownership gate, session lookup. Returns
|
|
@@ -2557,6 +2616,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2557
2616
|
async function channelAPreamble(
|
|
2558
2617
|
c: Context,
|
|
2559
2618
|
permission: "files:read" | "files:write" | "terminal:attach",
|
|
2619
|
+
operation: ChannelAOperation,
|
|
2560
2620
|
): Promise<ChannelARouteCtx> {
|
|
2561
2621
|
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
2562
2622
|
const grant = await requireAccessGrant(c, deps, workspaceId, permission);
|
|
@@ -2571,6 +2631,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2571
2631
|
workspaceId,
|
|
2572
2632
|
session,
|
|
2573
2633
|
subjectId: grant.subjectId,
|
|
2634
|
+
waitSignal: c.req.raw.signal,
|
|
2635
|
+
operation,
|
|
2574
2636
|
};
|
|
2575
2637
|
}
|
|
2576
2638
|
|
|
@@ -2590,42 +2652,53 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2590
2652
|
|
|
2591
2653
|
// ── FileSystem ──────────────────────────────────────────────────────────
|
|
2592
2654
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
|
|
2593
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2655
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.list");
|
|
2594
2656
|
const req = await parseChannelABody(c, FsListRequest);
|
|
2595
|
-
const out = await
|
|
2657
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsList(req));
|
|
2658
|
+
return c.json(out);
|
|
2659
|
+
});
|
|
2660
|
+
|
|
2661
|
+
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list-batch", async (c) => {
|
|
2662
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.list-batch");
|
|
2663
|
+
const req = await parseChannelABody(c, FsListBatchRequest);
|
|
2664
|
+
const out = await withChannelARead(channelAServices, ctx, async ({ service }) => ({
|
|
2665
|
+
results: await runConcurrentChannelAReads(
|
|
2666
|
+
req.requests.map((request) => async () => await service.fsList(request)),
|
|
2667
|
+
),
|
|
2668
|
+
}));
|
|
2596
2669
|
return c.json(out);
|
|
2597
2670
|
});
|
|
2598
2671
|
|
|
2599
2672
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
|
|
2600
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2673
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.read");
|
|
2601
2674
|
const req = await parseChannelABody(c, FsReadRequest);
|
|
2602
|
-
const out = await
|
|
2675
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsRead(req));
|
|
2603
2676
|
return c.json(out);
|
|
2604
2677
|
});
|
|
2605
2678
|
|
|
2606
2679
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
|
|
2607
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
2680
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.write");
|
|
2608
2681
|
const req = await parseChannelABody(c, FsWriteRequest);
|
|
2609
2682
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsWrite(req));
|
|
2610
2683
|
return c.json(out);
|
|
2611
2684
|
});
|
|
2612
2685
|
|
|
2613
2686
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
|
|
2614
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
2687
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.delete");
|
|
2615
2688
|
const req = await parseChannelABody(c, FsDeleteRequest);
|
|
2616
2689
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsDelete(req));
|
|
2617
2690
|
return c.json(out);
|
|
2618
2691
|
});
|
|
2619
2692
|
|
|
2620
2693
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
|
|
2621
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
2694
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.move");
|
|
2622
2695
|
const req = await parseChannelABody(c, FsMoveRequest);
|
|
2623
2696
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMove(req));
|
|
2624
2697
|
return c.json(out);
|
|
2625
2698
|
});
|
|
2626
2699
|
|
|
2627
2700
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
|
|
2628
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
2701
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.mkdir");
|
|
2629
2702
|
const req = await parseChannelABody(c, FsMkdirRequest);
|
|
2630
2703
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMkdir(req));
|
|
2631
2704
|
return c.json(out);
|
|
@@ -2633,30 +2706,87 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2633
2706
|
|
|
2634
2707
|
// ── Git (read-only) ─────────────────────────────────────────────────────
|
|
2635
2708
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
|
|
2636
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2709
|
+
const ctx = await channelAPreamble(c, "files:read", "git.status");
|
|
2637
2710
|
const req = await parseChannelABody(c, GitStatusRequest);
|
|
2638
|
-
const out = await
|
|
2711
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
2712
|
+
service.gitStatus(req),
|
|
2713
|
+
);
|
|
2639
2714
|
return c.json(out);
|
|
2640
2715
|
});
|
|
2641
2716
|
|
|
2642
2717
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
|
|
2643
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2718
|
+
const ctx = await channelAPreamble(c, "files:read", "git.diff");
|
|
2644
2719
|
const req = await parseChannelABody(c, GitDiffRequest);
|
|
2645
|
-
const out = await
|
|
2720
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
2721
|
+
service.gitDiff(req),
|
|
2722
|
+
);
|
|
2723
|
+
return c.json(out);
|
|
2724
|
+
});
|
|
2725
|
+
|
|
2726
|
+
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/read-batch", async (c) => {
|
|
2727
|
+
const ctx = await channelAPreamble(c, "files:read", "git.read-batch");
|
|
2728
|
+
const req = await parseChannelABody(c, GitReadBatchRequest);
|
|
2729
|
+
const out = await withChannelARead(channelAServices, ctx, async ({ service }) => {
|
|
2730
|
+
type StatusResult = Awaited<ReturnType<typeof service.gitStatus>>;
|
|
2731
|
+
type DiffResult = Awaited<ReturnType<typeof service.gitDiff>>;
|
|
2732
|
+
type ReadResult =
|
|
2733
|
+
| { requestIndex: number; kind: "status"; value: StatusResult }
|
|
2734
|
+
| { requestIndex: number; kind: "diff"; value: DiffResult };
|
|
2735
|
+
const operations: Array<() => Promise<ReadResult>> = [];
|
|
2736
|
+
req.requests.forEach((request, requestIndex) => {
|
|
2737
|
+
operations.push(async () => ({
|
|
2738
|
+
requestIndex,
|
|
2739
|
+
kind: "status" as const,
|
|
2740
|
+
value: await service.gitStatus(request.status),
|
|
2741
|
+
}));
|
|
2742
|
+
if (request.diff) {
|
|
2743
|
+
const diffRequest = request.diff;
|
|
2744
|
+
operations.push(async () => ({
|
|
2745
|
+
requestIndex,
|
|
2746
|
+
kind: "diff" as const,
|
|
2747
|
+
value: await service.gitDiff(diffRequest),
|
|
2748
|
+
}));
|
|
2749
|
+
}
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
const reads = await runConcurrentChannelAReads(operations);
|
|
2753
|
+
const statuses = new Map<number, StatusResult>();
|
|
2754
|
+
const diffs = new Map<number, DiffResult>();
|
|
2755
|
+
for (const read of reads) {
|
|
2756
|
+
if (read.kind === "status") statuses.set(read.requestIndex, read.value);
|
|
2757
|
+
else diffs.set(read.requestIndex, read.value);
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
return {
|
|
2761
|
+
results: req.requests.map((request, requestIndex) => {
|
|
2762
|
+
const status = statuses.get(requestIndex);
|
|
2763
|
+
if (!status) {
|
|
2764
|
+
throw new Error(`Workspace Git batch omitted status result ${requestIndex}.`);
|
|
2765
|
+
}
|
|
2766
|
+
const diff = request.diff ? diffs.get(requestIndex) : undefined;
|
|
2767
|
+
if (request.diff && !diff) {
|
|
2768
|
+
throw new Error(`Workspace Git batch omitted diff result ${requestIndex}.`);
|
|
2769
|
+
}
|
|
2770
|
+
return { status, ...(diff ? { diff } : {}) };
|
|
2771
|
+
}),
|
|
2772
|
+
};
|
|
2773
|
+
});
|
|
2646
2774
|
return c.json(out);
|
|
2647
2775
|
});
|
|
2648
2776
|
|
|
2649
2777
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
|
|
2650
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2778
|
+
const ctx = await channelAPreamble(c, "files:read", "git.log");
|
|
2651
2779
|
const req = await parseChannelABody(c, GitLogRequest);
|
|
2652
|
-
const out = await
|
|
2780
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.gitLog(req));
|
|
2653
2781
|
return c.json(out);
|
|
2654
2782
|
});
|
|
2655
2783
|
|
|
2656
2784
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
|
|
2657
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2785
|
+
const ctx = await channelAPreamble(c, "files:read", "git.show");
|
|
2658
2786
|
const req = await parseChannelABody(c, GitShowRequest);
|
|
2659
|
-
const out = await
|
|
2787
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
2788
|
+
service.gitShow(req),
|
|
2789
|
+
);
|
|
2660
2790
|
return c.json(out);
|
|
2661
2791
|
});
|
|
2662
2792
|
|
|
@@ -2719,7 +2849,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2719
2849
|
|
|
2720
2850
|
// ── Terminal: synchronous exec ────────────────────────────────────────────
|
|
2721
2851
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
|
|
2722
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2852
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.exec");
|
|
2723
2853
|
const req = await parseChannelABody(c, TerminalExecRequest);
|
|
2724
2854
|
const out = await withChannelA(channelAServices, ctx, ({ service }) =>
|
|
2725
2855
|
service.terminalExec(req),
|
|
@@ -2729,7 +2859,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2729
2859
|
|
|
2730
2860
|
// ── Terminal: interactive PTY control (output rides A1) ───────────────────
|
|
2731
2861
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
|
|
2732
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2862
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.open");
|
|
2733
2863
|
const req = await parseChannelABody(c, PtyOpenRequest);
|
|
2734
2864
|
if (ctx.session.sandboxBackend === "selfhosted" || ctx.session.activeSandboxId !== null) {
|
|
2735
2865
|
throw new HTTPException(409, {
|
|
@@ -2838,7 +2968,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2838
2968
|
});
|
|
2839
2969
|
|
|
2840
2970
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
|
|
2841
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2971
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.write");
|
|
2842
2972
|
const req = await parseChannelABody(c, PtyWriteRequest);
|
|
2843
2973
|
const pty = await getOpenPtySession(db, {
|
|
2844
2974
|
workspaceId: ctx.workspaceId,
|
|
@@ -2902,7 +3032,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2902
3032
|
});
|
|
2903
3033
|
|
|
2904
3034
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
|
|
2905
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3035
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.resize");
|
|
2906
3036
|
const req = await parseChannelABody(c, PtyResizeRequest);
|
|
2907
3037
|
const pty = await getOpenPtySession(db, {
|
|
2908
3038
|
workspaceId: ctx.workspaceId,
|
|
@@ -2934,7 +3064,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2934
3064
|
});
|
|
2935
3065
|
|
|
2936
3066
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
|
|
2937
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3067
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.close");
|
|
2938
3068
|
const req = await parseChannelABody(c, PtyCloseRequest);
|
|
2939
3069
|
const pty = await getOpenPtySession(db, {
|
|
2940
3070
|
workspaceId: ctx.workspaceId,
|
|
@@ -3101,7 +3231,7 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
3101
3231
|
if (suffix.startsWith("/viewers/") && ["POST", "DELETE"].includes(verb)) {
|
|
3102
3232
|
return "session.viewer.control";
|
|
3103
3233
|
}
|
|
3104
|
-
if (suffix === "/fs/list" || suffix === "/fs/read") {
|
|
3234
|
+
if (suffix === "/fs/list" || suffix === "/fs/list-batch" || suffix === "/fs/read") {
|
|
3105
3235
|
return verb === "POST" ? "session.files.read" : null;
|
|
3106
3236
|
}
|
|
3107
3237
|
if (["/fs/write", "/fs/delete", "/fs/move", "/fs/mkdir"].includes(suffix)) {
|
|
@@ -3246,6 +3376,99 @@ function sessionListQuery(
|
|
|
3246
3376
|
};
|
|
3247
3377
|
}
|
|
3248
3378
|
|
|
3379
|
+
export type AgentTopologyCursorEnvelope = {
|
|
3380
|
+
cursor: SessionDiscoveryCursor;
|
|
3381
|
+
parentSessionId: string | null;
|
|
3382
|
+
search: string | null;
|
|
3383
|
+
};
|
|
3384
|
+
|
|
3385
|
+
export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): string {
|
|
3386
|
+
return Buffer.from(JSON.stringify({ v: 1, ...value }), "utf8").toString("base64url");
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
|
|
3390
|
+
if (value.length > 2_048) {
|
|
3391
|
+
throw new HTTPException(400, { message: "agent topology cursor is invalid" });
|
|
3392
|
+
}
|
|
3393
|
+
try {
|
|
3394
|
+
const parsed = z
|
|
3395
|
+
.object({
|
|
3396
|
+
v: z.literal(1),
|
|
3397
|
+
parentSessionId: z.string().uuid().nullable(),
|
|
3398
|
+
search: z.string().max(200).nullable(),
|
|
3399
|
+
cursor: z.object({
|
|
3400
|
+
orderBy: z.enum(["createdAt", "updatedAt"]),
|
|
3401
|
+
sortRevision: z.string().max(64),
|
|
3402
|
+
sortAt: z.string().max(64),
|
|
3403
|
+
id: z.string().uuid(),
|
|
3404
|
+
snapshotAt: z.string().max(64),
|
|
3405
|
+
snapshotRevision: z.string().max(64),
|
|
3406
|
+
updatedAfter: z.string().max(64).nullable(),
|
|
3407
|
+
}),
|
|
3408
|
+
})
|
|
3409
|
+
.parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
|
|
3410
|
+
if (
|
|
3411
|
+
parsed.cursor.orderBy !== "updatedAt" ||
|
|
3412
|
+
parsed.cursor.updatedAfter !== null ||
|
|
3413
|
+
!/^(?:0|[1-9]\d*)$/.test(parsed.cursor.sortRevision) ||
|
|
3414
|
+
!/^(?:0|[1-9]\d*)$/.test(parsed.cursor.snapshotRevision) ||
|
|
3415
|
+
BigInt(parsed.cursor.sortRevision) > 9_223_372_036_854_775_807n ||
|
|
3416
|
+
BigInt(parsed.cursor.snapshotRevision) > 9_223_372_036_854_775_807n ||
|
|
3417
|
+
Number.isNaN(Date.parse(parsed.cursor.sortAt)) ||
|
|
3418
|
+
Number.isNaN(Date.parse(parsed.cursor.snapshotAt))
|
|
3419
|
+
) {
|
|
3420
|
+
throw new Error("invalid topology cursor fields");
|
|
3421
|
+
}
|
|
3422
|
+
return parsed;
|
|
3423
|
+
} catch {
|
|
3424
|
+
throw new HTTPException(400, { message: "agent topology cursor is invalid" });
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
export function agentTopologyQuery(query: Record<string, string>): {
|
|
3429
|
+
limit: number;
|
|
3430
|
+
parentSessionId: string | null;
|
|
3431
|
+
search: string | undefined;
|
|
3432
|
+
cursor: SessionDiscoveryCursor | undefined;
|
|
3433
|
+
} {
|
|
3434
|
+
const rawLimit = query.limit;
|
|
3435
|
+
const limit = rawLimit === undefined ? 25 : Number(rawLimit);
|
|
3436
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
3437
|
+
throw new HTTPException(400, { message: "limit must be an integer between 1 and 100" });
|
|
3438
|
+
}
|
|
3439
|
+
const rawParent = query.parentSessionId;
|
|
3440
|
+
if (
|
|
3441
|
+
rawParent !== undefined &&
|
|
3442
|
+
rawParent !== "null" &&
|
|
3443
|
+
!z.string().uuid().safeParse(rawParent).success
|
|
3444
|
+
) {
|
|
3445
|
+
throw new HTTPException(400, {
|
|
3446
|
+
message: 'parentSessionId must be a session id or the literal "null"',
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
|
|
3450
|
+
const search = query.search?.trim();
|
|
3451
|
+
if (search && search.length > 200) {
|
|
3452
|
+
throw new HTTPException(400, { message: "search must be at most 200 characters" });
|
|
3453
|
+
}
|
|
3454
|
+
if (search && rawParent !== undefined) {
|
|
3455
|
+
throw new HTTPException(400, { message: "search cannot be combined with parentSessionId" });
|
|
3456
|
+
}
|
|
3457
|
+
const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
|
|
3458
|
+
if (
|
|
3459
|
+
envelope &&
|
|
3460
|
+
(envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
|
|
3461
|
+
) {
|
|
3462
|
+
throw new HTTPException(400, { message: "agent topology cursor does not match its filters" });
|
|
3463
|
+
}
|
|
3464
|
+
return {
|
|
3465
|
+
limit,
|
|
3466
|
+
parentSessionId,
|
|
3467
|
+
search: search || undefined,
|
|
3468
|
+
cursor: envelope?.cursor,
|
|
3469
|
+
};
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3249
3472
|
function compactEvents(raw: string | undefined): boolean {
|
|
3250
3473
|
return raw === "1" || raw === "true";
|
|
3251
3474
|
}
|
|
@@ -3298,6 +3521,22 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
|
|
|
3298
3521
|
throw error;
|
|
3299
3522
|
}
|
|
3300
3523
|
|
|
3524
|
+
export function parseSessionEventAdmission(raw: unknown): ClientSessionEvent {
|
|
3525
|
+
const parsed = ClientSessionEvent.safeParse(raw);
|
|
3526
|
+
if (!parsed.success) {
|
|
3527
|
+
throw new HTTPException(422, { message: "invalid session event" });
|
|
3528
|
+
}
|
|
3529
|
+
return parsed.data;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
export function parseSteerSessionAdmission(raw: unknown): SteerSessionMessageRequest {
|
|
3533
|
+
const parsed = SteerSessionMessageRequest.safeParse(raw);
|
|
3534
|
+
if (!parsed.success) {
|
|
3535
|
+
throw new HTTPException(422, { message: "invalid steer request" });
|
|
3536
|
+
}
|
|
3537
|
+
return parsed.data;
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3301
3540
|
function zodErrorFields(error: ZodError): string {
|
|
3302
3541
|
const paths = [
|
|
3303
3542
|
...new Set(
|
|
@@ -3333,10 +3572,11 @@ type EffectivePolicyContext = {
|
|
|
3333
3572
|
async function loadEffectivePolicyContext(
|
|
3334
3573
|
deps: ApiRouteDeps,
|
|
3335
3574
|
workspaceId: string,
|
|
3575
|
+
subjectId: string,
|
|
3336
3576
|
): Promise<EffectivePolicyContext> {
|
|
3337
3577
|
const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
|
|
3338
|
-
workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
|
|
3339
|
-
workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
|
|
3578
|
+
workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
|
|
3579
|
+
workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
|
|
3340
3580
|
]);
|
|
3341
3581
|
return { workspaceServerIds, workspaceDefaultServerIds };
|
|
3342
3582
|
}
|
|
@@ -3344,9 +3584,10 @@ async function loadEffectivePolicyContext(
|
|
|
3344
3584
|
async function withEffectivePolicy(
|
|
3345
3585
|
deps: ApiRouteDeps,
|
|
3346
3586
|
workspaceId: string,
|
|
3587
|
+
subjectId: string,
|
|
3347
3588
|
session: Session,
|
|
3348
3589
|
): Promise<Session> {
|
|
3349
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
3590
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId);
|
|
3350
3591
|
return sessionWithEffectiveToolPolicy(
|
|
3351
3592
|
session,
|
|
3352
3593
|
policy.workspaceServerIds,
|