@opengeni/api-router 0.23.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +3 -2
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +61 -0
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/controller-data-plane.d.ts +12 -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/auth.d.ts +1 -1
- package/dist/http/cors.d.ts +1 -0
- package/dist/http/sse.d.ts +18 -1
- 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/fiken.d.ts +233 -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 +17 -0
- package/dist/integrations/slack-bot.d.ts +175 -1
- package/dist/integrations/slack-interactions.d.ts +24 -6
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- 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 +16 -4
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/model-catalog.d.ts +1 -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/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -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-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +18 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +62 -2
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +7 -3
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +20 -14
- package/src/app.ts +604 -64
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +186 -0
- package/src/browser-network-route.ts +34 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/controller-data-plane.ts +35 -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 +29 -9
- package/src/http/cors.ts +3 -0
- package/src/http/sse.ts +229 -5
- package/src/index.ts +119 -20
- package/src/integrations/api-integrations.ts +365 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1005 -64
- package/src/integrations/oauth-client.ts +123 -33
- package/src/integrations/provider-oauth.ts +756 -0
- package/src/integrations/slack-bot.ts +669 -11
- package/src/integrations/slack-interactions.ts +2063 -93
- package/src/integrations/social-api.ts +11 -0
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +146 -6
- 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 +1756 -355
- package/src/memory-slack-delivery.ts +209 -0
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +410 -0
- package/src/routes/browser-identities.ts +160 -0
- package/src/routes/browser-sessions.ts +4482 -0
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1602 -0
- package/src/routes/connections.ts +421 -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-facets.ts +258 -0
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +752 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +67 -40
- package/src/routes/sessions.ts +830 -278
- package/src/routes/skills.ts +256 -0
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +162 -0
- package/src/routes/workspaces.ts +68 -28
- package/src/sandbox/channel-a.ts +750 -116
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +39 -12
- package/src/slack-reaction-files.ts +181 -0
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AcknowledgeStreamRequest,
|
|
3
|
+
ApplySessionGoalRevisionRequest,
|
|
3
4
|
ActivateCodexRealtimeConnectionRequest,
|
|
4
5
|
AttachViewerRequest,
|
|
5
6
|
BeginSessionRealtimeRequest,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
SteerSessionQueueItemRequest,
|
|
48
49
|
SteerSessionMessageRequest,
|
|
49
50
|
TerminalExecRequest,
|
|
51
|
+
UpdateSessionChannelRequest,
|
|
50
52
|
UpdateSessionPinRequest,
|
|
51
53
|
UpdateSessionGoalRequest,
|
|
52
54
|
UpdateSessionMcpApprovalPolicyRequest,
|
|
@@ -55,9 +57,13 @@ import {
|
|
|
55
57
|
ViewerHeartbeatRequest,
|
|
56
58
|
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
57
59
|
workspaceControlUtf8Bytes,
|
|
60
|
+
type AccessGrant,
|
|
61
|
+
type AttachViewerResponse,
|
|
58
62
|
type SandboxBackend,
|
|
59
63
|
type LineageNode,
|
|
60
64
|
type Session,
|
|
65
|
+
type SessionGoalRevision,
|
|
66
|
+
type AgentTopologyPageResponse,
|
|
61
67
|
type ErrorCode,
|
|
62
68
|
type SessionAuthorizationOperation,
|
|
63
69
|
type SessionQueueSnapshot,
|
|
@@ -72,6 +78,7 @@ import {
|
|
|
72
78
|
clearSessionGoal,
|
|
73
79
|
clearSessionContext,
|
|
74
80
|
getOpenPtySession,
|
|
81
|
+
getEnrollment,
|
|
75
82
|
getRetainedProcess,
|
|
76
83
|
getSandbox,
|
|
77
84
|
getSession,
|
|
@@ -80,12 +87,16 @@ import {
|
|
|
80
87
|
getSessionGoal,
|
|
81
88
|
getSessionHumanInputRequest,
|
|
82
89
|
getSessionGoalWithContinuation,
|
|
90
|
+
getSessionGoalRevision,
|
|
83
91
|
getSessionQueueSnapshot,
|
|
84
92
|
getStreamAcknowledgment,
|
|
85
93
|
insertPtySession,
|
|
86
94
|
listSessionEventPage,
|
|
87
95
|
listSessionHumanInputRequests,
|
|
96
|
+
listSessionGoalRevisions,
|
|
88
97
|
listSessionIdsInGroup,
|
|
98
|
+
listSessionDiscoverySummaries,
|
|
99
|
+
listSessionDiscoveryAncestorPaths,
|
|
89
100
|
listSessionsForSubject,
|
|
90
101
|
getLatestStartedSessionTurn,
|
|
91
102
|
listSessionTurns,
|
|
@@ -93,8 +104,10 @@ import {
|
|
|
93
104
|
projectSessionForRelatedAccess,
|
|
94
105
|
recordStreamAcknowledgment,
|
|
95
106
|
requestSessionCompaction,
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
setSessionCodexPinInTransaction,
|
|
108
|
+
withSessionCodexCapacityMutation,
|
|
109
|
+
setSessionChannel,
|
|
110
|
+
ChannelNotFoundError,
|
|
98
111
|
setSessionPin,
|
|
99
112
|
SessionPinVersionConflictError,
|
|
100
113
|
SessionPinAccessError,
|
|
@@ -105,6 +118,7 @@ import {
|
|
|
105
118
|
decodeSessionListCursor,
|
|
106
119
|
revokeViewer,
|
|
107
120
|
setSessionGoalStatusWithEvent,
|
|
121
|
+
upsertSessionGoalWithEvent,
|
|
108
122
|
updatePtySessionActivity,
|
|
109
123
|
QueueCommandConflictError,
|
|
110
124
|
beginSessionRealtimeInTransaction,
|
|
@@ -124,6 +138,7 @@ import {
|
|
|
124
138
|
sessionLatestWorkspaceCapture,
|
|
125
139
|
renewSessionRealtimeInTransaction,
|
|
126
140
|
syncSessionRealtimeLedgerInTransaction,
|
|
141
|
+
withWorkspaceSessionActivityRls,
|
|
127
142
|
withWorkspaceRls,
|
|
128
143
|
workspaceCaptureAtRevision,
|
|
129
144
|
type AppendEventInput,
|
|
@@ -131,6 +146,8 @@ import {
|
|
|
131
146
|
type SandboxPtyProcessIdentity,
|
|
132
147
|
type SandboxRetainedProcess,
|
|
133
148
|
type Database,
|
|
149
|
+
type SessionDiscoveryCursor,
|
|
150
|
+
type SessionDiscoveryAncestor,
|
|
134
151
|
} from "@opengeni/db";
|
|
135
152
|
import {
|
|
136
153
|
appendAndPublishEvents,
|
|
@@ -142,17 +159,32 @@ import {
|
|
|
142
159
|
createGatewayRealtimeConnectionSecret,
|
|
143
160
|
GatewayRealtimeBrokerError,
|
|
144
161
|
} from "../gateway-realtime";
|
|
162
|
+
import { createXaiRealtimeConnectionSecret, XaiRealtimeBrokerError } from "../xai-realtime";
|
|
145
163
|
import { z, ZodError } from "zod";
|
|
146
|
-
import {
|
|
147
|
-
|
|
164
|
+
import {
|
|
165
|
+
runConcurrentChannelAReads,
|
|
166
|
+
withChannelA,
|
|
167
|
+
withChannelARead,
|
|
168
|
+
type ChannelAContext,
|
|
169
|
+
type ChannelAHandle,
|
|
170
|
+
type ChannelAOperation,
|
|
171
|
+
} from "../sandbox/channel-a";
|
|
172
|
+
import {
|
|
173
|
+
NatsControlRpc,
|
|
174
|
+
negotiateCapabilities,
|
|
175
|
+
negotiateSelfhostedCapabilities,
|
|
176
|
+
SelfhostedSession,
|
|
177
|
+
} from "@opengeni/runtime/sandbox";
|
|
148
178
|
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
149
179
|
import { HTTPException } from "hono/http-exception";
|
|
150
180
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
151
181
|
import {
|
|
182
|
+
hasPermission,
|
|
152
183
|
requireAccessGrant,
|
|
153
184
|
requirePermission,
|
|
154
185
|
requireSessionAuthorization,
|
|
155
186
|
requireSessionAuthorizationListScope,
|
|
187
|
+
withResolvedSessionAuthorization,
|
|
156
188
|
SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
|
|
157
189
|
SessionAuthorizationDeniedError,
|
|
158
190
|
SessionAuthorizationUnavailableError,
|
|
@@ -195,6 +227,7 @@ import {
|
|
|
195
227
|
sessionWithEffectiveToolPolicy,
|
|
196
228
|
workspaceSessionToolPolicyDefaultServerIds,
|
|
197
229
|
workspaceSessionToolPolicyServerIds,
|
|
230
|
+
relayConfigFromSettings,
|
|
198
231
|
} from "@opengeni/core";
|
|
199
232
|
import { assertSessionExists, boundedLimit } from "../http/common";
|
|
200
233
|
import { sseSessionStream } from "../http/sse";
|
|
@@ -206,9 +239,27 @@ import {
|
|
|
206
239
|
|
|
207
240
|
type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
|
|
208
241
|
|
|
242
|
+
const VIEWER_LIFECYCLE_PERMISSIONS = ["stream:view", "terminal:attach", "files:write"] as const;
|
|
243
|
+
|
|
244
|
+
function requireViewerLifecyclePermission(grant: AccessGrant): void {
|
|
245
|
+
if (
|
|
246
|
+
VIEWER_LIFECYCLE_PERMISSIONS.some((permission) => hasPermission(grant.permissions, permission))
|
|
247
|
+
) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
throw new HTTPException(403, {
|
|
251
|
+
message: `missing permission: ${VIEWER_LIFECYCLE_PERMISSIONS.join(" or ")}`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
209
255
|
export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
210
256
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
211
|
-
const channelAServices = {
|
|
257
|
+
const channelAServices = {
|
|
258
|
+
db,
|
|
259
|
+
settings,
|
|
260
|
+
bus,
|
|
261
|
+
observability: deps.observability,
|
|
262
|
+
};
|
|
212
263
|
const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
|
|
213
264
|
const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
|
|
214
265
|
leaseId: pty.leaseId,
|
|
@@ -355,10 +406,6 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
355
406
|
if (!operation) {
|
|
356
407
|
throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
|
|
357
408
|
}
|
|
358
|
-
if (operation === "session.codex_account.write" && !deps.sessionAuthorization) {
|
|
359
|
-
await next();
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
409
|
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
363
410
|
try {
|
|
364
411
|
const authorization = await requireSessionAuthorization(deps, grant, {
|
|
@@ -367,6 +414,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
367
414
|
surface: "http",
|
|
368
415
|
});
|
|
369
416
|
if (authorization) requestSessionAuthorization.set(c.req.raw, authorization);
|
|
417
|
+
if (authorization) {
|
|
418
|
+
await withResolvedSessionAuthorization(authorization, next);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
370
421
|
} catch (error) {
|
|
371
422
|
throw sessionAuthorizationHttpError(error);
|
|
372
423
|
}
|
|
@@ -407,7 +458,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
407
458
|
// Creation has committed by this point. Keep response projection outside
|
|
408
459
|
// the create-rejection boundary so a post-commit policy read cannot be
|
|
409
460
|
// misreported as though the session itself was rejected.
|
|
410
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
|
|
461
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session), 202);
|
|
411
462
|
});
|
|
412
463
|
|
|
413
464
|
app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
|
|
@@ -511,7 +562,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
511
562
|
// body for older clients while still making its older-pin omission visible
|
|
512
563
|
// to raw HTTP consumers without changing that response shape.
|
|
513
564
|
c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
|
|
514
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
565
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
|
|
515
566
|
const decorate = (session: Session): Session =>
|
|
516
567
|
sessionWithEffectiveToolPolicy(
|
|
517
568
|
session,
|
|
@@ -533,6 +584,83 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
533
584
|
return c.json([...page.pinned, ...page.sessions].map(decorate));
|
|
534
585
|
});
|
|
535
586
|
|
|
587
|
+
app.get("/v1/workspaces/:workspaceId/agent-topology", async (c) => {
|
|
588
|
+
const workspaceId = c.req.param("workspaceId");
|
|
589
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
590
|
+
let authorizationScope;
|
|
591
|
+
try {
|
|
592
|
+
authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
|
|
593
|
+
} catch (error) {
|
|
594
|
+
throw sessionAuthorizationHttpError(error);
|
|
595
|
+
}
|
|
596
|
+
const query = agentTopologyQuery(c.req.query());
|
|
597
|
+
const page = await listSessionDiscoverySummaries(db, workspaceId, {
|
|
598
|
+
limit: query.limit,
|
|
599
|
+
orderBy: "updatedAt",
|
|
600
|
+
subjectId: grant.subjectId,
|
|
601
|
+
...(query.cursor ? { cursor: query.cursor } : {}),
|
|
602
|
+
...(query.search ? { search: query.search } : { parentSessionId: query.parentSessionId }),
|
|
603
|
+
...(authorizationScope ? { authorizationScope } : {}),
|
|
604
|
+
});
|
|
605
|
+
const ancestorPaths = query.search
|
|
606
|
+
? await listSessionDiscoveryAncestorPaths(
|
|
607
|
+
db,
|
|
608
|
+
workspaceId,
|
|
609
|
+
page.sessions.map((session) => session.id),
|
|
610
|
+
authorizationScope ?? undefined,
|
|
611
|
+
)
|
|
612
|
+
: new Map<string, SessionDiscoveryAncestor[]>();
|
|
613
|
+
const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
|
|
614
|
+
const blocker = session.effectiveControl.primaryBlocker;
|
|
615
|
+
return {
|
|
616
|
+
id: session.id,
|
|
617
|
+
title: session.title,
|
|
618
|
+
titleTruncated:
|
|
619
|
+
session.titleOriginalChars !== null &&
|
|
620
|
+
session.titleOriginalChars > Array.from(session.title ?? "").length,
|
|
621
|
+
parentSessionId: session.parentSessionId,
|
|
622
|
+
rootSessionId: session.rootSessionId,
|
|
623
|
+
nestedAgentDepth: session.nestedAgentDepth,
|
|
624
|
+
ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
|
|
625
|
+
id: ancestor.id,
|
|
626
|
+
title: ancestor.title,
|
|
627
|
+
titleTruncated:
|
|
628
|
+
ancestor.titleOriginalChars !== null &&
|
|
629
|
+
ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
|
|
630
|
+
})),
|
|
631
|
+
status: session.status,
|
|
632
|
+
pause: {
|
|
633
|
+
state: session.effectiveControl.state,
|
|
634
|
+
additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
|
|
635
|
+
source: blocker
|
|
636
|
+
? {
|
|
637
|
+
kind: blocker.kind,
|
|
638
|
+
...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
|
|
639
|
+
displayName: blocker.displayName,
|
|
640
|
+
displayNameTruncated:
|
|
641
|
+
blocker.displayNameOriginalChars > Array.from(blocker.displayName).length,
|
|
642
|
+
}
|
|
643
|
+
: null,
|
|
644
|
+
},
|
|
645
|
+
children: session.treeStats,
|
|
646
|
+
createdAt: session.createdAt,
|
|
647
|
+
updatedAt: session.updatedAt,
|
|
648
|
+
};
|
|
649
|
+
});
|
|
650
|
+
return c.json({
|
|
651
|
+
sessions,
|
|
652
|
+
total: page.total,
|
|
653
|
+
hasMore: page.hasMore,
|
|
654
|
+
nextCursor: page.nextCursor
|
|
655
|
+
? encodeAgentTopologyCursor({
|
|
656
|
+
cursor: page.nextCursor,
|
|
657
|
+
parentSessionId: query.parentSessionId,
|
|
658
|
+
search: query.search ?? null,
|
|
659
|
+
})
|
|
660
|
+
: null,
|
|
661
|
+
} satisfies AgentTopologyPageResponse);
|
|
662
|
+
});
|
|
663
|
+
|
|
536
664
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
|
|
537
665
|
const workspaceId = c.req.param("workspaceId");
|
|
538
666
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -550,7 +678,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
550
678
|
if (!session) {
|
|
551
679
|
throw new HTTPException(404, { message: "session not found" });
|
|
552
680
|
}
|
|
553
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
681
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
554
682
|
});
|
|
555
683
|
|
|
556
684
|
const publishRealtimeMutation = async (
|
|
@@ -586,19 +714,19 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
586
714
|
}
|
|
587
715
|
const parsed = BeginSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
|
|
588
716
|
if (!parsed.success) {
|
|
589
|
-
throw new HTTPException(400, {
|
|
717
|
+
throw new HTTPException(400, {
|
|
718
|
+
message: "invalid session realtime request",
|
|
719
|
+
});
|
|
590
720
|
}
|
|
591
721
|
try {
|
|
592
|
-
const result = await
|
|
593
|
-
scopedDb
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}),
|
|
601
|
-
),
|
|
722
|
+
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
723
|
+
beginSessionRealtimeInTransaction(scopedDb, {
|
|
724
|
+
accountId: grant.accountId,
|
|
725
|
+
workspaceId,
|
|
726
|
+
sessionId,
|
|
727
|
+
ownerSubjectId: grant.subjectId,
|
|
728
|
+
...parsed.data,
|
|
729
|
+
}),
|
|
602
730
|
);
|
|
603
731
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
604
732
|
c.header("cache-control", "private, no-store");
|
|
@@ -619,23 +747,25 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
619
747
|
!z.string().uuid().safeParse(sessionId).success ||
|
|
620
748
|
!z.string().uuid().safeParse(realtimeId).success
|
|
621
749
|
) {
|
|
622
|
-
throw new HTTPException(400, {
|
|
750
|
+
throw new HTTPException(400, {
|
|
751
|
+
message: "invalid realtime lifecycle id",
|
|
752
|
+
});
|
|
623
753
|
}
|
|
624
754
|
const parsed = RenewSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
|
|
625
755
|
if (!parsed.success) {
|
|
626
|
-
throw new HTTPException(400, {
|
|
756
|
+
throw new HTTPException(400, {
|
|
757
|
+
message: "invalid realtime heartbeat request",
|
|
758
|
+
});
|
|
627
759
|
}
|
|
628
760
|
try {
|
|
629
|
-
const result = await
|
|
630
|
-
scopedDb
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
}),
|
|
638
|
-
),
|
|
761
|
+
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
762
|
+
renewSessionRealtimeInTransaction(scopedDb, {
|
|
763
|
+
workspaceId,
|
|
764
|
+
sessionId,
|
|
765
|
+
realtimeId,
|
|
766
|
+
ownerSubjectId: grant.subjectId,
|
|
767
|
+
...parsed.data,
|
|
768
|
+
}),
|
|
639
769
|
);
|
|
640
770
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
641
771
|
c.header("cache-control", "private, no-store");
|
|
@@ -655,23 +785,25 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
655
785
|
!z.string().uuid().safeParse(sessionId).success ||
|
|
656
786
|
!z.string().uuid().safeParse(realtimeId).success
|
|
657
787
|
) {
|
|
658
|
-
throw new HTTPException(400, {
|
|
788
|
+
throw new HTTPException(400, {
|
|
789
|
+
message: "invalid realtime lifecycle id",
|
|
790
|
+
});
|
|
659
791
|
}
|
|
660
792
|
const parsed = EndSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
|
|
661
793
|
if (!parsed.success) {
|
|
662
|
-
throw new HTTPException(400, {
|
|
794
|
+
throw new HTTPException(400, {
|
|
795
|
+
message: "invalid realtime end request",
|
|
796
|
+
});
|
|
663
797
|
}
|
|
664
798
|
try {
|
|
665
|
-
const result = await
|
|
666
|
-
scopedDb
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}),
|
|
674
|
-
),
|
|
799
|
+
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
800
|
+
endSessionRealtimeInTransaction(scopedDb, {
|
|
801
|
+
workspaceId,
|
|
802
|
+
sessionId,
|
|
803
|
+
realtimeId,
|
|
804
|
+
ownerSubjectId: grant.subjectId,
|
|
805
|
+
...parsed.data,
|
|
806
|
+
}),
|
|
675
807
|
);
|
|
676
808
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
677
809
|
c.header("cache-control", "private, no-store");
|
|
@@ -774,7 +906,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
774
906
|
deps.codexFetch,
|
|
775
907
|
);
|
|
776
908
|
try {
|
|
777
|
-
const answer = await broker({
|
|
909
|
+
const answer = await broker({
|
|
910
|
+
request: providerRequest,
|
|
911
|
+
signal: c.req.raw.signal,
|
|
912
|
+
});
|
|
778
913
|
const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
779
914
|
scopedDb.transaction(async (tx) =>
|
|
780
915
|
completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
|
|
@@ -867,7 +1002,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
867
1002
|
}
|
|
868
1003
|
const parsed = GatewayRealtimeConnectRequest.safeParse(await c.req.json().catch(() => null));
|
|
869
1004
|
if (!parsed.success) {
|
|
870
|
-
throw new HTTPException(422, {
|
|
1005
|
+
throw new HTTPException(422, {
|
|
1006
|
+
message: "invalid Gateway realtime request",
|
|
1007
|
+
});
|
|
871
1008
|
}
|
|
872
1009
|
c.header("cache-control", "private, no-store");
|
|
873
1010
|
const {
|
|
@@ -972,6 +1109,121 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
972
1109
|
}
|
|
973
1110
|
});
|
|
974
1111
|
|
|
1112
|
+
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/supergrok", async (c) => {
|
|
1113
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1114
|
+
const sessionId = c.req.param("sessionId");
|
|
1115
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1116
|
+
if (!z.string().uuid().safeParse(sessionId).success) {
|
|
1117
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
1118
|
+
}
|
|
1119
|
+
const parsed = GatewayRealtimeConnectRequest.safeParse(await c.req.json().catch(() => null));
|
|
1120
|
+
if (!parsed.success) {
|
|
1121
|
+
throw new HTTPException(422, { message: "invalid SuperGrok realtime request" });
|
|
1122
|
+
}
|
|
1123
|
+
c.header("cache-control", "private, no-store");
|
|
1124
|
+
const {
|
|
1125
|
+
realtimeId,
|
|
1126
|
+
operationId,
|
|
1127
|
+
browserInstanceId,
|
|
1128
|
+
ownerKey,
|
|
1129
|
+
expectedVersion,
|
|
1130
|
+
expectedConnectionEpoch,
|
|
1131
|
+
rotate,
|
|
1132
|
+
} = parsed.data;
|
|
1133
|
+
let claim: Awaited<ReturnType<typeof claimSessionRealtimeConnectionInTransaction>> | null =
|
|
1134
|
+
null;
|
|
1135
|
+
let connectionCompleted = false;
|
|
1136
|
+
try {
|
|
1137
|
+
claim = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
1138
|
+
scopedDb.transaction(async (tx) =>
|
|
1139
|
+
claimSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
|
|
1140
|
+
workspaceId,
|
|
1141
|
+
sessionId,
|
|
1142
|
+
realtimeId,
|
|
1143
|
+
operationId,
|
|
1144
|
+
ownerSubjectId: grant.subjectId,
|
|
1145
|
+
browserInstanceId,
|
|
1146
|
+
ownerKey,
|
|
1147
|
+
expectedVersion,
|
|
1148
|
+
expectedConnectionEpoch,
|
|
1149
|
+
rotate,
|
|
1150
|
+
promotionMode: "staged",
|
|
1151
|
+
}),
|
|
1152
|
+
),
|
|
1153
|
+
);
|
|
1154
|
+
if (claim.replay) {
|
|
1155
|
+
throw new SessionRealtimeConflictError(
|
|
1156
|
+
"REALTIME_CONNECTION_STATE_CHANGED",
|
|
1157
|
+
"SuperGrok realtime tokens are single-use; reconnect with a new operation",
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
const secret = await createXaiRealtimeConnectionSecret({
|
|
1161
|
+
db,
|
|
1162
|
+
settings,
|
|
1163
|
+
accountId: grant.accountId,
|
|
1164
|
+
workspaceId,
|
|
1165
|
+
subjectId: grant.subjectId,
|
|
1166
|
+
sessionId,
|
|
1167
|
+
model: claim.mode.model,
|
|
1168
|
+
fetchImpl: deps.xaiFetch ?? fetch,
|
|
1169
|
+
});
|
|
1170
|
+
const claimed = claim;
|
|
1171
|
+
const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
1172
|
+
scopedDb.transaction(async (tx) =>
|
|
1173
|
+
completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
|
|
1174
|
+
workspaceId,
|
|
1175
|
+
sessionId,
|
|
1176
|
+
realtimeId,
|
|
1177
|
+
connectionId: claimed.connection.id,
|
|
1178
|
+
operationId,
|
|
1179
|
+
connectionEpoch: claimed.connection.connectionEpoch,
|
|
1180
|
+
sdpAnswer: "supergrok-client-secret-minted",
|
|
1181
|
+
}),
|
|
1182
|
+
),
|
|
1183
|
+
);
|
|
1184
|
+
connectionCompleted = true;
|
|
1185
|
+
return c.json({
|
|
1186
|
+
...secret,
|
|
1187
|
+
connectionId: completed.connection.id,
|
|
1188
|
+
connectionEpoch: completed.connection.connectionEpoch,
|
|
1189
|
+
startupFenceSequence: completed.connection.startupFenceSequence,
|
|
1190
|
+
modeVersion: claimed.modeVersion,
|
|
1191
|
+
replay: false as const,
|
|
1192
|
+
});
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
if (claim !== null && !claim.replay && !connectionCompleted) {
|
|
1195
|
+
const claimed = claim;
|
|
1196
|
+
await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
1197
|
+
scopedDb.transaction(async (tx) =>
|
|
1198
|
+
failSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
|
|
1199
|
+
workspaceId,
|
|
1200
|
+
sessionId,
|
|
1201
|
+
realtimeId,
|
|
1202
|
+
connectionId: claimed.connection.id,
|
|
1203
|
+
operationId,
|
|
1204
|
+
connectionEpoch: claimed.connection.connectionEpoch,
|
|
1205
|
+
failureCode: error instanceof XaiRealtimeBrokerError ? error.code : "supergrok_error",
|
|
1206
|
+
}),
|
|
1207
|
+
),
|
|
1208
|
+
).catch(() => undefined);
|
|
1209
|
+
}
|
|
1210
|
+
if (error instanceof SessionRealtimeConflictError) throw sessionRealtimeHttpError(error);
|
|
1211
|
+
if (!(error instanceof XaiRealtimeBrokerError)) throw error;
|
|
1212
|
+
const status = error.code === "credential_unavailable" ? 409 : 502;
|
|
1213
|
+
return c.json(
|
|
1214
|
+
{
|
|
1215
|
+
error: {
|
|
1216
|
+
status,
|
|
1217
|
+
code: `SUPERGROK_REALTIME_${error.code.toUpperCase()}`,
|
|
1218
|
+
message: error.message,
|
|
1219
|
+
retryable: error.code === "provider_error",
|
|
1220
|
+
},
|
|
1221
|
+
},
|
|
1222
|
+
status,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
|
|
975
1227
|
app.post(
|
|
976
1228
|
"/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/:realtimeId/connections/:connectionId/activate",
|
|
977
1229
|
async (c) => {
|
|
@@ -985,13 +1237,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
985
1237
|
!z.string().uuid().safeParse(realtimeId).success ||
|
|
986
1238
|
!z.string().uuid().safeParse(connectionId).success
|
|
987
1239
|
) {
|
|
988
|
-
throw new HTTPException(400, {
|
|
1240
|
+
throw new HTTPException(400, {
|
|
1241
|
+
message: "invalid realtime connection id",
|
|
1242
|
+
});
|
|
989
1243
|
}
|
|
990
1244
|
const parsed = ActivateCodexRealtimeConnectionRequest.safeParse(
|
|
991
1245
|
await c.req.json().catch(() => null),
|
|
992
1246
|
);
|
|
993
1247
|
if (!parsed.success) {
|
|
994
|
-
throw new HTTPException(422, {
|
|
1248
|
+
throw new HTTPException(422, {
|
|
1249
|
+
message: "invalid realtime connection activation",
|
|
1250
|
+
});
|
|
995
1251
|
}
|
|
996
1252
|
try {
|
|
997
1253
|
const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
|
|
@@ -1031,19 +1287,19 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1031
1287
|
await c.req.json().catch(() => null),
|
|
1032
1288
|
);
|
|
1033
1289
|
if (!parsed.success) {
|
|
1034
|
-
throw new HTTPException(422, {
|
|
1290
|
+
throw new HTTPException(422, {
|
|
1291
|
+
message: "invalid realtime ledger sync request",
|
|
1292
|
+
});
|
|
1035
1293
|
}
|
|
1036
1294
|
try {
|
|
1037
|
-
const result = await
|
|
1038
|
-
scopedDb
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
}),
|
|
1046
|
-
),
|
|
1295
|
+
const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
|
|
1296
|
+
syncSessionRealtimeLedgerInTransaction(scopedDb, {
|
|
1297
|
+
workspaceId,
|
|
1298
|
+
sessionId,
|
|
1299
|
+
realtimeId,
|
|
1300
|
+
ownerSubjectId: grant.subjectId,
|
|
1301
|
+
...parsed.data,
|
|
1302
|
+
}),
|
|
1047
1303
|
);
|
|
1048
1304
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
1049
1305
|
c.header("cache-control", "private, no-store");
|
|
@@ -1082,6 +1338,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1082
1338
|
await withEffectivePolicy(
|
|
1083
1339
|
deps,
|
|
1084
1340
|
workspaceId,
|
|
1341
|
+
grant.subjectId,
|
|
1085
1342
|
projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
|
|
1086
1343
|
),
|
|
1087
1344
|
);
|
|
@@ -1106,7 +1363,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1106
1363
|
const workspaceId = c.req.param("workspaceId");
|
|
1107
1364
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
1108
1365
|
const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
|
|
1109
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
1366
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
|
|
1110
1367
|
return c.json({
|
|
1111
1368
|
...lineage,
|
|
1112
1369
|
ancestors: lineage.ancestors.map((session) =>
|
|
@@ -1148,11 +1405,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1148
1405
|
}
|
|
1149
1406
|
}
|
|
1150
1407
|
const pinned = target === "auto" ? null : target;
|
|
1151
|
-
const mutation = await
|
|
1408
|
+
const mutation = await withSessionCodexCapacityMutation(
|
|
1152
1409
|
db,
|
|
1153
1410
|
{ workspaceId, reason: "codex_manual_session_pin_changed" },
|
|
1154
1411
|
async (tx) => {
|
|
1155
|
-
const changed = await
|
|
1412
|
+
const changed = await setSessionCodexPinInTransaction(tx, workspaceId, sessionId, pinned);
|
|
1156
1413
|
return { result: changed, changed };
|
|
1157
1414
|
},
|
|
1158
1415
|
);
|
|
@@ -1185,6 +1442,43 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1185
1442
|
return c.json({ pinned: target === "auto" ? "auto" : target });
|
|
1186
1443
|
});
|
|
1187
1444
|
|
|
1445
|
+
// Re-file the session into a workspace channel (rail organization only;
|
|
1446
|
+
// null = back to the unfiled inbox). Shared, workspace-visible state, so it
|
|
1447
|
+
// requires sessions:control like rename. Returns the refreshed session.
|
|
1448
|
+
app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/channel", async (c) => {
|
|
1449
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1450
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1451
|
+
const sessionId = c.req.param("sessionId");
|
|
1452
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
1453
|
+
const payload = UpdateSessionChannelRequest.parse(await c.req.json());
|
|
1454
|
+
try {
|
|
1455
|
+
const updated = await setSessionChannel(db, {
|
|
1456
|
+
workspaceId,
|
|
1457
|
+
sessionId,
|
|
1458
|
+
channelId: payload.channelId,
|
|
1459
|
+
});
|
|
1460
|
+
if (!updated) {
|
|
1461
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
1462
|
+
}
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
if (error instanceof ChannelNotFoundError) {
|
|
1465
|
+
throw new HTTPException(422, { message: error.message });
|
|
1466
|
+
}
|
|
1467
|
+
throw error;
|
|
1468
|
+
}
|
|
1469
|
+
const session = await getSessionForSubject(
|
|
1470
|
+
db,
|
|
1471
|
+
workspaceId,
|
|
1472
|
+
sessionId,
|
|
1473
|
+
grant.subjectId,
|
|
1474
|
+
relatedSessionAccessFor(c),
|
|
1475
|
+
);
|
|
1476
|
+
if (!session) {
|
|
1477
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
1478
|
+
}
|
|
1479
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1188
1482
|
// Manual rename. A user-set title is permanent: the db write is
|
|
1189
1483
|
// unconditional (source='user'), so it always pins the session over later
|
|
1190
1484
|
// agent writes. Returns the refreshed session, mirroring GET detail.
|
|
@@ -1208,7 +1502,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1208
1502
|
if (!session) {
|
|
1209
1503
|
throw new HTTPException(404, { message: "session not found" });
|
|
1210
1504
|
}
|
|
1211
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
1505
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
1212
1506
|
});
|
|
1213
1507
|
|
|
1214
1508
|
app.patch(
|
|
@@ -1246,7 +1540,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1246
1540
|
const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
|
|
1247
1541
|
try {
|
|
1248
1542
|
const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
|
|
1249
|
-
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
1543
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
|
|
1250
1544
|
} catch (error) {
|
|
1251
1545
|
if (error instanceof SessionToolPolicyVersionConflictError) {
|
|
1252
1546
|
return c.json(
|
|
@@ -1274,6 +1568,77 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1274
1568
|
return c.json(goal);
|
|
1275
1569
|
});
|
|
1276
1570
|
|
|
1571
|
+
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions", async (c) => {
|
|
1572
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1573
|
+
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
1574
|
+
const sessionId = c.req.param("sessionId");
|
|
1575
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
1576
|
+
return c.json(await listSessionGoalRevisions(db, workspaceId, sessionId));
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
app.post(
|
|
1580
|
+
"/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions/:revisionId/apply",
|
|
1581
|
+
async (c) => {
|
|
1582
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1583
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1584
|
+
const sessionId = c.req.param("sessionId");
|
|
1585
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
1586
|
+
const payload = ApplySessionGoalRevisionRequest.parse(await c.req.json());
|
|
1587
|
+
const revision = await getSessionGoalRevision(
|
|
1588
|
+
db,
|
|
1589
|
+
workspaceId,
|
|
1590
|
+
sessionId,
|
|
1591
|
+
c.req.param("revisionId"),
|
|
1592
|
+
);
|
|
1593
|
+
if (!revision || revision.disposition !== "proposed") {
|
|
1594
|
+
throw new HTTPException(404, {
|
|
1595
|
+
message: "goal rewrite proposal not found",
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
if (!goalProposalMatchesExpectedRevision(revision, payload.expectedObjectiveRevision)) {
|
|
1599
|
+
throw new HTTPException(409, {
|
|
1600
|
+
message: `goal proposal was based on objective revision ${revision.baseObjectiveRevision}; requested fence ${payload.expectedObjectiveRevision} cannot apply it`,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
try {
|
|
1604
|
+
const { goal, workflowWakeRevision, events } = await upsertSessionGoalWithEvent(db, {
|
|
1605
|
+
accountId: grant.accountId,
|
|
1606
|
+
workspaceId,
|
|
1607
|
+
sessionId,
|
|
1608
|
+
text: revision.text,
|
|
1609
|
+
successCriteria: revision.successCriteria,
|
|
1610
|
+
mutationPolicy: revision.mutationPolicy,
|
|
1611
|
+
expectedObjectiveRevision: payload.expectedObjectiveRevision,
|
|
1612
|
+
expectedGoalId: revision.goalId,
|
|
1613
|
+
changeKind: revision.changeKind,
|
|
1614
|
+
changeRationale: payload.rationale ?? `Applied goal proposal ${revision.id}`,
|
|
1615
|
+
sourceProposalId: revision.id,
|
|
1616
|
+
createdBy: "api",
|
|
1617
|
+
actor: "api",
|
|
1618
|
+
});
|
|
1619
|
+
await publishDurableSessionEvents(bus, workspaceId, sessionId, events);
|
|
1620
|
+
if (workflowWakeRevision !== null) {
|
|
1621
|
+
await workflowClient.wakeSessionWorkflow({
|
|
1622
|
+
accountId: grant.accountId,
|
|
1623
|
+
workspaceId,
|
|
1624
|
+
sessionId,
|
|
1625
|
+
workflowId: workflowIdForSession(sessionId),
|
|
1626
|
+
wakeRevision: workflowWakeRevision,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
if (error instanceof SessionControlConflictError) {
|
|
1632
|
+
throw new HTTPException(409, {
|
|
1633
|
+
message: error.message,
|
|
1634
|
+
cause: error,
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
throw error;
|
|
1638
|
+
}
|
|
1639
|
+
},
|
|
1640
|
+
);
|
|
1641
|
+
|
|
1277
1642
|
app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
|
|
1278
1643
|
const workspaceId = c.req.param("workspaceId");
|
|
1279
1644
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
@@ -1284,6 +1649,48 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1284
1649
|
if (!existing) {
|
|
1285
1650
|
throw new HTTPException(404, { message: "session goal not found" });
|
|
1286
1651
|
}
|
|
1652
|
+
if (!("status" in payload)) {
|
|
1653
|
+
try {
|
|
1654
|
+
const { goal, workflowWakeRevision, events } = await upsertSessionGoalWithEvent(db, {
|
|
1655
|
+
accountId: grant.accountId,
|
|
1656
|
+
workspaceId,
|
|
1657
|
+
sessionId,
|
|
1658
|
+
text: payload.text,
|
|
1659
|
+
successCriteria:
|
|
1660
|
+
payload.successCriteria !== undefined
|
|
1661
|
+
? payload.successCriteria
|
|
1662
|
+
: existing.successCriteria,
|
|
1663
|
+
maxAutoContinuations: existing.maxAutoContinuations,
|
|
1664
|
+
mutationPolicy: payload.mutationPolicy ?? existing.mutationPolicy,
|
|
1665
|
+
expectedObjectiveRevision: payload.expectedObjectiveRevision,
|
|
1666
|
+
changeKind: "replacement",
|
|
1667
|
+
changeRationale: payload.rationale,
|
|
1668
|
+
createdBy: "api",
|
|
1669
|
+
actor: "api",
|
|
1670
|
+
});
|
|
1671
|
+
if (events.length > 0) {
|
|
1672
|
+
await bus.publish(workspaceId, sessionId, events);
|
|
1673
|
+
}
|
|
1674
|
+
if (workflowWakeRevision !== null) {
|
|
1675
|
+
await workflowClient.wakeSessionWorkflow({
|
|
1676
|
+
accountId: grant.accountId,
|
|
1677
|
+
workspaceId,
|
|
1678
|
+
sessionId,
|
|
1679
|
+
workflowId: workflowIdForSession(sessionId),
|
|
1680
|
+
wakeRevision: workflowWakeRevision,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
if (error instanceof SessionControlConflictError) {
|
|
1686
|
+
throw new HTTPException(409, {
|
|
1687
|
+
message: error.message,
|
|
1688
|
+
cause: error,
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
throw error;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1287
1694
|
if (existing.status === "completed") {
|
|
1288
1695
|
throw new HTTPException(409, {
|
|
1289
1696
|
message: "session goal is completed; set a new goal instead",
|
|
@@ -1563,6 +1970,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1563
1970
|
const projected = compact ? coalesceSessionEventDeltas(events) : events;
|
|
1564
1971
|
const page = boundSessionEventHttpPage(projected, {
|
|
1565
1972
|
direction,
|
|
1973
|
+
eventProjection: mode === "forensic" && payloadMode === "full" ? "exact" : "bounded",
|
|
1566
1974
|
});
|
|
1567
1975
|
const hasMore = dbPage.hasMore || page.truncated;
|
|
1568
1976
|
c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
|
|
@@ -1845,10 +2253,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1845
2253
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1846
2254
|
const sessionId = c.req.param("sessionId");
|
|
1847
2255
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
1848
|
-
const
|
|
1849
|
-
const payload = SteerSessionMessageRequest.parse(raw);
|
|
2256
|
+
const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
|
|
1850
2257
|
const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
|
|
1851
2258
|
text: payload.text,
|
|
2259
|
+
annotations: payload.annotations,
|
|
1852
2260
|
turnInstructions: payload.turnInstructions ?? null,
|
|
1853
2261
|
resources: payload.resources,
|
|
1854
2262
|
model: payload.model ?? null,
|
|
@@ -1870,8 +2278,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1870
2278
|
const workspaceId = c.req.param("workspaceId");
|
|
1871
2279
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
1872
2280
|
const sessionId = c.req.param("sessionId");
|
|
1873
|
-
const
|
|
1874
|
-
const event = ClientSessionEvent.parse(rawEvent);
|
|
2281
|
+
const event = parseSessionEventAdmission(await c.req.json().catch(() => null));
|
|
1875
2282
|
const refinedOperation =
|
|
1876
2283
|
event.type === "user.approvalDecision"
|
|
1877
2284
|
? "session.approval.write"
|
|
@@ -1892,6 +2299,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1892
2299
|
if (event.type === "user.message") {
|
|
1893
2300
|
const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
|
|
1894
2301
|
text: event.payload.text,
|
|
2302
|
+
annotations: event.payload.annotations,
|
|
1895
2303
|
turnInstructions: event.payload.turnInstructions ?? null,
|
|
1896
2304
|
resources: event.payload.resources ?? [],
|
|
1897
2305
|
model: event.payload.model ?? null,
|
|
@@ -2056,8 +2464,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2056
2464
|
// GET .../stream-capabilities — the capability-negotiation read. Returns the
|
|
2057
2465
|
// SessionCapabilities doc (descriptor + lease liveness/epoch + os + the
|
|
2058
2466
|
// shared-exposure disclosure + the calling principal's acknowledgment state),
|
|
2059
|
-
// API-direct.
|
|
2060
|
-
//
|
|
2467
|
+
// API-direct. It is a pure descriptor read: every URL/token stays null until an
|
|
2468
|
+
// exact, permission-checked POST /viewers grant mints it just in time.
|
|
2061
2469
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities", async (c) => {
|
|
2062
2470
|
const workspaceId = c.req.param("workspaceId");
|
|
2063
2471
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -2067,6 +2475,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2067
2475
|
if (!session) {
|
|
2068
2476
|
throw new HTTPException(404, { message: "session not found" });
|
|
2069
2477
|
}
|
|
2478
|
+
// Capability truth follows the ACTIVE placement, not the session's managed
|
|
2479
|
+
// home lease. A connected machine has no Modal group lease; treating that
|
|
2480
|
+
// absence as `cold` makes the client suppress the very viewer attach that
|
|
2481
|
+
// would mint its relay terminal/desktop cells. Resolve the active target once
|
|
2482
|
+
// and use the selfhosted liveness probe when it is a connected machine.
|
|
2483
|
+
const activeSandbox = session.activeSandboxId
|
|
2484
|
+
? await getSandbox(db, workspaceId, session.activeSandboxId)
|
|
2485
|
+
: null;
|
|
2486
|
+
const selfhostedActive = activeSandbox?.kind === "selfhosted";
|
|
2070
2487
|
const lease = await readGroupLease(
|
|
2071
2488
|
{ db, settings },
|
|
2072
2489
|
{ workspaceId, sandboxGroupId: session.sandboxGroupId },
|
|
@@ -2074,12 +2491,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2074
2491
|
const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
|
|
2075
2492
|
const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
|
|
2076
2493
|
// 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).
|
|
2494
|
+
// Surface consent state so the UI can decide whether an explicit desktop
|
|
2495
|
+
// grant may be requested. This descriptor read never mints a credential; the
|
|
2496
|
+
// POST /viewers grant below re-checks both consent bits and stream:view.
|
|
2083
2497
|
const ack = await getStreamAcknowledgment(db, {
|
|
2084
2498
|
workspaceId,
|
|
2085
2499
|
sandboxGroupId: session.sandboxGroupId,
|
|
@@ -2089,117 +2503,68 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2089
2503
|
? ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)
|
|
2090
2504
|
: false;
|
|
2091
2505
|
|
|
2092
|
-
//
|
|
2093
|
-
//
|
|
2094
|
-
//
|
|
2095
|
-
//
|
|
2096
|
-
|
|
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
|
-
|
|
2151
|
-
const capabilities = negotiateCapabilities({
|
|
2506
|
+
// This GET is deliberately descriptor-only: no provider resume, display/ttyd
|
|
2507
|
+
// startup, port exposure, or short-lived bearer mint. Besides enforcing least
|
|
2508
|
+
// privilege, that keeps the 120-second stream credentials from aging before a
|
|
2509
|
+
// user opens their surface. POST /viewers is the sole credential grant.
|
|
2510
|
+
const commonNegotiation = {
|
|
2152
2511
|
sessionId,
|
|
2153
|
-
backend: session.sandboxBackend as SandboxBackend,
|
|
2154
|
-
os: session.sandboxOs,
|
|
2155
|
-
liveness: lease?.liveness ?? "cold",
|
|
2156
|
-
leaseEpoch: lease?.leaseEpoch ?? 0,
|
|
2157
|
-
workspaceGeneration: lease?.workspaceGeneration ?? null,
|
|
2158
|
-
archiveGeneration: lease?.archiveGeneration ?? null,
|
|
2159
|
-
archiveComplete: lease?.archiveComplete ?? false,
|
|
2160
2512
|
desktopEnabled: settings.sandboxDesktopEnabled,
|
|
2161
2513
|
// Human take-control: when the desktop is available + this policy is on
|
|
2162
2514
|
// (default), the cell is mode "interactive" — the noVNC viewer drives :0
|
|
2163
2515
|
// (x11vnc runs without -viewonly). Off → mode "read-only" (client disables
|
|
2164
2516
|
// take-control). Independent of the agent's computerUseReadOnly.
|
|
2165
2517
|
desktopInteractive: settings.sandboxDesktopInteractive,
|
|
2166
|
-
// P4.3 computer-use: the agent drives
|
|
2518
|
+
// P4.3 computer-use: the agent drives the active display; availability
|
|
2167
2519
|
// tracks the desktop tier + a desktop-capable backend.
|
|
2168
2520
|
computerUseEnabled: settings.computerUseEnabled,
|
|
2169
2521
|
computerUseReadOnly: settings.computerUseReadOnly,
|
|
2170
|
-
// Graceful degrade
|
|
2171
|
-
// secret is resolvable, the desktop cell reports transport:null rather
|
|
2172
|
-
// than advertising a plane we can never authorize.
|
|
2522
|
+
// Graceful degrade when scoped stream credentials cannot be minted.
|
|
2173
2523
|
streamTokenSecretAvailable: !streamTokenDegraded(settings),
|
|
2174
2524
|
desktopAcknowledged: acknowledged,
|
|
2175
2525
|
shared,
|
|
2176
2526
|
sharedSessionIds: visibleSharedSessionIds,
|
|
2177
|
-
// The minted live address (null when not unlocked/degraded). The resolver
|
|
2178
|
-
// only folds it in when the desktop gates pass + the ack is present.
|
|
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.
|
|
2192
2527
|
terminalEnabled: settings.sandboxTerminalEnabled,
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2528
|
+
} as const;
|
|
2529
|
+
|
|
2530
|
+
let capabilities;
|
|
2531
|
+
if (selfhostedActive && activeSandbox.enrollmentId) {
|
|
2532
|
+
const enrollment = await getEnrollment(db, workspaceId, activeSandbox.enrollmentId);
|
|
2533
|
+
let probeResponded = false;
|
|
2534
|
+
if (enrollment?.status === "active") {
|
|
2535
|
+
const machine = new SelfhostedSession({
|
|
2536
|
+
workspaceId,
|
|
2537
|
+
agentId: enrollment.id,
|
|
2538
|
+
controlRpc: new NatsControlRpc(async () => bus.getRequestConnection()),
|
|
2539
|
+
relay: relayConfigFromSettings(settings),
|
|
2540
|
+
epoch: session.activeEpoch,
|
|
2541
|
+
timeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
|
|
2542
|
+
});
|
|
2543
|
+
try {
|
|
2544
|
+
probeResponded = await machine.ping();
|
|
2545
|
+
} catch {
|
|
2546
|
+
probeResponded = false;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
capabilities = await negotiateSelfhostedCapabilities({
|
|
2550
|
+
...commonNegotiation,
|
|
2551
|
+
os: enrollment?.os ?? session.sandboxOs,
|
|
2552
|
+
leaseEpoch: session.activeEpoch,
|
|
2553
|
+
enrollment,
|
|
2554
|
+
probeResponded,
|
|
2555
|
+
});
|
|
2556
|
+
} else {
|
|
2557
|
+
capabilities = negotiateCapabilities({
|
|
2558
|
+
...commonNegotiation,
|
|
2559
|
+
backend: session.sandboxBackend as SandboxBackend,
|
|
2560
|
+
os: session.sandboxOs,
|
|
2561
|
+
liveness: lease?.liveness ?? "cold",
|
|
2562
|
+
leaseEpoch: lease?.leaseEpoch ?? 0,
|
|
2563
|
+
workspaceGeneration: lease?.workspaceGeneration ?? null,
|
|
2564
|
+
archiveGeneration: lease?.archiveGeneration ?? null,
|
|
2565
|
+
archiveComplete: lease?.archiveComplete ?? false,
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2203
2568
|
|
|
2204
2569
|
const repositoryRoots = [
|
|
2205
2570
|
...new Set(
|
|
@@ -2238,11 +2603,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2238
2603
|
},
|
|
2239
2604
|
};
|
|
2240
2605
|
if (capabilities.DesktopStream.transport !== null) {
|
|
2241
|
-
const activeSandbox = session.activeSandboxId
|
|
2242
|
-
? await getSandbox(db, workspaceId, session.activeSandboxId)
|
|
2243
|
-
: null;
|
|
2244
2606
|
const wire = resolveActiveDesktopTransport(
|
|
2245
|
-
|
|
2607
|
+
selfhostedActive,
|
|
2246
2608
|
settings.sandboxDesktopInteractive !== false,
|
|
2247
2609
|
);
|
|
2248
2610
|
responseCapabilities = {
|
|
@@ -2316,20 +2678,31 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2316
2678
|
message: "invalid viewer attach request",
|
|
2317
2679
|
});
|
|
2318
2680
|
}
|
|
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.
|
|
2681
|
+
// Resolve exact requested planes before authorization. Empty and
|
|
2682
|
+
// `desktop:false` v1 bodies remain terminal-only during rolling upgrades;
|
|
2683
|
+
// either new plane flag selects exact semantics. This closes desktop→terminal
|
|
2684
|
+
// privilege bleed and lets file edits avoid unrelated terminal bearers.
|
|
2329
2685
|
const wantDesktop = parsed.data.desktop ?? false;
|
|
2330
|
-
|
|
2331
|
-
const
|
|
2686
|
+
const wantFiles = parsed.data.files ?? false;
|
|
2687
|
+
const hasExactPlaneSet = parsed.data.terminal !== undefined || parsed.data.files !== undefined;
|
|
2688
|
+
// v1 clients sent only `desktop:false` for both terminal and file warming;
|
|
2689
|
+
// retain its terminal-only grant during rolling upgrades. Presence of either
|
|
2690
|
+
// v2 flag switches to exact-plane semantics.
|
|
2691
|
+
const wantTerminal = parsed.data.terminal ?? (!hasExactPlaneSet && !wantDesktop);
|
|
2692
|
+
if (!wantDesktop && !wantTerminal && !wantFiles) {
|
|
2693
|
+
throw new HTTPException(400, {
|
|
2694
|
+
message: "viewer attach requires a live plane",
|
|
2695
|
+
});
|
|
2696
|
+
}
|
|
2697
|
+
if (wantDesktop) requirePermission(grant, "stream:view");
|
|
2698
|
+
if (wantTerminal) requirePermission(grant, "terminal:attach");
|
|
2699
|
+
if (wantFiles) requirePermission(grant, "files:write");
|
|
2700
|
+
|
|
2701
|
+
// Consent gate (P3.2 / addendum E.1): only the explicitly requested,
|
|
2702
|
+
// un-redacted desktop plane needs acknowledgment. Terminal and files retain
|
|
2703
|
+
// their independent permission boundaries.
|
|
2332
2704
|
if (wantDesktop) {
|
|
2705
|
+
const { shared } = await resolveSharedExposure(workspaceId, session);
|
|
2333
2706
|
const ack = await getStreamAcknowledgment(db, {
|
|
2334
2707
|
workspaceId,
|
|
2335
2708
|
sandboxGroupId: session.sandboxGroupId,
|
|
@@ -2372,7 +2745,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2372
2745
|
dataPlaneUrl: null,
|
|
2373
2746
|
};
|
|
2374
2747
|
if (
|
|
2375
|
-
(
|
|
2748
|
+
((wantDesktop && settings.sandboxDesktopEnabled) ||
|
|
2749
|
+
(wantTerminal && settings.sandboxTerminalEnabled)) &&
|
|
2376
2750
|
!streamTokenDegraded(settings)
|
|
2377
2751
|
) {
|
|
2378
2752
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
@@ -2384,7 +2758,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2384
2758
|
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
2385
2759
|
});
|
|
2386
2760
|
}
|
|
2387
|
-
if (settings.sandboxTerminalEnabled) {
|
|
2761
|
+
if (wantTerminal && settings.sandboxTerminalEnabled) {
|
|
2388
2762
|
terminal = await mintTerminalStream(viewerServices, {
|
|
2389
2763
|
accountId: grant.accountId,
|
|
2390
2764
|
workspaceId,
|
|
@@ -2399,6 +2773,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2399
2773
|
accountId: grant.accountId,
|
|
2400
2774
|
workspaceId,
|
|
2401
2775
|
session,
|
|
2776
|
+
waitSignal: c.req.raw.signal,
|
|
2402
2777
|
...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
|
|
2403
2778
|
});
|
|
2404
2779
|
|
|
@@ -2410,7 +2785,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2410
2785
|
// box is warm here (attachViewer spun it up or attached), so the handshake's
|
|
2411
2786
|
// never-spin-up rule does not apply.
|
|
2412
2787
|
if (
|
|
2413
|
-
(
|
|
2788
|
+
((wantDesktop && settings.sandboxDesktopEnabled) ||
|
|
2789
|
+
(wantTerminal && settings.sandboxTerminalEnabled)) &&
|
|
2414
2790
|
!streamTokenDegraded(settings)
|
|
2415
2791
|
) {
|
|
2416
2792
|
const lease = await readGroupLease(
|
|
@@ -2418,9 +2794,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2418
2794
|
{ workspaceId, sandboxGroupId: session.sandboxGroupId },
|
|
2419
2795
|
);
|
|
2420
2796
|
if (lease) {
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
// the terminal mint below still runs.
|
|
2797
|
+
// Mint only explicitly authorized plane credentials. The shared holder
|
|
2798
|
+
// supplies liveness; it is not itself authority for another plane.
|
|
2424
2799
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
2425
2800
|
stream = await mintDesktopStream(viewerServices, {
|
|
2426
2801
|
accountId: grant.accountId,
|
|
@@ -2430,10 +2805,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2430
2805
|
lease,
|
|
2431
2806
|
});
|
|
2432
2807
|
}
|
|
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) {
|
|
2808
|
+
if (wantTerminal && settings.sandboxTerminalEnabled) {
|
|
2437
2809
|
terminal = await mintTerminalStream(viewerServices, {
|
|
2438
2810
|
accountId: grant.accountId,
|
|
2439
2811
|
workspaceId,
|
|
@@ -2445,42 +2817,42 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2445
2817
|
}
|
|
2446
2818
|
}
|
|
2447
2819
|
}
|
|
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
|
-
);
|
|
2820
|
+
const response = {
|
|
2821
|
+
...result,
|
|
2822
|
+
dataPlaneUrl: stream?.url ?? null,
|
|
2823
|
+
streamToken: stream?.token ?? null,
|
|
2824
|
+
streamExpiresAt: stream?.expiresAt ?? null,
|
|
2825
|
+
resolution: stream?.resolution ?? null,
|
|
2826
|
+
// Transport MUST match where the pixels were minted: a selfhosted-active box
|
|
2827
|
+
// serves the RELAY framebuffer (relay-frames/frames), a Modal box serves noVNC
|
|
2828
|
+
// (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
|
|
2829
|
+
// noVNC renderer (and vice-versa on the swap-away case) → "closed before it
|
|
2830
|
+
// opened". Key off the SAME selfhostedActive the mint routed on.
|
|
2831
|
+
transport: stream
|
|
2832
|
+
? selfhostedActive
|
|
2833
|
+
? ("relay-frames" as const)
|
|
2834
|
+
: ("vnc-ws" as const)
|
|
2835
|
+
: null,
|
|
2836
|
+
client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
|
|
2837
|
+
// The REAL PTY terminal address (pty-ws), null when degraded.
|
|
2838
|
+
terminalUrl: terminal?.url ?? null,
|
|
2839
|
+
terminalToken: terminal?.token ?? null,
|
|
2840
|
+
terminalExpiresAt: terminal?.expiresAt ?? null,
|
|
2841
|
+
terminalTransport: terminal?.transport ?? null,
|
|
2842
|
+
} satisfies AttachViewerResponse;
|
|
2843
|
+
return c.json(response, 201);
|
|
2474
2844
|
});
|
|
2475
2845
|
|
|
2476
2846
|
// POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
|
|
2477
|
-
// A holder
|
|
2478
|
-
//
|
|
2847
|
+
// A holder may belong to any exact live plane. Lifecycle control accepts any
|
|
2848
|
+
// permission that could have minted it; the unguessable holder id and workspace
|
|
2849
|
+
// grant remain the ownership boundary.
|
|
2479
2850
|
app.post(
|
|
2480
2851
|
"/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
|
|
2481
2852
|
async (c) => {
|
|
2482
2853
|
const workspaceId = c.req.param("workspaceId");
|
|
2483
|
-
const grant = await requireAccessGrant(c, deps, workspaceId
|
|
2854
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
2855
|
+
requireViewerLifecyclePermission(grant);
|
|
2484
2856
|
assertOwnershipEnabled();
|
|
2485
2857
|
const sessionId = c.req.param("sessionId");
|
|
2486
2858
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2510,7 +2882,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2510
2882
|
// DELETE .../viewers/:viewerId — release the holder (idempotent).
|
|
2511
2883
|
app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
|
|
2512
2884
|
const workspaceId = c.req.param("workspaceId");
|
|
2513
|
-
const grant = await requireAccessGrant(c, deps, workspaceId
|
|
2885
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
2886
|
+
requireViewerLifecyclePermission(grant);
|
|
2514
2887
|
assertOwnershipEnabled();
|
|
2515
2888
|
const sessionId = c.req.param("sessionId");
|
|
2516
2889
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2577,11 +2950,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2577
2950
|
// FS uses files:read for reads, files:write for mutations; Git is read-only
|
|
2578
2951
|
// (rides files:read); Terminal exec + PTY ride terminal:attach.
|
|
2579
2952
|
|
|
2580
|
-
type ChannelARouteCtx = {
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
session: Session;
|
|
2584
|
-
subjectId: string;
|
|
2953
|
+
type ChannelARouteCtx = ChannelAContext & {
|
|
2954
|
+
waitSignal: AbortSignal;
|
|
2955
|
+
operation: ChannelAOperation;
|
|
2585
2956
|
};
|
|
2586
2957
|
|
|
2587
2958
|
// Shared preamble: grant BEFORE parse, ownership gate, session lookup. Returns
|
|
@@ -2589,6 +2960,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2589
2960
|
async function channelAPreamble(
|
|
2590
2961
|
c: Context,
|
|
2591
2962
|
permission: "files:read" | "files:write" | "terminal:attach",
|
|
2963
|
+
operation: ChannelAOperation,
|
|
2592
2964
|
): Promise<ChannelARouteCtx> {
|
|
2593
2965
|
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
2594
2966
|
const grant = await requireAccessGrant(c, deps, workspaceId, permission);
|
|
@@ -2603,6 +2975,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2603
2975
|
workspaceId,
|
|
2604
2976
|
session,
|
|
2605
2977
|
subjectId: grant.subjectId,
|
|
2978
|
+
waitSignal: c.req.raw.signal,
|
|
2979
|
+
operation,
|
|
2606
2980
|
};
|
|
2607
2981
|
}
|
|
2608
2982
|
|
|
@@ -2622,55 +2996,53 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2622
2996
|
|
|
2623
2997
|
// ── FileSystem ──────────────────────────────────────────────────────────
|
|
2624
2998
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
|
|
2625
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
2999
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.list");
|
|
2626
3000
|
const req = await parseChannelABody(c, FsListRequest);
|
|
2627
|
-
const out = await
|
|
3001
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsList(req));
|
|
2628
3002
|
return c.json(out);
|
|
2629
3003
|
});
|
|
2630
3004
|
|
|
2631
3005
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list-batch", async (c) => {
|
|
2632
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
3006
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.list-batch");
|
|
2633
3007
|
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
|
-
),
|
|
3008
|
+
const out = await withChannelARead(channelAServices, ctx, async ({ service }) => ({
|
|
3009
|
+
results: await runConcurrentChannelAReads(
|
|
3010
|
+
req.requests.map((request) => async () => await service.fsList(request)),
|
|
2639
3011
|
),
|
|
2640
3012
|
}));
|
|
2641
3013
|
return c.json(out);
|
|
2642
3014
|
});
|
|
2643
3015
|
|
|
2644
3016
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
|
|
2645
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
3017
|
+
const ctx = await channelAPreamble(c, "files:read", "fs.read");
|
|
2646
3018
|
const req = await parseChannelABody(c, FsReadRequest);
|
|
2647
|
-
const out = await
|
|
3019
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsRead(req));
|
|
2648
3020
|
return c.json(out);
|
|
2649
3021
|
});
|
|
2650
3022
|
|
|
2651
3023
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
|
|
2652
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
3024
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.write");
|
|
2653
3025
|
const req = await parseChannelABody(c, FsWriteRequest);
|
|
2654
3026
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsWrite(req));
|
|
2655
3027
|
return c.json(out);
|
|
2656
3028
|
});
|
|
2657
3029
|
|
|
2658
3030
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
|
|
2659
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
3031
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.delete");
|
|
2660
3032
|
const req = await parseChannelABody(c, FsDeleteRequest);
|
|
2661
3033
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsDelete(req));
|
|
2662
3034
|
return c.json(out);
|
|
2663
3035
|
});
|
|
2664
3036
|
|
|
2665
3037
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
|
|
2666
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
3038
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.move");
|
|
2667
3039
|
const req = await parseChannelABody(c, FsMoveRequest);
|
|
2668
3040
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMove(req));
|
|
2669
3041
|
return c.json(out);
|
|
2670
3042
|
});
|
|
2671
3043
|
|
|
2672
3044
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
|
|
2673
|
-
const ctx = await channelAPreamble(c, "files:write");
|
|
3045
|
+
const ctx = await channelAPreamble(c, "files:write", "fs.mkdir");
|
|
2674
3046
|
const req = await parseChannelABody(c, FsMkdirRequest);
|
|
2675
3047
|
const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMkdir(req));
|
|
2676
3048
|
return c.json(out);
|
|
@@ -2678,50 +3050,87 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2678
3050
|
|
|
2679
3051
|
// ── Git (read-only) ─────────────────────────────────────────────────────
|
|
2680
3052
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
|
|
2681
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
3053
|
+
const ctx = await channelAPreamble(c, "files:read", "git.status");
|
|
2682
3054
|
const req = await parseChannelABody(c, GitStatusRequest);
|
|
2683
|
-
const out = await
|
|
3055
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
3056
|
+
service.gitStatus(req),
|
|
3057
|
+
);
|
|
2684
3058
|
return c.json(out);
|
|
2685
3059
|
});
|
|
2686
3060
|
|
|
2687
3061
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
|
|
2688
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
3062
|
+
const ctx = await channelAPreamble(c, "files:read", "git.diff");
|
|
2689
3063
|
const req = await parseChannelABody(c, GitDiffRequest);
|
|
2690
|
-
const out = await
|
|
3064
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
3065
|
+
service.gitDiff(req),
|
|
3066
|
+
);
|
|
2691
3067
|
return c.json(out);
|
|
2692
3068
|
});
|
|
2693
3069
|
|
|
2694
3070
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/read-batch", async (c) => {
|
|
2695
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
3071
|
+
const ctx = await channelAPreamble(c, "files:read", "git.read-batch");
|
|
2696
3072
|
const req = await parseChannelABody(c, GitReadBatchRequest);
|
|
2697
|
-
const out = await
|
|
2698
|
-
|
|
2699
|
-
|
|
3073
|
+
const out = await withChannelARead(channelAServices, ctx, async ({ service }) => {
|
|
3074
|
+
type StatusResult = Awaited<ReturnType<typeof service.gitStatus>>;
|
|
3075
|
+
type DiffResult = Awaited<ReturnType<typeof service.gitDiff>>;
|
|
3076
|
+
type ReadResult =
|
|
3077
|
+
| { requestIndex: number; kind: "status"; value: StatusResult }
|
|
3078
|
+
| { requestIndex: number; kind: "diff"; value: DiffResult };
|
|
3079
|
+
const operations: Array<() => Promise<ReadResult>> = [];
|
|
3080
|
+
req.requests.forEach((request, requestIndex) => {
|
|
3081
|
+
operations.push(async () => ({
|
|
3082
|
+
requestIndex,
|
|
3083
|
+
kind: "status" as const,
|
|
3084
|
+
value: await service.gitStatus(request.status),
|
|
3085
|
+
}));
|
|
3086
|
+
if (request.diff) {
|
|
2700
3087
|
const diffRequest = request.diff;
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
3088
|
+
operations.push(async () => ({
|
|
3089
|
+
requestIndex,
|
|
3090
|
+
kind: "diff" as const,
|
|
3091
|
+
value: await service.gitDiff(diffRequest),
|
|
3092
|
+
}));
|
|
3093
|
+
}
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
const reads = await runConcurrentChannelAReads(operations);
|
|
3097
|
+
const statuses = new Map<number, StatusResult>();
|
|
3098
|
+
const diffs = new Map<number, DiffResult>();
|
|
3099
|
+
for (const read of reads) {
|
|
3100
|
+
if (read.kind === "status") statuses.set(read.requestIndex, read.value);
|
|
3101
|
+
else diffs.set(read.requestIndex, read.value);
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
return {
|
|
3105
|
+
results: req.requests.map((request, requestIndex) => {
|
|
3106
|
+
const status = statuses.get(requestIndex);
|
|
3107
|
+
if (!status) {
|
|
3108
|
+
throw new Error(`Workspace Git batch omitted status result ${requestIndex}.`);
|
|
3109
|
+
}
|
|
3110
|
+
const diff = request.diff ? diffs.get(requestIndex) : undefined;
|
|
3111
|
+
if (request.diff && !diff) {
|
|
3112
|
+
throw new Error(`Workspace Git batch omitted diff result ${requestIndex}.`);
|
|
3113
|
+
}
|
|
2707
3114
|
return { status, ...(diff ? { diff } : {}) };
|
|
2708
3115
|
}),
|
|
2709
|
-
|
|
2710
|
-
})
|
|
3116
|
+
};
|
|
3117
|
+
});
|
|
2711
3118
|
return c.json(out);
|
|
2712
3119
|
});
|
|
2713
3120
|
|
|
2714
3121
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
|
|
2715
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
3122
|
+
const ctx = await channelAPreamble(c, "files:read", "git.log");
|
|
2716
3123
|
const req = await parseChannelABody(c, GitLogRequest);
|
|
2717
|
-
const out = await
|
|
3124
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.gitLog(req));
|
|
2718
3125
|
return c.json(out);
|
|
2719
3126
|
});
|
|
2720
3127
|
|
|
2721
3128
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
|
|
2722
|
-
const ctx = await channelAPreamble(c, "files:read");
|
|
3129
|
+
const ctx = await channelAPreamble(c, "files:read", "git.show");
|
|
2723
3130
|
const req = await parseChannelABody(c, GitShowRequest);
|
|
2724
|
-
const out = await
|
|
3131
|
+
const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
|
|
3132
|
+
service.gitShow(req),
|
|
3133
|
+
);
|
|
2725
3134
|
return c.json(out);
|
|
2726
3135
|
});
|
|
2727
3136
|
|
|
@@ -2784,7 +3193,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2784
3193
|
|
|
2785
3194
|
// ── Terminal: synchronous exec ────────────────────────────────────────────
|
|
2786
3195
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
|
|
2787
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3196
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.exec");
|
|
2788
3197
|
const req = await parseChannelABody(c, TerminalExecRequest);
|
|
2789
3198
|
const out = await withChannelA(channelAServices, ctx, ({ service }) =>
|
|
2790
3199
|
service.terminalExec(req),
|
|
@@ -2794,7 +3203,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2794
3203
|
|
|
2795
3204
|
// ── Terminal: interactive PTY control (output rides A1) ───────────────────
|
|
2796
3205
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
|
|
2797
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3206
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.open");
|
|
2798
3207
|
const req = await parseChannelABody(c, PtyOpenRequest);
|
|
2799
3208
|
if (ctx.session.sandboxBackend === "selfhosted" || ctx.session.activeSandboxId !== null) {
|
|
2800
3209
|
throw new HTTPException(409, {
|
|
@@ -2903,7 +3312,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2903
3312
|
});
|
|
2904
3313
|
|
|
2905
3314
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
|
|
2906
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3315
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.write");
|
|
2907
3316
|
const req = await parseChannelABody(c, PtyWriteRequest);
|
|
2908
3317
|
const pty = await getOpenPtySession(db, {
|
|
2909
3318
|
workspaceId: ctx.workspaceId,
|
|
@@ -2967,7 +3376,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2967
3376
|
});
|
|
2968
3377
|
|
|
2969
3378
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
|
|
2970
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3379
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.resize");
|
|
2971
3380
|
const req = await parseChannelABody(c, PtyResizeRequest);
|
|
2972
3381
|
const pty = await getOpenPtySession(db, {
|
|
2973
3382
|
workspaceId: ctx.workspaceId,
|
|
@@ -2999,7 +3408,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2999
3408
|
});
|
|
3000
3409
|
|
|
3001
3410
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
|
|
3002
|
-
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
3411
|
+
const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.close");
|
|
3003
3412
|
const req = await parseChannelABody(c, PtyCloseRequest);
|
|
3004
3413
|
const pty = await getOpenPtySession(db, {
|
|
3005
3414
|
workspaceId: ctx.workspaceId,
|
|
@@ -3028,6 +3437,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
3028
3437
|
});
|
|
3029
3438
|
}
|
|
3030
3439
|
|
|
3440
|
+
export function goalProposalMatchesExpectedRevision(
|
|
3441
|
+
revision: Pick<SessionGoalRevision, "baseObjectiveRevision">,
|
|
3442
|
+
expectedObjectiveRevision: number,
|
|
3443
|
+
): boolean {
|
|
3444
|
+
return revision.baseObjectiveRevision === expectedObjectiveRevision;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3031
3447
|
function eventListLimit(raw: string | undefined, max = 2000, fallback = 500): number {
|
|
3032
3448
|
const limit = Number(raw ?? fallback);
|
|
3033
3449
|
if (!Number.isFinite(limit)) {
|
|
@@ -3097,6 +3513,7 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
3097
3513
|
return null;
|
|
3098
3514
|
}
|
|
3099
3515
|
if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
|
|
3516
|
+
if (suffix === "/channel" && verb === "PUT") return "session.channel.write";
|
|
3100
3517
|
if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
|
|
3101
3518
|
if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
|
|
3102
3519
|
return "session.mcp.approval_policy.write";
|
|
@@ -3111,6 +3528,9 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
3111
3528
|
if (suffix === "/realtime/gateway" && verb === "POST") {
|
|
3112
3529
|
return "session.realtime.start";
|
|
3113
3530
|
}
|
|
3531
|
+
if (suffix === "/realtime/supergrok" && verb === "POST") {
|
|
3532
|
+
return "session.realtime.start";
|
|
3533
|
+
}
|
|
3114
3534
|
if (suffix === "/realtime" && verb === "POST") {
|
|
3115
3535
|
return "session.realtime.start";
|
|
3116
3536
|
}
|
|
@@ -3133,6 +3553,10 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
3133
3553
|
? "session.goal.write"
|
|
3134
3554
|
: null;
|
|
3135
3555
|
}
|
|
3556
|
+
if (suffix === "/goal/revisions" && verb === "GET") return "session.goal.read";
|
|
3557
|
+
if (/^\/goal\/revisions\/[^/]+\/apply$/.test(suffix) && verb === "POST") {
|
|
3558
|
+
return "session.goal.write";
|
|
3559
|
+
}
|
|
3136
3560
|
if (suffix === "/context/clear" || suffix === "/context/compact") {
|
|
3137
3561
|
return verb === "POST" ? "session.context.write" : null;
|
|
3138
3562
|
}
|
|
@@ -3311,6 +3735,111 @@ function sessionListQuery(
|
|
|
3311
3735
|
};
|
|
3312
3736
|
}
|
|
3313
3737
|
|
|
3738
|
+
export type AgentTopologyCursorEnvelope = {
|
|
3739
|
+
cursor: SessionDiscoveryCursor;
|
|
3740
|
+
parentSessionId: string | null;
|
|
3741
|
+
search: string | null;
|
|
3742
|
+
};
|
|
3743
|
+
|
|
3744
|
+
export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): string {
|
|
3745
|
+
return Buffer.from(JSON.stringify({ v: 1, ...value }), "utf8").toString("base64url");
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
|
|
3749
|
+
if (value.length > 2_048) {
|
|
3750
|
+
throw new HTTPException(400, {
|
|
3751
|
+
message: "agent topology cursor is invalid",
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
try {
|
|
3755
|
+
const parsed = z
|
|
3756
|
+
.object({
|
|
3757
|
+
v: z.literal(1),
|
|
3758
|
+
parentSessionId: z.string().uuid().nullable(),
|
|
3759
|
+
search: z.string().max(200).nullable(),
|
|
3760
|
+
cursor: z.object({
|
|
3761
|
+
orderBy: z.enum(["createdAt", "updatedAt"]),
|
|
3762
|
+
sortRevision: z.string().max(64),
|
|
3763
|
+
sortAt: z.string().max(64),
|
|
3764
|
+
id: z.string().uuid(),
|
|
3765
|
+
snapshotAt: z.string().max(64),
|
|
3766
|
+
snapshotRevision: z.string().max(64),
|
|
3767
|
+
updatedAfter: z.string().max(64).nullable(),
|
|
3768
|
+
}),
|
|
3769
|
+
})
|
|
3770
|
+
.parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
|
|
3771
|
+
if (
|
|
3772
|
+
parsed.cursor.orderBy !== "updatedAt" ||
|
|
3773
|
+
parsed.cursor.updatedAfter !== null ||
|
|
3774
|
+
!/^(?:0|[1-9]\d*)$/.test(parsed.cursor.sortRevision) ||
|
|
3775
|
+
!/^(?:0|[1-9]\d*)$/.test(parsed.cursor.snapshotRevision) ||
|
|
3776
|
+
BigInt(parsed.cursor.sortRevision) > 9_223_372_036_854_775_807n ||
|
|
3777
|
+
BigInt(parsed.cursor.snapshotRevision) > 9_223_372_036_854_775_807n ||
|
|
3778
|
+
Number.isNaN(Date.parse(parsed.cursor.sortAt)) ||
|
|
3779
|
+
Number.isNaN(Date.parse(parsed.cursor.snapshotAt))
|
|
3780
|
+
) {
|
|
3781
|
+
throw new Error("invalid topology cursor fields");
|
|
3782
|
+
}
|
|
3783
|
+
return parsed;
|
|
3784
|
+
} catch {
|
|
3785
|
+
throw new HTTPException(400, {
|
|
3786
|
+
message: "agent topology cursor is invalid",
|
|
3787
|
+
});
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
export function agentTopologyQuery(query: Record<string, string>): {
|
|
3792
|
+
limit: number;
|
|
3793
|
+
parentSessionId: string | null;
|
|
3794
|
+
search: string | undefined;
|
|
3795
|
+
cursor: SessionDiscoveryCursor | undefined;
|
|
3796
|
+
} {
|
|
3797
|
+
const rawLimit = query.limit;
|
|
3798
|
+
const limit = rawLimit === undefined ? 25 : Number(rawLimit);
|
|
3799
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
3800
|
+
throw new HTTPException(400, {
|
|
3801
|
+
message: "limit must be an integer between 1 and 100",
|
|
3802
|
+
});
|
|
3803
|
+
}
|
|
3804
|
+
const rawParent = query.parentSessionId;
|
|
3805
|
+
if (
|
|
3806
|
+
rawParent !== undefined &&
|
|
3807
|
+
rawParent !== "null" &&
|
|
3808
|
+
!z.string().uuid().safeParse(rawParent).success
|
|
3809
|
+
) {
|
|
3810
|
+
throw new HTTPException(400, {
|
|
3811
|
+
message: 'parentSessionId must be a session id or the literal "null"',
|
|
3812
|
+
});
|
|
3813
|
+
}
|
|
3814
|
+
const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
|
|
3815
|
+
const search = query.search?.trim();
|
|
3816
|
+
if (search && search.length > 200) {
|
|
3817
|
+
throw new HTTPException(400, {
|
|
3818
|
+
message: "search must be at most 200 characters",
|
|
3819
|
+
});
|
|
3820
|
+
}
|
|
3821
|
+
if (search && rawParent !== undefined) {
|
|
3822
|
+
throw new HTTPException(400, {
|
|
3823
|
+
message: "search cannot be combined with parentSessionId",
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
|
|
3827
|
+
if (
|
|
3828
|
+
envelope &&
|
|
3829
|
+
(envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
|
|
3830
|
+
) {
|
|
3831
|
+
throw new HTTPException(400, {
|
|
3832
|
+
message: "agent topology cursor does not match its filters",
|
|
3833
|
+
});
|
|
3834
|
+
}
|
|
3835
|
+
return {
|
|
3836
|
+
limit,
|
|
3837
|
+
parentSessionId,
|
|
3838
|
+
search: search || undefined,
|
|
3839
|
+
cursor: envelope?.cursor,
|
|
3840
|
+
};
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3314
3843
|
function compactEvents(raw: string | undefined): boolean {
|
|
3315
3844
|
return raw === "1" || raw === "true";
|
|
3316
3845
|
}
|
|
@@ -3336,6 +3865,11 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
|
|
|
3336
3865
|
|
|
3337
3866
|
/** Stable, value-free JSON errors for only the create-session boundary. */
|
|
3338
3867
|
export function sessionCreateErrorResponse(c: Context, error: unknown): Response {
|
|
3868
|
+
if (error instanceof ChannelNotFoundError) {
|
|
3869
|
+
// Covers the create-vs-channel-delete race the pre-validation cannot: the
|
|
3870
|
+
// insert's FK rejection surfaces as the same 422 an unknown id gets.
|
|
3871
|
+
return c.json({ code: "SESSION_CREATE_REJECTED", message: error.message }, 422);
|
|
3872
|
+
}
|
|
3339
3873
|
if (error instanceof SessionSpawnDeniedError) {
|
|
3340
3874
|
return c.json(
|
|
3341
3875
|
sessionSpawnDenialEnvelope(error),
|
|
@@ -3363,6 +3897,22 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
|
|
|
3363
3897
|
throw error;
|
|
3364
3898
|
}
|
|
3365
3899
|
|
|
3900
|
+
export function parseSessionEventAdmission(raw: unknown): ClientSessionEvent {
|
|
3901
|
+
const parsed = ClientSessionEvent.safeParse(raw);
|
|
3902
|
+
if (!parsed.success) {
|
|
3903
|
+
throw new HTTPException(422, { message: "invalid session event" });
|
|
3904
|
+
}
|
|
3905
|
+
return parsed.data;
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
export function parseSteerSessionAdmission(raw: unknown): SteerSessionMessageRequest {
|
|
3909
|
+
const parsed = SteerSessionMessageRequest.safeParse(raw);
|
|
3910
|
+
if (!parsed.success) {
|
|
3911
|
+
throw new HTTPException(422, { message: "invalid steer request" });
|
|
3912
|
+
}
|
|
3913
|
+
return parsed.data;
|
|
3914
|
+
}
|
|
3915
|
+
|
|
3366
3916
|
function zodErrorFields(error: ZodError): string {
|
|
3367
3917
|
const paths = [
|
|
3368
3918
|
...new Set(
|
|
@@ -3398,10 +3948,11 @@ type EffectivePolicyContext = {
|
|
|
3398
3948
|
async function loadEffectivePolicyContext(
|
|
3399
3949
|
deps: ApiRouteDeps,
|
|
3400
3950
|
workspaceId: string,
|
|
3951
|
+
subjectId: string,
|
|
3401
3952
|
): Promise<EffectivePolicyContext> {
|
|
3402
3953
|
const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
|
|
3403
|
-
workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
|
|
3404
|
-
workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
|
|
3954
|
+
workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
|
|
3955
|
+
workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
|
|
3405
3956
|
]);
|
|
3406
3957
|
return { workspaceServerIds, workspaceDefaultServerIds };
|
|
3407
3958
|
}
|
|
@@ -3409,9 +3960,10 @@ async function loadEffectivePolicyContext(
|
|
|
3409
3960
|
async function withEffectivePolicy(
|
|
3410
3961
|
deps: ApiRouteDeps,
|
|
3411
3962
|
workspaceId: string,
|
|
3963
|
+
subjectId: string,
|
|
3412
3964
|
session: Session,
|
|
3413
3965
|
): Promise<Session> {
|
|
3414
|
-
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
3966
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId);
|
|
3415
3967
|
return sessionWithEffectiveToolPolicy(
|
|
3416
3968
|
session,
|
|
3417
3969
|
policy.workspaceServerIds,
|