@opengeni/contracts 0.1.0 → 0.3.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/src/index.ts CHANGED
@@ -10,9 +10,348 @@ export const SessionStatus = z.enum([
10
10
  ]);
11
11
  export type SessionStatus = z.infer<typeof SessionStatus>;
12
12
 
13
- export const SandboxBackend = z.enum(["docker", "modal", "local", "none"]);
13
+ // 10 backends; 3-way enum parity (contracts / sdk / deployment) is pinned by
14
+ // `packages/sdk/test/contract-parity.test.ts`. The existing four keep their
15
+ // positions; the six new backends are additive.
16
+ export const SandboxBackend = z.enum([
17
+ "docker",
18
+ "modal",
19
+ "local",
20
+ "none",
21
+ "daytona",
22
+ "runloop",
23
+ "e2b",
24
+ "blaxel",
25
+ "cloudflare",
26
+ "vercel",
27
+ ]);
14
28
  export type SandboxBackend = z.infer<typeof SandboxBackend>;
15
29
 
30
+ // OS axis. Only "linux" is reachable in v1; macos/windows are seam placeholders.
31
+ export const SandboxOs = z.enum(["linux", "macos", "windows"]);
32
+ export type SandboxOs = z.infer<typeof SandboxOs>;
33
+
34
+ // The five surfaceable sandbox capabilities (PascalCase, the canonical names).
35
+ export const SandboxCapabilityName = z.enum([
36
+ "FileSystem", // Channel A: list/read/write/search (Pierre tree)
37
+ "Terminal", // Channel A: command-output firehose (+ future pty-ws)
38
+ "Git", // Channel A: status/diff/log/show (Pierre diff)
39
+ "DesktopStream", // Channel B: noVNC pixels over a scoped tunnel URL
40
+ "Recording", // ffmpeg x11grab -> object storage
41
+ ]);
42
+ export type SandboxCapabilityName = z.infer<typeof SandboxCapabilityName>;
43
+
44
+ // How a backend exposes a network port to the data plane.
45
+ export type PortExposureKind = "provider-tunnel" | "preview-url" | "local-port" | "none";
46
+
47
+ // Static per-backend metadata — pure data, no runtime state. This table lives
48
+ // in CONTRACTS (not runtime) so config can read it without an import cycle
49
+ // through runtime (ledger CR8). Everything downstream (config boot-validation,
50
+ // OS image selection, capability negotiation, env/mount branch) reads this
51
+ // data, never a hard-coded backend name.
52
+ export type CapabilityDescriptor = {
53
+ backend: SandboxBackend;
54
+ backendId: string; // asserted === SDK client.backendId at registry build (deferred to P0.3)
55
+ tier: "desktop" | "headless" | "dev" | "none";
56
+ os: { supported: SandboxOs[]; default: SandboxOs };
57
+ capabilities: {
58
+ FileSystem: { available: boolean; readOnly: boolean };
59
+ Terminal: { available: boolean; transport: "sse-events" | "pty-ws" | null; pty: boolean };
60
+ Git: { available: boolean };
61
+ DesktopStream: { available: boolean; transport: "vnc-ws" | "rdp-ws" | "webrtc" | null };
62
+ // Feasibility only (== DesktopStream.available && os==linux); NOT a request.
63
+ Recording: { available: boolean };
64
+ };
65
+ lifetime: {
66
+ hardLifetimeMs?: number; // modal 24h, vercel 5h
67
+ requiresSnapshotRollover: boolean;
68
+ hasIdleKiller: boolean;
69
+ supportsSuspendResume: boolean; // runloop/e2b/vercel/modal true
70
+ resumeIsLockFree: boolean; // modal true (fromId, no lock)
71
+ idleKillDisableHint?: string;
72
+ };
73
+ snapshot: {
74
+ kind: "native-fs" | "native-dir" | "native-snapshot-id" | "tar-only" | "none";
75
+ hasTarFallback: boolean;
76
+ };
77
+ portExposure: { kind: PortExposureKind; supportsOnDemandPorts: boolean }; // runloop=false; blaxel only true
78
+ workspaceRoot: string; // os-overridable; per-backend default (providers owns; os defers)
79
+ nativeBucketMount: boolean; // modal true -> mount/signed-download branch
80
+ persistable: boolean;
81
+ supportsRunAs: boolean;
82
+ };
83
+
84
+ // The websockify/noVNC desktop port that is merged into `exposedPorts` for
85
+ // every desktop-capable (backend, os). Asserted present by boot-validation.
86
+ export const DESKTOP_STREAM_PORT = 6080;
87
+
88
+ // The ttyd PTY-over-websocket port that is exposed over the SAME Modal raw-TLS
89
+ // tunnel as the desktop, for the REAL interactive terminal (Channel-B-symmetric).
90
+ // ttyd's default; the box bakes ttyd and launches it on this port. The pty-ws
91
+ // Terminal cell's `url` is the tunnel address resolved against this port.
92
+ export const TERMINAL_STREAM_PORT = 7681;
93
+
94
+ // The Part-D matrix (master-spine PART D + module 03-providers). One row per
95
+ // backend (10 rows). v1 reachable cells are all Linux; macos/windows are seam
96
+ // placeholders (no enum members shipped). Reading rule: a capability cell is
97
+ // `available:false` + a reason in the negotiated doc, never absent.
98
+ export const CAPABILITY_DESCRIPTORS: Record<SandboxBackend, CapabilityDescriptor> = {
99
+ modal: {
100
+ backend: "modal",
101
+ backendId: "modal",
102
+ tier: "desktop",
103
+ os: { supported: ["linux"], default: "linux" },
104
+ capabilities: {
105
+ FileSystem: { available: true, readOnly: false },
106
+ Terminal: { available: true, transport: "sse-events", pty: true },
107
+ Git: { available: true },
108
+ DesktopStream: { available: true, transport: "vnc-ws" },
109
+ Recording: { available: true },
110
+ },
111
+ lifetime: {
112
+ hardLifetimeMs: 24 * 60 * 60 * 1000,
113
+ requiresSnapshotRollover: true,
114
+ hasIdleKiller: true,
115
+ supportsSuspendResume: true,
116
+ resumeIsLockFree: true,
117
+ },
118
+ snapshot: { kind: "native-fs", hasTarFallback: true },
119
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: false }, // pre-declare 6080
120
+ workspaceRoot: "/workspace",
121
+ nativeBucketMount: true,
122
+ persistable: true,
123
+ supportsRunAs: true,
124
+ },
125
+ daytona: {
126
+ backend: "daytona",
127
+ backendId: "daytona",
128
+ tier: "desktop",
129
+ os: { supported: ["linux"], default: "linux" },
130
+ capabilities: {
131
+ FileSystem: { available: true, readOnly: false },
132
+ Terminal: { available: true, transport: "sse-events", pty: true },
133
+ Git: { available: true },
134
+ DesktopStream: { available: true, transport: "vnc-ws" },
135
+ Recording: { available: true },
136
+ },
137
+ lifetime: {
138
+ requiresSnapshotRollover: false,
139
+ hasIdleKiller: true,
140
+ supportsSuspendResume: true,
141
+ resumeIsLockFree: false,
142
+ },
143
+ snapshot: { kind: "native-snapshot-id", hasTarFallback: true },
144
+ portExposure: { kind: "preview-url", supportsOnDemandPorts: false },
145
+ workspaceRoot: "/workspace",
146
+ nativeBucketMount: false,
147
+ persistable: true,
148
+ supportsRunAs: true,
149
+ },
150
+ runloop: {
151
+ backend: "runloop",
152
+ backendId: "runloop",
153
+ tier: "desktop",
154
+ os: { supported: ["linux"], default: "linux" },
155
+ capabilities: {
156
+ FileSystem: { available: true, readOnly: false },
157
+ Terminal: { available: true, transport: "sse-events", pty: false },
158
+ Git: { available: true },
159
+ DesktopStream: { available: true, transport: "vnc-ws" },
160
+ Recording: { available: true },
161
+ },
162
+ lifetime: {
163
+ requiresSnapshotRollover: false,
164
+ hasIdleKiller: true,
165
+ supportsSuspendResume: true,
166
+ resumeIsLockFree: false,
167
+ },
168
+ snapshot: { kind: "native-snapshot-id", hasTarFallback: true },
169
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: false }, // CR9: pre-declare 6080
170
+ workspaceRoot: "/workspace",
171
+ nativeBucketMount: false,
172
+ persistable: true,
173
+ supportsRunAs: false,
174
+ },
175
+ e2b: {
176
+ backend: "e2b",
177
+ backendId: "e2b",
178
+ tier: "desktop",
179
+ os: { supported: ["linux"], default: "linux" },
180
+ capabilities: {
181
+ FileSystem: { available: true, readOnly: false },
182
+ Terminal: { available: true, transport: "sse-events", pty: false }, // pty-until-proven=no
183
+ Git: { available: true },
184
+ DesktopStream: { available: true, transport: "vnc-ws" },
185
+ Recording: { available: true },
186
+ },
187
+ lifetime: {
188
+ requiresSnapshotRollover: false,
189
+ hasIdleKiller: true,
190
+ supportsSuspendResume: true,
191
+ resumeIsLockFree: false,
192
+ },
193
+ snapshot: { kind: "native-snapshot-id", hasTarFallback: true },
194
+ portExposure: { kind: "preview-url", supportsOnDemandPorts: false },
195
+ workspaceRoot: "/home/user",
196
+ nativeBucketMount: false,
197
+ persistable: true,
198
+ supportsRunAs: false,
199
+ },
200
+ blaxel: {
201
+ backend: "blaxel",
202
+ backendId: "blaxel",
203
+ tier: "desktop",
204
+ os: { supported: ["linux"], default: "linux" },
205
+ capabilities: {
206
+ FileSystem: { available: true, readOnly: false },
207
+ Terminal: { available: true, transport: "sse-events", pty: false }, // pty-until-proven=no
208
+ Git: { available: true },
209
+ DesktopStream: { available: true, transport: "vnc-ws" },
210
+ Recording: { available: true },
211
+ },
212
+ lifetime: {
213
+ requiresSnapshotRollover: false,
214
+ hasIdleKiller: true,
215
+ supportsSuspendResume: false,
216
+ resumeIsLockFree: false,
217
+ },
218
+ snapshot: { kind: "tar-only", hasTarFallback: true },
219
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: true }, // only on-demand backend
220
+ workspaceRoot: "/workspace",
221
+ nativeBucketMount: false,
222
+ persistable: true,
223
+ supportsRunAs: false,
224
+ },
225
+ cloudflare: {
226
+ backend: "cloudflare",
227
+ backendId: "cloudflare",
228
+ tier: "headless",
229
+ os: { supported: ["linux"], default: "linux" },
230
+ capabilities: {
231
+ FileSystem: { available: true, readOnly: false },
232
+ Terminal: { available: true, transport: "sse-events", pty: true },
233
+ Git: { available: true },
234
+ DesktopStream: { available: false, transport: null },
235
+ Recording: { available: false },
236
+ },
237
+ lifetime: {
238
+ requiresSnapshotRollover: false,
239
+ hasIdleKiller: true,
240
+ supportsSuspendResume: false,
241
+ resumeIsLockFree: false,
242
+ },
243
+ snapshot: { kind: "tar-only", hasTarFallback: true },
244
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: false },
245
+ workspaceRoot: "/workspace",
246
+ nativeBucketMount: false,
247
+ persistable: true,
248
+ supportsRunAs: true,
249
+ },
250
+ vercel: {
251
+ backend: "vercel",
252
+ backendId: "vercel",
253
+ tier: "headless",
254
+ os: { supported: ["linux"], default: "linux" },
255
+ capabilities: {
256
+ FileSystem: { available: true, readOnly: false },
257
+ Terminal: { available: true, transport: "sse-events", pty: false },
258
+ Git: { available: true },
259
+ DesktopStream: { available: false, transport: null },
260
+ Recording: { available: false },
261
+ },
262
+ lifetime: {
263
+ hardLifetimeMs: 5 * 60 * 60 * 1000,
264
+ requiresSnapshotRollover: true,
265
+ hasIdleKiller: true,
266
+ supportsSuspendResume: true,
267
+ resumeIsLockFree: false,
268
+ },
269
+ snapshot: { kind: "tar-only", hasTarFallback: true },
270
+ portExposure: { kind: "preview-url", supportsOnDemandPorts: false },
271
+ workspaceRoot: "/vercel/sandbox",
272
+ nativeBucketMount: false,
273
+ persistable: true,
274
+ supportsRunAs: false,
275
+ },
276
+ docker: {
277
+ backend: "docker",
278
+ backendId: "docker",
279
+ tier: "dev",
280
+ os: { supported: ["linux"], default: "linux" },
281
+ capabilities: {
282
+ FileSystem: { available: true, readOnly: false },
283
+ Terminal: { available: true, transport: "sse-events", pty: true },
284
+ Git: { available: true },
285
+ DesktopStream: { available: false, transport: null }, // local
286
+ Recording: { available: false },
287
+ },
288
+ lifetime: {
289
+ requiresSnapshotRollover: false,
290
+ hasIdleKiller: false,
291
+ supportsSuspendResume: false,
292
+ resumeIsLockFree: true,
293
+ },
294
+ snapshot: { kind: "native-dir", hasTarFallback: true },
295
+ portExposure: { kind: "local-port", supportsOnDemandPorts: false },
296
+ workspaceRoot: "/workspace",
297
+ nativeBucketMount: false,
298
+ persistable: true,
299
+ supportsRunAs: true,
300
+ },
301
+ local: {
302
+ backend: "local",
303
+ // The SDK's UnixLocalSandboxClient reports backendId "unix_local" — this MUST
304
+ // match it (it is the resume-fence field compared against client.backendId).
305
+ backendId: "unix_local",
306
+ tier: "dev",
307
+ os: { supported: ["linux"], default: "linux" },
308
+ capabilities: {
309
+ FileSystem: { available: true, readOnly: false },
310
+ Terminal: { available: true, transport: "sse-events", pty: true },
311
+ Git: { available: true },
312
+ DesktopStream: { available: false, transport: null },
313
+ Recording: { available: false },
314
+ },
315
+ lifetime: {
316
+ requiresSnapshotRollover: false,
317
+ hasIdleKiller: false,
318
+ supportsSuspendResume: false,
319
+ resumeIsLockFree: true,
320
+ },
321
+ snapshot: { kind: "native-dir", hasTarFallback: true },
322
+ portExposure: { kind: "local-port", supportsOnDemandPorts: false },
323
+ workspaceRoot: "/workspace",
324
+ nativeBucketMount: false,
325
+ persistable: false,
326
+ supportsRunAs: false,
327
+ },
328
+ none: {
329
+ backend: "none",
330
+ backendId: "none",
331
+ tier: "none",
332
+ os: { supported: ["linux"], default: "linux" },
333
+ capabilities: {
334
+ FileSystem: { available: false, readOnly: true },
335
+ Terminal: { available: false, transport: null, pty: false },
336
+ Git: { available: false },
337
+ DesktopStream: { available: false, transport: null },
338
+ Recording: { available: false },
339
+ },
340
+ lifetime: {
341
+ requiresSnapshotRollover: false,
342
+ hasIdleKiller: false,
343
+ supportsSuspendResume: false,
344
+ resumeIsLockFree: true,
345
+ },
346
+ snapshot: { kind: "none", hasTarFallback: false },
347
+ portExposure: { kind: "none", supportsOnDemandPorts: false },
348
+ workspaceRoot: "/workspace",
349
+ nativeBucketMount: false,
350
+ persistable: false,
351
+ supportsRunAs: false,
352
+ },
353
+ };
354
+
16
355
  export const ReasoningEffort = z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]);
