@opengeni/api-router 0.23.1 → 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/browser-controller-authority.d.ts +43 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
- 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/index.d.ts +3 -2
- package/dist/index.js +1796 -22
- 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 +22 -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 +8 -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/scheduled-task-view.d.ts +168 -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/channel-a.d.ts +55 -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 +274 -37
- 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/index.ts +119 -20
- 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 +123 -33
- package/src/integrations/provider-oauth.ts +777 -0
- package/src/integrations/slack-bot.ts +31 -2
- package/src/integrations/slack-interactions.ts +487 -27
- package/src/integrations/social-api.ts +11 -0
- package/src/mcp/documents.ts +32 -1
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/scheduled-task-view.ts +34 -0
- package/src/mcp/server.ts +923 -145
- package/src/memory-slack-delivery.ts +209 -0
- package/src/routes/api-integrations.ts +407 -0
- package/src/routes/browser-identities.ts +136 -0
- package/src/routes/browser-sessions.ts +2543 -0
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1247 -0
- package/src/routes/connections.ts +284 -99
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/files.ts +106 -4
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +7 -1
- package/src/routes/integration-features.ts +258 -0
- 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 +67 -40
- package/src/routes/sessions.ts +421 -245
- package/src/routes/skills.ts +174 -0
- package/src/routes/video-generation.ts +132 -0
- package/src/routes/workspaces.ts +46 -24
- package/src/sandbox/channel-a.ts +723 -116
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +29 -9
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/dist/chunk-T4T2PGU4.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -71
- package/src/mcp/toolspace.ts +0 -1190
package/src/routes/sessions.ts
CHANGED
|
@@ -55,9 +55,12 @@ import {
|
|
|
55
55
|
ViewerHeartbeatRequest,
|
|
56
56
|
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
57
57
|
workspaceControlUtf8Bytes,
|
|
58
|
+
type AccessGrant,
|
|
59
|
+
type AttachViewerResponse,
|
|
58
60
|
type SandboxBackend,
|
|
59
61
|
type LineageNode,
|
|
60
62
|
type Session,
|
|
63
|
+
type AgentTopologyPageResponse,
|
|
61
64
|
type ErrorCode,
|
|
62
65
|
type SessionAuthorizationOperation,
|
|
63
66
|
type SessionQueueSnapshot,
|
|
@@ -86,6 +89,8 @@ import {
|
|
|
86
89
|
listSessionEventPage,
|
|
87
90
|
listSessionHumanInputRequests,
|
|
88
91
|
listSessionIdsInGroup,
|
|
92
|
+
listSessionDiscoverySummaries,
|
|
93
|
+
listSessionDiscoveryAncestorPaths,
|
|
89
94
|
listSessionsForSubject,
|
|
90
95
|
getLatestStartedSessionTurn,
|
|
91
96
|
listSessionTurns,
|
|
@@ -93,8 +98,8 @@ import {
|
|
|
93
98
|
projectSessionForRelatedAccess,
|
|
94
99
|
recordStreamAcknowledgment,
|
|
95
100
|
requestSessionCompaction,
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
setSessionCodexPinInTransaction,
|
|
102
|
+
withSessionCodexCapacityMutation,
|
|
98
103
|
setSessionPin,
|
|
99
104
|
SessionPinVersionConflictError,
|
|
100
105
|
SessionPinAccessError,
|
|
@@ -124,6 +129,7 @@ import {
|
|
|
124
129
|
sessionLatestWorkspaceCapture,
|
|
125
130
|
renewSessionRealtimeInTransaction,
|
|
126
131
|
syncSessionRealtimeLedgerInTransaction,
|
|
132
|
+
withWorkspaceSessionActivityRls,
|
|
127
133
|
withWorkspaceRls,
|
|
128
134
|
workspaceCaptureAtRevision,
|
|
129
135
|
type AppendEventInput,
|
|
@@ -131,6 +137,8 @@ import {
|
|
|
131
137
|
type SandboxPtyProcessIdentity,
|
|
132
138
|
type SandboxRetainedProcess,
|
|
133
139
|
type Database,
|
|
140
|
+
type SessionDiscoveryCursor,
|
|
141
|
+
type SessionDiscoveryAncestor,
|
|
134
142
|
} from "@opengeni/db";
|
|
135
143
|
import {
|
|
136
144
|
appendAndPublishEvents,
|
|
@@ -143,12 +151,20 @@ import {
|
|
|
143
151
|
GatewayRealtimeBrokerError,
|
|
144
152
|
} from "../gateway-realtime";
|
|
145
153
|
import { z, ZodError } from "zod";
|
|
146
|
-
import {
|
|
154
|
+
import {
|
|
155
|
+
runConcurrentChannelAReads,
|
|
156
|
+
withChannelA,
|
|
157
|
+
withChannelARead,
|
|
158
|
+
type ChannelAContext,
|
|
159
|
+
type ChannelAHandle,
|
|
160
|
+
type ChannelAOperation,
|
|
161
|
+
} from "../sandbox/channel-a";
|
|
147
162
|
import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
|
|
148
163
|
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
149
164
|
import { HTTPException } from "hono/http-exception";
|
|
150
165
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
151
166
|
import {
|
|
167
|
+
hasPermission,
|
|
152
168
|
requireAccessGrant,
|
|
153
169
|
requirePermission,
|
|
154
170
|
requireSessionAuthorization,
|
|
@@ -206,6 +222,19 @@ import {
|
|
|
206
222
|
|
|
207
223
|
type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
|
|
208
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
|
+
|
|
209
238
|
export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
210
239
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
211
240
|
const channelAServices = { db, settings, bus, observability: deps.observability };
|
|
@@ -407,7 +436,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
407
436
|
// Creation has committed by this point. Keep response projection outside
|
|
408
437
|
// the create-rejection boundary so a post-commit policy read cannot be
|
|
409
438
|
// misreported as though the session itself was rejected.
|
|
410
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
|
|
439
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session), 202);
|
|
411
440
|
});
|
|
412
441
|
|
|
413
442
|
app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
|
|
@@ -511,7 +540,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
511
540
|
// body for older clients while still making its older-pin omission visible
|
|
512
541
|
// to raw HTTP consumers without changing that response shape.
|
|
513
542
|
c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
|
|
514
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
543
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
|
|
515
544
|
const decorate = (session: Session): Session =>
|
|
516
545
|
sessionWithEffectiveToolPolicy(
|
|
517
546
|
session,
|
|
@@ -533,6 +562,83 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
533
562
|
return c.json([...page.pinned, ...page.sessions].map(decorate));
|
|
534
563
|
});
|
|
535
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
|
+
|
|
536
642
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
|
|
537
643
|
const workspaceId = c.req.param("workspaceId");
|
|
538
644
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -550,7 +656,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
550
656
|
if (!session) {
|
|
551
657
|
throw new HTTPException(404, { message: "session not found" });
|
|
552
658
|
}
|
|
553
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
659
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
554
660
|
});
|
|
555
661
|
|
|
556
662
|
const publishRealtimeMutation = async (
|
|
@@ -589,16 +695,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
589
695
|
throw new HTTPException(400, { message: "invalid session realtime request" });
|
|
590
696
|
}
|
|
591
697
|
try {
|
|
592
|
-
const result = await
|
|
593
|
-
scopedDb
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}),
|
|
601
|
-
),
|
|
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
|
+
}),
|
|
602
706
|
);
|
|
603
707
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
604
708
|
c.header("cache-control", "private, no-store");
|
|
@@ -626,16 +730,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
626
730
|
throw new HTTPException(400, { message: "invalid realtime heartbeat request" });
|
|
627
731
|
}
|
|
628
732
|
try {
|
|
629
|
-
const result = await
|
|
630
|
-
scopedDb
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
}),
|
|
638
|
-
),
|
|
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
|
+
}),
|
|
639
741
|
);
|
|
640
742
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
641
743
|
c.header("cache-control", "private, no-store");
|
|
@@ -662,16 +764,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
662
764
|
throw new HTTPException(400, { message: "invalid realtime end request" });
|
|
663
765
|
}
|
|
664
766
|
try {
|
|
665
|
-
const result = await
|
|
666
|
-
scopedDb
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}),
|
|
674
|
-
),
|
|
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
|
+
}),
|
|
675
775
|
);
|
|
676
776
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
677
777
|
c.header("cache-control", "private, no-store");
|
|
@@ -1034,16 +1134,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1034
1134
|
throw new HTTPException(422, { message: "invalid realtime ledger sync request" });
|
|
1035
1135
|
}
|
|
1036
1136
|
try {
|
|
1037
|
-
const result = await
|
|
1038
|
-
scopedDb
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
}),
|
|
1046
|
-
),
|
|
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
|
+
}),
|
|
1047
1145
|
);
|
|
1048
1146
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
1049
1147
|
c.header("cache-control", "private, no-store");
|
|
@@ -1082,6 +1180,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1082
1180
|
await withEffectivePolicy(
|
|
1083
1181
|
deps,
|
|
1084
1182
|
workspaceId,
|
|
1183
|
+
grant.subjectId,
|
|
1085
1184
|
projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
|
|
1086
1185
|
),
|
|
1087
1186
|
);
|
|
@@ -1106,7 +1205,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1106
1205
|
const workspaceId = c.req.param("workspaceId");
|
|
1107
1206
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
1108
1207
|
const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
|
|
1109
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
1208
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
|
|
1110
1209
|
return c.json({
|
|
1111
1210
|
...lineage,
|
|
1112
1211
|
ancestors: lineage.ancestors.map((session) =>
|
|
@@ -1148,11 +1247,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1148
1247
|
}
|
|
1149
1248
|
}
|
|
1150
1249
|
const pinned = target === "auto" ? null : target;
|
|
1151
|
-
const mutation = await
|
|
1250
|
+
const mutation = await withSessionCodexCapacityMutation(
|
|
1152
1251
|
db,
|
|
1153
1252
|
{ workspaceId, reason: "codex_manual_session_pin_changed" },
|
|
1154
1253
|
async (tx) => {
|
|
1155
|
-
const changed = await
|
|
1254
|
+
const changed = await setSessionCodexPinInTransaction(tx, workspaceId, sessionId, pinned);
|
|
1156
1255
|
return { result: changed, changed };
|
|
1157
1256
|
},
|
|
1158
1257
|
);
|
|
@@ -1208,7 +1307,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1208
1307
|
if (!session) {
|
|
1209
1308
|
throw new HTTPException(404, { message: "session not found" });
|
|
1210
1309
|
}
|
|
1211
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
1310
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
1212
1311
|
});
|
|
1213
1312
|
|
|
1214
1313
|
app.patch(
|
|
@@ -1246,7 +1345,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1246
1345
|
const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
|
|
1247
1346
|
try {
|
|
1248
1347
|
const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
|
|
1249
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
1348
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
1250
1349
|
} catch (error) {
|
|
1251
1350
|
if (error instanceof SessionToolPolicyVersionConflictError) {
|
|
1252
1351
|
return c.json(
|
|
@@ -1563,6 +1662,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1563
1662
|
const projected = compact ? coalesceSessionEventDeltas(events) : events;
|
|
1564
1663
|
const page = boundSessionEventHttpPage(projected, {
|
|
1565
1664
|
direction,
|
|
1665
|
+
eventProjection: mode === "forensic" && payloadMode === "full" ? "exact" : "bounded",
|
|
1566
1666
|
});
|
|
1567
1667
|
const hasMore = dbPage.hasMore || page.truncated;
|
|
1568
1668
|
c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
|
|
@@ -1845,10 +1945,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1845
1945
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1846
1946
|
const sessionId = c.req.param("sessionId");
|
|
1847
1947
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
1848
|
-
const
|
|
1849
|
-
const payload = SteerSessionMessageRequest.parse(raw);
|
|
1948
|
+
const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
|
|
1850
1949
|
const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
|
|
1851
1950
|
text: payload.text,
|
|
1951
|
+
annotations: payload.annotations,
|
|
1852
1952
|
turnInstructions: payload.turnInstructions ?? null,
|
|
1853
1953
|
resources: payload.resources,
|
|
1854
1954
|
model: payload.model ?? null,
|
|
@@ -1870,8 +1970,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1870
1970
|
const workspaceId = c.req.param("workspaceId");
|
|
1871
1971
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1872
1972
|
const sessionId = c.req.param("sessionId");
|
|
1873
|
-
const
|
|
1874
|
-
const event = ClientSessionEvent.parse(rawEvent);
|
|
1973
|
+
const event = parseSessionEventAdmission(await c.req.json().catch(() => null));
|
|
1875
1974
|
const refinedOperation =
|
|
1876
1975
|
event.type === "user.approvalDecision"
|
|
1877
1976
|
? "session.approval.write"
|
|
@@ -1892,6 +1991,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1892
1991
|
if (event.type === "user.message") {
|
|
1893
1992
|
const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
|
|
1894
1993
|
text: event.payload.text,
|
|
1994
|
+
annotations: event.payload.annotations,
|
|
1895
1995
|
turnInstructions: event.payload.turnInstructions ?? null,
|
|
1896
1996
|
resources: event.payload.resources ?? [],
|
|
1897
1997
|
model: event.payload.model ?? null,
|
|
@@ -2056,8 +2156,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2056
2156
|
// GET .../stream-capabilities — the capability-negotiation read. Returns the
|
|
2057
2157
|
// SessionCapabilities doc (descriptor + lease liveness/epoch + os + the
|
|
2058
2158
|
// shared-exposure disclosure + the calling principal's acknowledgment state),
|
|
2059
|
-
// API-direct.
|
|
2060
|
-
//
|
|
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.
|
|
2061
2161
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities", async (c) => {
|
|
2062
2162
|
const workspaceId = c.req.param("workspaceId");
|
|
2063
2163
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -2074,12 +2174,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2074
2174
|
const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
|
|
2075
2175
|
const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
|
|
2076
2176
|
// Per-principal acknowledgment: A acknowledging does not consent for B. The
|
|
2077
|
-
//
|
|
2078
|
-
//
|
|
2079
|
-
// /viewers
|
|
2080
|
-
// WITHOUT un-redacted consent could be handed a live VNC URL + scoped token
|
|
2081
|
-
// from this read path while being correctly 409'd on attach (a consent-gate
|
|
2082
|
-
// 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.
|
|
2083
2180
|
const ack = await getStreamAcknowledgment(db, {
|
|
2084
2181
|
workspaceId,
|
|
2085
2182
|
sandboxGroupId: session.sandboxGroupId,
|
|
@@ -2089,65 +2186,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2089
2186
|
? ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)
|
|
2090
2187
|
: false;
|
|
2091
2188
|
|
|
2092
|
-
//
|
|
2093
|
-
//
|
|
2094
|
-
//
|
|
2095
|
-
//
|
|
2096
|
-
// exposeStreamPort (resolve the 6080 tunnel + mint the scoped token), record
|
|
2097
|
-
// data_plane_url under the epoch fence, and emit stream.url.rotated to other
|
|
2098
|
-
// viewers on a box rollover. The handshake never SPINS UP a cold box (that is
|
|
2099
|
-
// the viewer-attach path) — a cold lease stays lease_cold. A degraded mint
|
|
2100
|
-
// (no secret / display-stack or tunnel failure) returns null → transport:null.
|
|
2101
|
-
let desktopStream: DesktopStreamMint | null = null;
|
|
2102
|
-
const desktopUnlocked =
|
|
2103
|
-
settings.sandboxDesktopEnabled &&
|
|
2104
|
-
!streamTokenDegraded(settings) &&
|
|
2105
|
-
acknowledged &&
|
|
2106
|
-
(session.activeSandboxId != null ||
|
|
2107
|
-
lease?.liveness === "warm" ||
|
|
2108
|
-
lease?.liveness === "draining");
|
|
2109
|
-
if (desktopUnlocked) {
|
|
2110
|
-
desktopStream = await mintDesktopStream(
|
|
2111
|
-
{ db, settings, bus },
|
|
2112
|
-
{
|
|
2113
|
-
accountId: grant.accountId,
|
|
2114
|
-
workspaceId,
|
|
2115
|
-
session,
|
|
2116
|
-
// The handshake's token is scoped to the calling principal (it is a read,
|
|
2117
|
-
// not a viewer-holder acquire); the per-holder token is re-minted on
|
|
2118
|
-
// POST /viewers. A previousEpoch != current would have rotated already
|
|
2119
|
-
// via the warming-commit; the read does not itself drive rotation.
|
|
2120
|
-
viewerId: grant.subjectId,
|
|
2121
|
-
...(lease ? { lease } : {}),
|
|
2122
|
-
},
|
|
2123
|
-
);
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
// P5.t — the REAL PTY terminal cell, served API-DIRECT. Independent of the
|
|
2127
|
-
// desktop: it gates ONLY on sandboxTerminalEnabled + a real-PTY backend + a
|
|
2128
|
-
// WARM box (NO un-redacted ack — the terminal cell has no acknowledgment
|
|
2129
|
-
// gate). A degraded mint (terminal off / no secret / ttyd or tunnel failure)
|
|
2130
|
-
// returns null → the Terminal cell falls back to the sse-events firehose.
|
|
2131
|
-
let terminalStream: TerminalStreamMint | null = null;
|
|
2132
|
-
const terminalUnlocked =
|
|
2133
|
-
settings.sandboxTerminalEnabled &&
|
|
2134
|
-
!streamTokenDegraded(settings) &&
|
|
2135
|
-
(session.activeSandboxId != null ||
|
|
2136
|
-
lease?.liveness === "warm" ||
|
|
2137
|
-
lease?.liveness === "draining");
|
|
2138
|
-
if (terminalUnlocked) {
|
|
2139
|
-
terminalStream = await mintTerminalStream(
|
|
2140
|
-
{ db, settings, bus },
|
|
2141
|
-
{
|
|
2142
|
-
accountId: grant.accountId,
|
|
2143
|
-
workspaceId,
|
|
2144
|
-
session,
|
|
2145
|
-
viewerId: grant.subjectId,
|
|
2146
|
-
...(lease ? { lease } : {}),
|
|
2147
|
-
},
|
|
2148
|
-
);
|
|
2149
|
-
}
|
|
2150
|
-
|
|
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.
|
|
2151
2193
|
const capabilities = negotiateCapabilities({
|
|
2152
2194
|
sessionId,
|
|
2153
2195
|
backend: session.sandboxBackend as SandboxBackend,
|
|
@@ -2174,31 +2216,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2174
2216
|
desktopAcknowledged: acknowledged,
|
|
2175
2217
|
shared,
|
|
2176
2218
|
sharedSessionIds: visibleSharedSessionIds,
|
|
2177
|
-
//
|
|
2178
|
-
//
|
|
2179
|
-
...(desktopStream
|
|
2180
|
-
? {
|
|
2181
|
-
desktopStream: {
|
|
2182
|
-
url: desktopStream.url,
|
|
2183
|
-
token: desktopStream.token,
|
|
2184
|
-
expiresAt: desktopStream.expiresAt,
|
|
2185
|
-
resolution: desktopStream.resolution,
|
|
2186
|
-
},
|
|
2187
|
-
}
|
|
2188
|
-
: {}),
|
|
2189
|
-
// P5.t — the terminal policy toggle + the minted pty-ws address. The
|
|
2190
|
-
// resolver advertises sse-events (firehose) on a cold/disabled terminal and
|
|
2191
|
-
// 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.
|
|
2192
2221
|
terminalEnabled: settings.sandboxTerminalEnabled,
|
|
2193
|
-
...(terminalStream
|
|
2194
|
-
? {
|
|
2195
|
-
terminalStream: {
|
|
2196
|
-
url: terminalStream.url,
|
|
2197
|
-
token: terminalStream.token,
|
|
2198
|
-
expiresAt: terminalStream.expiresAt,
|
|
2199
|
-
},
|
|
2200
|
-
}
|
|
2201
|
-
: {}),
|
|
2202
2222
|
});
|
|
2203
2223
|
|
|
2204
2224
|
const repositoryRoots = [
|
|
@@ -2316,20 +2336,29 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2316
2336
|
message: "invalid viewer attach request",
|
|
2317
2337
|
});
|
|
2318
2338
|
}
|
|
2319
|
-
//
|
|
2320
|
-
//
|
|
2321
|
-
//
|
|
2322
|
-
//
|
|
2323
|
-
// is interactive by nature and the gate is the scoped tunnel URL + stream token
|
|
2324
|
-
// — so it warms the box and mints the pty-ws terminal cell without a 409. Gating
|
|
2325
|
-
// the terminal attach behind the desktop ack (the bug this fixes) dead-ended the
|
|
2326
|
-
// interactive terminal: the box never warmed → the Terminal cell stayed on the
|
|
2327
|
-
// read-only sse-events firehose forever ("read only"), and with the desktop tier
|
|
2328
|
-
// 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.
|
|
2329
2343
|
const wantDesktop = parsed.data.desktop ?? false;
|
|
2330
|
-
|
|
2331
|
-
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.
|
|
2332
2360
|
if (wantDesktop) {
|
|
2361
|
+
const { shared } = await resolveSharedExposure(workspaceId, session);
|
|
2333
2362
|
const ack = await getStreamAcknowledgment(db, {
|
|
2334
2363
|
workspaceId,
|
|
2335
2364
|
sandboxGroupId: session.sandboxGroupId,
|
|
@@ -2372,7 +2401,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2372
2401
|
dataPlaneUrl: null,
|
|
2373
2402
|
};
|
|
2374
2403
|
if (
|
|
2375
|
-
(
|
|
2404
|
+
((wantDesktop && settings.sandboxDesktopEnabled) ||
|
|
2405
|
+
(wantTerminal && settings.sandboxTerminalEnabled)) &&
|
|
2376
2406
|
!streamTokenDegraded(settings)
|
|
2377
2407
|
) {
|
|
2378
2408
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
@@ -2384,7 +2414,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2384
2414
|
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
2385
2415
|
});
|
|
2386
2416
|
}
|
|
2387
|
-
if (settings.sandboxTerminalEnabled) {
|
|
2417
|
+
if (wantTerminal && settings.sandboxTerminalEnabled) {
|
|
2388
2418
|
terminal = await mintTerminalStream(viewerServices, {
|
|
2389
2419
|
accountId: grant.accountId,
|
|
2390
2420
|
workspaceId,
|
|
@@ -2399,6 +2429,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2399
2429
|
accountId: grant.accountId,
|
|
2400
2430
|
workspaceId,
|
|
2401
2431
|
session,
|
|
2432
|
+
waitSignal: c.req.raw.signal,
|
|
2402
2433
|
...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
|
|
2403
2434
|
});
|
|
2404
2435
|
|
|
@@ -2410,7 +2441,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2410
2441
|
// box is warm here (attachViewer spun it up or attached), so the handshake's
|
|
2411
2442
|
// never-spin-up rule does not apply.
|
|
2412
2443
|
if (
|
|
2413
|
-
(
|
|
2444
|
+
((wantDesktop && settings.sandboxDesktopEnabled) ||
|
|
2445
|
+
(wantTerminal && settings.sandboxTerminalEnabled)) &&
|
|
2414
2446
|
!streamTokenDegraded(settings)
|
|
2415
2447
|
) {
|
|
2416
2448
|
const lease = await readGroupLease(
|
|
@@ -2418,9 +2450,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2418
2450
|
{ workspaceId, sandboxGroupId: session.sandboxGroupId },
|
|
2419
2451
|
);
|
|
2420
2452
|
if (lease) {
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
// 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.
|
|
2424
2455
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
2425
2456
|
stream = await mintDesktopStream(viewerServices, {
|
|
2426
2457
|
accountId: grant.accountId,
|
|
@@ -2430,10 +2461,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2430
2461
|
lease,
|
|
2431
2462
|
});
|
|
2432
2463
|
}
|
|
2433
|
-
|
|
2434
|
-
// address (independent of the desktop toggle). A degraded mint leaves the
|
|
2435
|
-
// terminal fields null → the client falls back to the sse-events firehose.
|
|
2436
|
-
if (settings.sandboxTerminalEnabled) {
|
|
2464
|
+
if (wantTerminal && settings.sandboxTerminalEnabled) {
|
|
2437
2465
|
terminal = await mintTerminalStream(viewerServices, {
|
|
2438
2466
|
accountId: grant.accountId,
|
|
2439
2467
|
workspaceId,
|
|
@@ -2445,42 +2473,42 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2445
2473
|
}
|
|
2446
2474
|
}
|
|
2447
2475
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
?
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
201,
|
|
2473
|
-
);
|
|
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);
|
|
2474
2500
|
});
|
|
2475
2501
|
|
|
2476
2502
|
// POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
|
|
2477
|
-
// A holder
|
|
2478
|
-
//
|
|
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.
|
|
2479
2506
|
app.post(
|
|
2480
2507
|
"/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
|
|
2481
2508
|
async (c) => {
|
|
2482
2509
|
const workspaceId = c.req.param("workspaceId");
|
|
2483
|
-
const grant = await requireAccessGrant(c, deps, workspaceId
|
|
2510
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
2511
|
+
requireViewerLifecyclePermission(grant);
|
|
2484
2512
|
assertOwnershipEnabled();
|
|
2485
2513
|
const sessionId = c.req.param("sessionId");
|
|
2486
2514
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2510,7 +2538,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2510
2538
|
// DELETE .../viewers/:viewerId — release the holder (idempotent).
|
|
2511
2539
|
app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
|
|
2512
2540
|
const workspaceId = c.req.param("workspaceId");
|
|
2513
|
-
const grant = await requireAccessGrant(c, deps, workspaceId
|
|
2541
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
2542
|
+
requireViewerLifecyclePermission(grant);
|
|
2514
2543
|
assertOwnershipEnabled();
|
|
2515
2544
|
const sessionId = c.req.param("sessionId");
|
|
2516
2545
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2577,11 +2606,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2577
2606
|
// FS uses files:read for reads, files:write for mutations; Git is read-only
|
|
2578
2607
|
// (rides files:read); Terminal exec + PTY ride terminal:attach.
|
|
2579
2608
|
|
|
2580
|
-
type ChannelARouteCtx = {
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
session: Session;
|
|
2584
|
-
subjectId: string;
|
|
2609
|
+
type ChannelARouteCtx = ChannelAContext & {
|
|
2610
|
+
waitSignal: AbortSignal;
|
|
2611
|
+
operation: ChannelAOperation;
|
|
2585
2612
|
};
|
|
2586
2613
|
|
|
2587
2614
|
// Shared preamble: grant BEFORE parse, ownership gate, session lookup. Returns
|
|
@@ -2589,6 +2616,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2589
2616
|
async function channelAPreamble(
|
|
2590
2617
|
c: Context,
|
|
2591
2618
|
permission: "files:read" | "files:write" | "terminal:attach",
|
|
2619
|
+
operation: ChannelAOperation,
|
|
2592
2620
|
): Promise<ChannelARouteCtx> {
|
|
2593
2621
|
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
2594
2622
|
const grant = await requireAccessGrant(c, deps, workspaceId, permission);
|
|
@@ -2603,6 +2631,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2603
2631
|
workspaceId,
|
|
2604
2632
|
session,
|
|
2605
2633
|
subjectId: grant.subjectId,
|
|
2634
|
+
waitSignal: c.req.raw.signal,
|
|
2635
|
+
operation,
|
|
2606
2636
|
};
|
|
2607
2637
|
}
|
|
2608
2638
|
|
|
@@ -2622,55 +2652,53 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2622
2652
|
|
|
2623
2653
|
// ── FileSystem ──────────────────────────────────────────────────────────
|
|
2624
2654
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
|
|
2625
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2655
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.list");
|
|
2626
2656
|
const req = await parseChannelABody(c, FsListRequest);
|
|
2627
|
-
const out = await
|
|
2657
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsList(req));
|
|
2628
2658
|
return c.json(out);
|
|
2629
2659
|
});
|
|
2630
2660
|
|
|
2631
2661
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list-batch", async (c) => {
|
|
2632
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2662
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.list-batch");
|
|
2633
2663
|
const req = await parseChannelABody(c, FsListBatchRequest);
|
|
2634
|
-
const out = await
|
|
2635
|
-
results: await
|
|
2636
|
-
req.requests.map((request) =>
|
|
2637
|
-
Promise.resolve().then(async () => await service.fsList(request)),
|
|
2638
|
-
),
|
|
2664
|
+
const out = await withChannelARead(channelAServices, ctx, async ({ service }) => ({
|
|
2665
|
+
results: await runConcurrentChannelAReads(
|
|
2666
|
+
req.requests.map((request) => async () => await service.fsList(request)),
|
|
2639
2667
|
),
|
|
2640
2668
|
}));
|
|
2641
2669
|
return c.json(out);
|
|
2642
2670
|
});
|
|
2643
2671
|
|
|
2644
2672
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
|
|
2645
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2673
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.read");
|
|
2646
2674
|
const req = await parseChannelABody(c, FsReadRequest);
|
|
2647
|
-
const out = await
|
|
2675
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsRead(req));
|
|
2648
2676
|
return c.json(out);
|
|
2649
2677
|
});
|
|
2650
2678
|
|
|
2651
2679
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
|
|
2652
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
2680
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.write");
|
|
2653
2681
|
const req = await parseChannelABody(c, FsWriteRequest);
|
|
2654
2682
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsWrite(req));
|
|
2655
2683
|
return c.json(out);
|
|
2656
2684
|
});
|
|
2657
2685
|
|
|
2658
2686
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
|
|
2659
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
2687
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.delete");
|
|
2660
2688
|
const req = await parseChannelABody(c, FsDeleteRequest);
|
|
2661
2689
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsDelete(req));
|
|
2662
2690
|
return c.json(out);
|
|
2663
2691
|
});
|
|
2664
2692
|
|
|
2665
2693
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
|
|
2666
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
2694
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.move");
|
|
2667
2695
|
const req = await parseChannelABody(c, FsMoveRequest);
|
|
2668
2696
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMove(req));
|
|
2669
2697
|
return c.json(out);
|
|
2670
2698
|
});
|
|
2671
2699
|
|
|
2672
2700
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
|
|
2673
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
2701
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.mkdir");
|
|
2674
2702
|
const req = await parseChannelABody(c, FsMkdirRequest);
|
|
2675
2703
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMkdir(req));
|
|
2676
2704
|
return c.json(out);
|
|
@@ -2678,50 +2706,87 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2678
2706
|
|
|
2679
2707
|
// ── Git (read-only) ─────────────────────────────────────────────────────
|
|
2680
2708
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
|
|
2681
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2709
|
+
const ctx = await channelAPreamble(c, "files:read", "git.status");
|
|
2682
2710
|
const req = await parseChannelABody(c, GitStatusRequest);
|
|
2683
|
-
const out = await
|
|
2711
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
2712
|
+
service.gitStatus(req),
|
|
2713
|
+
);
|
|
2684
2714
|
return c.json(out);
|
|
2685
2715
|
});
|
|
2686
2716
|
|
|
2687
2717
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
|
|
2688
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2718
|
+
const ctx = await channelAPreamble(c, "files:read", "git.diff");
|
|
2689
2719
|
const req = await parseChannelABody(c, GitDiffRequest);
|
|
2690
|
-
const out = await
|
|
2720
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
2721
|
+
service.gitDiff(req),
|
|
2722
|
+
);
|
|
2691
2723
|
return c.json(out);
|
|
2692
2724
|
});
|
|
2693
2725
|
|
|
2694
2726
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/read-batch", async (c) => {
|
|
2695
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2727
|
+
const ctx = await channelAPreamble(c, "files:read", "git.read-batch");
|
|
2696
2728
|
const req = await parseChannelABody(c, GitReadBatchRequest);
|
|
2697
|
-
const out = await
|
|
2698
|
-
|
|
2699
|
-
|
|
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) {
|
|
2700
2743
|
const diffRequest = request.diff;
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
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
|
+
}
|
|
2707
2770
|
return { status, ...(diff ? { diff } : {}) };
|
|
2708
2771
|
}),
|
|
2709
|
-
|
|
2710
|
-
})
|
|
2772
|
+
};
|
|
2773
|
+
});
|
|
2711
2774
|
return c.json(out);
|
|
2712
2775
|
});
|
|
2713
2776
|
|
|
2714
2777
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
|
|
2715
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2778
|
+
const ctx = await channelAPreamble(c, "files:read", "git.log");
|
|
2716
2779
|
const req = await parseChannelABody(c, GitLogRequest);
|
|
2717
|
-
const out = await
|
|
2780
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.gitLog(req));
|
|
2718
2781
|
return c.json(out);
|
|
2719
2782
|
});
|
|
2720
2783
|
|
|
2721
2784
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
|
|
2722
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2785
|
+
const ctx = await channelAPreamble(c, "files:read", "git.show");
|
|
2723
2786
|
const req = await parseChannelABody(c, GitShowRequest);
|
|
2724
|
-
const out = await
|
|
2787
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
2788
|
+
service.gitShow(req),
|
|
2789
|
+
);
|
|
2725
2790
|
return c.json(out);
|
|
2726
2791
|
});
|
|
2727
2792
|
|
|
@@ -2784,7 +2849,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2784
2849
|
|
|
2785
2850
|
// ── Terminal: synchronous exec ────────────────────────────────────────────
|
|
2786
2851
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
|
|
2787
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2852
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.exec");
|
|
2788
2853
|
const req = await parseChannelABody(c, TerminalExecRequest);
|
|
2789
2854
|
const out = await withChannelA(channelAServices, ctx, ({ service }) =>
|
|
2790
2855
|
service.terminalExec(req),
|
|
@@ -2794,7 +2859,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2794
2859
|
|
|
2795
2860
|
// ── Terminal: interactive PTY control (output rides A1) ───────────────────
|
|
2796
2861
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
|
|
2797
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2862
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.open");
|
|
2798
2863
|
const req = await parseChannelABody(c, PtyOpenRequest);
|
|
2799
2864
|
if (ctx.session.sandboxBackend === "selfhosted" || ctx.session.activeSandboxId !== null) {
|
|
2800
2865
|
throw new HTTPException(409, {
|
|
@@ -2903,7 +2968,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2903
2968
|
});
|
|
2904
2969
|
|
|
2905
2970
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
|
|
2906
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
2971
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.write");
|
|
2907
2972
|
const req = await parseChannelABody(c, PtyWriteRequest);
|
|
2908
2973
|
const pty = await getOpenPtySession(db, {
|
|
2909
2974
|
workspaceId: ctx.workspaceId,
|
|
@@ -2967,7 +3032,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2967
3032
|
});
|
|
2968
3033
|
|
|
2969
3034
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
|
|
2970
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3035
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.resize");
|
|
2971
3036
|
const req = await parseChannelABody(c, PtyResizeRequest);
|
|
2972
3037
|
const pty = await getOpenPtySession(db, {
|
|
2973
3038
|
workspaceId: ctx.workspaceId,
|
|
@@ -2999,7 +3064,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2999
3064
|
});
|
|
3000
3065
|
|
|
3001
3066
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
|
|
3002
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3067
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.close");
|
|
3003
3068
|
const req = await parseChannelABody(c, PtyCloseRequest);
|
|
3004
3069
|
const pty = await getOpenPtySession(db, {
|
|
3005
3070
|
workspaceId: ctx.workspaceId,
|
|
@@ -3311,6 +3376,99 @@ function sessionListQuery(
|
|
|
3311
3376
|
};
|
|
3312
3377
|
}
|
|
3313
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
|
+
|
|
3314
3472
|
function compactEvents(raw: string | undefined): boolean {
|
|
3315
3473
|
return raw === "1" || raw === "true";
|
|
3316
3474
|
}
|
|
@@ -3363,6 +3521,22 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
|
|
|
3363
3521
|
throw error;
|
|
3364
3522
|
}
|
|
3365
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
|
+
|
|
3366
3540
|
function zodErrorFields(error: ZodError): string {
|
|
3367
3541
|
const paths = [
|
|
3368
3542
|
...new Set(
|
|
@@ -3398,10 +3572,11 @@ type EffectivePolicyContext = {
|
|
|
3398
3572
|
async function loadEffectivePolicyContext(
|
|
3399
3573
|
deps: ApiRouteDeps,
|
|
3400
3574
|
workspaceId: string,
|
|
3575
|
+
subjectId: string,
|
|
3401
3576
|
): Promise<EffectivePolicyContext> {
|
|
3402
3577
|
const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
|
|
3403
|
-
workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
|
|
3404
|
-
workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
|
|
3578
|
+
workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
|
|
3579
|
+
workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
|
|
3405
3580
|
]);
|
|
3406
3581
|
return { workspaceServerIds, workspaceDefaultServerIds };
|
|
3407
3582
|
}
|
|
@@ -3409,9 +3584,10 @@ async function loadEffectivePolicyContext(
|
|
|
3409
3584
|
async function withEffectivePolicy(
|
|
3410
3585
|
deps: ApiRouteDeps,
|
|
3411
3586
|
workspaceId: string,
|
|
3587
|
+
subjectId: string,
|
|
3412
3588
|
session: Session,
|
|
3413
3589
|
): Promise<Session> {
|
|
3414
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
3590
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId);
|
|
3415
3591
|
return sessionWithEffectiveToolPolicy(
|
|
3416
3592
|
session,
|
|
3417
3593
|
policy.workspaceServerIds,
|