@opengeni/api-router 0.7.3 → 0.11.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.js +1 -1
- package/dist/{chunk-EYYTFA7N.js → chunk-7PQKPKW5.js} +4117 -1863
- package/dist/chunk-7PQKPKW5.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +12 -11
- package/src/app.ts +37 -15
- package/src/codex-redemption-security.ts +96 -0
- package/src/integrations/oauth-client.ts +147 -61
- package/src/mcp/server.ts +151 -83
- package/src/mcp/session-view.ts +102 -1
- package/src/mcp/toolspace.ts +389 -124
- package/src/model-catalog.ts +337 -0
- package/src/routes/codex.ts +866 -14
- package/src/routes/files.ts +153 -0
- package/src/routes/machines.ts +22 -2
- package/src/routes/sessions.ts +637 -152
- package/src/routes/workspaces.ts +36 -2
- package/src/sandbox/channel-a.ts +54 -59
- package/src/sandbox/machines.ts +7 -0
- package/src/sandbox/rematerialize.ts +287 -0
- package/src/sandbox/viewer.ts +207 -129
- package/dist/chunk-EYYTFA7N.js.map +0 -1
package/src/routes/sessions.ts
CHANGED
|
@@ -27,19 +27,26 @@ import {
|
|
|
27
27
|
SessionEventPayloadMode,
|
|
28
28
|
SessionEventReadDirection,
|
|
29
29
|
SessionEventReadMode,
|
|
30
|
+
SessionEventLatestClass,
|
|
31
|
+
SessionEventResultMode,
|
|
30
32
|
SessionEventSemanticClass,
|
|
31
33
|
SessionEventType,
|
|
34
|
+
SessionMcpServerId,
|
|
35
|
+
compactSessionEventResult,
|
|
36
|
+
sessionEventLatestClassToSemanticClass,
|
|
32
37
|
SaveComposerDraftRequest,
|
|
33
38
|
SteerSessionQueueItemRequest,
|
|
34
39
|
SteerSessionMessageRequest,
|
|
35
40
|
TerminalExecRequest,
|
|
36
41
|
UpdateSessionPinRequest,
|
|
37
42
|
UpdateSessionGoalRequest,
|
|
43
|
+
UpdateSessionMcpApprovalPolicyRequest,
|
|
38
44
|
UpdateSessionRequest,
|
|
39
45
|
ViewerHeartbeatRequest,
|
|
40
46
|
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
41
47
|
workspaceControlUtf8Bytes,
|
|
42
48
|
type SandboxBackend,
|
|
49
|
+
type LineageNode,
|
|
43
50
|
type Session,
|
|
44
51
|
type SessionAuthorizationOperation,
|
|
45
52
|
type SessionQueueSnapshot,
|
|
@@ -53,13 +60,14 @@ import {
|
|
|
53
60
|
acceptSessionHumanInputResponse,
|
|
54
61
|
clearSessionGoal,
|
|
55
62
|
clearSessionContext,
|
|
56
|
-
closePtySession,
|
|
57
63
|
getOpenPtySession,
|
|
64
|
+
getRetainedProcess,
|
|
58
65
|
getSandbox,
|
|
59
66
|
getSession,
|
|
60
67
|
getSessionForSubject,
|
|
61
68
|
getSessionGoal,
|
|
62
69
|
getSessionHumanInputRequest,
|
|
70
|
+
getSessionGoalWithContinuation,
|
|
63
71
|
getSessionQueueSnapshot,
|
|
64
72
|
getStreamAcknowledgment,
|
|
65
73
|
insertPtySession,
|
|
@@ -79,11 +87,14 @@ import {
|
|
|
79
87
|
SessionPinAccessError,
|
|
80
88
|
SessionListAccessError,
|
|
81
89
|
SessionListCursorError,
|
|
90
|
+
SessionListCursorExpiredError,
|
|
91
|
+
SessionListSnapshotLimitError,
|
|
82
92
|
decodeSessionListCursor,
|
|
83
93
|
revokeViewer,
|
|
84
|
-
|
|
94
|
+
setSessionGoalStatusWithEvent,
|
|
85
95
|
updatePtySessionActivity,
|
|
86
96
|
QueueCommandConflictError,
|
|
97
|
+
NewSessionDraftConflictError,
|
|
87
98
|
SessionCommandIdempotencyError,
|
|
88
99
|
SessionControlConflictError,
|
|
89
100
|
SessionContextBusyError,
|
|
@@ -91,6 +102,9 @@ import {
|
|
|
91
102
|
latestWorkspaceCapture,
|
|
92
103
|
workspaceCaptureAtRevision,
|
|
93
104
|
type AppendEventInput,
|
|
105
|
+
type SandboxOpenPtySessionRow,
|
|
106
|
+
type SandboxPtyProcessIdentity,
|
|
107
|
+
type SandboxRetainedProcess,
|
|
94
108
|
} from "@opengeni/db";
|
|
95
109
|
import {
|
|
96
110
|
appendAndPublishEvents,
|
|
@@ -98,8 +112,8 @@ import {
|
|
|
98
112
|
coalesceSessionEventDeltas,
|
|
99
113
|
publishDurableSessionEvents,
|
|
100
114
|
} from "@opengeni/events";
|
|
101
|
-
import { z } from "zod";
|
|
102
|
-
import { withChannelA } from "../sandbox/channel-a";
|
|
115
|
+
import { z, ZodError } from "zod";
|
|
116
|
+
import { withChannelA, type ChannelAContext, type ChannelAHandle } from "../sandbox/channel-a";
|
|
103
117
|
import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
|
|
104
118
|
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
105
119
|
import { HTTPException } from "hono/http-exception";
|
|
@@ -124,6 +138,7 @@ import {
|
|
|
124
138
|
viewerHeartbeatIntervalMs,
|
|
125
139
|
type DesktopStreamMint,
|
|
126
140
|
type TerminalStreamMint,
|
|
141
|
+
type ViewerServices,
|
|
127
142
|
} from "../sandbox/viewer";
|
|
128
143
|
import {
|
|
129
144
|
acceptSessionUserMessage,
|
|
@@ -131,20 +146,125 @@ import {
|
|
|
131
146
|
createSessionForRequest,
|
|
132
147
|
deleteHumanQueuePrompt,
|
|
133
148
|
editHumanQueuePrompt,
|
|
149
|
+
getActorNewSessionDraft,
|
|
134
150
|
getHumanComposerDraft,
|
|
135
151
|
moveHumanQueuePrompt,
|
|
136
152
|
readSessionLineage,
|
|
137
153
|
saveHumanComposerDraft,
|
|
154
|
+
saveActorNewSessionDraft,
|
|
155
|
+
SessionSpawnDeniedError,
|
|
156
|
+
sessionSpawnDenialEnvelope,
|
|
138
157
|
steerHumanQueuePrompt,
|
|
158
|
+
updateSessionMcpApprovalPolicy,
|
|
139
159
|
updateSessionTitle,
|
|
140
160
|
workflowIdForSession,
|
|
161
|
+
sessionWithEffectiveToolPolicy,
|
|
162
|
+
workspaceSessionToolPolicyDefaultServerIds,
|
|
163
|
+
workspaceSessionToolPolicyServerIds,
|
|
141
164
|
} from "@opengeni/core";
|
|
142
165
|
import { assertSessionExists, boundedLimit } from "../http/common";
|
|
143
166
|
import { sseSessionStream } from "../http/sse";
|
|
144
167
|
import { serveWorkspaceCapture, serveWorkspaceCaptureFile } from "./workspace-capture";
|
|
145
168
|
|
|
146
|
-
|
|
169
|
+
type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
|
|
170
|
+
|
|
171
|
+
export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
147
172
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
173
|
+
const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
|
|
174
|
+
leaseId: pty.leaseId,
|
|
175
|
+
sandboxGroupId: pty.sandboxGroupId,
|
|
176
|
+
retainedProcessId: pty.retainedProcessId,
|
|
177
|
+
openAdmissionId: pty.openAdmissionId,
|
|
178
|
+
execSessionId: pty.execSessionId,
|
|
179
|
+
leaseEpoch: pty.leaseEpoch,
|
|
180
|
+
providerBackend: pty.providerBackend,
|
|
181
|
+
providerInstanceId: pty.providerInstanceId,
|
|
182
|
+
routeKind: pty.routeKind,
|
|
183
|
+
routeTargetId: pty.routeTargetId,
|
|
184
|
+
routeEpoch: pty.routeEpoch,
|
|
185
|
+
});
|
|
186
|
+
const adoptPtyProcess = async (
|
|
187
|
+
ctx: ChannelAContext,
|
|
188
|
+
handle: ChannelAHandle,
|
|
189
|
+
pty: SandboxOpenPtySessionRow,
|
|
190
|
+
): Promise<SandboxRetainedProcess> => {
|
|
191
|
+
const process = await getRetainedProcess(db, {
|
|
192
|
+
workspaceId: ctx.workspaceId,
|
|
193
|
+
sessionId: ctx.session.id,
|
|
194
|
+
processId: pty.retainedProcessId,
|
|
195
|
+
});
|
|
196
|
+
if (
|
|
197
|
+
!process ||
|
|
198
|
+
process.state !== "active" ||
|
|
199
|
+
process.ownerActorKind !== "direct" ||
|
|
200
|
+
process.accountId !== ctx.accountId ||
|
|
201
|
+
process.leaseId !== pty.leaseId ||
|
|
202
|
+
process.sandboxGroupId !== pty.sandboxGroupId ||
|
|
203
|
+
process.parentAdmissionId !== pty.openAdmissionId ||
|
|
204
|
+
process.leaseEpoch !== pty.leaseEpoch ||
|
|
205
|
+
process.providerBackend !== pty.providerBackend ||
|
|
206
|
+
process.providerInstanceId !== pty.providerInstanceId ||
|
|
207
|
+
process.routeKind !== pty.routeKind ||
|
|
208
|
+
process.routeTargetId !== pty.routeTargetId ||
|
|
209
|
+
process.routeEpoch !== pty.routeEpoch ||
|
|
210
|
+
process.providerSessionId !== pty.execSessionId ||
|
|
211
|
+
// Only a persistable home backend can currently be reconstructed by an
|
|
212
|
+
// API request without consulting the mutable active pointer.
|
|
213
|
+
process.routeTargetId !== null ||
|
|
214
|
+
handle.lease.id !== process.leaseId ||
|
|
215
|
+
handle.lease.sandboxGroupId !== process.sandboxGroupId ||
|
|
216
|
+
handle.lease.leaseEpoch !== process.leaseEpoch ||
|
|
217
|
+
handle.lease.backend !== process.providerBackend ||
|
|
218
|
+
handle.lease.instanceId !== process.providerInstanceId
|
|
219
|
+
) {
|
|
220
|
+
throw new HTTPException(409, {
|
|
221
|
+
message: "pty retained-process identity is stale; reopen the terminal",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
handle.routingSession.adoptRetainedProcess({
|
|
225
|
+
process: { id: process.id, providerSessionId: process.providerSessionId },
|
|
226
|
+
backend: {
|
|
227
|
+
sandboxId: null,
|
|
228
|
+
leaseEpoch: process.leaseEpoch,
|
|
229
|
+
providerInstanceId: process.providerInstanceId,
|
|
230
|
+
activeEpoch: process.routeEpoch,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
return process;
|
|
234
|
+
};
|
|
235
|
+
const emitPtyExited = async (
|
|
236
|
+
ctx: ChannelAContext,
|
|
237
|
+
ptyId: string,
|
|
238
|
+
process: SandboxRetainedProcess,
|
|
239
|
+
): Promise<void> => {
|
|
240
|
+
const exited: TerminalPtyExitedPayload = {
|
|
241
|
+
ptyId,
|
|
242
|
+
exitCode: process.exitCode,
|
|
243
|
+
reason: process.state === "exited" ? "exit" : "lost",
|
|
244
|
+
};
|
|
245
|
+
await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
|
|
246
|
+
{ type: "terminal.pty.exited", payload: exited },
|
|
247
|
+
]);
|
|
248
|
+
};
|
|
249
|
+
const drainOpenedPty = async (handle: ChannelAHandle, execSessionId: number): Promise<void> => {
|
|
250
|
+
let chars = "\u0004";
|
|
251
|
+
while (handle.routingSession.hasRetainedProcess(execSessionId)) {
|
|
252
|
+
await handle.routingSession.writeStdinForProcessControl({
|
|
253
|
+
sessionId: execSessionId,
|
|
254
|
+
chars,
|
|
255
|
+
yieldTimeMs: 250,
|
|
256
|
+
maxOutputTokens: 128,
|
|
257
|
+
});
|
|
258
|
+
chars = "";
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const failPtyPersistenceAndDrain = (persistenceError: unknown, drainError: unknown): never => {
|
|
262
|
+
throw new AggregateError(
|
|
263
|
+
[persistenceError, drainError],
|
|
264
|
+
"PTY persistence failed and the exact opened process could not be drained",
|
|
265
|
+
{ cause: drainError },
|
|
266
|
+
);
|
|
267
|
+
};
|
|
148
268
|
const requestSessionAuthorization = new WeakMap<Request, ResolvedSessionAuthorization>();
|
|
149
269
|
const relatedSessionAccessFor = (c: Context): "target" | "root" =>
|
|
150
270
|
requestSessionAuthorization.get(c.req.raw)?.relatedSessionAccess ?? "root";
|
|
@@ -200,11 +320,94 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
200
320
|
};
|
|
201
321
|
app.use("/v1/workspaces/:workspaceId/sessions/:sessionId/*", authorizeSessionHttp);
|
|
202
322
|
|
|
323
|
+
const viewerServices: ViewerServices = {
|
|
324
|
+
db,
|
|
325
|
+
settings,
|
|
326
|
+
bus,
|
|
327
|
+
...(deps.establishSandboxSession
|
|
328
|
+
? { establishSandboxSession: deps.establishSandboxSession }
|
|
329
|
+
: {}),
|
|
330
|
+
};
|
|
331
|
+
|
|
203
332
|
app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
|
|
204
333
|
const workspaceId = c.req.param("workspaceId");
|
|
205
334
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
206
|
-
|
|
207
|
-
|
|
335
|
+
let payload: unknown;
|
|
336
|
+
try {
|
|
337
|
+
payload = await c.req.json();
|
|
338
|
+
} catch {
|
|
339
|
+
return c.json(
|
|
340
|
+
{
|
|
341
|
+
code: "INVALID_SESSION_CREATE_REQUEST",
|
|
342
|
+
message: "Invalid session create request: request body must contain valid JSON",
|
|
343
|
+
},
|
|
344
|
+
422,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
let session: Session;
|
|
348
|
+
try {
|
|
349
|
+
session = await createSessionForRequest(deps, grant, workspaceId, payload);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
return sessionCreateErrorResponse(c, error);
|
|
352
|
+
}
|
|
353
|
+
// Creation has committed by this point. Keep response projection outside
|
|
354
|
+
// the create-rejection boundary so a post-commit policy read cannot be
|
|
355
|
+
// misreported as though the session itself was rejected.
|
|
356
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
|
|
360
|
+
const workspaceId = c.req.param("workspaceId");
|
|
361
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
362
|
+
return c.json(await getActorNewSessionDraft({ settings, db }, grant, workspaceId));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
app.put("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
|
|
366
|
+
const workspaceId = c.req.param("workspaceId");
|
|
367
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
368
|
+
let payload: unknown;
|
|
369
|
+
try {
|
|
370
|
+
payload = await c.req.json();
|
|
371
|
+
} catch {
|
|
372
|
+
return c.json(
|
|
373
|
+
{
|
|
374
|
+
code: "INVALID_NEW_SESSION_DRAFT_REQUEST",
|
|
375
|
+
message: "Invalid new-session draft request: request body must contain valid JSON",
|
|
376
|
+
},
|
|
377
|
+
422,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
return c.json(
|
|
382
|
+
await saveActorNewSessionDraft(
|
|
383
|
+
{ settings, db, objectStorage },
|
|
384
|
+
grant,
|
|
385
|
+
workspaceId,
|
|
386
|
+
payload,
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error instanceof NewSessionDraftConflictError) {
|
|
391
|
+
return c.json(
|
|
392
|
+
{
|
|
393
|
+
code: "NEW_SESSION_DRAFT_CONFLICT",
|
|
394
|
+
message: error.message,
|
|
395
|
+
currentRevision: error.currentRevision,
|
|
396
|
+
},
|
|
397
|
+
409,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (error instanceof ZodError) {
|
|
401
|
+
return c.json(
|
|
402
|
+
{
|
|
403
|
+
code: "INVALID_NEW_SESSION_DRAFT_REQUEST",
|
|
404
|
+
message: `Invalid new-session draft request: ${zodErrorFields(error)} failed schema validation`,
|
|
405
|
+
},
|
|
406
|
+
422,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
208
411
|
});
|
|
209
412
|
|
|
210
413
|
app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
|
|
@@ -223,8 +426,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
223
426
|
page = await listSessionsForSubject(db, workspaceId, {
|
|
224
427
|
subjectId: grant.subjectId,
|
|
225
428
|
limit: boundedLimit(query.limit),
|
|
429
|
+
materializeSnapshot: pageView,
|
|
226
430
|
...(query.cursor ? { cursor: query.cursor } : {}),
|
|
227
431
|
...(query.search ? { search: query.search } : {}),
|
|
432
|
+
...(query.pinsOnly ? { pinsOnly: true } : {}),
|
|
228
433
|
...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
|
|
229
434
|
...(authorizationScope ? { authorizationScope } : {}),
|
|
230
435
|
});
|
|
@@ -232,24 +437,46 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
232
437
|
if (error instanceof SessionListAccessError) {
|
|
233
438
|
throw new HTTPException(403, { message: error.message });
|
|
234
439
|
}
|
|
440
|
+
if (error instanceof SessionListCursorExpiredError) {
|
|
441
|
+
// The caller's short-lived snapshot is no longer usable. Keep this
|
|
442
|
+
// distinct from auth, network, and validation failures so clients can
|
|
443
|
+
// rebase a retained continuation exactly once instead of retrying the
|
|
444
|
+
// expired cursor forever.
|
|
445
|
+
throw new HTTPException(410, { message: error.message });
|
|
446
|
+
}
|
|
235
447
|
if (error instanceof SessionListCursorError) {
|
|
236
448
|
throw new HTTPException(400, { message: error.message });
|
|
237
449
|
}
|
|
450
|
+
if (error instanceof SessionListSnapshotLimitError) {
|
|
451
|
+
c.header("Retry-After", "5");
|
|
452
|
+
throw new HTTPException(429, { message: error.message });
|
|
453
|
+
}
|
|
238
454
|
throw error;
|
|
239
455
|
}
|
|
240
456
|
// The page body carries this fact directly. Preserve the historical array
|
|
241
457
|
// body for older clients while still making its older-pin omission visible
|
|
242
458
|
// to raw HTTP consumers without changing that response shape.
|
|
243
459
|
c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
|
|
460
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
461
|
+
const decorate = (session: Session): Session =>
|
|
462
|
+
sessionWithEffectiveToolPolicy(
|
|
463
|
+
session,
|
|
464
|
+
policy.workspaceServerIds,
|
|
465
|
+
policy.workspaceDefaultServerIds,
|
|
466
|
+
);
|
|
244
467
|
if (pageView) {
|
|
245
|
-
return c.json(
|
|
468
|
+
return c.json({
|
|
469
|
+
...page,
|
|
470
|
+
pinned: page.pinned.map(decorate),
|
|
471
|
+
sessions: page.sessions.map(decorate),
|
|
472
|
+
});
|
|
246
473
|
}
|
|
247
474
|
// Same-major compatibility: listSessions() has historically returned an
|
|
248
475
|
// array. Preserve that wire shape while adding personal pin metadata/order;
|
|
249
476
|
// cursor consumers opt into the additive page view. A query flag rather
|
|
250
477
|
// than a /sessions/page path is deliberate: an older API safely ignores it
|
|
251
478
|
// and returns its historical array instead of treating "page" as a UUID.
|
|
252
|
-
return c.json([...page.pinned, ...page.sessions]);
|
|
479
|
+
return c.json([...page.pinned, ...page.sessions].map(decorate));
|
|
253
480
|
});
|
|
254
481
|
|
|
255
482
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
|
|
@@ -269,7 +496,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
269
496
|
if (!session) {
|
|
270
497
|
throw new HTTPException(404, { message: "session not found" });
|
|
271
498
|
}
|
|
272
|
-
return c.json(session);
|
|
499
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
273
500
|
});
|
|
274
501
|
|
|
275
502
|
// Personal pin only: this is organization state for the authenticated member,
|
|
@@ -296,7 +523,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
296
523
|
if (!session) {
|
|
297
524
|
throw new HTTPException(404, { message: "session not found" });
|
|
298
525
|
}
|
|
299
|
-
return c.json(
|
|
526
|
+
return c.json(
|
|
527
|
+
await withEffectivePolicy(
|
|
528
|
+
deps,
|
|
529
|
+
workspaceId,
|
|
530
|
+
projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
|
|
531
|
+
),
|
|
532
|
+
);
|
|
300
533
|
} catch (error) {
|
|
301
534
|
if (error instanceof SessionPinAccessError) {
|
|
302
535
|
throw new HTTPException(403, { message: error.message });
|
|
@@ -317,7 +550,19 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
317
550
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
|
|
318
551
|
const workspaceId = c.req.param("workspaceId");
|
|
319
552
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
320
|
-
|
|
553
|
+
const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
|
|
554
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
555
|
+
return c.json({
|
|
556
|
+
...lineage,
|
|
557
|
+
ancestors: lineage.ancestors.map((session) =>
|
|
558
|
+
sessionWithEffectiveToolPolicy(
|
|
559
|
+
session,
|
|
560
|
+
policy.workspaceServerIds,
|
|
561
|
+
policy.workspaceDefaultServerIds,
|
|
562
|
+
),
|
|
563
|
+
),
|
|
564
|
+
children: mapLineageNodes(lineage.children, policy),
|
|
565
|
+
});
|
|
321
566
|
});
|
|
322
567
|
|
|
323
568
|
// Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
|
|
@@ -397,15 +642,41 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
397
642
|
if (!session) {
|
|
398
643
|
throw new HTTPException(404, { message: "session not found" });
|
|
399
644
|
}
|
|
400
|
-
return c.json(session);
|
|
645
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
401
646
|
});
|
|
402
647
|
|
|
648
|
+
app.patch(
|
|
649
|
+
"/v1/workspaces/:workspaceId/sessions/:sessionId/mcp-servers/:serverId/approval-policy",
|
|
650
|
+
async (c) => {
|
|
651
|
+
const workspaceId = c.req.param("workspaceId");
|
|
652
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
653
|
+
const sessionId = c.req.param("sessionId");
|
|
654
|
+
const parsedServerId = SessionMcpServerId.safeParse(c.req.param("serverId"));
|
|
655
|
+
const payload = UpdateSessionMcpApprovalPolicyRequest.safeParse(
|
|
656
|
+
await c.req.json().catch(() => null),
|
|
657
|
+
);
|
|
658
|
+
if (!parsedServerId.success || !payload.success) {
|
|
659
|
+
throw new HTTPException(400, { message: "invalid MCP approval-policy request" });
|
|
660
|
+
}
|
|
661
|
+
await assertSessionExists(db, workspaceId, sessionId);
|
|
662
|
+
return c.json(
|
|
663
|
+
await updateSessionMcpApprovalPolicy(
|
|
664
|
+
deps,
|
|
665
|
+
grant,
|
|
666
|
+
sessionId,
|
|
667
|
+
parsedServerId.data,
|
|
668
|
+
payload.data.requireApproval,
|
|
669
|
+
),
|
|
670
|
+
);
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
|
|
403
674
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
|
|
404
675
|
const workspaceId = c.req.param("workspaceId");
|
|
405
676
|
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
406
677
|
const sessionId = c.req.param("sessionId");
|
|
407
678
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
408
|
-
const goal = await
|
|
679
|
+
const goal = await getSessionGoalWithContinuation(db, workspaceId, sessionId);
|
|
409
680
|
if (!goal) {
|
|
410
681
|
throw new HTTPException(404, { message: "session goal not found" });
|
|
411
682
|
}
|
|
@@ -428,27 +699,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
428
699
|
});
|
|
429
700
|
}
|
|
430
701
|
if (payload.status === "paused") {
|
|
431
|
-
const { goal,
|
|
702
|
+
const { goal, events } = await setSessionGoalStatusWithEvent(db, workspaceId, sessionId, {
|
|
432
703
|
status: "paused",
|
|
433
704
|
...(payload.rationale ? { rationale: payload.rationale } : {}),
|
|
434
705
|
pausedReason: "api",
|
|
706
|
+
event: {
|
|
707
|
+
type: "goal.paused",
|
|
708
|
+
actor: "api",
|
|
709
|
+
reason: "api",
|
|
710
|
+
...(payload.rationale ? { rationale: payload.rationale } : {}),
|
|
711
|
+
},
|
|
435
712
|
});
|
|
436
|
-
if (
|
|
437
|
-
await
|
|
438
|
-
{
|
|
439
|
-
type: "goal.paused",
|
|
440
|
-
payload: {
|
|
441
|
-
goalId: goal.id,
|
|
442
|
-
actor: "api",
|
|
443
|
-
reason: "api",
|
|
444
|
-
...(payload.rationale ? { rationale: payload.rationale } : {}),
|
|
445
|
-
autoContinuations: goal.autoContinuations,
|
|
446
|
-
noProgressStreak: goal.noProgressStreak,
|
|
447
|
-
},
|
|
448
|
-
},
|
|
449
|
-
]);
|
|
713
|
+
if (events.length > 0) {
|
|
714
|
+
await bus.publish(workspaceId, sessionId, events);
|
|
450
715
|
}
|
|
451
|
-
return c.json(goal);
|
|
716
|
+
return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
|
|
452
717
|
}
|
|
453
718
|
// Resume: only valid from paused; resets counters and re-arms the loop.
|
|
454
719
|
if (existing.status !== "paused") {
|
|
@@ -456,32 +721,24 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
456
721
|
message: `session goal is ${existing.status}; only paused goals can be resumed`,
|
|
457
722
|
});
|
|
458
723
|
}
|
|
459
|
-
const { goal, changed, workflowWakeRevision } = await
|
|
724
|
+
const { goal, changed, workflowWakeRevision, events } = await setSessionGoalStatusWithEvent(
|
|
460
725
|
db,
|
|
461
726
|
workspaceId,
|
|
462
727
|
sessionId,
|
|
463
728
|
{
|
|
464
729
|
status: "active",
|
|
730
|
+
event: { type: "goal.resumed", actor: "api" },
|
|
465
731
|
},
|
|
466
732
|
);
|
|
467
733
|
// `changed` guards the racing-PATCH case: both requests can pass the
|
|
468
734
|
// status pre-check, but only the transition winner emits and wakes.
|
|
469
735
|
if (changed) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
|
|
477
|
-
version: goal.version,
|
|
478
|
-
actor: "api",
|
|
479
|
-
},
|
|
480
|
-
},
|
|
481
|
-
]);
|
|
482
|
-
// signalWithStart restarts an eligible idle workflow so maybeContinueGoal
|
|
483
|
-
// fires. A closed workspace/session gate keeps the resumed goal durable
|
|
484
|
-
// and inert until that gate's own Resume mutation commits its wake.
|
|
736
|
+
if (events.length > 0) {
|
|
737
|
+
await bus.publish(workspaceId, sessionId, events);
|
|
738
|
+
}
|
|
739
|
+
// signalWithStart restarts an eligible idle workflow so the durable goal
|
|
740
|
+
// revision is evaluated. A closed workspace/session gate keeps the
|
|
741
|
+
// revision inert until that gate's own Resume mutation commits its wake.
|
|
485
742
|
if (workflowWakeRevision !== null) {
|
|
486
743
|
await workflowClient.wakeSessionWorkflow({
|
|
487
744
|
accountId: grant.accountId,
|
|
@@ -492,7 +749,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
492
749
|
});
|
|
493
750
|
}
|
|
494
751
|
}
|
|
495
|
-
return c.json(goal);
|
|
752
|
+
return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
|
|
496
753
|
});
|
|
497
754
|
|
|
498
755
|
app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
|
|
@@ -597,12 +854,27 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
597
854
|
"mode",
|
|
598
855
|
explicitReplay ? "forensic" : "monitoring",
|
|
599
856
|
);
|
|
600
|
-
const
|
|
857
|
+
const latestRequested = eventEnumValue(
|
|
601
858
|
c.req.query("latest"),
|
|
602
|
-
|
|
859
|
+
SessionEventLatestClass,
|
|
603
860
|
"latest",
|
|
604
861
|
undefined,
|
|
605
862
|
);
|
|
863
|
+
const latestClass =
|
|
864
|
+
latestRequested === undefined
|
|
865
|
+
? undefined
|
|
866
|
+
: sessionEventLatestClassToSemanticClass(latestRequested);
|
|
867
|
+
const resultMode = eventEnumValue(
|
|
868
|
+
c.req.query("resultMode") ?? c.req.query("result"),
|
|
869
|
+
SessionEventResultMode,
|
|
870
|
+
"resultMode",
|
|
871
|
+
"events",
|
|
872
|
+
);
|
|
873
|
+
if (resultMode === "compact" && latestClass === undefined) {
|
|
874
|
+
throw new HTTPException(400, {
|
|
875
|
+
message: "resultMode=compact requires latest",
|
|
876
|
+
});
|
|
877
|
+
}
|
|
606
878
|
if (
|
|
607
879
|
latestClass &&
|
|
608
880
|
["includeTypes", "excludeTypes", "includeClasses", "excludeClasses"].some(
|
|
@@ -660,19 +932,39 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
660
932
|
compact ? 5000 : mode === "monitoring" ? 250 : 2000,
|
|
661
933
|
mode === "monitoring" ? 40 : 500,
|
|
662
934
|
);
|
|
935
|
+
const dbPayloadMode = resultMode === "compact" ? ("full" as const) : payloadMode;
|
|
663
936
|
const dbPage = await listSessionEventPage(db, workspaceId, sessionId, {
|
|
664
937
|
after,
|
|
665
938
|
...(before !== undefined ? { before } : {}),
|
|
666
939
|
limit,
|
|
667
940
|
direction,
|
|
668
|
-
payloadMode,
|
|
941
|
+
payloadMode: dbPayloadMode,
|
|
669
942
|
includeTypes,
|
|
670
943
|
excludeTypes,
|
|
671
944
|
includeClasses: latestClass ? [latestClass] : includeClasses,
|
|
672
945
|
excludeClasses,
|
|
673
946
|
...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
|
|
947
|
+
...(latestClass ? { authoritativeLatest: true } : {}),
|
|
674
948
|
});
|
|
675
949
|
const events = dbPage.events;
|
|
950
|
+
if (resultMode === "compact") {
|
|
951
|
+
const event = events[0];
|
|
952
|
+
c.header("X-OpenGeni-Event-Result-Mode", "compact");
|
|
953
|
+
c.header("X-OpenGeni-Event-Result", event ? "found" : "not_found");
|
|
954
|
+
c.header("X-OpenGeni-Event-Mode", mode);
|
|
955
|
+
c.header("X-OpenGeni-Event-Direction", direction);
|
|
956
|
+
c.header("X-OpenGeni-Payload-Mode", "full");
|
|
957
|
+
c.header("X-OpenGeni-Forensic-Exact", "false");
|
|
958
|
+
if (!event) return c.json(null, 200);
|
|
959
|
+
const result = compactSessionEventResult(
|
|
960
|
+
event,
|
|
961
|
+
latestClass!,
|
|
962
|
+
dbPage.coveredSequence ?? { first: event.sequence, last: event.sequence },
|
|
963
|
+
);
|
|
964
|
+
c.header("X-OpenGeni-Covered-First", String(result.coveredSequence.first));
|
|
965
|
+
c.header("X-OpenGeni-Covered-Last", String(result.coveredSequence.last));
|
|
966
|
+
return c.json(result);
|
|
967
|
+
}
|
|
676
968
|
const projected = compact ? coalesceSessionEventDeltas(events) : events;
|
|
677
969
|
const page = boundSessionEventHttpPage(projected, {
|
|
678
970
|
direction,
|
|
@@ -1250,6 +1542,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1250
1542
|
os: session.sandboxOs,
|
|
1251
1543
|
liveness: lease?.liveness ?? "cold",
|
|
1252
1544
|
leaseEpoch: lease?.leaseEpoch ?? 0,
|
|
1545
|
+
workspaceGeneration: lease?.workspaceGeneration ?? null,
|
|
1546
|
+
archiveGeneration: lease?.archiveGeneration ?? null,
|
|
1547
|
+
archiveComplete: lease?.archiveComplete ?? false,
|
|
1253
1548
|
desktopEnabled: settings.sandboxDesktopEnabled,
|
|
1254
1549
|
// Human take-control: when the desktop is available + this policy is on
|
|
1255
1550
|
// (default), the cell is mode "interactive" — the noVNC viewer drives :0
|
|
@@ -1437,6 +1732,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1437
1732
|
viewerId,
|
|
1438
1733
|
liveness: "warm",
|
|
1439
1734
|
leaseEpoch: session.activeEpoch,
|
|
1735
|
+
workspaceGeneration: null,
|
|
1736
|
+
archiveGeneration: null,
|
|
1737
|
+
archiveComplete: false,
|
|
1440
1738
|
sandboxGroupId: session.sandboxGroupId,
|
|
1441
1739
|
viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
|
|
1442
1740
|
dataPlaneUrl: null,
|
|
@@ -1446,40 +1744,31 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1446
1744
|
!streamTokenDegraded(settings)
|
|
1447
1745
|
) {
|
|
1448
1746
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
1449
|
-
stream = await mintDesktopStream(
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
1457
|
-
},
|
|
1458
|
-
);
|
|
1747
|
+
stream = await mintDesktopStream(viewerServices, {
|
|
1748
|
+
accountId: grant.accountId,
|
|
1749
|
+
workspaceId,
|
|
1750
|
+
session,
|
|
1751
|
+
viewerId,
|
|
1752
|
+
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
1753
|
+
});
|
|
1459
1754
|
}
|
|
1460
1755
|
if (settings.sandboxTerminalEnabled) {
|
|
1461
|
-
terminal = await mintTerminalStream(
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
1469
|
-
},
|
|
1470
|
-
);
|
|
1756
|
+
terminal = await mintTerminalStream(viewerServices, {
|
|
1757
|
+
accountId: grant.accountId,
|
|
1758
|
+
workspaceId,
|
|
1759
|
+
session,
|
|
1760
|
+
viewerId,
|
|
1761
|
+
// No Modal lease for selfhosted-active; the mint routes to the relay.
|
|
1762
|
+
});
|
|
1471
1763
|
}
|
|
1472
1764
|
}
|
|
1473
1765
|
} else {
|
|
1474
|
-
result = await attachViewer(
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
|
|
1481
|
-
},
|
|
1482
|
-
);
|
|
1766
|
+
result = await attachViewer(viewerServices, {
|
|
1767
|
+
accountId: grant.accountId,
|
|
1768
|
+
workspaceId,
|
|
1769
|
+
session,
|
|
1770
|
+
...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
|
|
1771
|
+
});
|
|
1483
1772
|
|
|
1484
1773
|
// P4.2 — the viewer now holds a WARM box; mint the real pixel cell IN-PROCESS
|
|
1485
1774
|
// (resume by id → ensureDisplayStack → exposeStreamPort) scoped to THIS
|
|
@@ -1501,31 +1790,25 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1501
1790
|
// (and consented above). A terminal-only attach skips it — the box is warm,
|
|
1502
1791
|
// the terminal mint below still runs.
|
|
1503
1792
|
if (wantDesktop && settings.sandboxDesktopEnabled) {
|
|
1504
|
-
stream = await mintDesktopStream(
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
lease,
|
|
1512
|
-
},
|
|
1513
|
-
);
|
|
1793
|
+
stream = await mintDesktopStream(viewerServices, {
|
|
1794
|
+
accountId: grant.accountId,
|
|
1795
|
+
workspaceId,
|
|
1796
|
+
session,
|
|
1797
|
+
viewerId: result.viewerId,
|
|
1798
|
+
lease,
|
|
1799
|
+
});
|
|
1514
1800
|
}
|
|
1515
1801
|
// P5.t — the same warm-box viewer attach also mints the REAL PTY terminal
|
|
1516
1802
|
// address (independent of the desktop toggle). A degraded mint leaves the
|
|
1517
1803
|
// terminal fields null → the client falls back to the sse-events firehose.
|
|
1518
1804
|
if (settings.sandboxTerminalEnabled) {
|
|
1519
|
-
terminal = await mintTerminalStream(
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
lease,
|
|
1527
|
-
},
|
|
1528
|
-
);
|
|
1805
|
+
terminal = await mintTerminalStream(viewerServices, {
|
|
1806
|
+
accountId: grant.accountId,
|
|
1807
|
+
workspaceId,
|
|
1808
|
+
session,
|
|
1809
|
+
viewerId: result.viewerId,
|
|
1810
|
+
lease,
|
|
1811
|
+
});
|
|
1529
1812
|
}
|
|
1530
1813
|
}
|
|
1531
1814
|
}
|
|
@@ -1866,23 +2149,83 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1866
2149
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
|
|
1867
2150
|
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
1868
2151
|
const req = await parseChannelABody(c, PtyOpenRequest);
|
|
2152
|
+
if (ctx.session.sandboxBackend === "selfhosted" || ctx.session.activeSandboxId !== null) {
|
|
2153
|
+
throw new HTTPException(409, {
|
|
2154
|
+
message:
|
|
2155
|
+
"durable interactive terminals require the session-home provider route and are unavailable on active swaps or non-persistable routes; use synchronous exec or attach the session home sandbox",
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
1869
2158
|
const ptyId = crypto.randomUUID();
|
|
1870
|
-
const out = await withChannelA({ db, settings, bus }, ctx, async (
|
|
2159
|
+
const out = await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2160
|
+
const { service } = handle;
|
|
1871
2161
|
const opened = await service.ptyOpen(req, ptyId);
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2162
|
+
const execSessionId = opened.execSessionId;
|
|
2163
|
+
const retained =
|
|
2164
|
+
execSessionId === null
|
|
2165
|
+
? null
|
|
2166
|
+
: handle.routingSession.retainedProcessIdentity(execSessionId);
|
|
2167
|
+
const process = retained
|
|
2168
|
+
? await getRetainedProcess(db, {
|
|
2169
|
+
workspaceId: ctx.workspaceId,
|
|
2170
|
+
sessionId: ctx.session.id,
|
|
2171
|
+
processId: retained.id,
|
|
2172
|
+
})
|
|
2173
|
+
: null;
|
|
2174
|
+
if (
|
|
2175
|
+
execSessionId === null ||
|
|
2176
|
+
!retained ||
|
|
2177
|
+
!process ||
|
|
2178
|
+
process.state !== "active" ||
|
|
2179
|
+
process.ownerActorKind !== "direct" ||
|
|
2180
|
+
process.providerSessionId !== execSessionId ||
|
|
2181
|
+
process.routeTargetId !== null ||
|
|
2182
|
+
process.leaseId !== handle.lease.id ||
|
|
2183
|
+
process.sandboxGroupId !== handle.lease.sandboxGroupId ||
|
|
2184
|
+
process.leaseEpoch !== handle.lease.leaseEpoch ||
|
|
2185
|
+
process.providerBackend !== handle.lease.backend ||
|
|
2186
|
+
process.providerInstanceId !== handle.lease.instanceId
|
|
2187
|
+
) {
|
|
2188
|
+
if (execSessionId !== null && handle.routingSession.hasRetainedProcess(execSessionId)) {
|
|
2189
|
+
await drainOpenedPty(handle, execSessionId);
|
|
2190
|
+
}
|
|
2191
|
+
throw new HTTPException(409, {
|
|
2192
|
+
message: "interactive terminal did not acquire durable process authority",
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
const identity: SandboxPtyProcessIdentity = {
|
|
2196
|
+
leaseId: process.leaseId,
|
|
2197
|
+
sandboxGroupId: process.sandboxGroupId,
|
|
2198
|
+
retainedProcessId: process.id,
|
|
2199
|
+
openAdmissionId: process.parentAdmissionId,
|
|
2200
|
+
execSessionId: process.providerSessionId,
|
|
2201
|
+
leaseEpoch: process.leaseEpoch,
|
|
2202
|
+
providerBackend: process.providerBackend,
|
|
2203
|
+
providerInstanceId: process.providerInstanceId,
|
|
2204
|
+
routeKind: process.routeKind,
|
|
2205
|
+
routeTargetId: process.routeTargetId,
|
|
2206
|
+
routeEpoch: process.routeEpoch,
|
|
2207
|
+
};
|
|
2208
|
+
try {
|
|
2209
|
+
await insertPtySession(db, {
|
|
2210
|
+
id: ptyId,
|
|
2211
|
+
accountId: ctx.accountId,
|
|
2212
|
+
workspaceId: ctx.workspaceId,
|
|
2213
|
+
sessionId: ctx.session.id,
|
|
2214
|
+
identity,
|
|
2215
|
+
cols: req.cols,
|
|
2216
|
+
rows: req.rows,
|
|
2217
|
+
shell: opened.shell,
|
|
2218
|
+
cwd: req.cwd,
|
|
2219
|
+
openedBy: ctx.subjectId,
|
|
2220
|
+
});
|
|
2221
|
+
} catch (persistenceError) {
|
|
2222
|
+
try {
|
|
2223
|
+
await drainOpenedPty(handle, execSessionId);
|
|
2224
|
+
} catch (drainError) {
|
|
2225
|
+
failPtyPersistenceAndDrain(persistenceError, drainError);
|
|
2226
|
+
}
|
|
2227
|
+
throw persistenceError;
|
|
2228
|
+
}
|
|
1886
2229
|
// Emit terminal.pty.started + any initial banner output on A1.
|
|
1887
2230
|
const started: TerminalPtyStartedPayload = {
|
|
1888
2231
|
ptyId,
|
|
@@ -1910,24 +2253,52 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1910
2253
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
|
|
1911
2254
|
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
1912
2255
|
const req = await parseChannelABody(c, PtyWriteRequest);
|
|
1913
|
-
const pty = await getOpenPtySession(db,
|
|
2256
|
+
const pty = await getOpenPtySession(db, {
|
|
2257
|
+
workspaceId: ctx.workspaceId,
|
|
2258
|
+
sessionId: ctx.session.id,
|
|
2259
|
+
ptyId: req.ptyId,
|
|
2260
|
+
});
|
|
1914
2261
|
if (!pty) {
|
|
1915
2262
|
throw new HTTPException(404, { message: "pty not found or closed" });
|
|
1916
2263
|
}
|
|
1917
|
-
if (pty.execSessionId === null) {
|
|
1918
|
-
throw new HTTPException(409, {
|
|
1919
|
-
message: "interactive terminal unsupported on this backend",
|
|
1920
|
-
});
|
|
1921
|
-
}
|
|
1922
2264
|
let seq = 1;
|
|
1923
|
-
await withChannelA({ db, settings, bus }, ctx, async (
|
|
1924
|
-
|
|
1925
|
-
|
|
2265
|
+
await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2266
|
+
await adoptPtyProcess(ctx, handle, pty);
|
|
2267
|
+
let output: string;
|
|
2268
|
+
try {
|
|
2269
|
+
output = await handle.service.ptyWrite(req, pty.execSessionId, req.data);
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
const terminal = await getRetainedProcess(db, {
|
|
2272
|
+
workspaceId: ctx.workspaceId,
|
|
2273
|
+
sessionId: ctx.session.id,
|
|
2274
|
+
processId: pty.retainedProcessId,
|
|
2275
|
+
});
|
|
2276
|
+
if (terminal && terminal.state !== "active") {
|
|
2277
|
+
await emitPtyExited(ctx, req.ptyId, terminal);
|
|
2278
|
+
}
|
|
2279
|
+
throw error;
|
|
2280
|
+
}
|
|
2281
|
+
const updated = await updatePtySessionActivity(db, {
|
|
1926
2282
|
accountId: ctx.accountId,
|
|
1927
2283
|
workspaceId: ctx.workspaceId,
|
|
2284
|
+
sessionId: ctx.session.id,
|
|
1928
2285
|
ptyId: req.ptyId,
|
|
1929
|
-
|
|
2286
|
+
identity: ptyIdentity(pty),
|
|
1930
2287
|
});
|
|
2288
|
+
if (!updated) {
|
|
2289
|
+
const terminal = await getRetainedProcess(db, {
|
|
2290
|
+
workspaceId: ctx.workspaceId,
|
|
2291
|
+
sessionId: ctx.session.id,
|
|
2292
|
+
processId: pty.retainedProcessId,
|
|
2293
|
+
});
|
|
2294
|
+
if (terminal && terminal.state !== "active") {
|
|
2295
|
+
await emitPtyExited(ctx, req.ptyId, terminal);
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
throw new HTTPException(409, {
|
|
2299
|
+
message: "pty identity changed while input was in flight; reopen the terminal",
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
1931
2302
|
if (output) {
|
|
1932
2303
|
const delta: TerminalPtyOutputDeltaPayload = {
|
|
1933
2304
|
ptyId: req.ptyId,
|
|
@@ -1946,21 +2317,31 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1946
2317
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
|
|
1947
2318
|
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
1948
2319
|
const req = await parseChannelABody(c, PtyResizeRequest);
|
|
1949
|
-
const pty = await getOpenPtySession(db,
|
|
2320
|
+
const pty = await getOpenPtySession(db, {
|
|
2321
|
+
workspaceId: ctx.workspaceId,
|
|
2322
|
+
sessionId: ctx.session.id,
|
|
2323
|
+
ptyId: req.ptyId,
|
|
2324
|
+
});
|
|
1950
2325
|
if (!pty) {
|
|
1951
2326
|
throw new HTTPException(404, { message: "pty not found or closed" });
|
|
1952
2327
|
}
|
|
1953
|
-
|
|
1954
|
-
await
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
2328
|
+
await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2329
|
+
await adoptPtyProcess(ctx, handle, pty);
|
|
2330
|
+
await handle.service.ptyResize(req, pty.execSessionId);
|
|
2331
|
+
const updated = await updatePtySessionActivity(db, {
|
|
2332
|
+
accountId: ctx.accountId,
|
|
2333
|
+
workspaceId: ctx.workspaceId,
|
|
2334
|
+
sessionId: ctx.session.id,
|
|
2335
|
+
ptyId: req.ptyId,
|
|
2336
|
+
identity: ptyIdentity(pty),
|
|
2337
|
+
cols: req.cols,
|
|
2338
|
+
rows: req.rows,
|
|
2339
|
+
});
|
|
2340
|
+
if (!updated) {
|
|
2341
|
+
throw new HTTPException(409, {
|
|
2342
|
+
message: "pty identity changed while resize was in flight; reopen the terminal",
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
1964
2345
|
});
|
|
1965
2346
|
return c.body(null, 204);
|
|
1966
2347
|
});
|
|
@@ -1968,25 +2349,28 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1968
2349
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
|
|
1969
2350
|
const ctx = await channelAPreamble(c, "terminal:attach");
|
|
1970
2351
|
const req = await parseChannelABody(c, PtyCloseRequest);
|
|
1971
|
-
const pty = await getOpenPtySession(db,
|
|
2352
|
+
const pty = await getOpenPtySession(db, {
|
|
2353
|
+
workspaceId: ctx.workspaceId,
|
|
2354
|
+
sessionId: ctx.session.id,
|
|
2355
|
+
ptyId: req.ptyId,
|
|
2356
|
+
});
|
|
1972
2357
|
// Idempotent: closing an already-closed/absent PTY is a 204 no-op.
|
|
1973
2358
|
if (pty) {
|
|
1974
|
-
await withChannelA({ db, settings, bus }, ctx, (
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
2359
|
+
await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2360
|
+
await adoptPtyProcess(ctx, handle, pty);
|
|
2361
|
+
await handle.service.ptyClose(req, pty.execSessionId);
|
|
2362
|
+
const terminal = await getRetainedProcess(db, {
|
|
2363
|
+
workspaceId: ctx.workspaceId,
|
|
2364
|
+
sessionId: ctx.session.id,
|
|
2365
|
+
processId: pty.retainedProcessId,
|
|
2366
|
+
});
|
|
2367
|
+
if (!terminal || terminal.state === "active") {
|
|
2368
|
+
throw new HTTPException(409, {
|
|
2369
|
+
message: "pty close is pending exact provider exit proof; retry",
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
await emitPtyExited(ctx, req.ptyId, terminal);
|
|
1981
2373
|
});
|
|
1982
|
-
const exited: TerminalPtyExitedPayload = {
|
|
1983
|
-
ptyId: req.ptyId,
|
|
1984
|
-
exitCode: 0,
|
|
1985
|
-
reason: "exit",
|
|
1986
|
-
};
|
|
1987
|
-
await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
|
|
1988
|
-
{ type: "terminal.pty.exited", payload: exited },
|
|
1989
|
-
]);
|
|
1990
2374
|
}
|
|
1991
2375
|
return c.body(null, 204);
|
|
1992
2376
|
});
|
|
@@ -2022,6 +2406,9 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
2022
2406
|
return null;
|
|
2023
2407
|
}
|
|
2024
2408
|
if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
|
|
2409
|
+
if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
|
|
2410
|
+
return "session.mcp.approval_policy.write";
|
|
2411
|
+
}
|
|
2025
2412
|
if (suffix === "/lineage" && verb === "GET") return "session.lineage.read";
|
|
2026
2413
|
if (suffix === "/codex-account" && verb === "POST") {
|
|
2027
2414
|
return "session.codex_account.write";
|
|
@@ -2151,6 +2538,7 @@ function sessionListQuery(
|
|
|
2151
2538
|
parentSessionId: string | null | undefined;
|
|
2152
2539
|
cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
|
|
2153
2540
|
search: string | undefined;
|
|
2541
|
+
pinsOnly: boolean;
|
|
2154
2542
|
} {
|
|
2155
2543
|
const parentSessionId = query.parentSessionId;
|
|
2156
2544
|
// "null" = roots only; a uuid = children of that session; anything else is
|
|
@@ -2176,6 +2564,18 @@ function sessionListQuery(
|
|
|
2176
2564
|
message: "search must be at most 200 characters",
|
|
2177
2565
|
});
|
|
2178
2566
|
}
|
|
2567
|
+
if (query.pinsOnly !== undefined && query.pinsOnly !== "true") {
|
|
2568
|
+
throw new HTTPException(400, { message: 'pinsOnly must be the literal "true"' });
|
|
2569
|
+
}
|
|
2570
|
+
const pinsOnly = query.pinsOnly === "true";
|
|
2571
|
+
if (pinsOnly && !allowCursor) {
|
|
2572
|
+
throw new HTTPException(400, { message: 'pinsOnly requires view="page"' });
|
|
2573
|
+
}
|
|
2574
|
+
if (pinsOnly && (rawCursor || parentSessionId !== undefined || search)) {
|
|
2575
|
+
throw new HTTPException(400, {
|
|
2576
|
+
message: "pinsOnly cannot be combined with cursor, parentSessionId, or search",
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2179
2579
|
return {
|
|
2180
2580
|
limit: query.limit,
|
|
2181
2581
|
parentSessionId:
|
|
@@ -2186,6 +2586,7 @@ function sessionListQuery(
|
|
|
2186
2586
|
: parentSessionId,
|
|
2187
2587
|
cursor,
|
|
2188
2588
|
search: search || undefined,
|
|
2589
|
+
pinsOnly,
|
|
2189
2590
|
};
|
|
2190
2591
|
}
|
|
2191
2592
|
|
|
@@ -2226,6 +2627,49 @@ function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean
|
|
|
2226
2627
|
return hasOwnProperty(payload, key);
|
|
2227
2628
|
}
|
|
2228
2629
|
|
|
2630
|
+
/** Stable, value-free JSON errors for only the create-session boundary. */
|
|
2631
|
+
export function sessionCreateErrorResponse(c: Context, error: unknown): Response {
|
|
2632
|
+
if (error instanceof SessionSpawnDeniedError) {
|
|
2633
|
+
return c.json(
|
|
2634
|
+
sessionSpawnDenialEnvelope(error),
|
|
2635
|
+
error.denial.code === "nested_agent_depth_override_forbidden" ? 403 : 409,
|
|
2636
|
+
);
|
|
2637
|
+
}
|
|
2638
|
+
if (error instanceof ZodError) {
|
|
2639
|
+
return c.json(
|
|
2640
|
+
{
|
|
2641
|
+
code: "INVALID_SESSION_CREATE_REQUEST",
|
|
2642
|
+
message: `Invalid session create request: ${zodErrorFields(error)} failed schema validation`,
|
|
2643
|
+
},
|
|
2644
|
+
422,
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
if (error instanceof HTTPException && error.status === 422) {
|
|
2648
|
+
return c.json(
|
|
2649
|
+
{
|
|
2650
|
+
code: "SESSION_CREATE_REJECTED",
|
|
2651
|
+
message: error.message,
|
|
2652
|
+
},
|
|
2653
|
+
422,
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
throw error;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
function zodErrorFields(error: ZodError): string {
|
|
2660
|
+
const paths = [
|
|
2661
|
+
...new Set(
|
|
2662
|
+
error.issues.map((issue) => {
|
|
2663
|
+
const path = issue.path.map(String).join(".");
|
|
2664
|
+
return path || "request";
|
|
2665
|
+
}),
|
|
2666
|
+
),
|
|
2667
|
+
];
|
|
2668
|
+
const shown = paths.slice(0, 5);
|
|
2669
|
+
const remainder = paths.length - shown.length;
|
|
2670
|
+
return `${shown.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2229
2673
|
function commandConflictResponse(c: Context, error: unknown): Response {
|
|
2230
2674
|
if (error instanceof QueueCommandConflictError) {
|
|
2231
2675
|
return c.json({ code: error.code, message: error.message, current: error.current }, 409);
|
|
@@ -2238,3 +2682,44 @@ function commandConflictResponse(c: Context, error: unknown): Response {
|
|
|
2238
2682
|
}
|
|
2239
2683
|
throw error;
|
|
2240
2684
|
}
|
|
2685
|
+
|
|
2686
|
+
type EffectivePolicyContext = {
|
|
2687
|
+
workspaceServerIds: string[];
|
|
2688
|
+
workspaceDefaultServerIds: string[];
|
|
2689
|
+
};
|
|
2690
|
+
|
|
2691
|
+
async function loadEffectivePolicyContext(
|
|
2692
|
+
deps: ApiRouteDeps,
|
|
2693
|
+
workspaceId: string,
|
|
2694
|
+
): Promise<EffectivePolicyContext> {
|
|
2695
|
+
const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
|
|
2696
|
+
workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
|
|
2697
|
+
workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
|
|
2698
|
+
]);
|
|
2699
|
+
return { workspaceServerIds, workspaceDefaultServerIds };
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
async function withEffectivePolicy(
|
|
2703
|
+
deps: ApiRouteDeps,
|
|
2704
|
+
workspaceId: string,
|
|
2705
|
+
session: Session,
|
|
2706
|
+
): Promise<Session> {
|
|
2707
|
+
const policy = await loadEffectivePolicyContext(deps, workspaceId);
|
|
2708
|
+
return sessionWithEffectiveToolPolicy(
|
|
2709
|
+
session,
|
|
2710
|
+
policy.workspaceServerIds,
|
|
2711
|
+
policy.workspaceDefaultServerIds,
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function mapLineageNodes(nodes: LineageNode[], policy: EffectivePolicyContext): LineageNode[] {
|
|
2716
|
+
return nodes.map((node) => ({
|
|
2717
|
+
...node,
|
|
2718
|
+
session: sessionWithEffectiveToolPolicy(
|
|
2719
|
+
node.session as Session,
|
|
2720
|
+
policy.workspaceServerIds,
|
|
2721
|
+
policy.workspaceDefaultServerIds,
|
|
2722
|
+
),
|
|
2723
|
+
children: mapLineageNodes(node.children, policy),
|
|
2724
|
+
}));
|
|
2725
|
+
}
|