17
356
  export type ReasoningEffort = z.infer<typeof ReasoningEffort>;
18
357
 
@@ -66,8 +405,28 @@ export const Permission = z.enum([
66
405
  "sessions:create",
67
406
  "sessions:read",
68
407
  "sessions:control",
408
+ // Sandbox-surfacing (master-spine §C.3 / crosscut PART 1.2). stream:view is a
409
+ // REAL, distinct permission — strictly BROADER than sessions:read — because the
410
+ // pixel plane (Channel B) is UN-REDACTED: a viewer of raw pixels can see cloud
411
+ // creds the agent cat's into a terminal, which the redacted Channel-A event log
412
+ // never exposes. sessions:read is NOT permission to watch raw pixels.
413
+ "stream:view",
414
+ // SEPARATE from stream:view: raw input to the desktop (bypasses approvalQueue /
415
+ // interrupt). NEVER granted by default in v1 (the input plane is OFF —
416
+ // streamControlEnabled=false); the permission exists so later hardening is a
417
+ // flag flip, not a redesign.
418
+ "stream:control",
419
+ // Accept the pixel-plane secret-leak acknowledgment (consent gate before the
420
+ // un-redacted desktop URL is handed out).
421
+ "stream:acknowledge",
69
422
  "files:upload",
70
423
  "files:read",
424
+ // Channel-A structured write surface (FS writes / apply-patch); distinct from
425
+ // files:read so a read-only viewer can't mutate the box filesystem.
426
+ "files:write",
427
+ // Attach to an interactive PTY (terminal-as-pty, Channel A); distinct from
428
+ // sessions:read which only reads the command-output firehose.
429
+ "terminal:attach",
71
430
  "documents:manage",
72
431
  "documents:search",
73
432
  "scheduled_tasks:manage",
@@ -196,6 +555,84 @@ export async function verifyDelegatedAccessToken(secret: string, token: string,
196
555
  return payload.data;
197
556
  }
198
557
 
558
+ // --- Scoped data-plane stream token (master-spine §C.3 / crosscut PART 1.3) ---
559
+ //
560
+ // REUSES the existing HMAC envelope (sign/verifyDelegatedAccessToken's
561
+ // base64Url + hmacSha256Base64Url) — NOT a second crypto — but with a distinct
562
+ // `ogs_` prefix and a HARD-NARROW claim set. The token is a CLAIM the OpenGeni
563
+ // control plane mints; it is NOT the provider's tunnel secret. The browser
564
+ // receives { providerUrl, streamToken }; the provider tunnel URL is the
565
+ // transport, the streamToken is what the in-box edge validates (websockify
566
+ // TokenFile is later-hardening; in v1 the URL's short TTL + the acknowledged
567
+ // stream:view gate are the real boundary). The token is minted + recorded
568
+ // against the holder from day one. It is NEVER appended to the URL as a query
569
+ // param (the provider's own scoped token already lives in the URL).
570
+ //
571
+ // `leaseEpoch` is the fence: when the box is re-elected (warming→warm bumps the
572
+ // epoch) the URL is re-minted with epoch+1 and the old tunnel is torn down, so a
573
+ // stale token points at a dead tunnel. Epoch mismatch is enforced at USE (by the
574
+ // caller comparing the claim against the live lease), not inside verify.
575
+ export const StreamTokenPayload = z.object({
576
+ workspaceId: z.string().uuid(),
577
+ sessionId: z.string().uuid(),
578
+ // Identifies the sandbox_lease_holders row (the viewer holder).
579
+ viewerId: z.string().uuid(),
580
+ // Fence: the token logically dies when the box is re-elected (epoch++).
581
+ leaseEpoch: z.number().int().nonnegative(),
582
+ // v1 is always "view"; "control" is the never-granted raw-input plane.
583
+ mode: z.enum(["view", "control"]),
584
+ // 6080 (noVNC); pins the token to ONE exposed port.
585
+ port: z.number().int().positive(),
586
+ // Short TTL (120s default); rotation is event-driven under the epoch fence,
587
+ // not on a keepalive clock.
588
+ exp: z.number().int().positive(),
589
+ });
590
+ export type StreamTokenPayload = z.infer<typeof StreamTokenPayload>;
591
+
592
+ export async function signStreamToken(secret: string, payload: StreamTokenPayload): Promise<string> {
593
+ const encodedPayload = base64UrlEncode(JSON.stringify(StreamTokenPayload.parse(payload)));
594
+ const signature = await hmacSha256Base64Url(secret, encodedPayload);
595
+ return `ogs_${encodedPayload}.${signature}`;
596
+ }
597
+
598
+ /**
599
+ * Verify a stream token: rejects (returns null) on a bad prefix, malformed
600
+ * envelope, bad HMAC signature (constant-time), schema-invalid claims, or an
601
+ * expired token (`exp < now`). Mirrors verifyDelegatedAccessToken exactly.
602
+ *
603
+ * The epoch fence (claim.leaseEpoch vs the LIVE lease epoch) and the
604
+ * workspace/session scope are checked by the CALLER at use against the live
605
+ * lease + route params — verify proves the token is authentic + unexpired, the
606
+ * caller proves it is for THIS box's current epoch and THIS workspace+session.
607
+ */
608
+ export async function verifyStreamToken(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<StreamTokenPayload | null> {
609
+ if (!token.startsWith("ogs_")) {
610
+ return null;
611
+ }
612
+ const withoutPrefix = token.slice("ogs_".length);
613
+ const dot = withoutPrefix.lastIndexOf(".");
614
+ if (dot <= 0) {
615
+ return null;
616
+ }
617
+ const encodedPayload = withoutPrefix.slice(0, dot);
618
+ const signature = withoutPrefix.slice(dot + 1);
619
+ const expected = await hmacSha256Base64Url(secret, encodedPayload);
620
+ if (!constantTimeEqual(signature, expected)) {
621
+ return null;
622
+ }
623
+ let decoded: unknown;
624
+ try {
625
+ decoded = JSON.parse(base64UrlDecode(encodedPayload));
626
+ } catch {
627
+ return null;
628
+ }
629
+ const payload = StreamTokenPayload.safeParse(decoded);
630
+ if (!payload.success || payload.data.exp < nowSeconds) {
631
+ return null;
632
+ }
633
+ return payload.data;
634
+ }
635
+
199
636
  export const CreateWorkspaceRequest = z.object({
200
637
  accountId: z.string().uuid().optional(),
201
638
  name: z.string().min(1),
@@ -246,6 +683,38 @@ export const CreateApiKeyResponse = z.object({
246
683
  });
247
684
  export type CreateApiKeyResponse = z.infer<typeof CreateApiKeyResponse>;
248
685
 
686
+ // A person (or API key) with access to a workspace: one workspace_memberships
687
+ // row. `subjectId` is `user:<betterAuthUserId>` or `api_key:<id>`; the People
688
+ // surface lists the `user:` subjects (api_key subjects belong to API keys).
689
+ export const WorkspaceMember = z.object({
690
+ subjectId: z.string().min(1),
691
+ subjectLabel: z.string().nullable(),
692
+ role: z.string(),
693
+ permissions: z.array(Permission),
694
+ createdAt: z.string(),
695
+ });
696
+ export type WorkspaceMember = z.infer<typeof WorkspaceMember>;
697
+
698
+ export const ListWorkspaceMembersResponse = z.object({
699
+ members: z.array(WorkspaceMember),
700
+ });
701
+ export type ListWorkspaceMembersResponse = z.infer<typeof ListWorkspaceMembersResponse>;
702
+
703
+ export const AddWorkspaceMemberRequest = z.object({
704
+ // Resolved against the managed (Better Auth) users; email invites for
705
+ // not-yet-registered users are deferred, so an unknown email returns 404.
706
+ email: z.string().email(),
707
+ role: z.string().min(1).optional(),
708
+ permissions: z.array(Permission),
709
+ });
710
+ export type AddWorkspaceMemberRequest = z.infer<typeof AddWorkspaceMemberRequest>;
711
+
712
+ export const UpdateWorkspaceMemberRequest = z.object({
713
+ role: z.string().min(1).optional(),
714
+ permissions: z.array(Permission),
715
+ });
716
+ export type UpdateWorkspaceMemberRequest = z.infer<typeof UpdateWorkspaceMemberRequest>;
717
+
249
718
  export const UsageEventType = z.enum([
250
719
  "agent_run.created",
251
720
  "agent_run.completed",
@@ -256,6 +725,15 @@ export const UsageEventType = z.enum([
256
725
  "document.indexed",
257
726
  "scheduled_task.fired",
258
727
  "api_key.request",
728
+ // --- sandbox warm-time metering (P2.1) ---
729
+ // Wall-clock seconds a box was warm — the billable warm-time meter. Accrued on
730
+ // the two stateless ticks (turn heartbeat + reaper sweep), idempotent on
731
+ // (sandbox_group_id, lease_epoch, tick) so a shared box (N sessions) is metered
732
+ // EXACTLY ONCE per tick (N sessions != N x bill). Orthogonal to model.tokens /
733
+ // model.cost (model API cost vs provider compute cost — both real, no overlap).
734
+ "sandbox.warm_seconds",
735
+ // usd_micros: warm-seconds x the per-provider per-second warm rate.
736
+ "sandbox.warm_cost",
259
737
  ]);
260
738
  export type UsageEventType = z.infer<typeof UsageEventType>;
261
739
 
@@ -616,6 +1094,11 @@ export const UpdateSessionGoalRequest = z.object({
616
1094
  });
617
1095
  export type UpdateSessionGoalRequest = z.infer<typeof UpdateSessionGoalRequest>;
618
1096
 
1097
+ export const UpdateSessionRequest = z.object({
1098
+ title: z.string().min(1).max(200),
1099
+ });
1100
+ export type UpdateSessionRequest = z.infer<typeof UpdateSessionRequest>;
1101
+
619
1102
  // Operator context controls (slash-command palette: /clear, /compact). These
620
1103
  // are session/operator actions, NOT a structured way to talk to the agent —
621
1104
  // the human↔agent channel stays plain chat. Both require `sessions:control`.
@@ -694,6 +1177,8 @@ export const SessionTurn = z.object({
694
1177
  model: z.string().min(1),
695
1178
  reasoningEffort: ReasoningEffort,
696
1179
  sandboxBackend: SandboxBackend,
1180
+ // Per-turn OS override. NULL = inherit the session's sandboxOs.
1181
+ sandboxOs: SandboxOs.nullable(),
697
1182
  metadata: z.record(z.string(), z.unknown()),
698
1183
  startedAt: z.string().nullable(),
699
1184
  finishedAt: z.string().nullable(),
@@ -1237,11 +1722,19 @@ export const Session = z.object({
1237
1722
  accountId: z.string().uuid(),
1238
1723
  status: SessionStatus,
1239
1724
  initialMessage: z.string(),
1725
+ title: z.string().nullable(),
1726
+ titleSource: z.enum(["user", "agent"]).nullable(),
1240
1727
  resources: z.array(ResourceRef),
1241
1728
  tools: z.array(ToolRef),
1242
1729
  metadata: z.record(z.string(), z.unknown()),
1243
1730
  model: z.string(),
1244
1731
  sandboxBackend: SandboxBackend,
1732
+ // The OS the session's box runs. Defaults to 'linux' (today's only OS).
1733
+ sandboxOs: SandboxOs,
1734
+ // The shared-sandbox group the session's box belongs to. Equals the session's
1735
+ // own id for a singleton group (today's 1:1 default); equals the parent's
1736
+ // group when spawned shared (both sessions run in ONE box).
1737
+ sandboxGroupId: z.string().uuid(),
1245
1738
  environmentId: z.string().uuid().nullable(),
1246
1739
  // Non-default first-party MCP token permissions (manager-style sessions);
1247
1740
  // null means the fixed worker default set.
@@ -1300,9 +1793,498 @@ export const SessionEventType = z.enum([
1300
1793
  "goal.paused",
1301
1794
  "goal.resumed",
1302
1795
  "goal.continuation",
1796
+ // Channel-B desktop pixel-plane signals (07-channel-b §1.2). The pixel socket
1797
+ // carries opaque RFB and cannot carry a control message the client can act on,
1798
+ // so these ride the durable, sequenced, gap-filled Channel-A SSE spine.
1799
+ "stream.url.rotated", // re-minted {url,token,expiresAt} on box rollover (event-driven)
1800
+ "stream.opened", // a viewer attached (audit + refcount visibility)
1801
+ "stream.closed", // a viewer detached / was reaped
1802
+ "stream.revoked", // a grant was revoked → connected clients MUST disconnect now
1803
+ // Channel-B recording signals (P4.3 / module 05 §3.4). The "agent films itself
1804
+ // proving the fix" loop: ffmpeg x11grab of the SAME :0 humans watch → artifact
1805
+ // → storage. The artifact ref rides the AVAILABLE event (storageKey, NOT a
1806
+ // long-lived URL — clients mint a short-TTL signed GET via the route).
1807
+ "recording.started", // ffmpeg launched on :0 (mode/codec/dimensions)
1808
+ "recording.available", // finalized: bytes PUT to storage, replayable
1809
+ "recording.failed", // ffmpeg/box-death/rollover/upload error — no artifact
1810
+ // Channel-A structured-service notifications (P4.4 / modules/08-channel-a.md
1811
+ // §2.2). The A2 reads (fs/git/terminal exec) are SYNCHRONOUS API-direct point
1812
+ // queries (their result is the HTTP response, NEVER an event). What rides A1
1813
+ // here are the side-effect NOTIFICATIONS — a path changed, git state changed,
1814
+ // a pty opened/printed/exited — durable, sequenced, gap-filled like every
1815
+ // other session event, so any viewer's Pierre tree / diff / terminal stays
1816
+ // live. fs.changed/git.changed are cache-invalidation signals; the pty.*
1817
+ // events carry the interactive terminal byte stream.
1818
+ "fs.changed", // a path was created/modified/deleted (write or agent mutation)
1819
+ "git.changed", // working-tree/index/HEAD changed (debounced re-probe)
1820
+ "terminal.pty.started", // an interactive PTY session opened (carries ptyId)
1821
+ "terminal.pty.output.delta", // PTY stdout/stderr bytes (separate from command.output)
1822
+ "terminal.pty.exited", // PTY session ended (exitCode/reason)
1823
+ "session.title_set",
1303
1824
  ]);
1304
1825
  export type SessionEventType = z.infer<typeof SessionEventType>;
1305
1826
 
1827
+ // Channel-B stream-event payloads (07-channel-b §1.2). SessionEvent.payload is
1828
+ // z.unknown() (NOT a discriminated union) — these are standalone schemas parsed
1829
+ // explicitly at the producer (the API-direct handshake/rotation) and the SDK/
1830
+ // React consumer. The rotation payload carries the freshly-minted data-plane URL
1831
+ // + the scoped stream token so a connected client hot-swaps its noVNC socket.
1832
+ export const StreamUrlRotatedPayload = z.object({
1833
+ url: z.string().url(),
1834
+ token: z.string().nullable(),
1835
+ expiresAt: z.string().datetime().nullable(),
1836
+ // The epoch the new URL was minted under (the box-rollover fence the client
1837
+ // reconciles against). A client must drop a rotation event whose epoch it has
1838
+ // already advanced past.
1839
+ leaseEpoch: z.number().int().nonnegative(),
1840
+ transport: z.literal("vnc-ws"),
1841
+ // The viewer holder this URL is for (so a client filters out other viewers').
1842
+ viewerId: z.string().uuid().nullable().default(null),
1843
+ });
1844
+ export type StreamUrlRotatedPayload = z.infer<typeof StreamUrlRotatedPayload>;
1845
+
1846
+ export const StreamOpenedPayload = z.object({
1847
+ viewerId: z.string().uuid(),
1848
+ shared: z.boolean().default(false),
1849
+ viewerCount: z.number().int().nonnegative(),
1850
+ });
1851
+ export type StreamOpenedPayload = z.infer<typeof StreamOpenedPayload>;
1852
+
1853
+ export const StreamClosedPayload = z.object({
1854
+ viewerId: z.string().uuid(),
1855
+ reason: z.enum(["client-disconnect", "reaped", "revoked", "box-rollover"]),
1856
+ viewerCount: z.number().int().nonnegative(),
1857
+ });
1858
+ export type StreamClosedPayload = z.infer<typeof StreamClosedPayload>;
1859
+
1860
+ export const StreamRevokedPayload = z.object({
1861
+ viewerId: z.string().uuid().nullable().default(null),
1862
+ reason: z.enum(["grant-revoked", "session-failed", "admin"]),
1863
+ });
1864
+ export type StreamRevokedPayload = z.infer<typeof StreamRevokedPayload>;
1865
+
1866
+ // ── Recording payloads (P4.3 / module 05 §3.4) ──────────────────────────────
1867
+ // SessionEvent.payload is z.unknown() (NOT a discriminated union) — these are
1868
+ // standalone schemas parsed explicitly at the producer (the recording activity)
1869
+ // and the SDK/React consumer. The codec/contentType pair stays consistent
1870
+ // (h264-mp4↔video/mp4, vp9-webm↔video/webm).
1871
+ export const RecordingMode = z.enum(["manual", "on-turn", "on-verify"]);
1872
+ export type RecordingMode = z.infer<typeof RecordingMode>;
1873
+ export const RecordingCodec = z.enum(["h264-mp4", "vp9-webm"]);
1874
+ export type RecordingCodec = z.infer<typeof RecordingCodec>;
1875
+ export const RecordingContentType = z.enum(["video/mp4", "video/webm"]);
1876
+ export type RecordingContentType = z.infer<typeof RecordingContentType>;
1877
+
1878
+ export const RecordingStartedPayload = z.object({
1879
+ recordingId: z.string().uuid(),
1880
+ turnId: z.string().uuid().nullable(),
1881
+ mode: RecordingMode,
1882
+ codec: RecordingCodec,
1883
+ dimensions: z.tuple([z.number().int().positive(), z.number().int().positive()]),
1884
+ framerate: z.number().int().positive(),
1885
+ startedAt: z.string(), // ISO
1886
+ // The verification rationale ("agent-verification: tf apply succeeded"). Agent-
1887
+ // authored free text — the producer caps + scrubs it before emit.
1888
+ reason: z.string().nullable().optional(),
1889
+ });
1890
+ export type RecordingStartedPayload = z.infer<typeof RecordingStartedPayload>;
1891
+
1892
+ export const RecordingAvailablePayload = z.object({
1893
+ recordingId: z.string().uuid(),
1894
+ turnId: z.string().uuid().nullable(),
1895
+ codec: RecordingCodec,
1896
+ contentType: RecordingContentType,
1897
+ // The @opengeni/storage object key. NO long-lived URL in the event — clients
1898
+ // mint a short-TTL signed GET via GET …/recordings/:id/url.
1899
+ storageKey: z.string(),
1900
+ durationSeconds: z.number().nonnegative().nullable(),
1901
+ sizeBytes: z.number().int().nonnegative(),
1902
+ dimensions: z.tuple([z.number().int().positive(), z.number().int().positive()]),
1903
+ });
1904
+ export type RecordingAvailablePayload = z.infer<typeof RecordingAvailablePayload>;
1905
+
1906
+ // `max-bytes-exceeded` is distinct from `timeout` (the -t ceiling hitting is a
1907
+ // SUCCESSFUL finalize, never a failure) — the adversarial-review F7 fix.
1908
+ export const RecordingFailedReason = z.enum([
1909
+ "ffmpeg-error",
1910
+ "box-death",
1911
+ "box-rollover",
1912
+ "upload-failed",
1913
+ "max-bytes-exceeded",
1914
+ "display-unavailable",
1915
+ ]);
1916
+ export type RecordingFailedReason = z.infer<typeof RecordingFailedReason>;
1917
+
1918
+ export const RecordingFailedPayload = z.object({
1919
+ recordingId: z.string().uuid(),
1920
+ turnId: z.string().uuid().nullable(),
1921
+ reason: RecordingFailedReason,
1922
+ // ffmpeg-stderr tail / error detail — agent/ffmpeg-controlled, so the producer
1923
+ // caps + scrubs it before emit (it rides redact() like every payload).
1924
+ detail: z.string().nullable().optional(),
1925
+ });
1926
+ export type RecordingFailedPayload = z.infer<typeof RecordingFailedPayload>;
1927
+
1928
+ // ── Channel-A structured services (P4.4 / modules/08-channel-a.md) ───────────
1929
+ // Two transports on one spine: the A2 request/response shapes (FsNode tree,
1930
+ // GitDiff hunks, terminal exec) are returned INLINE on synchronous API-direct
1931
+ // routes (never the bus); the A1 notification payloads below ride the durable
1932
+ // SSE event log so every viewer's Pierre tree / diff / terminal stays live.
1933
+
1934
+ // --- A1 event payloads -------------------------------------------------------
1935
+
1936
+ // The agent's command-output firehose, enriched. Backward-compatible widening
1937
+ // of the existing sandbox.command.output.delta (consumers read `chunk`); the
1938
+ // producer may now also stamp stream/commandId/seq for finer terminal rendering.
1939
+ export const SandboxCommandOutputDeltaPayload = z.object({
1940
+ stream: z.enum(["stdout", "stderr"]).default("stdout"),
1941
+ chunk: z.string(), // raw bytes, utf-8 (lossy) — terminal is opaque-ish
1942
+ commandId: z.string().optional(), // groups deltas to one agent command
1943
+ seq: z.number().int().nonnegative().optional(), // intra-command ordering hint
1944
+ });
1945
+ export type SandboxCommandOutputDeltaPayload = z.infer<typeof SandboxCommandOutputDeltaPayload>;
1946
+
1947
+ export const FsChangeKind = z.enum(["created", "modified", "deleted", "renamed"]);
1948
+ export type FsChangeKind = z.infer<typeof FsChangeKind>;
1949
+ export const FsChangedPayload = z.object({
1950
+ changes: z.array(z.object({
1951
+ path: z.string(), // workspace-relative POSIX path
1952
+ kind: FsChangeKind,
1953
+ isDir: z.boolean().default(false),
1954
+ sizeBytes: z.number().int().nonnegative().nullable().default(null),
1955
+ oldPath: z.string().optional(), // for "renamed"
1956
+ })).min(1),
1957
+ source: z.enum(["write", "watch", "agent"]).default("write"),
1958
+ // Monotonic FS revision (per-lease, paired with leaseEpoch for staleness).
1959
+ revision: z.number().int().nonnegative(),
1960
+ // The lease epoch the revision was minted under: a client invalidates on a
1961
+ // (leaseEpoch, revision) tuple change, never a bare revision compare (H3 —
1962
+ // revision resets to 0 on box re-key, so a bare monotonic compare goes stale).
1963
+ leaseEpoch: z.number().int().nonnegative().default(0),
1964
+ });
1965
+ export type FsChangedPayload = z.infer<typeof FsChangedPayload>;
1966
+
1967
+ export const GitChangedPayload = z.object({
1968
+ head: z.string().nullable(), // current branch or detached SHA
1969
+ dirty: z.boolean(), // working tree has uncommitted changes
1970
+ ahead: z.number().int().nonnegative().default(0),
1971
+ behind: z.number().int().nonnegative().default(0),
1972
+ changedFileCount: z.number().int().nonnegative(),
1973
+ reason: z.enum(["commit", "checkout", "stage", "worktree", "fetch", "unknown"]).default("unknown"),
1974
+ revision: z.number().int().nonnegative().default(0),
1975
+ leaseEpoch: z.number().int().nonnegative().default(0),
1976
+ });
1977
+ export type GitChangedPayload = z.infer<typeof GitChangedPayload>;
1978
+
1979
+ export const TerminalPtyStartedPayload = z.object({
1980
+ ptyId: z.string().uuid(),
1981
+ cols: z.number().int().positive(),
1982
+ rows: z.number().int().positive(),
1983
+ shell: z.string(), // resolved shell, e.g. "/bin/bash"
1984
+ cwd: z.string(),
1985
+ });
1986
+ export type TerminalPtyStartedPayload = z.infer<typeof TerminalPtyStartedPayload>;
1987
+
1988
+ export const TerminalPtyOutputDeltaPayload = z.object({
1989
+ ptyId: z.string().uuid(),
1990
+ stream: z.enum(["stdout", "stderr"]).default("stdout"),
1991
+ chunk: z.string(), // raw terminal bytes (incl. ANSI), utf-8 lossy
1992
+ seq: z.number().int().nonnegative(), // strict per-pty ordering (owner-assigned)
1993
+ });
1994
+ export type TerminalPtyOutputDeltaPayload = z.infer<typeof TerminalPtyOutputDeltaPayload>;
1995
+
1996
+ export const TerminalPtyExitedPayload = z.object({
1997
+ ptyId: z.string().uuid(),
1998
+ exitCode: z.number().int().nullable(),
1999
+ reason: z.enum(["exit", "killed", "owner_gone", "timeout"]),
2000
+ });
2001
+ export type TerminalPtyExitedPayload = z.infer<typeof TerminalPtyExitedPayload>;
2002
+
2003
+ // --- A2 FileSystem request/response (NOT events; returned inline) ------------
2004
+ export const FsNodeType = z.enum(["file", "dir", "symlink", "other"]);
2005
+ export type FsNodeType = z.infer<typeof FsNodeType>;
2006
+ // The Pierre-tree node. `children` is present only when the dir was listed with
2007
+ // depth>0; the tree lazy-expands via repeated depth-1 lists at deeper paths.
2008
+ export interface FsTreeNode {
2009
+ name: string;
2010
+ path: string; // workspace-relative POSIX, no leading slash
2011
+ type: z.infer<typeof FsNodeType>;
2012
+ sizeBytes: number | null; // null for dirs
2013
+ mtimeMs: number | null;
2014
+ mode: number | null; // unix mode bits, for Pierre tree icons/perms
2015
+ children?: FsTreeNode[] | undefined;
2016
+ truncated: boolean; // dir had more entries than the cap
2017
+ }
2018
+ export const FsTreeNode: z.ZodType<FsTreeNode> = z.lazy(() => z.object({
2019
+ name: z.string(),
2020
+ path: z.string(),
2021
+ type: FsNodeType,
2022
+ sizeBytes: z.number().int().nonnegative().nullable(),
2023
+ mtimeMs: z.number().int().nonnegative().nullable(),
2024
+ mode: z.number().int().nullable(),
2025
+ children: z.array(FsTreeNode).optional(),
2026
+ truncated: z.boolean().default(false),
2027
+ })) as z.ZodType<FsTreeNode>;
2028
+
2029
+ export const FsListRequest = z.object({
2030
+ path: z.string().default(""), // "" = workspace root
2031
+ depth: z.number().int().min(0).max(8).default(1),
2032
+ maxEntries: z.number().int().positive().max(20_000).default(2_000),
2033
+ includeHidden: z.boolean().default(true),
2034
+ });
2035
+ export type FsListRequest = z.infer<typeof FsListRequest>;
2036
+ export const FsListResponse = z.object({
2037
+ root: FsTreeNode,
2038
+ revision: z.number().int().nonnegative(),
2039
+ truncated: z.boolean(), // global cap hit
2040
+ });
2041
+ export type FsListResponse = z.infer<typeof FsListResponse>;
2042
+
2043
+ export const FsEncoding = z.enum(["utf8", "base64"]);
2044
+ export type FsEncoding = z.infer<typeof FsEncoding>;
2045
+ export const FsReadRequest = z.object({
2046
+ path: z.string(),
2047
+ encoding: FsEncoding.default("utf8"),
2048
+ maxBytes: z.number().int().positive().max(25 * 1024 * 1024).default(5 * 1024 * 1024),
2049
+ });
2050
+ export type FsReadRequest = z.infer<typeof FsReadRequest>;
2051
+ export const FsReadResponse = z.object({
2052
+ path: z.string(),
2053
+ encoding: FsEncoding,
2054
+ content: z.string(), // text or base64 per encoding
2055
+ sizeBytes: z.number().int().nonnegative(), // bytes returned (== content size)
2056
+ truncated: z.boolean(), // sizeBytes hit maxBytes; content is the prefix
2057
+ isBinary: z.boolean(), // sniffed NUL byte in first 8KB
2058
+ revision: z.number().int().nonnegative(),
2059
+ });
2060
+ export type FsReadResponse = z.infer<typeof FsReadResponse>;
2061
+
2062
+ export const FsWriteRequest = z.object({
2063
+ path: z.string(),
2064
+ encoding: FsEncoding.default("utf8"),
2065
+ content: z.string(),
2066
+ overwrite: z.boolean().default(true), // false + existing path => 409
2067
+ createParents: z.boolean().default(true),
2068
+ });
2069
+ export type FsWriteRequest = z.infer<typeof FsWriteRequest>;
2070
+ export const FsWriteResponse = z.object({
2071
+ path: z.string(),
2072
+ sizeBytes: z.number().int().nonnegative(),
2073
+ revision: z.number().int().nonnegative(), // == the fs.changed revision
2074
+ });
2075
+ export type FsWriteResponse = z.infer<typeof FsWriteResponse>;
2076
+
2077
+ export const FsDeleteRequest = z.object({
2078
+ path: z.string(),
2079
+ recursive: z.boolean().default(false), // required true to delete a non-empty dir
2080
+ });
2081
+ export type FsDeleteRequest = z.infer<typeof FsDeleteRequest>;
2082
+ export const FsDeleteResponse = z.object({ revision: z.number().int().nonnegative() });
2083
+ export type FsDeleteResponse = z.infer<typeof FsDeleteResponse>;
2084
+
2085
+ export const FsMoveRequest = z.object({
2086
+ path: z.string(),
2087
+ newPath: z.string(),
2088
+ overwrite: z.boolean().default(false), // false + existing destination => 409
2089
+ createParents: z.boolean().default(true),
2090
+ });
2091
+ export type FsMoveRequest = z.infer<typeof FsMoveRequest>;
2092
+ export const FsMoveResponse = z.object({
2093
+ path: z.string(),
2094
+ newPath: z.string(),
2095
+ revision: z.number().int().nonnegative(), // == the fs.changed revision
2096
+ });
2097
+ export type FsMoveResponse = z.infer<typeof FsMoveResponse>;
2098
+
2099
+ export const FsMkdirRequest = z.object({
2100
+ path: z.string(),
2101
+ recursive: z.boolean().default(true), // false + existing path => 400
2102
+ });
2103
+ export type FsMkdirRequest = z.infer<typeof FsMkdirRequest>;
2104
+ export const FsMkdirResponse = z.object({
2105
+ path: z.string(),
2106
+ revision: z.number().int().nonnegative(), // == the fs.changed revision
2107
+ });
2108
+ export type FsMkdirResponse = z.infer<typeof FsMkdirResponse>;
2109
+
2110
+ // --- A2 Git request/response (read-only; feeds Pierre diff/tree) -------------
2111
+ export const GitFileStatusCode = z.enum([
2112
+ "added", "modified", "deleted", "renamed", "copied", "untracked", "ignored", "conflicted", "typechange",
2113
+ ]);
2114
+ export type GitFileStatusCode = z.infer<typeof GitFileStatusCode>;
2115
+ export const GitFileStatus = z.object({
2116
+ path: z.string(),
2117
+ oldPath: z.string().nullable(), // for renamed/copied
2118
+ index: GitFileStatusCode.nullable(), // staged change (X in porcelain XY)
2119
+ worktree: GitFileStatusCode.nullable(), // unstaged change (Y in porcelain XY)
2120
+ isConflicted: z.boolean().default(false),
2121
+ });
2122
+ export type GitFileStatus = z.infer<typeof GitFileStatus>;
2123
+ export const GitStatusRequest = z.object({
2124
+ path: z.string().default(""), // repo root within workspace (multi-repo support)
2125
+ });
2126
+ export type GitStatusRequest = z.infer<typeof GitStatusRequest>;
2127
+ export const GitStatusResponse = z.object({
2128
+ isRepo: z.boolean(),
2129
+ head: z.string().nullable(), // branch name
2130
+ detached: z.boolean().default(false),
2131
+ upstream: z.string().nullable(),
2132
+ ahead: z.number().int().nonnegative().default(0),
2133
+ behind: z.number().int().nonnegative().default(0),
2134
+ files: z.array(GitFileStatus),
2135
+ revision: z.number().int().nonnegative(),
2136
+ });
2137
+ export type GitStatusResponse = z.infer<typeof GitStatusResponse>;
2138
+
2139
+ // The structured hunk shape that feeds Pierre diff — the whole point of Git.
2140
+ export const GitDiffLineType = z.enum(["context", "add", "del", "meta"]);
2141
+ export type GitDiffLineType = z.infer<typeof GitDiffLineType>;
2142
+ export const GitDiffLine = z.object({
2143
+ type: GitDiffLineType,
2144
+ // null on the side that doesn't have the line (add => oldNo null; del => newNo null)
2145
+ oldNo: z.number().int().positive().nullable(),
2146
+ newNo: z.number().int().positive().nullable(),
2147
+ text: z.string(), // line WITHOUT leading +/-/space marker
2148
+ });
2149
+ export type GitDiffLine = z.infer<typeof GitDiffLine>;
2150
+ export const GitDiffHunk = z.object({
2151
+ oldStart: z.number().int().nonnegative(),
2152
+ oldLines: z.number().int().nonnegative(),
2153
+ newStart: z.number().int().nonnegative(),
2154
+ newLines: z.number().int().nonnegative(),
2155
+ header: z.string(), // the @@ ... @@ section heading
2156
+ lines: z.array(GitDiffLine),
2157
+ });
2158
+ export type GitDiffHunk = z.infer<typeof GitDiffHunk>;
2159
+ export const GitFileDiff = z.object({
2160
+ path: z.string(),
2161
+ oldPath: z.string().nullable(),
2162
+ status: GitFileStatusCode,
2163
+ isBinary: z.boolean().default(false),
2164
+ isImage: z.boolean().default(false),
2165
+ additions: z.number().int().nonnegative(),
2166
+ deletions: z.number().int().nonnegative(),
2167
+ hunks: z.array(GitDiffHunk), // empty if binary or truncated
2168
+ truncated: z.boolean().default(false), // diff exceeded maxBytes; hunks omitted
2169
+ });
2170
+ export type GitFileDiff = z.infer<typeof GitFileDiff>;
2171
+ export const GitDiffRequest = z.object({
2172
+ path: z.string().default(""), // repo root
2173
+ // diff selectors, mutually exclusive precedence: refs > staged > worktree
2174
+ staged: z.boolean().default(false), // --cached (index vs HEAD)
2175
+ fromRef: z.string().optional(),
2176
+ toRef: z.string().optional(),
2177
+ pathspec: z.array(z.string()).default([]),
2178
+ contextLines: z.number().int().min(0).max(10).default(3),
2179
+ maxBytesPerFile: z.number().int().positive().max(2 * 1024 * 1024).default(512 * 1024),
2180
+ });
2181
+ export type GitDiffRequest = z.infer<typeof GitDiffRequest>;
2182
+ export const GitDiffResponse = z.object({
2183
+ files: z.array(GitFileDiff),
2184
+ revision: z.number().int().nonnegative(),
2185
+ });
2186
+ export type GitDiffResponse = z.infer<typeof GitDiffResponse>;
2187
+
2188
+ export const GitLogRequest = z.object({
2189
+ path: z.string().default(""),
2190
+ ref: z.string().default("HEAD"),
2191
+ maxCount: z.number().int().positive().max(1_000).default(100),
2192
+ skip: z.number().int().nonnegative().default(0),
2193
+ pathspec: z.array(z.string()).default([]),
2194
+ });
2195
+ export type GitLogRequest = z.infer<typeof GitLogRequest>;
2196
+ export const GitCommit = z.object({
2197
+ sha: z.string(),
2198
+ shortSha: z.string(),
2199
+ parents: z.array(z.string()),
2200
+ author: z.object({ name: z.string(), email: z.string(), timestamp: z.number().int() }),
2201
+ committer: z.object({ name: z.string(), email: z.string(), timestamp: z.number().int() }),
2202
+ subject: z.string(),
2203
+ body: z.string(),
2204
+ refs: z.array(z.string()).default([]), // decorations: branch/tag pointers
2205
+ });
2206
+ export type GitCommit = z.infer<typeof GitCommit>;
2207
+ export const GitLogResponse = z.object({ commits: z.array(GitCommit), hasMore: z.boolean() });
2208
+ export type GitLogResponse = z.infer<typeof GitLogResponse>;
2209
+
2210
+ export const GitShowRequest = z.object({
2211
+ path: z.string().default(""),
2212
+ ref: z.string(), // a commit/tag/tree-ish
2213
+ filePath: z.string().optional(), // ref + filePath => raw blob ("open file at commit")
2214
+ encoding: FsEncoding.default("utf8"),
2215
+ maxBytesPerFile: z.number().int().positive().max(2 * 1024 * 1024).default(512 * 1024),
2216
+ });
2217
+ export type GitShowRequest = z.infer<typeof GitShowRequest>;
2218
+ export const GitShowResponse = z.object({
2219
+ commit: GitCommit.nullable(), // null when fetching a raw blob
2220
+ files: z.array(GitFileDiff), // commit diff vs first parent
2221
+ blob: z.object({ content: z.string(), encoding: FsEncoding, sizeBytes: z.number().int(), truncated: z.boolean() }).nullable(),
2222
+ revision: z.number().int().nonnegative(),
2223
+ });
2224
+ export type GitShowResponse = z.infer<typeof GitShowResponse>;
2225
+
2226
+ // --- A2 Terminal exec (run a command in-box, stream stdout/stderr) -----------
2227
+ // The command-output FIREHOSE rides A1 (sandbox.command.output.delta). This is
2228
+ // the SYNCHRONOUS exec: run a bounded command and return its stdout/stderr +
2229
+ // exit code inline (the result IS the HTTP response). Full interactive PTY
2230
+ // (open/write/resize) layers on top via the pty.* events; exec ships now.
2231
+ export const TerminalExecRequest = z.object({
2232
+ command: z.string().min(1),
2233
+ cwd: z.string().default(""), // workspace-relative
2234
+ // Soft per-call wall-clock bound (the box yields output back when reached).
2235
+ timeoutMs: z.number().int().positive().max(120_000).default(30_000),
2236
+ // Stream the deltas onto A1 as the agent firehose (so other viewers see it),
2237
+ // in addition to returning the buffered result inline.
2238
+ emitStream: z.boolean().default(true),
2239
+ });
2240
+ export type TerminalExecRequest = z.infer<typeof TerminalExecRequest>;
2241
+ export const TerminalExecResponse = z.object({
2242
+ stdout: z.string(),
2243
+ stderr: z.string(),
2244
+ exitCode: z.number().int().nullable(),
2245
+ // True when the process was still running when the call yielded (a long
2246
+ // command); the remaining output drains onto A1 if emitStream was set.
2247
+ running: z.boolean(),
2248
+ wallTimeSeconds: z.number().nonnegative(),
2249
+ });
2250
+ export type TerminalExecResponse = z.infer<typeof TerminalExecResponse>;
2251
+
2252
+ // --- A2 Terminal PTY control (output rides A1) -------------------------------
2253
+ export const PtyOpenRequest = z.object({
2254
+ cols: z.number().int().positive().max(500).default(80),
2255
+ rows: z.number().int().positive().max(300).default(24),
2256
+ cwd: z.string().default(""), // workspace-relative
2257
+ shell: z.string().optional(), // default: resolved login shell
2258
+ });
2259
+ export type PtyOpenRequest = z.infer<typeof PtyOpenRequest>;
2260
+ export const PtyOpenResponse = z.object({
2261
+ ptyId: z.string().uuid(),
2262
+ // output streams as terminal.pty.output.delta on the SSE channel the client holds
2263
+ streamVia: z.literal("sse-events"),
2264
+ supportsInput: z.boolean(), // false on backends without writeStdin
2265
+ });
2266
+ export type PtyOpenResponse = z.infer<typeof PtyOpenResponse>;
2267
+ export const PtyWriteRequest = z.object({ ptyId: z.string().uuid(), data: z.string() }); // utf-8 stdin
2268
+ export type PtyWriteRequest = z.infer<typeof PtyWriteRequest>;
2269
+ export const PtyResizeRequest = z.object({ ptyId: z.string().uuid(), cols: z.number().int().positive(), rows: z.number().int().positive() });
2270
+ export type PtyResizeRequest = z.infer<typeof PtyResizeRequest>;
2271
+ export const PtyCloseRequest = z.object({ ptyId: z.string().uuid() });
2272
+ export type PtyCloseRequest = z.infer<typeof PtyCloseRequest>;
2273
+
2274
+ // Per-session structured-service capabilities (the Channel-A slice of the
2275
+ // negotiation). The full SessionCapabilities doc already carries FileSystem /
2276
+ // Terminal / Git blocks (P0.1); this is the compact projection the SDK mirrors.
2277
+ export const SessionStructuredCapabilities = z.object({
2278
+ FileSystem: z.object({ available: z.boolean(), readOnly: z.boolean(), root: z.string() }),
2279
+ Terminal: z.object({
2280
+ events: z.boolean(), // command.output firehose (always on if a box exists)
2281
+ exec: z.boolean(), // synchronous terminal exec
2282
+ pty: z.object({ available: z.boolean() }), // interactive stdin (writeStdin)
2283
+ }),
2284
+ Git: z.object({ available: z.boolean(), repos: z.array(z.string()) }),
2285
+ });
2286
+ export type SessionStructuredCapabilities = z.infer<typeof SessionStructuredCapabilities>;
2287
+
1306
2288
  export const SessionEvent = z.object({
1307
2289
  id: z.string().uuid(),
1308
2290
  workspaceId: z.string().uuid(),
@@ -1341,6 +2323,23 @@ export const CreateSessionRequest = z.object({
1341
2323
  // the orchestration/environment/github tools. Capped at creation: every
1342
2324
  // requested permission must be held by the creating grant (no escalation).
1343
2325
  firstPartyMcpPermissions: z.array(Permission).optional(),
2326
+ // Shared-sandbox placement (addendum 05 §D.1). Three-way union; OMITTED ⇒
2327
+ // today's behavior (a context-dependent default resolved server-side: from
2328
+ // inside a session → "shared" with the creator's box, top-level → "new").
2329
+ // - "shared": join the CREATOR's box. Requires a parent session (inferred
2330
+ // from the worker-signed sessionId claim, never caller-supplied);
2331
+ // top-level "shared" is a 422.
2332
+ // - "new": mint a fresh singleton box (group ≡ the new session's id).
2333
+ // - {groupId}: join a SPECIFIC sibling group in THIS workspace (manager
2334
+ // fan-out). Validated workspace-scoped (cross-workspace → 404).
2335
+ // A shared spawn inherits the box's (backend, os) — it is literally the same
2336
+ // box; the child cannot pick its own backend. Cross-workspace sharing is
2337
+ // forbidden by construction (the parent/group reads are RLS-workspace-scoped).
2338
+ sandbox: z.union([
2339
+ z.literal("shared"),
2340
+ z.literal("new"),
2341
+ z.object({ groupId: z.string().uuid() }),
2342
+ ]).optional(),
1344
2343
  });
1345
2344
  export type CreateSessionRequest = z.infer<typeof CreateSessionRequest>;
1346
2345
 
@@ -1422,10 +2421,198 @@ export const ClientAuthConfig = z.discriminatedUnion("mode", [
1422
2421
  ]);
1423
2422
  export type ClientAuthConfig = z.infer<typeof ClientAuthConfig>;
1424
2423
 
2424
+ // The negotiated capability handshake document (master-spine C.3). ONE shape;
2425
+ // collapses the parallel per-module definitions. A capability cell is always
2426
+ // present with `available`/`transport` + a `reason` when unavailable — never
2427
+ // absent.
2428
+ export const CapabilityUnavailableReason = z.enum([
2429
+ "backend_unsupported",
2430
+ "os_unsupported",
2431
+ "not_provisioned",
2432
+ "disabled_by_policy",
2433
+ "lease_cold",
2434
+ "tier_headless",
2435
+ ]);
2436
+ export type CapabilityUnavailableReason = z.infer<typeof CapabilityUnavailableReason>;
2437
+
2438
+ export const SessionCapabilities = z.object({
2439
+ sessionId: z.string().uuid(),
2440
+ backend: SandboxBackend,
2441
+ os: SandboxOs,
2442
+ liveness: z.enum(["cold", "warming", "warm", "draining"]),
2443
+ // Echoed on viewer heartbeats (the split-brain fence).
2444
+ leaseEpoch: z.number().int().nonnegative(),
2445
+ viewerHeartbeatIntervalMs: z.number().int().positive().default(30_000),
2446
+ FileSystem: z.object({
2447
+ available: z.boolean(),
2448
+ readOnly: z.boolean(),
2449
+ root: z.string(),
2450
+ pathSep: z.enum(["/", "\\"]),
2451
+ treeMode: z.enum(["lazy", "snapshot"]),
2452
+ reason: CapabilityUnavailableReason.nullable(),
2453
+ }),
2454
+ Terminal: z.object({
2455
+ transport: z.enum(["sse-events", "pty-ws"]).nullable(),
2456
+ ptyCapable: z.boolean(),
2457
+ shell: z.string(),
2458
+ // The direct-to-provider ttyd PTY-over-websocket URL (pty-ws) resolved on the
2459
+ // SAME tunnel as the desktop; null on a cold lease / read-only sse-events
2460
+ // firehose / degraded terminal. The scoped stream token is recorded against
2461
+ // the holder (NEVER a URL query param), symmetric with DesktopStream.
2462
+ url: z.string().url().nullable(),
2463
+ token: z.string().nullable(),
2464
+ // ISO absolute expiry of the minted stream token (symmetric with
2465
+ // DesktopStream.expiresAt). Null when no live URL/token is minted.
2466
+ expiresAt: z.string().nullable(),
2467
+ reason: CapabilityUnavailableReason.nullable(),
2468
+ }),
2469
+ Git: z.object({
2470
+ available: z.boolean(),
2471
+ repos: z.array(z.string()),
2472
+ reason: CapabilityUnavailableReason.nullable(),
2473
+ }),
2474
+ DesktopStream: z.object({
2475
+ transport: z.enum(["vnc-ws", "rdp-ws", "webrtc"]).nullable(),
2476
+ client: z.enum(["novnc", "web-rdp"]).nullable(),
2477
+ mode: z.enum(["read-only", "interactive"]).default("read-only"),
2478
+ url: z.string().url().nullable(),
2479
+ token: z.string().nullable(),
2480
+ expiresAt: z.string().nullable(),
2481
+ resolution: z
2482
+ .tuple([z.number().int().positive(), z.number().int().positive()])
2483
+ .default([1024, 768]),
2484
+ // REQUIRED, no default (the server must assert un-redacted pixels).
2485
+ unredacted: z.boolean(),
2486
+ requiresAcknowledgment: z.boolean(),
2487
+ acknowledged: z.boolean(),
2488
+ // SHARED-EXPOSURE disclosure (addendum E.1). `shared` is true when the box's
2489
+ // group has >1 session: watching this desktop ALSO shows the sibling
2490
+ // sessions' agents on the one :0 framebuffer (the pixels cannot be redacted).
2491
+ // `sharedSessionIds` lists the OTHER sessions whose agents may appear — IDS
2492
+ // ONLY, never their goal/metadata/conversation (a viewer of A must not be
2493
+ // able to use "I can see B's id" to subscribe to B's events; stress g). When
2494
+ // shared, the consent gate requires the shared-exposure acknowledgment (409
2495
+ // shared_acknowledgment_required) before the desktop path is handed out.
2496
+ shared: z.boolean().default(false),
2497
+ sharedSessionIds: z.array(z.string().uuid()).default([]),
2498
+ reason: CapabilityUnavailableReason.nullable(),
2499
+ }),
2500
+ Recording: z.object({
2501
+ available: z.boolean(),
2502
+ modes: z.array(z.enum(["manual", "on-turn", "on-verify"])),
2503
+ codecs: z.array(z.enum(["h264-mp4", "vp9-webm"])),
2504
+ reason: CapabilityUnavailableReason.nullable(),
2505
+ }),
2506
+ // The AGENT drives the SAME :0 (xdotool/XTEST + scrot) the human watches; the
2507
+ // human viewer plane is read-only by default (§6). `available` == desktop-
2508
+ // capable backend && computerUseEnabled; `readOnly` reports whether the agent
2509
+ // driver itself is gated to no-op input (v1 default false — the agent clicks).
2510
+ ComputerUse: z.object({
2511
+ available: z.boolean(),
2512
+ readOnly: z.boolean(),
2513
+ reason: CapabilityUnavailableReason.nullable(),
2514
+ }),
2515
+ negotiatedAt: z.string(),
2516
+ });
2517
+ export type SessionCapabilities = z.infer<typeof SessionCapabilities>;
2518
+
2519
+ // ── API-direct viewer attach (P1.4) ─────────────────────────────────────────
2520
+ // A viewer holds the GROUP lease (keeping the box warm while watched). These
2521
+ // shape the in-process attach/heartbeat/detach handlers. The scoped stream
2522
+ // token + the un-redacted-pixel acknowledgment are P3/P4 — here it is the
2523
+ // viewer-HOLDER lifecycle only.
2524
+
2525
+ // POST .../viewers — acquire a viewer holder. An omitted viewerId mints a fresh
2526
+ // one (returned in the response, to carry through heartbeats + detach).
2527
+ //
2528
+ // `desktop` declares intent to attach the UN-REDACTED pixel plane (noVNC). ONLY
2529
+ // that plane carries the consent gate (the un-redacted/shared acknowledgment). A
2530
+ // terminal-only warm attach (`desktop:false`, the default) needs NO consent — a
2531
+ // shell is interactive by nature and the gate is the scoped tunnel URL + stream
2532
+ // token — so it warms the box and mints the pty-ws terminal cell WITHOUT a 409.
2533
+ // Omitted defaults to `false` so a terminal-only client never trips the gate.
2534
+ export const AttachViewerRequest = z.object({
2535
+ viewerId: z.string().uuid().optional(),
2536
+ desktop: z.boolean().optional(),
2537
+ });
2538
+ export type AttachViewerRequest = z.infer<typeof AttachViewerRequest>;
2539
+
2540
+ export const ViewerHolder = z.object({
2541
+ viewerId: z.string().uuid(),
2542
+ sandboxGroupId: z.string().uuid(),
2543
+ liveness: z.enum(["cold", "warming", "warm", "draining"]),
2544
+ // The epoch the viewer is fenced on; echoed back on heartbeats.
2545
+ leaseEpoch: z.number().int().nonnegative(),
2546
+ viewerHeartbeatIntervalMs: z.number().int().positive(),
2547
+ // The desktop pixel tunnel URL the viewer connects to directly; null until
2548
+ // P4 mints it (gated until then).
2549
+ dataPlaneUrl: z.string().nullable(),
2550
+ });
2551
+ export type ViewerHolder = z.infer<typeof ViewerHolder>;
2552
+
2553
+ // POST .../stream-capabilities/acknowledge — record the calling principal's
2554
+ // acknowledgment of the un-redacted pixel plane (P3.2; modules/07-channel-b.md
2555
+ // §6 + addendum E.1). Reuses the acknowledgment machinery — no new endpoint
2556
+ // shape beyond this body, no new permission beyond stream:acknowledge.
2557
+ //
2558
+ // `acknowledgeShared` MUST be true when the box is shared (the group has >1
2559
+ // session): the un-redacted desktop path returns 409 shared_acknowledgment_required
2560
+ // until a shared box is acknowledged WITH the shared-exposure consent. For a
2561
+ // solo box `acknowledgeShared` is irrelevant (the un-redacted ack alone gates).
2562
+ export const AcknowledgeStreamRequest = z.object({
2563
+ // The principal accepts that the desktop pixel plane is un-redacted (can show
2564
+ // cloud creds the agent cat's into a terminal). Always true to record consent;
2565
+ // present for self-documentation + a future explicit withdraw.
2566
+ acknowledgeUnredacted: z.boolean().default(true),
2567
+ // The principal accepts the shared-exposure disclosure: watching this desktop
2568
+ // also shows sibling sessions' agents on the one framebuffer.
2569
+ acknowledgeShared: z.boolean().default(false),
2570
+ });
2571
+ export type AcknowledgeStreamRequest = z.infer<typeof AcknowledgeStreamRequest>;
2572
+
2573
+ export const AcknowledgeStreamResponse = z.object({
2574
+ acknowledged: z.boolean(),
2575
+ acknowledgedShared: z.boolean(),
2576
+ });
2577
+ export type AcknowledgeStreamResponse = z.infer<typeof AcknowledgeStreamResponse>;
2578
+
2579
+ // POST .../viewers/:viewerId/heartbeat — refresh the holder TTL. Epoch-fenced:
2580
+ // a stale-epoch beat (a box re-established under a newer epoch) is rejected.
2581
+ export const ViewerHeartbeatRequest = z.object({
2582
+ leaseEpoch: z.number().int().nonnegative(),
2583
+ });
2584
+ export type ViewerHeartbeatRequest = z.infer<typeof ViewerHeartbeatRequest>;
2585
+
2586
+ export const ViewerHeartbeatResponse = z.object({
2587
+ // false ⇒ the holder was reaped or the epoch is stale; the client re-attaches.
2588
+ alive: z.boolean(),
2589
+ });
2590
+ export type ViewerHeartbeatResponse = z.infer<typeof ViewerHeartbeatResponse>;
2591
+
2592
+ /**
2593
+ * A single host-exposed model + the provider that serves it, as surfaced to
2594
+ * clients (SDK + React composer) by GET /v1/config/client. The wire `api`
2595
+ * ("responses" | "chat") lets a client reason about provider capabilities; the
2596
+ * provider id/label drive the picker's grouping. This mirrors the runtime's
2597
+ * ConfiguredModel (packages/config) projected to the client-safe fields.
2598
+ */
2599
+ export const ClientModel = z.object({
2600
+ id: z.string(),
2601
+ label: z.string(),
2602
+ provider: z.string(), // provider id
2603
+ providerLabel: z.string(),
2604
+ api: z.enum(["responses", "chat"]),
2605
+ contextWindowTokens: z.number().int().positive().optional(),
2606
+ });
2607
+ export type ClientModel = z.infer<typeof ClientModel>;
2608
+
1425
2609
  export const ClientConfig = z.object({
1426
2610
  deploymentRevision: z.string(),
1427
2611
  defaultModel: z.string(),
1428
2612
  allowedModels: z.array(z.string()).min(1),
2613
+ // Richer model list (provider-grouped) for the picker. Defaults to [] for
2614
+ // back-compat: callers that only read allowedModels are unaffected.
2615
+ models: z.array(ClientModel).default([]),
1429
2616
  defaultReasoningEffort: ReasoningEffort,
1430
2617
  allowedReasoningEfforts: z.array(ReasoningEffort).min(1),
1431
2618
  mcpServers: z.array(z.object({
@@ -1438,6 +2625,15 @@ export const ClientConfig = z.object({
1438
2625
  }),
1439
2626
  productAccessMode: ProductAccessMode,
1440
2627
  auth: ClientAuthConfig.default({ mode: "none" }),
2628
+ // Server-wide hint: does this deployment support Channel-A structured services
2629
+ // at all (P4.4). Per-session availability is negotiated on /stream-capabilities
2630
+ // (it depends on the session's pinned backend); this is the coarse on/off the
2631
+ // client uses to decide whether to even attempt the fs/git/terminal panels.
2632
+ structuredServices: z.object({
2633
+ fileSystem: z.boolean(),
2634
+ git: z.boolean(),
2635
+ terminalEvents: z.boolean(),
2636
+ }).default({ fileSystem: false, git: false, terminalEvents: false }),
1441
2637
  });
1442
2638
  export type ClientConfig = z.infer<typeof ClientConfig>;
1443
2639