@opengeni/contracts 0.2.0 → 0.4.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,398 @@ 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
+ // 11 backends; 3-way enum parity (contracts / sdk / deployment) is pinned by
14
+ // `packages/sdk/test/contract-parity.test.ts`. Every member is ADDITIVE AT THE
15
+ // END (the parity test pins positions): the original four, then the six cloud
16
+ // backends, then `selfhosted` (bring-your-own-compute — a user's own machine
17
+ // enrolled as a first-class sandbox).
18
+ export const SandboxBackend = z.enum([
19
+ "docker",
20
+ "modal",
21
+ "local",
22
+ "none",
23
+ "daytona",
24
+ "runloop",
25
+ "e2b",
26
+ "blaxel",
27
+ "cloudflare",
28
+ "vercel",
29
+ "selfhosted",
30
+ ]);
14
31
  export type SandboxBackend = z.infer<typeof SandboxBackend>;
15
32
 
33
+ // OS axis. Only "linux" is reachable in v1; macos/windows are seam placeholders.
34
+ export const SandboxOs = z.enum(["linux", "macos", "windows"]);
35
+ export type SandboxOs = z.infer<typeof SandboxOs>;
36
+
37
+ // The five surfaceable sandbox capabilities (PascalCase, the canonical names).
38
+ export const SandboxCapabilityName = z.enum([
39
+ "FileSystem", // Channel A: list/read/write/search (Pierre tree)
40
+ "Terminal", // Channel A: command-output firehose (+ future pty-ws)
41
+ "Git", // Channel A: status/diff/log/show (Pierre diff)
42
+ "DesktopStream", // Channel B: noVNC pixels over a scoped tunnel URL
43
+ "Recording", // ffmpeg x11grab -> object storage
44
+ ]);
45
+ export type SandboxCapabilityName = z.infer<typeof SandboxCapabilityName>;
46
+
47
+ // How a backend exposes a network port to the data plane.
48
+ export type PortExposureKind = "provider-tunnel" | "preview-url" | "local-port" | "none";
49
+
50
+ // Static per-backend metadata — pure data, no runtime state. This table lives
51
+ // in CONTRACTS (not runtime) so config can read it without an import cycle
52
+ // through runtime (ledger CR8). Everything downstream (config boot-validation,
53
+ // OS image selection, capability negotiation, env/mount branch) reads this
54
+ // data, never a hard-coded backend name.
55
+ export type CapabilityDescriptor = {
56
+ backend: SandboxBackend;
57
+ backendId: string; // asserted === SDK client.backendId at registry build (deferred to P0.3)
58
+ tier: "desktop" | "headless" | "dev" | "none";
59
+ os: { supported: SandboxOs[]; default: SandboxOs };
60
+ capabilities: {
61
+ FileSystem: { available: boolean; readOnly: boolean };
62
+ Terminal: { available: boolean; transport: "sse-events" | "pty-ws" | null; pty: boolean };
63
+ Git: { available: boolean };
64
+ DesktopStream: { available: boolean; transport: "vnc-ws" | "rdp-ws" | "webrtc" | null };
65
+ // Feasibility only (== DesktopStream.available && os==linux); NOT a request.
66
+ Recording: { available: boolean };
67
+ };
68
+ lifetime: {
69
+ hardLifetimeMs?: number; // modal 24h, vercel 5h
70
+ requiresSnapshotRollover: boolean;
71
+ hasIdleKiller: boolean;
72
+ supportsSuspendResume: boolean; // runloop/e2b/vercel/modal true
73
+ resumeIsLockFree: boolean; // modal true (fromId, no lock)
74
+ idleKillDisableHint?: string;
75
+ };
76
+ snapshot: {
77
+ kind: "native-fs" | "native-dir" | "native-snapshot-id" | "tar-only" | "none";
78
+ hasTarFallback: boolean;
79
+ };
80
+ portExposure: { kind: PortExposureKind; supportsOnDemandPorts: boolean }; // runloop=false; blaxel only true
81
+ workspaceRoot: string; // os-overridable; per-backend default (providers owns; os defers)
82
+ nativeBucketMount: boolean; // modal true -> mount/signed-download branch
83
+ persistable: boolean;
84
+ supportsRunAs: boolean;
85
+ };
86
+
87
+ // The websockify/noVNC desktop port that is merged into `exposedPorts` for
88
+ // every desktop-capable (backend, os). Asserted present by boot-validation.
89
+ export const DESKTOP_STREAM_PORT = 6080;
90
+
91
+ // The ttyd PTY-over-websocket port that is exposed over the SAME Modal raw-TLS
92
+ // tunnel as the desktop, for the REAL interactive terminal (Channel-B-symmetric).
93
+ // ttyd's default; the box bakes ttyd and launches it on this port. The pty-ws
94
+ // Terminal cell's `url` is the tunnel address resolved against this port.
95
+ export const TERMINAL_STREAM_PORT = 7681;
96
+
97
+ // The Part-D matrix (master-spine PART D + module 03-providers). One row per
98
+ // backend (10 rows). v1 reachable cells are all Linux; macos/windows are seam
99
+ // placeholders (no enum members shipped). Reading rule: a capability cell is
100
+ // `available:false` + a reason in the negotiated doc, never absent.
101
+ export const CAPABILITY_DESCRIPTORS: Record<SandboxBackend, CapabilityDescriptor> = {
102
+ modal: {
103
+ backend: "modal",
104
+ backendId: "modal",
105
+ tier: "desktop",
106
+ os: { supported: ["linux"], default: "linux" },
107
+ capabilities: {
108
+ FileSystem: { available: true, readOnly: false },
109
+ Terminal: { available: true, transport: "sse-events", pty: true },
110
+ Git: { available: true },
111
+ DesktopStream: { available: true, transport: "vnc-ws" },
112
+ Recording: { available: true },
113
+ },
114
+ lifetime: {
115
+ hardLifetimeMs: 24 * 60 * 60 * 1000,
116
+ requiresSnapshotRollover: true,
117
+ hasIdleKiller: true,
118
+ supportsSuspendResume: true,
119
+ resumeIsLockFree: true,
120
+ },
121
+ snapshot: { kind: "native-fs", hasTarFallback: true },
122
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: false }, // pre-declare 6080
123
+ workspaceRoot: "/workspace",
124
+ nativeBucketMount: true,
125
+ persistable: true,
126
+ supportsRunAs: true,
127
+ },
128
+ daytona: {
129
+ backend: "daytona",
130
+ backendId: "daytona",
131
+ tier: "desktop",
132
+ os: { supported: ["linux"], default: "linux" },
133
+ capabilities: {
134
+ FileSystem: { available: true, readOnly: false },
135
+ Terminal: { available: true, transport: "sse-events", pty: true },
136
+ Git: { available: true },
137
+ DesktopStream: { available: true, transport: "vnc-ws" },
138
+ Recording: { available: true },
139
+ },
140
+ lifetime: {
141
+ requiresSnapshotRollover: false,
142
+ hasIdleKiller: true,
143
+ supportsSuspendResume: true,
144
+ resumeIsLockFree: false,
145
+ },
146
+ snapshot: { kind: "native-snapshot-id", hasTarFallback: true },
147
+ portExposure: { kind: "preview-url", supportsOnDemandPorts: false },
148
+ workspaceRoot: "/workspace",
149
+ nativeBucketMount: false,
150
+ persistable: true,
151
+ supportsRunAs: true,
152
+ },
153
+ runloop: {
154
+ backend: "runloop",
155
+ backendId: "runloop",
156
+ tier: "desktop",
157
+ os: { supported: ["linux"], default: "linux" },
158
+ capabilities: {
159
+ FileSystem: { available: true, readOnly: false },
160
+ Terminal: { available: true, transport: "sse-events", pty: false },
161
+ Git: { available: true },
162
+ DesktopStream: { available: true, transport: "vnc-ws" },
163
+ Recording: { available: true },
164
+ },
165
+ lifetime: {
166
+ requiresSnapshotRollover: false,
167
+ hasIdleKiller: true,
168
+ supportsSuspendResume: true,
169
+ resumeIsLockFree: false,
170
+ },
171
+ snapshot: { kind: "native-snapshot-id", hasTarFallback: true },
172
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: false }, // CR9: pre-declare 6080
173
+ workspaceRoot: "/workspace",
174
+ nativeBucketMount: false,
175
+ persistable: true,
176
+ supportsRunAs: false,
177
+ },
178
+ e2b: {
179
+ backend: "e2b",
180
+ backendId: "e2b",
181
+ tier: "desktop",
182
+ os: { supported: ["linux"], default: "linux" },
183
+ capabilities: {
184
+ FileSystem: { available: true, readOnly: false },
185
+ Terminal: { available: true, transport: "sse-events", pty: false }, // pty-until-proven=no
186
+ Git: { available: true },
187
+ DesktopStream: { available: true, transport: "vnc-ws" },
188
+ Recording: { available: true },
189
+ },
190
+ lifetime: {
191
+ requiresSnapshotRollover: false,
192
+ hasIdleKiller: true,
193
+ supportsSuspendResume: true,
194
+ resumeIsLockFree: false,
195
+ },
196
+ snapshot: { kind: "native-snapshot-id", hasTarFallback: true },
197
+ portExposure: { kind: "preview-url", supportsOnDemandPorts: false },
198
+ workspaceRoot: "/home/user",
199
+ nativeBucketMount: false,
200
+ persistable: true,
201
+ supportsRunAs: false,
202
+ },
203
+ blaxel: {
204
+ backend: "blaxel",
205
+ backendId: "blaxel",
206
+ tier: "desktop",
207
+ os: { supported: ["linux"], default: "linux" },
208
+ capabilities: {
209
+ FileSystem: { available: true, readOnly: false },
210
+ Terminal: { available: true, transport: "sse-events", pty: false }, // pty-until-proven=no
211
+ Git: { available: true },
212
+ DesktopStream: { available: true, transport: "vnc-ws" },
213
+ Recording: { available: true },
214
+ },
215
+ lifetime: {
216
+ requiresSnapshotRollover: false,
217
+ hasIdleKiller: true,
218
+ supportsSuspendResume: false,
219
+ resumeIsLockFree: false,
220
+ },
221
+ snapshot: { kind: "tar-only", hasTarFallback: true },
222
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: true }, // only on-demand backend
223
+ workspaceRoot: "/workspace",
224
+ nativeBucketMount: false,
225
+ persistable: true,
226
+ supportsRunAs: false,
227
+ },
228
+ cloudflare: {
229
+ backend: "cloudflare",
230
+ backendId: "cloudflare",
231
+ tier: "headless",
232
+ os: { supported: ["linux"], default: "linux" },
233
+ capabilities: {
234
+ FileSystem: { available: true, readOnly: false },
235
+ Terminal: { available: true, transport: "sse-events", pty: true },
236
+ Git: { available: true },
237
+ DesktopStream: { available: false, transport: null },
238
+ Recording: { available: false },
239
+ },
240
+ lifetime: {
241
+ requiresSnapshotRollover: false,
242
+ hasIdleKiller: true,
243
+ supportsSuspendResume: false,
244
+ resumeIsLockFree: false,
245
+ },
246
+ snapshot: { kind: "tar-only", hasTarFallback: true },
247
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: false },
248
+ workspaceRoot: "/workspace",
249
+ nativeBucketMount: false,
250
+ persistable: true,
251
+ supportsRunAs: true,
252
+ },
253
+ vercel: {
254
+ backend: "vercel",
255
+ backendId: "vercel",
256
+ tier: "headless",
257
+ os: { supported: ["linux"], default: "linux" },
258
+ capabilities: {
259
+ FileSystem: { available: true, readOnly: false },
260
+ Terminal: { available: true, transport: "sse-events", pty: false },
261
+ Git: { available: true },
262
+ DesktopStream: { available: false, transport: null },
263
+ Recording: { available: false },
264
+ },
265
+ lifetime: {
266
+ hardLifetimeMs: 5 * 60 * 60 * 1000,
267
+ requiresSnapshotRollover: true,
268
+ hasIdleKiller: true,
269
+ supportsSuspendResume: true,
270
+ resumeIsLockFree: false,
271
+ },
272
+ snapshot: { kind: "tar-only", hasTarFallback: true },
273
+ portExposure: { kind: "preview-url", supportsOnDemandPorts: false },
274
+ workspaceRoot: "/vercel/sandbox",
275
+ nativeBucketMount: false,
276
+ persistable: true,
277
+ supportsRunAs: false,
278
+ },
279
+ docker: {
280
+ backend: "docker",
281
+ backendId: "docker",
282
+ tier: "dev",
283
+ os: { supported: ["linux"], default: "linux" },
284
+ capabilities: {
285
+ FileSystem: { available: true, readOnly: false },
286
+ Terminal: { available: true, transport: "sse-events", pty: true },
287
+ Git: { available: true },
288
+ DesktopStream: { available: false, transport: null }, // local
289
+ Recording: { available: false },
290
+ },
291
+ lifetime: {
292
+ requiresSnapshotRollover: false,
293
+ hasIdleKiller: false,
294
+ supportsSuspendResume: false,
295
+ resumeIsLockFree: true,
296
+ },
297
+ snapshot: { kind: "native-dir", hasTarFallback: true },
298
+ portExposure: { kind: "local-port", supportsOnDemandPorts: false },
299
+ workspaceRoot: "/workspace",
300
+ nativeBucketMount: false,
301
+ persistable: true,
302
+ supportsRunAs: true,
303
+ },
304
+ local: {
305
+ backend: "local",
306
+ // The SDK's UnixLocalSandboxClient reports backendId "unix_local" — this MUST
307
+ // match it (it is the resume-fence field compared against client.backendId).
308
+ backendId: "unix_local",
309
+ tier: "dev",
310
+ os: { supported: ["linux"], default: "linux" },
311
+ capabilities: {
312
+ FileSystem: { available: true, readOnly: false },
313
+ Terminal: { available: true, transport: "sse-events", pty: true },
314
+ Git: { available: true },
315
+ DesktopStream: { available: false, transport: null },
316
+ Recording: { available: false },
317
+ },
318
+ lifetime: {
319
+ requiresSnapshotRollover: false,
320
+ hasIdleKiller: false,
321
+ supportsSuspendResume: false,
322
+ resumeIsLockFree: true,
323
+ },
324
+ snapshot: { kind: "native-dir", hasTarFallback: true },
325
+ portExposure: { kind: "local-port", supportsOnDemandPorts: false },
326
+ workspaceRoot: "/workspace",
327
+ nativeBucketMount: false,
328
+ persistable: false,
329
+ supportsRunAs: false,
330
+ },
331
+ none: {
332
+ backend: "none",
333
+ backendId: "none",
334
+ tier: "none",
335
+ os: { supported: ["linux"], default: "linux" },
336
+ capabilities: {
337
+ FileSystem: { available: false, readOnly: true },
338
+ Terminal: { available: false, transport: null, pty: false },
339
+ Git: { available: false },
340
+ DesktopStream: { available: false, transport: null },
341
+ Recording: { available: false },
342
+ },
343
+ lifetime: {
344
+ requiresSnapshotRollover: false,
345
+ hasIdleKiller: false,
346
+ supportsSuspendResume: false,
347
+ resumeIsLockFree: true,
348
+ },
349
+ snapshot: { kind: "none", hasTarFallback: false },
350
+ portExposure: { kind: "none", supportsOnDemandPorts: false },
351
+ workspaceRoot: "/workspace",
352
+ nativeBucketMount: false,
353
+ persistable: false,
354
+ supportsRunAs: false,
355
+ },
356
+ // Bring-your-own-compute: the user's OWN machine, enrolled via a Rust agent,
357
+ // becomes ONE shared whole-machine sandbox (the agent IS the box). It is the
358
+ // first backend to make macOS/Windows reachable (default linux). Desktop is
359
+ // capability-PROCLAIMED ("vnc-ws") — the agent serves a native display stack
360
+ // (Linux X11/Xvfb, macOS CGEvent/ScreenCaptureKit) consent-gated at enroll;
361
+ // the online/offline/consent/display negotiation lives in select.ts (M3), this
362
+ // row is the static feasibility ceiling. Always-on (process-lifetime, never
363
+ // idle-reaped) and NOT persistable — OpenGeni cannot snapshot the user's disk,
364
+ // so resume = "is the agent's subject live?", never a cold re-create. Ports
365
+ // surface on-demand through the stateless relay edge, which lands behind the
366
+ // `resolveExposedPort` swap-seam later; until then it reuses the existing
367
+ // `provider-tunnel` exposure kind (the relay IS the provider tunnel for the
368
+ // agent) so no new PortExposureKind literal — and no new switch arms — are
369
+ // introduced. supportsOnDemandPorts:true: the agent opens a stream channel for
370
+ // a port on request rather than pre-declaring 6080/7681 at construction.
371
+ selfhosted: {
372
+ backend: "selfhosted",
373
+ backendId: "selfhosted",
374
+ tier: "desktop",
375
+ os: { supported: ["linux", "macos", "windows"], default: "linux" },
376
+ capabilities: {
377
+ FileSystem: { available: true, readOnly: false },
378
+ Terminal: { available: true, transport: "pty-ws", pty: true }, // real PTY over the relay
379
+ Git: { available: true },
380
+ DesktopStream: { available: true, transport: "vnc-ws" }, // proclaimed; consent-gated at enroll
381
+ Recording: { available: true }, // boot invariant: == DesktopStream.available
382
+ },
383
+ lifetime: {
384
+ // Whole-machine, always-there: online while the agent process runs, offline
385
+ // when it stops. The lease is NEVER idle-killed (it's the user's machine,
386
+ // not a reapable cloud box) and there is nothing to suspend/resume — the
387
+ // machine simply is or isn't reachable.
388
+ requiresSnapshotRollover: false,
389
+ hasIdleKiller: false,
390
+ supportsSuspendResume: false,
391
+ resumeIsLockFree: true, // resume = address the live NATS subject; no provider lock
392
+ },
393
+ // persistable:false forces snapshot.kind:"none" (the descriptor invariant
394
+ // `persistable ⇒ snapshot.kind!=="none"`): OpenGeni cannot snapshot the
395
+ // user's disk — the machine itself is the persistence.
396
+ snapshot: { kind: "none", hasTarFallback: false },
397
+ portExposure: { kind: "provider-tunnel", supportsOnDemandPorts: true },
398
+ workspaceRoot: "/", // agent-reported machine root (the whole machine is the sandbox)
399
+ nativeBucketMount: false,
400
+ persistable: false,
401
+ supportsRunAs: false,
402
+ },
403
+ };
404
+
16
405
  export const ReasoningEffort = z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]);
17
406
  export type ReasoningEffort = z.infer<typeof ReasoningEffort>;
18
407
 
@@ -66,8 +455,28 @@ export const Permission = z.enum([
66
455
  "sessions:create",
67
456
  "sessions:read",
68
457
  "sessions:control",
458
+ // Sandbox-surfacing (master-spine §C.3 / crosscut PART 1.2). stream:view is a
459
+ // REAL, distinct permission — strictly BROADER than sessions:read — because the
460
+ // pixel plane (Channel B) is UN-REDACTED: a viewer of raw pixels can see cloud
461
+ // creds the agent cat's into a terminal, which the redacted Channel-A event log
462
+ // never exposes. sessions:read is NOT permission to watch raw pixels.
463
+ "stream:view",
464
+ // SEPARATE from stream:view: raw input to the desktop (bypasses approvalQueue /
465
+ // interrupt). NEVER granted by default in v1 (the input plane is OFF —
466
+ // streamControlEnabled=false); the permission exists so later hardening is a
467
+ // flag flip, not a redesign.
468
+ "stream:control",
469
+ // Accept the pixel-plane secret-leak acknowledgment (consent gate before the
470
+ // un-redacted desktop URL is handed out).
471
+ "stream:acknowledge",
69
472
  "files:upload",
70
473
  "files:read",
474
+ // Channel-A structured write surface (FS writes / apply-patch); distinct from
475
+ // files:read so a read-only viewer can't mutate the box filesystem.
476
+ "files:write",
477
+ // Attach to an interactive PTY (terminal-as-pty, Channel A); distinct from
478
+ // sessions:read which only reads the command-output firehose.
479
+ "terminal:attach",
71
480
  "documents:manage",
72
481
  "documents:search",
73
482
  "scheduled_tasks:manage",
@@ -78,6 +487,13 @@ export const Permission = z.enum([
78
487
  "environments:manage",
79
488
  "environments:use",
80
489
  "goals:manage",
490
+ // Bring-your-own-compute (M5). enrollments:read lists a workspace's machines;
491
+ // enrollments:manage approves a device-flow enrollment (the LOUD whole-machine
492
+ // consent) + revokes a machine. Distinct from sessions/stream perms because an
493
+ // enrollment grants WHOLE-MACHINE access to a user's own hardware — a high-trust,
494
+ // admin-shaped action. workspace:admin is the super-wildcard over both.
495
+ "enrollments:read",
496
+ "enrollments:manage",
81
497
  ]);
82
498
  export type Permission = z.infer<typeof Permission>;
83
499
 
@@ -196,6 +612,280 @@ export async function verifyDelegatedAccessToken(secret: string, token: string,
196
612
  return payload.data;
197
613
  }
198
614
 
615
+ // --- Enrollment bearer credential (bring-your-own-compute M5, dossier §10.2) ---
616
+ //
617
+ // The signed bearer the agent presents to the control plane after enrollment (the
618
+ // EnrollmentCredentials.bearer the poll returns). REUSES the SAME HMAC envelope as
619
+ // the delegated/stream tokens (base64Url payload + hmacSha256Base64Url) with a
620
+ // distinct `oge_` prefix so it can never be confused with an `ogd_` access token or
621
+ // an `ogs_` stream token. It binds (workspaceId, agentId, enrollmentId) so the
622
+ // control plane can verify the agent owns the subject `agent.<ws>.<id>` it
623
+ // subscribes to. Signed with resolveEnrollmentSigningSecret; the secret value is
624
+ // NEVER logged. The real per-workspace NATS Account creds binding is infra-deferred
625
+ // (M4/relay) — this bearer is the application-tier identity proof.
626
+ export const EnrollmentBearerPayload = z.object({
627
+ workspaceId: z.string().uuid(),
628
+ agentId: z.string().uuid(),
629
+ enrollmentId: z.string().uuid(),
630
+ // The Account-scoped control-plane subject prefix the agent subscribes to.
631
+ subjectPrefix: z.string().min(1),
632
+ exp: z.number().int().positive(),
633
+ });
634
+ export type EnrollmentBearerPayload = z.infer<typeof EnrollmentBearerPayload>;
635
+
636
+ export async function signEnrollmentBearer(secret: string, payload: EnrollmentBearerPayload): Promise<string> {
637
+ const encodedPayload = base64UrlEncode(JSON.stringify(EnrollmentBearerPayload.parse(payload)));
638
+ const signature = await hmacSha256Base64Url(secret, encodedPayload);
639
+ return `oge_${encodedPayload}.${signature}`;
640
+ }
641
+
642
+ export async function verifyEnrollmentBearer(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<EnrollmentBearerPayload | null> {
643
+ if (!token.startsWith("oge_")) {
644
+ return null;
645
+ }
646
+ const withoutPrefix = token.slice("oge_".length);
647
+ const dot = withoutPrefix.lastIndexOf(".");
648
+ if (dot <= 0) {
649
+ return null;
650
+ }
651
+ const encodedPayload = withoutPrefix.slice(0, dot);
652
+ const signature = withoutPrefix.slice(dot + 1);
653
+ const expected = await hmacSha256Base64Url(secret, encodedPayload);
654
+ if (!constantTimeEqual(signature, expected)) {
655
+ return null;
656
+ }
657
+ const payload = EnrollmentBearerPayload.safeParse(JSON.parse(base64UrlDecode(encodedPayload)));
658
+ if (!payload.success || payload.data.exp < nowSeconds) {
659
+ return null;
660
+ }
661
+ return payload.data;
662
+ }
663
+
664
+ // --- Non-interactive enroll token (self-hosted enrollment UX §A2.1) ----------
665
+ //
666
+ // The SHORT-TTL, secret, workspace-scoped token the headless/fleet enroll path
667
+ // presents to /v1/enrollments/token/exchange. The token IS the grant — there is
668
+ // no human approve step — so it is stateless-signed (no DB row): the holder of an
669
+ // unexpired token can enroll ONE machine identity into ONE workspace.
670
+ //
671
+ // It REUSES the SAME HMAC envelope as signEnrollmentBearer (base64Url payload +
672
+ // hmacSha256Base64Url) with a DISTINCT `oget_` prefix and a `typ: "enroll"` claim.
673
+ // DOMAIN SEPARATION: even though it shares the signing secret with the `oge_`
674
+ // bearer, the prefix + typ claim make an enroll token unusable as an `oge_`
675
+ // bearer (verifyEnrollmentBearer's `oge_` prefix check rejects it) and vice-versa
676
+ // (verifyEnrollToken's `oget_` prefix + typ check rejects an `oge_` bearer). The
677
+ // secret value is NEVER logged.
678
+ export const EnrollTokenPayload = z.object({
679
+ // Domain-separation claim — fixed "enroll" so an `oge_`/`ogd_`/`ogs_` payload (no
680
+ // typ, or a different typ) can never satisfy verifyEnrollToken even past the prefix.
681
+ typ: z.literal("enroll"),
682
+ workspaceId: z.string().uuid(),
683
+ accountId: z.string().uuid(),
684
+ // The screen-control consent baked into the token at mint (the minting user's
685
+ // decision); the exchange records it as consentedScreenControl on the enrollment.
686
+ allowScreenControl: z.boolean(),
687
+ iat: z.number().int().nonnegative(),
688
+ exp: z.number().int().positive(),
689
+ });
690
+ export type EnrollTokenPayload = z.infer<typeof EnrollTokenPayload>;
691
+
692
+ export async function signEnrollToken(secret: string, payload: EnrollTokenPayload): Promise<string> {
693
+ const encodedPayload = base64UrlEncode(JSON.stringify(EnrollTokenPayload.parse(payload)));
694
+ const signature = await hmacSha256Base64Url(secret, encodedPayload);
695
+ return `oget_${encodedPayload}.${signature}`;
696
+ }
697
+
698
+ /**
699
+ * Verify an enroll token: rejects (returns null) on a bad prefix (NOT `oget_`),
700
+ * a malformed envelope, a bad HMAC signature (constant-time), schema-invalid
701
+ * claims (which includes `typ !== "enroll"` — the `z.literal` rejects it), or an
702
+ * expired token (`exp < now`). Mirrors verifyEnrollmentBearer exactly. An `oge_`
703
+ * bearer fails the prefix gate; a same-secret token that lacks the typ claim fails
704
+ * the schema gate — both halves of the domain separation are enforced here.
705
+ */
706
+ export async function verifyEnrollToken(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<EnrollTokenPayload | null> {
707
+ if (!token.startsWith("oget_")) {
708
+ return null;
709
+ }
710
+ const withoutPrefix = token.slice("oget_".length);
711
+ const dot = withoutPrefix.lastIndexOf(".");
712
+ if (dot <= 0) {
713
+ return null;
714
+ }
715
+ const encodedPayload = withoutPrefix.slice(0, dot);
716
+ const signature = withoutPrefix.slice(dot + 1);
717
+ const expected = await hmacSha256Base64Url(secret, encodedPayload);
718
+ if (!constantTimeEqual(signature, expected)) {
719
+ return null;
720
+ }
721
+ let decoded: unknown;
722
+ try {
723
+ decoded = JSON.parse(base64UrlDecode(encodedPayload));
724
+ } catch {
725
+ return null;
726
+ }
727
+ const payload = EnrollTokenPayload.safeParse(decoded);
728
+ if (!payload.success || payload.data.exp < nowSeconds) {
729
+ return null;
730
+ }
731
+ return payload.data;
732
+ }
733
+
734
+ // --- Scoped data-plane stream token (master-spine §C.3 / crosscut PART 1.3) ---
735
+ //
736
+ // REUSES the existing HMAC envelope (sign/verifyDelegatedAccessToken's
737
+ // base64Url + hmacSha256Base64Url) — NOT a second crypto — but with a distinct
738
+ // `ogs_` prefix and a HARD-NARROW claim set. The token is a CLAIM the OpenGeni
739
+ // control plane mints; it is NOT the provider's tunnel secret. The browser
740
+ // receives { providerUrl, streamToken }; the provider tunnel URL is the
741
+ // transport, the streamToken is what the in-box edge validates (websockify
742
+ // TokenFile is later-hardening; in v1 the URL's short TTL + the acknowledged
743
+ // stream:view gate are the real boundary). The token is minted + recorded
744
+ // against the holder from day one. It is NEVER appended to the URL as a query
745
+ // param (the provider's own scoped token already lives in the URL).
746
+ //
747
+ // `leaseEpoch` is the fence: when the box is re-elected (warming→warm bumps the
748
+ // epoch) the URL is re-minted with epoch+1 and the old tunnel is torn down, so a
749
+ // stale token points at a dead tunnel. Epoch mismatch is enforced at USE (by the
750
+ // caller comparing the claim against the live lease), not inside verify.
751
+ export const StreamTokenPayload = z.object({
752
+ workspaceId: z.string().uuid(),
753
+ sessionId: z.string().uuid(),
754
+ // Identifies the sandbox_lease_holders row (the viewer holder).
755
+ viewerId: z.string().uuid(),
756
+ // Fence: the token logically dies when the box is re-elected (epoch++).
757
+ leaseEpoch: z.number().int().nonnegative(),
758
+ // v1 is always "view"; "control" is the never-granted raw-input plane.
759
+ mode: z.enum(["view", "control"]),
760
+ // 6080 (noVNC); pins the token to ONE exposed port.
761
+ port: z.number().int().positive(),
762
+ // Short TTL (120s default); rotation is event-driven under the epoch fence,
763
+ // not on a keepalive clock.
764
+ exp: z.number().int().positive(),
765
+ });
766
+ export type StreamTokenPayload = z.infer<typeof StreamTokenPayload>;
767
+
768
+ export async function signStreamToken(secret: string, payload: StreamTokenPayload): Promise<string> {
769
+ const encodedPayload = base64UrlEncode(JSON.stringify(StreamTokenPayload.parse(payload)));
770
+ const signature = await hmacSha256Base64Url(secret, encodedPayload);
771
+ return `ogs_${encodedPayload}.${signature}`;
772
+ }
773
+
774
+ /**
775
+ * Verify a stream token: rejects (returns null) on a bad prefix, malformed
776
+ * envelope, bad HMAC signature (constant-time), schema-invalid claims, or an
777
+ * expired token (`exp < now`). Mirrors verifyDelegatedAccessToken exactly.
778
+ *
779
+ * The epoch fence (claim.leaseEpoch vs the LIVE lease epoch) and the
780
+ * workspace/session scope are checked by the CALLER at use against the live
781
+ * lease + route params — verify proves the token is authentic + unexpired, the
782
+ * caller proves it is for THIS box's current epoch and THIS workspace+session.
783
+ */
784
+ export async function verifyStreamToken(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<StreamTokenPayload | null> {
785
+ if (!token.startsWith("ogs_")) {
786
+ return null;
787
+ }
788
+ const withoutPrefix = token.slice("ogs_".length);
789
+ const dot = withoutPrefix.lastIndexOf(".");
790
+ if (dot <= 0) {
791
+ return null;
792
+ }
793
+ const encodedPayload = withoutPrefix.slice(0, dot);
794
+ const signature = withoutPrefix.slice(dot + 1);
795
+ const expected = await hmacSha256Base64Url(secret, encodedPayload);
796
+ if (!constantTimeEqual(signature, expected)) {
797
+ return null;
798
+ }
799
+ let decoded: unknown;
800
+ try {
801
+ decoded = JSON.parse(base64UrlDecode(encodedPayload));
802
+ } catch {
803
+ return null;
804
+ }
805
+ const payload = StreamTokenPayload.safeParse(decoded);
806
+ if (!payload.success || payload.data.exp < nowSeconds) {
807
+ return null;
808
+ }
809
+ return payload.data;
810
+ }
811
+
812
+ // --- Relay PRODUCER token (bring-your-own-compute M8b, dossier §10.5) ---
813
+ //
814
+ // The token the AGENT presents to the relay edge when it registers a pty/desktop
815
+ // stream channel (role=AGENT) — distinct from the viewer's `ogs_` token. It is
816
+ // minted by the control plane at enrollment and threaded into EnrollmentCredentials
817
+ // (proto field `relay_token`); the relay verifies it on its own merits, then pairs
818
+ // the producer with the consumer by the shared channel key.
819
+ //
820
+ // REUSES the EXACT SAME HMAC envelope as the `ogs_`/`ogd_`/`oge_` tokens
821
+ // (base64Url JSON payload + hmacSha256Base64Url) — NOT a second crypto — with a
822
+ // distinct `ogr_` prefix so it can never be confused with the others. The claim
823
+ // set binds (workspaceId, agentId): the relay reads the channel-key's ws+agent
824
+ // from the StreamOpen and asserts the producer token claims the SAME pair, so a
825
+ // producer token for workspace A can never register a channel for workspace B.
826
+ // Signed with resolveRelayTokenSecret (the relay-token HMAC secret); the value is
827
+ // NEVER logged. Long-lived by design (it is enrollment-scoped, not per-stream —
828
+ // the agent presents it on every channel registration for the life of the
829
+ // enrollment); the relay additionally validates the channel key + (for the
830
+ // viewer's `ogs_`) the lease/active-epoch fence.
831
+ //
832
+ // The Rust relay re-implements this verify (the same base64url(JSON) + HMAC-SHA256
833
+ // + prefix split) so TS-mint and Rust-verify provably agree — see the cross-stack
834
+ // fixture in agent/crates/opengeni-relay/tests and the relay's `token` module doc.
835
+ export const RelayTokenPayload = z.object({
836
+ // The workspace the agent (and its channels) belong to — the relay asserts this
837
+ // equals the channel-key's ws so a producer can only register its own channels.
838
+ workspaceId: z.string().uuid(),
839
+ // The agent (machine) id — the relay asserts this equals the channel-key's agent.
840
+ agentId: z.string().uuid(),
841
+ // Expiry (unix seconds). Enrollment-scoped horizon (re-minted on re-enroll).
842
+ exp: z.number().int().positive(),
843
+ });
844
+ export type RelayTokenPayload = z.infer<typeof RelayTokenPayload>;
845
+
846
+ export async function signRelayToken(secret: string, payload: RelayTokenPayload): Promise<string> {
847
+ const encodedPayload = base64UrlEncode(JSON.stringify(RelayTokenPayload.parse(payload)));
848
+ const signature = await hmacSha256Base64Url(secret, encodedPayload);
849
+ return `ogr_${encodedPayload}.${signature}`;
850
+ }
851
+
852
+ /**
853
+ * Verify a relay producer token: rejects (returns null) on a bad prefix, malformed
854
+ * envelope, bad HMAC signature (constant-time), schema-invalid claims, or expiry.
855
+ * Mirrors verifyStreamToken exactly. The relay (Rust) re-implements this verify;
856
+ * the TS verify here proves the format for the cross-stack fixture + any TS caller.
857
+ *
858
+ * The channel-key scope (claim.workspaceId/agentId vs the StreamOpen channel key)
859
+ * is enforced by the relay at USE — verify proves authenticity + freshness only.
860
+ */
861
+ export async function verifyRelayToken(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<RelayTokenPayload | null> {
862
+ if (!token.startsWith("ogr_")) {
863
+ return null;
864
+ }
865
+ const withoutPrefix = token.slice("ogr_".length);
866
+ const dot = withoutPrefix.lastIndexOf(".");
867
+ if (dot <= 0) {
868
+ return null;
869
+ }
870
+ const encodedPayload = withoutPrefix.slice(0, dot);
871
+ const signature = withoutPrefix.slice(dot + 1);
872
+ const expected = await hmacSha256Base64Url(secret, encodedPayload);
873
+ if (!constantTimeEqual(signature, expected)) {
874
+ return null;
875
+ }
876
+ let decoded: unknown;
877
+ try {
878
+ decoded = JSON.parse(base64UrlDecode(encodedPayload));
879
+ } catch {
880
+ return null;
881
+ }
882
+ const payload = RelayTokenPayload.safeParse(decoded);
883
+ if (!payload.success || payload.data.exp < nowSeconds) {
884
+ return null;
885
+ }
886
+ return payload.data;
887
+ }
888
+
199
889
  export const CreateWorkspaceRequest = z.object({
200
890
  accountId: z.string().uuid().optional(),
201
891
  name: z.string().min(1),
@@ -246,6 +936,38 @@ export const CreateApiKeyResponse = z.object({
246
936
  });
247
937
  export type CreateApiKeyResponse = z.infer<typeof CreateApiKeyResponse>;
248
938
 
939
+ // A person (or API key) with access to a workspace: one workspace_memberships
940
+ // row. `subjectId` is `user:<betterAuthUserId>` or `api_key:<id>`; the People
941
+ // surface lists the `user:` subjects (api_key subjects belong to API keys).
942
+ export const WorkspaceMember = z.object({
943
+ subjectId: z.string().min(1),
944
+ subjectLabel: z.string().nullable(),
945
+ role: z.string(),
946
+ permissions: z.array(Permission),
947
+ createdAt: z.string(),
948
+ });
949
+ export type WorkspaceMember = z.infer<typeof WorkspaceMember>;
950
+
951
+ export const ListWorkspaceMembersResponse = z.object({
952
+ members: z.array(WorkspaceMember),
953
+ });
954
+ export type ListWorkspaceMembersResponse = z.infer<typeof ListWorkspaceMembersResponse>;
955
+
956
+ export const AddWorkspaceMemberRequest = z.object({
957
+ // Resolved against the managed (Better Auth) users; email invites for
958
+ // not-yet-registered users are deferred, so an unknown email returns 404.
959
+ email: z.string().email(),
960
+ role: z.string().min(1).optional(),
961
+ permissions: z.array(Permission),
962
+ });
963
+ export type AddWorkspaceMemberRequest = z.infer<typeof AddWorkspaceMemberRequest>;
964
+
965
+ export const UpdateWorkspaceMemberRequest = z.object({
966
+ role: z.string().min(1).optional(),
967
+ permissions: z.array(Permission),
968
+ });
969
+ export type UpdateWorkspaceMemberRequest = z.infer<typeof UpdateWorkspaceMemberRequest>;
970
+
249
971
  export const UsageEventType = z.enum([
250
972
  "agent_run.created",
251
973
  "agent_run.completed",
@@ -256,6 +978,15 @@ export const UsageEventType = z.enum([
256
978
  "document.indexed",
257
979
  "scheduled_task.fired",
258
980
  "api_key.request",
981
+ // --- sandbox warm-time metering (P2.1) ---
982
+ // Wall-clock seconds a box was warm — the billable warm-time meter. Accrued on
983
+ // the two stateless ticks (turn heartbeat + reaper sweep), idempotent on
984
+ // (sandbox_group_id, lease_epoch, tick) so a shared box (N sessions) is metered
985
+ // EXACTLY ONCE per tick (N sessions != N x bill). Orthogonal to model.tokens /
986
+ // model.cost (model API cost vs provider compute cost — both real, no overlap).
987
+ "sandbox.warm_seconds",
988
+ // usd_micros: warm-seconds x the per-provider per-second warm rate.
989
+ "sandbox.warm_cost",
259
990
  ]);
260
991
  export type UsageEventType = z.infer<typeof UsageEventType>;
261
992
 
@@ -312,6 +1043,155 @@ export const LimitDecision = z.discriminatedUnion("allowed", [
312
1043
  ]);
313
1044
  export type LimitDecision = z.infer<typeof LimitDecision>;
314
1045
 
1046
+ // ============ P3 — Entitlements port (§7.5) ============
1047
+ //
1048
+ // The host-providable admission seam over OpenGeni's TWO existing admission
1049
+ // sites: the API edge (`checkLimit`/`requireLimit`, billing/limits.ts) AND the
1050
+ // worker edge (`ensureRunAllowed`, agent-turn.ts — both turn-entry and the
1051
+ // mid-stream budget valve). A host that owns its OWN ledger/meter binds this to
1052
+ // keep OpenGeni from re-deriving admission from its local ledger.
1053
+ //
1054
+ // CRITICAL CONTRACT: `admitRun` returns a transport-neutral allow/deny decision
1055
+ // (+ optional structured reason + the echoed quantity it admitted) and NEVER
1056
+ // exposes `getBillingBalance` or any ledger internals — the host's balance math
1057
+ // stays on the host side of the boundary. This is what lets the same port serve
1058
+ // both PUSH (host funds OpenGeni's ledger; admission is a LOCAL read of that
1059
+ // funded ledger) and PULL (a network callback to the host's own meter).
1060
+ //
1061
+ // `action` is a free `string` (NOT the internal `LimitAction` enum) so a host
1062
+ // meter can key on actions OpenGeni does not model. `quantity` is the units the
1063
+ // caller is about to consume (tokens, bytes, 1 run, …); the decision MAY echo
1064
+ // the admitted quantity so a PULL host can grant a partial allowance.
1065
+ export const EntitlementDecision = z.discriminatedUnion("allowed", [
1066
+ z.object({ allowed: z.literal(true), quantity: z.number().optional() }),
1067
+ z.object({ allowed: z.literal(false), reason: z.string(), code: z.string().optional(), quantity: z.number().optional() }),
1068
+ ]);
1069
+ export type EntitlementDecision = z.infer<typeof EntitlementDecision>;
1070
+
1071
+ export type AdmitRunInput = {
1072
+ accountId: string;
1073
+ workspaceId: string;
1074
+ action: string;
1075
+ quantity: number;
1076
+ };
1077
+
1078
+ export type EntitlementsPort = {
1079
+ admitRun(input: AdmitRunInput): Promise<EntitlementDecision>;
1080
+ };
1081
+
1082
+ // ============ P4a — Connection-credential provider (§7.6) ============
1083
+ //
1084
+ // The host-providable per-run credential-mint seam over OpenGeni's TWO
1085
+ // run-scoped credential sites in the worker:
1086
+ // - GIT credentials: the GitHub App installation token minted in
1087
+ // `sandboxEnvironmentForRun` (today `createGitHubAppInstallationToken`
1088
+ // from `settings`) and injected as `GH_TOKEN`/`GITHUB_TOKEN`/the git
1089
+ // extraheader.
1090
+ // - SANDBOX secrets: the decrypted workspace environment values loaded in
1091
+ // `loadWorkspaceEnvironmentForRun` (today decrypted with
1092
+ // `environmentsEncryptionKeyBytes(settings)`).
1093
+ //
1094
+ // In embedded/separate topologies the HOST owns these external connections
1095
+ // (its GitHub App, its secret vault + encryption key). When a host binds this
1096
+ // port, OpenGeni asks the host to mint/decrypt per-run instead of self-minting
1097
+ // from `settings`. Unset (standalone default) → byte-for-byte today's
1098
+ // self-mint.
1099
+ //
1100
+ // FORK-7 CROSS-CHECK (the host-mapping safety guardrail): a credential
1101
+ // provider returns the `workspaceId` it scoped the credential to, and the
1102
+ // activity ASSERTS it agrees with the run's workspace BEFORE injecting
1103
+ // `GH_TOKEN` (or applying the decrypted values). A host mapping bug that
1104
+ // returns tenant B's creds while the run is tenant A is thereby caught at the
1105
+ // seam, never silently injected into tenant A's sandbox.
1106
+
1107
+ export type GitCredentialsRequest = {
1108
+ accountId: string;
1109
+ workspaceId: string;
1110
+ // The GitHub App installation the run's repository resources resolved to,
1111
+ // and the specific repositories the token must be scoped to. Mirrors the
1112
+ // shape `createGitHubAppInstallationToken` consumes today.
1113
+ installationId: number;
1114
+ repositoryIds: number[];
1115
+ };
1116
+
1117
+ export type GitCredentials = {
1118
+ // The minted installation token the activity injects as GH_TOKEN/GITHUB_TOKEN
1119
+ // and into the git http extraheader (identical downstream handling to the
1120
+ // self-mint path).
1121
+ token: string;
1122
+ // FORK-7 echo: the workspace the provider scoped this token to. The activity
1123
+ // asserts `workspaceId === request.workspaceId` before injecting.
1124
+ workspaceId: string;
1125
+ // Optional git identity override. When omitted the activity falls back to
1126
+ // today's `githubAppBotIdentity(settings)`.
1127
+ identity?: { name: string; email: string } | null;
1128
+ };
1129
+
1130
+ export type SandboxSecretsRequest = {
1131
+ accountId: string;
1132
+ workspaceId: string;
1133
+ // The workspace environment the run's session declares (null = unattached;
1134
+ // the provider, like the self-mint path, returns null values for it).
1135
+ environmentId: string;
1136
+ };
1137
+
1138
+ export type SandboxSecrets = {
1139
+ // The decrypted environment values the run injects, replacing the local
1140
+ // `environmentsEncryptionKeyBytes` decrypt. Same shape the self-mint path
1141
+ // produces (plaintext name→value).
1142
+ values: Record<string, string>;
1143
+ // FORK-7 echo: the workspace the provider scoped these secrets to.
1144
+ workspaceId: string;
1145
+ // Optional environment metadata; when omitted the activity uses the
1146
+ // environmentId as both id and name (the local decrypt carries the row's
1147
+ // id/name/description, but only `id` is load-bearing downstream).
1148
+ id?: string;
1149
+ name?: string;
1150
+ description?: string | null;
1151
+ };
1152
+
1153
+ export type ConnectionCredentialsPort = {
1154
+ // Both legs are optional: a host may drive ONLY git creds (BYO-GitHub-App)
1155
+ // and leave sandbox secrets to OpenGeni's local decrypt, or vice-versa. An
1156
+ // unset leg falls through to today's self-mint for THAT leg only.
1157
+ gitCredentials?: (input: GitCredentialsRequest) => Promise<GitCredentials>;
1158
+ sandboxSecrets?: (input: SandboxSecretsRequest) => Promise<SandboxSecrets>;
1159
+ };
1160
+
1161
+ // ============ P4a — GitHub App API port (BYO-App, §7.6 / SPIKE-2 remainder) ===
1162
+ //
1163
+ // The host-driven GitHub-API credential leg. SPIKE-2 closed the establishment +
1164
+ // gate (storage) axis; this closes the credential leg by making the two live
1165
+ // GitHub-API calls host-PROVIDABLE so a BYO-GitHub-App host drives its OWN App
1166
+ // credentials (its own JWT-signing key, its own OAuth client) instead of
1167
+ // OpenGeni self-minting from `settings`:
1168
+ // - verifyInstallationAccessForUser: the OAuth code→token + installation
1169
+ // lookup that PROVES the install is real (today
1170
+ // `verifyGitHubInstallationAccessForUser(settings, …)`).
1171
+ // - listRepositories: the installation-scoped repo listing behind
1172
+ // `GET /v1/workspaces/:id/github/repositories` (today
1173
+ // `listGitHubAppRepositories(settings, …)`).
1174
+ //
1175
+ // Unset (standalone default) → today's `settings`-based self-mint runs
1176
+ // byte-for-byte (the live GitHub-API verify/list against OpenGeni's own App).
1177
+
1178
+ export type GitHubInstallationSummary = {
1179
+ installationId: number;
1180
+ accountLogin: string | null;
1181
+ accountType: string | null;
1182
+ suspended: boolean;
1183
+ };
1184
+
1185
+ export type GitHubAppApiPort = {
1186
+ verifyInstallationAccessForUser?: (input: {
1187
+ code: string;
1188
+ installationId: number;
1189
+ }) => Promise<GitHubInstallationSummary>;
1190
+ listRepositories?: (input: {
1191
+ installationIds?: number[];
1192
+ }) => Promise<GitHubRepository[]>;
1193
+ };
1194
+
315
1195
  export const BillingBalance = z.object({
316
1196
  accountId: z.string().uuid(),
317
1197
  balanceMicros: z.number().int(),
@@ -471,6 +1351,16 @@ export type DocumentSearchRequest = z.infer<typeof DocumentSearchRequest>;
471
1351
  export const ToolRef = z.object({
472
1352
  kind: z.literal("mcp"),
473
1353
  id: z.string().min(1),
1354
+ // Non-fatal-on-connect marker for an AUTO-ATTACHED (workspace-default)
1355
+ // capability MCP server: when true, a connect / tools-list failure (e.g. an
1356
+ // expired capability credential returning 401) must SKIP that server with a
1357
+ // logged warning and let the turn proceed, rather than failing the whole
1358
+ // turn before the model runs. Absent/false ⇒ STRICT: an unavailable server
1359
+ // fails the turn (the contract for EXPLICITLY-requested tools). This flag is
1360
+ // set server-side only, at the default-capability auto-attach seam; it is
1361
+ // stripped from client-supplied tool refs so an explicit request always
1362
+ // stays strict.
1363
+ optional: z.boolean().optional(),
474
1364
  });
475
1365
  export type ToolRef = z.infer<typeof ToolRef>;
476
1366
 
@@ -482,17 +1372,26 @@ export class ResourceRefConflictError extends Error {
482
1372
  }
483
1373
 
484
1374
  export function mergeToolRefs(existing: ToolRef[], additions: ToolRef[]): ToolRef[] {
485
- const seen = new Set<string>();
486
- const out: ToolRef[] = [];
1375
+ const byKey = new Map<string, ToolRef>();
1376
+ const order: string[] = [];
487
1377
  for (const tool of [...existing, ...additions]) {
488
1378
  const key = `${tool.kind}:${tool.id}`;
489
- if (seen.has(key)) {
1379
+ const prior = byKey.get(key);
1380
+ if (!prior) {
1381
+ byKey.set(key, tool);
1382
+ order.push(key);
490
1383
  continue;
491
1384
  }
492
- seen.add(key);
493
- out.push(tool);
1385
+ // Strict wins: if the same server appears both auto-attached (optional) and
1386
+ // explicitly requested (non-optional), the explicit occurrence upgrades the
1387
+ // merged ref to strict — a later explicit request of an already-defaulted
1388
+ // capability MCP must still fail the turn when the server is unavailable.
1389
+ if (prior.optional === true && tool.optional !== true) {
1390
+ const { optional: _dropped, ...strict } = prior;
1391
+ byKey.set(key, strict);
1392
+ }
494
1393
  }
495
- return out;
1394
+ return order.map((key) => byKey.get(key)!);
496
1395
  }
497
1396
 
498
1397
  export function mergeResourceRefs(
@@ -616,6 +1515,11 @@ export const UpdateSessionGoalRequest = z.object({
616
1515
  });
617
1516
  export type UpdateSessionGoalRequest = z.infer<typeof UpdateSessionGoalRequest>;
618
1517
 
1518
+ export const UpdateSessionRequest = z.object({
1519
+ title: z.string().min(1).max(200),
1520
+ });
1521
+ export type UpdateSessionRequest = z.infer<typeof UpdateSessionRequest>;
1522
+
619
1523
  // Operator context controls (slash-command palette: /clear, /compact). These
620
1524
  // are session/operator actions, NOT a structured way to talk to the agent —
621
1525
  // the human↔agent channel stays plain chat. Both require `sessions:control`.
@@ -694,6 +1598,8 @@ export const SessionTurn = z.object({
694
1598
  model: z.string().min(1),
695
1599
  reasoningEffort: ReasoningEffort,
696
1600
  sandboxBackend: SandboxBackend,
1601
+ // Per-turn OS override. NULL = inherit the session's sandboxOs.
1602
+ sandboxOs: SandboxOs.nullable(),
697
1603
  metadata: z.record(z.string(), z.unknown()),
698
1604
  startedAt: z.string().nullable(),
699
1605
  finishedAt: z.string().nullable(),
@@ -1237,11 +2143,26 @@ export const Session = z.object({
1237
2143
  accountId: z.string().uuid(),
1238
2144
  status: SessionStatus,
1239
2145
  initialMessage: z.string(),
2146
+ title: z.string().nullable(),
2147
+ titleSource: z.enum(["user", "agent"]).nullable(),
1240
2148
  resources: z.array(ResourceRef),
1241
2149
  tools: z.array(ToolRef),
1242
2150
  metadata: z.record(z.string(), z.unknown()),
1243
2151
  model: z.string(),
1244
2152
  sandboxBackend: SandboxBackend,
2153
+ // The OS the session's box runs. Defaults to 'linux' (today's only OS).
2154
+ sandboxOs: SandboxOs,
2155
+ // The shared-sandbox group the session's box belongs to. Equals the session's
2156
+ // own id for a singleton group (today's 1:1 default); equals the parent's
2157
+ // group when spawned shared (both sessions run in ONE box).
2158
+ sandboxGroupId: z.string().uuid(),
2159
+ // The first-class swappable-sandbox POINTER (bring-your-own-compute M2). NULL
2160
+ // resolves to the session's own group sandbox (the backward-compat default);
2161
+ // a swap sets it to the target sandbox row. active_epoch is the second epoch
2162
+ // ABOVE the lease epoch, bumped on every swap so the routing proxy can fence a
2163
+ // stale in-flight op and retry against the new active sandbox.
2164
+ activeSandboxId: z.string().uuid().nullable(),
2165
+ activeEpoch: z.number().int().nonnegative(),
1245
2166
  environmentId: z.string().uuid().nullable(),
1246
2167
  // Non-default first-party MCP token permissions (manager-style sessions);
1247
2168
  // null means the fixed worker default set.
@@ -1262,6 +2183,12 @@ export const Session = z.object({
1262
2183
  // signal. Null until a turn with usage has completed.
1263
2184
  lastInputTokens: z.number().int().nonnegative().nullable(),
1264
2185
  lastSequence: z.number().int().nonnegative(),
2186
+ // Multi-account Codex (P1). codexPinnedCredentialId: the account this session is
2187
+ // manually PINNED to (null ⇒ follow the workspace active pointer).
2188
+ // codexLastCredentialId: the account the most recent turn actually ran on (the
2189
+ // "Running on:" indicator's source). Both are credential-row ids, null until set.
2190
+ codexPinnedCredentialId: z.string().uuid().nullable(),
2191
+ codexLastCredentialId: z.string().uuid().nullable(),
1265
2192
  createdAt: z.string(),
1266
2193
  updatedAt: z.string(),
1267
2194
  });
@@ -1300,9 +2227,502 @@ export const SessionEventType = z.enum([
1300
2227
  "goal.paused",
1301
2228
  "goal.resumed",
1302
2229
  "goal.continuation",
2230
+ // Channel-B desktop pixel-plane signals (07-channel-b §1.2). The pixel socket
2231
+ // carries opaque RFB and cannot carry a control message the client can act on,
2232
+ // so these ride the durable, sequenced, gap-filled Channel-A SSE spine.
2233
+ "stream.url.rotated", // re-minted {url,token,expiresAt} on box rollover (event-driven)
2234
+ "stream.opened", // a viewer attached (audit + refcount visibility)
2235
+ "stream.closed", // a viewer detached / was reaped
2236
+ "stream.revoked", // a grant was revoked → connected clients MUST disconnect now
2237
+ // Channel-B recording signals (P4.3 / module 05 §3.4). The "agent films itself
2238
+ // proving the fix" loop: ffmpeg x11grab of the SAME :0 humans watch → artifact
2239
+ // → storage. The artifact ref rides the AVAILABLE event (storageKey, NOT a
2240
+ // long-lived URL — clients mint a short-TTL signed GET via the route).
2241
+ "recording.started", // ffmpeg launched on :0 (mode/codec/dimensions)
2242
+ "recording.available", // finalized: bytes PUT to storage, replayable
2243
+ "recording.failed", // ffmpeg/box-death/rollover/upload error — no artifact
2244
+ // Channel-A structured-service notifications (P4.4 / modules/08-channel-a.md
2245
+ // §2.2). The A2 reads (fs/git/terminal exec) are SYNCHRONOUS API-direct point
2246
+ // queries (their result is the HTTP response, NEVER an event). What rides A1
2247
+ // here are the side-effect NOTIFICATIONS — a path changed, git state changed,
2248
+ // a pty opened/printed/exited — durable, sequenced, gap-filled like every
2249
+ // other session event, so any viewer's Pierre tree / diff / terminal stays
2250
+ // live. fs.changed/git.changed are cache-invalidation signals; the pty.*
2251
+ // events carry the interactive terminal byte stream.
2252
+ "fs.changed", // a path was created/modified/deleted (write or agent mutation)
2253
+ "git.changed", // working-tree/index/HEAD changed (debounced re-probe)
2254
+ "terminal.pty.started", // an interactive PTY session opened (carries ptyId)
2255
+ "terminal.pty.output.delta", // PTY stdout/stderr bytes (separate from command.output)
2256
+ "terminal.pty.exited", // PTY session ended (exitCode/reason)
2257
+ "session.title_set",
2258
+ // Multi-account Codex (P1): the account a session's turn runs on changed
2259
+ // (manual switch in P1; failover/rotation in P3 reuse the same event). Drives
2260
+ // the in-session "Running on:" indicator's live flip.
2261
+ "codex.account.switched",
1303
2262
  ]);
1304
2263
  export type SessionEventType = z.infer<typeof SessionEventType>;
1305
2264
 
2265
+ // Channel-B stream-event payloads (07-channel-b §1.2). SessionEvent.payload is
2266
+ // z.unknown() (NOT a discriminated union) — these are standalone schemas parsed
2267
+ // explicitly at the producer (the API-direct handshake/rotation) and the SDK/
2268
+ // React consumer. The rotation payload carries the freshly-minted data-plane URL
2269
+ // + the scoped stream token so a connected client hot-swaps its noVNC socket.
2270
+ export const StreamUrlRotatedPayload = z.object({
2271
+ url: z.string().url(),
2272
+ token: z.string().nullable(),
2273
+ expiresAt: z.string().datetime().nullable(),
2274
+ // The epoch the new URL was minted under (the box-rollover fence the client
2275
+ // reconciles against). A client must drop a rotation event whose epoch it has
2276
+ // already advanced past.
2277
+ leaseEpoch: z.number().int().nonnegative(),
2278
+ transport: z.literal("vnc-ws"),
2279
+ // The viewer holder this URL is for (so a client filters out other viewers').
2280
+ viewerId: z.string().uuid().nullable().default(null),
2281
+ });
2282
+ export type StreamUrlRotatedPayload = z.infer<typeof StreamUrlRotatedPayload>;
2283
+
2284
+ export const StreamOpenedPayload = z.object({
2285
+ viewerId: z.string().uuid(),
2286
+ shared: z.boolean().default(false),
2287
+ viewerCount: z.number().int().nonnegative(),
2288
+ });
2289
+ export type StreamOpenedPayload = z.infer<typeof StreamOpenedPayload>;
2290
+
2291
+ export const StreamClosedPayload = z.object({
2292
+ viewerId: z.string().uuid(),
2293
+ reason: z.enum(["client-disconnect", "reaped", "revoked", "box-rollover"]),
2294
+ viewerCount: z.number().int().nonnegative(),
2295
+ });
2296
+ export type StreamClosedPayload = z.infer<typeof StreamClosedPayload>;
2297
+
2298
+ export const StreamRevokedPayload = z.object({
2299
+ viewerId: z.string().uuid().nullable().default(null),
2300
+ reason: z.enum(["grant-revoked", "session-failed", "admin"]),
2301
+ });
2302
+ export type StreamRevokedPayload = z.infer<typeof StreamRevokedPayload>;
2303
+
2304
+ // ── Recording payloads (P4.3 / module 05 §3.4) ──────────────────────────────
2305
+ // SessionEvent.payload is z.unknown() (NOT a discriminated union) — these are
2306
+ // standalone schemas parsed explicitly at the producer (the recording activity)
2307
+ // and the SDK/React consumer. The codec/contentType pair stays consistent
2308
+ // (h264-mp4↔video/mp4, vp9-webm↔video/webm).
2309
+ export const RecordingMode = z.enum(["manual", "on-turn", "on-verify"]);
2310
+ export type RecordingMode = z.infer<typeof RecordingMode>;
2311
+ export const RecordingCodec = z.enum(["h264-mp4", "vp9-webm"]);
2312
+ export type RecordingCodec = z.infer<typeof RecordingCodec>;
2313
+ export const RecordingContentType = z.enum(["video/mp4", "video/webm"]);
2314
+ export type RecordingContentType = z.infer<typeof RecordingContentType>;
2315
+
2316
+ export const RecordingStartedPayload = z.object({
2317
+ recordingId: z.string().uuid(),
2318
+ turnId: z.string().uuid().nullable(),
2319
+ mode: RecordingMode,
2320
+ codec: RecordingCodec,
2321
+ dimensions: z.tuple([z.number().int().positive(), z.number().int().positive()]),
2322
+ framerate: z.number().int().positive(),
2323
+ startedAt: z.string(), // ISO
2324
+ // The verification rationale ("agent-verification: tf apply succeeded"). Agent-
2325
+ // authored free text — the producer caps + scrubs it before emit.
2326
+ reason: z.string().nullable().optional(),
2327
+ });
2328
+ export type RecordingStartedPayload = z.infer<typeof RecordingStartedPayload>;
2329
+
2330
+ export const RecordingAvailablePayload = z.object({
2331
+ recordingId: z.string().uuid(),
2332
+ turnId: z.string().uuid().nullable(),
2333
+ codec: RecordingCodec,
2334
+ contentType: RecordingContentType,
2335
+ // The @opengeni/storage object key. NO long-lived URL in the event — clients
2336
+ // mint a short-TTL signed GET via GET …/recordings/:id/url.
2337
+ storageKey: z.string(),
2338
+ durationSeconds: z.number().nonnegative().nullable(),
2339
+ sizeBytes: z.number().int().nonnegative(),
2340
+ dimensions: z.tuple([z.number().int().positive(), z.number().int().positive()]),
2341
+ });
2342
+ export type RecordingAvailablePayload = z.infer<typeof RecordingAvailablePayload>;
2343
+
2344
+ // `max-bytes-exceeded` is distinct from `timeout` (the -t ceiling hitting is a
2345
+ // SUCCESSFUL finalize, never a failure) — the adversarial-review F7 fix.
2346
+ export const RecordingFailedReason = z.enum([
2347
+ "ffmpeg-error",
2348
+ "box-death",
2349
+ "box-rollover",
2350
+ "upload-failed",
2351
+ "max-bytes-exceeded",
2352
+ "display-unavailable",
2353
+ ]);
2354
+ export type RecordingFailedReason = z.infer<typeof RecordingFailedReason>;
2355
+
2356
+ export const RecordingFailedPayload = z.object({
2357
+ recordingId: z.string().uuid(),
2358
+ turnId: z.string().uuid().nullable(),
2359
+ reason: RecordingFailedReason,
2360
+ // ffmpeg-stderr tail / error detail — agent/ffmpeg-controlled, so the producer
2361
+ // caps + scrubs it before emit (it rides redact() like every payload).
2362
+ detail: z.string().nullable().optional(),
2363
+ });
2364
+ export type RecordingFailedPayload = z.infer<typeof RecordingFailedPayload>;
2365
+
2366
+ // ── Channel-A structured services (P4.4 / modules/08-channel-a.md) ───────────
2367
+ // Two transports on one spine: the A2 request/response shapes (FsNode tree,
2368
+ // GitDiff hunks, terminal exec) are returned INLINE on synchronous API-direct
2369
+ // routes (never the bus); the A1 notification payloads below ride the durable
2370
+ // SSE event log so every viewer's Pierre tree / diff / terminal stays live.
2371
+
2372
+ // --- A1 event payloads -------------------------------------------------------
2373
+
2374
+ // The agent's command-output firehose, enriched. Backward-compatible widening
2375
+ // of the existing sandbox.command.output.delta (consumers read `chunk`); the
2376
+ // producer may now also stamp stream/commandId/seq for finer terminal rendering.
2377
+ export const SandboxCommandOutputDeltaPayload = z.object({
2378
+ stream: z.enum(["stdout", "stderr"]).default("stdout"),
2379
+ chunk: z.string(), // raw bytes, utf-8 (lossy) — terminal is opaque-ish
2380
+ commandId: z.string().optional(), // groups deltas to one agent command
2381
+ seq: z.number().int().nonnegative().optional(), // intra-command ordering hint
2382
+ });
2383
+ export type SandboxCommandOutputDeltaPayload = z.infer<typeof SandboxCommandOutputDeltaPayload>;
2384
+
2385
+ export const FsChangeKind = z.enum(["created", "modified", "deleted", "renamed"]);
2386
+ export type FsChangeKind = z.infer<typeof FsChangeKind>;
2387
+ export const FsChangedPayload = z.object({
2388
+ changes: z.array(z.object({
2389
+ path: z.string(), // workspace-relative POSIX path
2390
+ kind: FsChangeKind,
2391
+ isDir: z.boolean().default(false),
2392
+ sizeBytes: z.number().int().nonnegative().nullable().default(null),
2393
+ oldPath: z.string().optional(), // for "renamed"
2394
+ })).min(1),
2395
+ source: z.enum(["write", "watch", "agent"]).default("write"),
2396
+ // Monotonic FS revision (per-lease, paired with leaseEpoch for staleness).
2397
+ revision: z.number().int().nonnegative(),
2398
+ // The lease epoch the revision was minted under: a client invalidates on a
2399
+ // (leaseEpoch, revision) tuple change, never a bare revision compare (H3 —
2400
+ // revision resets to 0 on box re-key, so a bare monotonic compare goes stale).
2401
+ leaseEpoch: z.number().int().nonnegative().default(0),
2402
+ });
2403
+ export type FsChangedPayload = z.infer<typeof FsChangedPayload>;
2404
+
2405
+ export const GitChangedPayload = z.object({
2406
+ head: z.string().nullable(), // current branch or detached SHA
2407
+ dirty: z.boolean(), // working tree has uncommitted changes
2408
+ ahead: z.number().int().nonnegative().default(0),
2409
+ behind: z.number().int().nonnegative().default(0),
2410
+ changedFileCount: z.number().int().nonnegative(),
2411
+ reason: z.enum(["commit", "checkout", "stage", "worktree", "fetch", "unknown"]).default("unknown"),
2412
+ revision: z.number().int().nonnegative().default(0),
2413
+ leaseEpoch: z.number().int().nonnegative().default(0),
2414
+ });
2415
+ export type GitChangedPayload = z.infer<typeof GitChangedPayload>;
2416
+
2417
+ export const TerminalPtyStartedPayload = z.object({
2418
+ ptyId: z.string().uuid(),
2419
+ cols: z.number().int().positive(),
2420
+ rows: z.number().int().positive(),
2421
+ shell: z.string(), // resolved shell, e.g. "/bin/bash"
2422
+ cwd: z.string(),
2423
+ });
2424
+ export type TerminalPtyStartedPayload = z.infer<typeof TerminalPtyStartedPayload>;
2425
+
2426
+ export const TerminalPtyOutputDeltaPayload = z.object({
2427
+ ptyId: z.string().uuid(),
2428
+ stream: z.enum(["stdout", "stderr"]).default("stdout"),
2429
+ chunk: z.string(), // raw terminal bytes (incl. ANSI), utf-8 lossy
2430
+ seq: z.number().int().nonnegative(), // strict per-pty ordering (owner-assigned)
2431
+ });
2432
+ export type TerminalPtyOutputDeltaPayload = z.infer<typeof TerminalPtyOutputDeltaPayload>;
2433
+
2434
+ export const TerminalPtyExitedPayload = z.object({
2435
+ ptyId: z.string().uuid(),
2436
+ exitCode: z.number().int().nullable(),
2437
+ reason: z.enum(["exit", "killed", "owner_gone", "timeout"]),
2438
+ });
2439
+ export type TerminalPtyExitedPayload = z.infer<typeof TerminalPtyExitedPayload>;
2440
+
2441
+ // --- A2 FileSystem request/response (NOT events; returned inline) ------------
2442
+ export const FsNodeType = z.enum(["file", "dir", "symlink", "other"]);
2443
+ export type FsNodeType = z.infer<typeof FsNodeType>;
2444
+ // The Pierre-tree node. `children` is present only when the dir was listed with
2445
+ // depth>0; the tree lazy-expands via repeated depth-1 lists at deeper paths.
2446
+ export interface FsTreeNode {
2447
+ name: string;
2448
+ path: string; // workspace-relative POSIX, no leading slash
2449
+ type: z.infer<typeof FsNodeType>;
2450
+ sizeBytes: number | null; // null for dirs
2451
+ mtimeMs: number | null;
2452
+ mode: number | null; // unix mode bits, for Pierre tree icons/perms
2453
+ children?: FsTreeNode[] | undefined;
2454
+ truncated: boolean; // dir had more entries than the cap
2455
+ }
2456
+ export const FsTreeNode: z.ZodType<FsTreeNode> = z.lazy(() => z.object({
2457
+ name: z.string(),
2458
+ path: z.string(),
2459
+ type: FsNodeType,
2460
+ sizeBytes: z.number().int().nonnegative().nullable(),
2461
+ mtimeMs: z.number().int().nonnegative().nullable(),
2462
+ mode: z.number().int().nullable(),
2463
+ children: z.array(FsTreeNode).optional(),
2464
+ truncated: z.boolean().default(false),
2465
+ })) as z.ZodType<FsTreeNode>;
2466
+
2467
+ export const FsListRequest = z.object({
2468
+ path: z.string().default(""), // "" = workspace root
2469
+ depth: z.number().int().min(0).max(8).default(1),
2470
+ maxEntries: z.number().int().positive().max(20_000).default(2_000),
2471
+ includeHidden: z.boolean().default(true),
2472
+ });
2473
+ export type FsListRequest = z.infer<typeof FsListRequest>;
2474
+ export const FsListResponse = z.object({
2475
+ root: FsTreeNode,
2476
+ revision: z.number().int().nonnegative(),
2477
+ truncated: z.boolean(), // global cap hit
2478
+ });
2479
+ export type FsListResponse = z.infer<typeof FsListResponse>;
2480
+
2481
+ export const FsEncoding = z.enum(["utf8", "base64"]);
2482
+ export type FsEncoding = z.infer<typeof FsEncoding>;
2483
+ export const FsReadRequest = z.object({
2484
+ path: z.string(),
2485
+ encoding: FsEncoding.default("utf8"),
2486
+ maxBytes: z.number().int().positive().max(25 * 1024 * 1024).default(5 * 1024 * 1024),
2487
+ });
2488
+ export type FsReadRequest = z.infer<typeof FsReadRequest>;
2489
+ export const FsReadResponse = z.object({
2490
+ path: z.string(),
2491
+ encoding: FsEncoding,
2492
+ content: z.string(), // text or base64 per encoding
2493
+ sizeBytes: z.number().int().nonnegative(), // bytes returned (== content size)
2494
+ truncated: z.boolean(), // sizeBytes hit maxBytes; content is the prefix
2495
+ isBinary: z.boolean(), // sniffed NUL byte in first 8KB
2496
+ revision: z.number().int().nonnegative(),
2497
+ });
2498
+ export type FsReadResponse = z.infer<typeof FsReadResponse>;
2499
+
2500
+ export const FsWriteRequest = z.object({
2501
+ path: z.string(),
2502
+ encoding: FsEncoding.default("utf8"),
2503
+ content: z.string(),
2504
+ overwrite: z.boolean().default(true), // false + existing path => 409
2505
+ createParents: z.boolean().default(true),
2506
+ });
2507
+ export type FsWriteRequest = z.infer<typeof FsWriteRequest>;
2508
+ export const FsWriteResponse = z.object({
2509
+ path: z.string(),
2510
+ sizeBytes: z.number().int().nonnegative(),
2511
+ revision: z.number().int().nonnegative(), // == the fs.changed revision
2512
+ });
2513
+ export type FsWriteResponse = z.infer<typeof FsWriteResponse>;
2514
+
2515
+ export const FsDeleteRequest = z.object({
2516
+ path: z.string(),
2517
+ recursive: z.boolean().default(false), // required true to delete a non-empty dir
2518
+ });
2519
+ export type FsDeleteRequest = z.infer<typeof FsDeleteRequest>;
2520
+ export const FsDeleteResponse = z.object({ revision: z.number().int().nonnegative() });
2521
+ export type FsDeleteResponse = z.infer<typeof FsDeleteResponse>;
2522
+
2523
+ export const FsMoveRequest = z.object({
2524
+ path: z.string(),
2525
+ newPath: z.string(),
2526
+ overwrite: z.boolean().default(false), // false + existing destination => 409
2527
+ createParents: z.boolean().default(true),
2528
+ });
2529
+ export type FsMoveRequest = z.infer<typeof FsMoveRequest>;
2530
+ export const FsMoveResponse = z.object({
2531
+ path: z.string(),
2532
+ newPath: z.string(),
2533
+ revision: z.number().int().nonnegative(), // == the fs.changed revision
2534
+ });
2535
+ export type FsMoveResponse = z.infer<typeof FsMoveResponse>;
2536
+
2537
+ export const FsMkdirRequest = z.object({
2538
+ path: z.string(),
2539
+ recursive: z.boolean().default(true), // false + existing path => 400
2540
+ });
2541
+ export type FsMkdirRequest = z.infer<typeof FsMkdirRequest>;
2542
+ export const FsMkdirResponse = z.object({
2543
+ path: z.string(),
2544
+ revision: z.number().int().nonnegative(), // == the fs.changed revision
2545
+ });
2546
+ export type FsMkdirResponse = z.infer<typeof FsMkdirResponse>;
2547
+
2548
+ // --- A2 Git request/response (read-only; feeds Pierre diff/tree) -------------
2549
+ export const GitFileStatusCode = z.enum([
2550
+ "added", "modified", "deleted", "renamed", "copied", "untracked", "ignored", "conflicted", "typechange",
2551
+ ]);
2552
+ export type GitFileStatusCode = z.infer<typeof GitFileStatusCode>;
2553
+ export const GitFileStatus = z.object({
2554
+ path: z.string(),
2555
+ oldPath: z.string().nullable(), // for renamed/copied
2556
+ index: GitFileStatusCode.nullable(), // staged change (X in porcelain XY)
2557
+ worktree: GitFileStatusCode.nullable(), // unstaged change (Y in porcelain XY)
2558
+ isConflicted: z.boolean().default(false),
2559
+ });
2560
+ export type GitFileStatus = z.infer<typeof GitFileStatus>;
2561
+ export const GitStatusRequest = z.object({
2562
+ path: z.string().default(""), // repo root within workspace (multi-repo support)
2563
+ });
2564
+ export type GitStatusRequest = z.infer<typeof GitStatusRequest>;
2565
+ export const GitStatusResponse = z.object({
2566
+ isRepo: z.boolean(),
2567
+ head: z.string().nullable(), // branch name
2568
+ detached: z.boolean().default(false),
2569
+ upstream: z.string().nullable(),
2570
+ ahead: z.number().int().nonnegative().default(0),
2571
+ behind: z.number().int().nonnegative().default(0),
2572
+ files: z.array(GitFileStatus),
2573
+ revision: z.number().int().nonnegative(),
2574
+ });
2575
+ export type GitStatusResponse = z.infer<typeof GitStatusResponse>;
2576
+
2577
+ // The structured hunk shape that feeds Pierre diff — the whole point of Git.
2578
+ export const GitDiffLineType = z.enum(["context", "add", "del", "meta"]);
2579
+ export type GitDiffLineType = z.infer<typeof GitDiffLineType>;
2580
+ export const GitDiffLine = z.object({
2581
+ type: GitDiffLineType,
2582
+ // null on the side that doesn't have the line (add => oldNo null; del => newNo null)
2583
+ oldNo: z.number().int().positive().nullable(),
2584
+ newNo: z.number().int().positive().nullable(),
2585
+ text: z.string(), // line WITHOUT leading +/-/space marker
2586
+ });
2587
+ export type GitDiffLine = z.infer<typeof GitDiffLine>;
2588
+ export const GitDiffHunk = z.object({
2589
+ oldStart: z.number().int().nonnegative(),
2590
+ oldLines: z.number().int().nonnegative(),
2591
+ newStart: z.number().int().nonnegative(),
2592
+ newLines: z.number().int().nonnegative(),
2593
+ header: z.string(), // the @@ ... @@ section heading
2594
+ lines: z.array(GitDiffLine),
2595
+ });
2596
+ export type GitDiffHunk = z.infer<typeof GitDiffHunk>;
2597
+ export const GitFileDiff = z.object({
2598
+ path: z.string(),
2599
+ oldPath: z.string().nullable(),
2600
+ status: GitFileStatusCode,
2601
+ isBinary: z.boolean().default(false),
2602
+ isImage: z.boolean().default(false),
2603
+ additions: z.number().int().nonnegative(),
2604
+ deletions: z.number().int().nonnegative(),
2605
+ hunks: z.array(GitDiffHunk), // empty if binary or truncated
2606
+ truncated: z.boolean().default(false), // diff exceeded maxBytes; hunks omitted
2607
+ });
2608
+ export type GitFileDiff = z.infer<typeof GitFileDiff>;
2609
+ export const GitDiffRequest = z.object({
2610
+ path: z.string().default(""), // repo root
2611
+ // diff selectors, mutually exclusive precedence: refs > staged > worktree
2612
+ staged: z.boolean().default(false), // --cached (index vs HEAD)
2613
+ fromRef: z.string().optional(),
2614
+ toRef: z.string().optional(),
2615
+ pathspec: z.array(z.string()).default([]),
2616
+ contextLines: z.number().int().min(0).max(10).default(3),
2617
+ maxBytesPerFile: z.number().int().positive().max(2 * 1024 * 1024).default(512 * 1024),
2618
+ });
2619
+ export type GitDiffRequest = z.infer<typeof GitDiffRequest>;
2620
+ export const GitDiffResponse = z.object({
2621
+ files: z.array(GitFileDiff),
2622
+ revision: z.number().int().nonnegative(),
2623
+ });
2624
+ export type GitDiffResponse = z.infer<typeof GitDiffResponse>;
2625
+
2626
+ export const GitLogRequest = z.object({
2627
+ path: z.string().default(""),
2628
+ ref: z.string().default("HEAD"),
2629
+ maxCount: z.number().int().positive().max(1_000).default(100),
2630
+ skip: z.number().int().nonnegative().default(0),
2631
+ pathspec: z.array(z.string()).default([]),
2632
+ });
2633
+ export type GitLogRequest = z.infer<typeof GitLogRequest>;
2634
+ export const GitCommit = z.object({
2635
+ sha: z.string(),
2636
+ shortSha: z.string(),
2637
+ parents: z.array(z.string()),
2638
+ author: z.object({ name: z.string(), email: z.string(), timestamp: z.number().int() }),
2639
+ committer: z.object({ name: z.string(), email: z.string(), timestamp: z.number().int() }),
2640
+ subject: z.string(),
2641
+ body: z.string(),
2642
+ refs: z.array(z.string()).default([]), // decorations: branch/tag pointers
2643
+ });
2644
+ export type GitCommit = z.infer<typeof GitCommit>;
2645
+ export const GitLogResponse = z.object({ commits: z.array(GitCommit), hasMore: z.boolean() });
2646
+ export type GitLogResponse = z.infer<typeof GitLogResponse>;
2647
+
2648
+ export const GitShowRequest = z.object({
2649
+ path: z.string().default(""),
2650
+ ref: z.string(), // a commit/tag/tree-ish
2651
+ filePath: z.string().optional(), // ref + filePath => raw blob ("open file at commit")
2652
+ encoding: FsEncoding.default("utf8"),
2653
+ maxBytesPerFile: z.number().int().positive().max(2 * 1024 * 1024).default(512 * 1024),
2654
+ });
2655
+ export type GitShowRequest = z.infer<typeof GitShowRequest>;
2656
+ export const GitShowResponse = z.object({
2657
+ commit: GitCommit.nullable(), // null when fetching a raw blob
2658
+ files: z.array(GitFileDiff), // commit diff vs first parent
2659
+ blob: z.object({ content: z.string(), encoding: FsEncoding, sizeBytes: z.number().int(), truncated: z.boolean() }).nullable(),
2660
+ revision: z.number().int().nonnegative(),
2661
+ });
2662
+ export type GitShowResponse = z.infer<typeof GitShowResponse>;
2663
+
2664
+ // --- A2 Terminal exec (run a command in-box, stream stdout/stderr) -----------
2665
+ // The command-output FIREHOSE rides A1 (sandbox.command.output.delta). This is
2666
+ // the SYNCHRONOUS exec: run a bounded command and return its stdout/stderr +
2667
+ // exit code inline (the result IS the HTTP response). Full interactive PTY
2668
+ // (open/write/resize) layers on top via the pty.* events; exec ships now.
2669
+ export const TerminalExecRequest = z.object({
2670
+ command: z.string().min(1),
2671
+ cwd: z.string().default(""), // workspace-relative
2672
+ // Soft per-call wall-clock bound (the box yields output back when reached).
2673
+ timeoutMs: z.number().int().positive().max(120_000).default(30_000),
2674
+ // Stream the deltas onto A1 as the agent firehose (so other viewers see it),
2675
+ // in addition to returning the buffered result inline.
2676
+ emitStream: z.boolean().default(true),
2677
+ });
2678
+ export type TerminalExecRequest = z.infer<typeof TerminalExecRequest>;
2679
+ export const TerminalExecResponse = z.object({
2680
+ stdout: z.string(),
2681
+ stderr: z.string(),
2682
+ exitCode: z.number().int().nullable(),
2683
+ // True when the process was still running when the call yielded (a long
2684
+ // command); the remaining output drains onto A1 if emitStream was set.
2685
+ running: z.boolean(),
2686
+ wallTimeSeconds: z.number().nonnegative(),
2687
+ });
2688
+ export type TerminalExecResponse = z.infer<typeof TerminalExecResponse>;
2689
+
2690
+ // --- A2 Terminal PTY control (output rides A1) -------------------------------
2691
+ export const PtyOpenRequest = z.object({
2692
+ cols: z.number().int().positive().max(500).default(80),
2693
+ rows: z.number().int().positive().max(300).default(24),
2694
+ cwd: z.string().default(""), // workspace-relative
2695
+ shell: z.string().optional(), // default: resolved login shell
2696
+ });
2697
+ export type PtyOpenRequest = z.infer<typeof PtyOpenRequest>;
2698
+ export const PtyOpenResponse = z.object({
2699
+ ptyId: z.string().uuid(),
2700
+ // output streams as terminal.pty.output.delta on the SSE channel the client holds
2701
+ streamVia: z.literal("sse-events"),
2702
+ supportsInput: z.boolean(), // false on backends without writeStdin
2703
+ });
2704
+ export type PtyOpenResponse = z.infer<typeof PtyOpenResponse>;
2705
+ export const PtyWriteRequest = z.object({ ptyId: z.string().uuid(), data: z.string() }); // utf-8 stdin
2706
+ export type PtyWriteRequest = z.infer<typeof PtyWriteRequest>;
2707
+ export const PtyResizeRequest = z.object({ ptyId: z.string().uuid(), cols: z.number().int().positive(), rows: z.number().int().positive() });
2708
+ export type PtyResizeRequest = z.infer<typeof PtyResizeRequest>;
2709
+ export const PtyCloseRequest = z.object({ ptyId: z.string().uuid() });
2710
+ export type PtyCloseRequest = z.infer<typeof PtyCloseRequest>;
2711
+
2712
+ // Per-session structured-service capabilities (the Channel-A slice of the
2713
+ // negotiation). The full SessionCapabilities doc already carries FileSystem /
2714
+ // Terminal / Git blocks (P0.1); this is the compact projection the SDK mirrors.
2715
+ export const SessionStructuredCapabilities = z.object({
2716
+ FileSystem: z.object({ available: z.boolean(), readOnly: z.boolean(), root: z.string() }),
2717
+ Terminal: z.object({
2718
+ events: z.boolean(), // command.output firehose (always on if a box exists)
2719
+ exec: z.boolean(), // synchronous terminal exec
2720
+ pty: z.object({ available: z.boolean() }), // interactive stdin (writeStdin)
2721
+ }),
2722
+ Git: z.object({ available: z.boolean(), repos: z.array(z.string()) }),
2723
+ });
2724
+ export type SessionStructuredCapabilities = z.infer<typeof SessionStructuredCapabilities>;
2725
+
1306
2726
  export const SessionEvent = z.object({
1307
2727
  id: z.string().uuid(),
1308
2728
  workspaceId: z.string().uuid(),
@@ -1324,6 +2744,17 @@ export const CreateSessionRequest = z.object({
1324
2744
  model: z.string().min(1).optional(),
1325
2745
  reasoningEffort: ReasoningEffort.optional(),
1326
2746
  sandboxBackend: SandboxBackend.optional(),
2747
+ // The enrolled machine (a sandbox id) to run this session on; seeds the
2748
+ // active-sandbox pointer at creation so the FIRST turn routes to the chosen
2749
+ // machine (race-free: the pointer is committed before the worker turn
2750
+ // workflow can read it). An invalid/unowned/offline target fails the create.
2751
+ targetSandboxId: z.string().uuid().optional(),
2752
+ // The working directory the targeted machine runs the session under — the
2753
+ // path/cwd base for its agent exec, terminal, and file dock. Free-form pass-
2754
+ // through: a launch-workspace_root-relative subdir or an absolute machine path
2755
+ // (the agent's resolve_cwd handles both). Only valid WITH targetSandboxId
2756
+ // (workingDir alone is a 422); omitted ⇒ the machine's default workspace_root.
2757
+ workingDir: z.string().min(1).optional(),
1327
2758
  // Workspace environment attachment is fixed at session creation; follow-up
1328
2759
  // user.message events cannot switch or add one.
1329
2760
  environmentId: z.string().uuid().optional(),
@@ -1341,6 +2772,28 @@ export const CreateSessionRequest = z.object({
1341
2772
  // the orchestration/environment/github tools. Capped at creation: every
1342
2773
  // requested permission must be held by the creating grant (no escalation).
1343
2774
  firstPartyMcpPermissions: z.array(Permission).optional(),
2775
+ // Shared-sandbox placement (addendum 05 §D.1). Three-way union; OMITTED ⇒
2776
+ // today's behavior (a context-dependent default resolved server-side: from
2777
+ // inside a session → "shared" with the creator's box, top-level → "new").
2778
+ // - "shared": join the CREATOR's box. Requires a parent session (inferred
2779
+ // from the worker-signed sessionId claim, never caller-supplied);
2780
+ // top-level "shared" is a 422.
2781
+ // - "new": mint a fresh singleton box (group ≡ the new session's id).
2782
+ // - {groupId}: join a SPECIFIC sibling group in THIS workspace (manager
2783
+ // fan-out). Validated workspace-scoped (cross-workspace → 404).
2784
+ // A shared spawn inherits the box's (backend, os) — it is literally the same
2785
+ // box; the child cannot pick its own backend. Cross-workspace sharing is
2786
+ // forbidden by construction (the parent/group reads are RLS-workspace-scoped).
2787
+ // ENV-AWARE: the box's environment is fixed at creation, so a share requires
2788
+ // the SAME environmentId as the creator's box. On a mismatch the inherited
2789
+ // default silently falls back to an own box; an explicit "shared"/{groupId}
2790
+ // request 422s at create (instead of the first turn dying on the SDK's
2791
+ // manifest-env guard).
2792
+ sandbox: z.union([
2793
+ z.literal("shared"),
2794
+ z.literal("new"),
2795
+ z.object({ groupId: z.string().uuid() }),
2796
+ ]).optional(),
1344
2797
  });
1345
2798
  export type CreateSessionRequest = z.infer<typeof CreateSessionRequest>;
1346
2799
 
@@ -1422,10 +2875,569 @@ export const ClientAuthConfig = z.discriminatedUnion("mode", [
1422
2875
  ]);
1423
2876
  export type ClientAuthConfig = z.infer<typeof ClientAuthConfig>;
1424
2877
 
2878
+ // The negotiated capability handshake document (master-spine C.3). ONE shape;
2879
+ // collapses the parallel per-module definitions. A capability cell is always
2880
+ // present with `available`/`transport` + a `reason` when unavailable — never
2881
+ // absent.
2882
+ export const CapabilityUnavailableReason = z.enum([
2883
+ "backend_unsupported",
2884
+ "os_unsupported",
2885
+ "not_provisioned",
2886
+ "disabled_by_policy",
2887
+ "lease_cold",
2888
+ "tier_headless",
2889
+ // Selfhosted (bring-your-own-compute) negotiation states (M1 additive; the
2890
+ // selfhosted negotiation in select.ts wires them in M3):
2891
+ "agent_offline", // the enrolled agent process is not running / unreachable
2892
+ "agent_reconnecting", // a transient blip — the agent is reconnecting (warmable)
2893
+ "consent_required", // whole-machine / screen-control consent not yet acknowledged
2894
+ "display_unavailable", // headless machine with no display stack (no DesktopStream)
2895
+ ]);
2896
+ export type CapabilityUnavailableReason = z.infer<typeof CapabilityUnavailableReason>;
2897
+
2898
+ export const SessionCapabilities = z.object({
2899
+ sessionId: z.string().uuid(),
2900
+ backend: SandboxBackend,
2901
+ os: SandboxOs,
2902
+ liveness: z.enum(["cold", "warming", "warm", "draining"]),
2903
+ // Echoed on viewer heartbeats (the split-brain fence).
2904
+ leaseEpoch: z.number().int().nonnegative(),
2905
+ viewerHeartbeatIntervalMs: z.number().int().positive().default(30_000),
2906
+ FileSystem: z.object({
2907
+ available: z.boolean(),
2908
+ readOnly: z.boolean(),
2909
+ root: z.string(),
2910
+ pathSep: z.enum(["/", "\\"]),
2911
+ treeMode: z.enum(["lazy", "snapshot"]),
2912
+ reason: CapabilityUnavailableReason.nullable(),
2913
+ }),
2914
+ Terminal: z.object({
2915
+ transport: z.enum(["sse-events", "pty-ws"]).nullable(),
2916
+ ptyCapable: z.boolean(),
2917
+ shell: z.string(),
2918
+ // The direct-to-provider ttyd PTY-over-websocket URL (pty-ws) resolved on the
2919
+ // SAME tunnel as the desktop; null on a cold lease / read-only sse-events
2920
+ // firehose / degraded terminal. The scoped stream token is recorded against
2921
+ // the holder (NEVER a URL query param), symmetric with DesktopStream.
2922
+ url: z.string().url().nullable(),
2923
+ token: z.string().nullable(),
2924
+ // ISO absolute expiry of the minted stream token (symmetric with
2925
+ // DesktopStream.expiresAt). Null when no live URL/token is minted.
2926
+ expiresAt: z.string().nullable(),
2927
+ reason: CapabilityUnavailableReason.nullable(),
2928
+ }),
2929
+ Git: z.object({
2930
+ available: z.boolean(),
2931
+ repos: z.array(z.string()),
2932
+ reason: CapabilityUnavailableReason.nullable(),
2933
+ }),
2934
+ DesktopStream: z.object({
2935
+ // "relay-frames" is the selfhosted framebuffer stream: PNG-per-frame protobuf
2936
+ // datagrams spliced over the relay (NOT RFB). The viewer renders it with the
2937
+ // "frames" client (a canvas painter), distinct from Modal's "vnc-ws"/"novnc".
2938
+ transport: z.enum(["vnc-ws", "rdp-ws", "webrtc", "relay-frames"]).nullable(),
2939
+ client: z.enum(["novnc", "web-rdp", "frames"]).nullable(),
2940
+ mode: z.enum(["read-only", "interactive"]).default("read-only"),
2941
+ url: z.string().url().nullable(),
2942
+ token: z.string().nullable(),
2943
+ expiresAt: z.string().nullable(),
2944
+ resolution: z
2945
+ .tuple([z.number().int().positive(), z.number().int().positive()])
2946
+ .default([1024, 768]),
2947
+ // REQUIRED, no default (the server must assert un-redacted pixels).
2948
+ unredacted: z.boolean(),
2949
+ requiresAcknowledgment: z.boolean(),
2950
+ acknowledged: z.boolean(),
2951
+ // SHARED-EXPOSURE disclosure (addendum E.1). `shared` is true when the box's
2952
+ // group has >1 session: watching this desktop ALSO shows the sibling
2953
+ // sessions' agents on the one :0 framebuffer (the pixels cannot be redacted).
2954
+ // `sharedSessionIds` lists the OTHER sessions whose agents may appear — IDS
2955
+ // ONLY, never their goal/metadata/conversation (a viewer of A must not be
2956
+ // able to use "I can see B's id" to subscribe to B's events; stress g). When
2957
+ // shared, the consent gate requires the shared-exposure acknowledgment (409
2958
+ // shared_acknowledgment_required) before the desktop path is handed out.
2959
+ shared: z.boolean().default(false),
2960
+ sharedSessionIds: z.array(z.string().uuid()).default([]),
2961
+ reason: CapabilityUnavailableReason.nullable(),
2962
+ }),
2963
+ Recording: z.object({
2964
+ available: z.boolean(),
2965
+ modes: z.array(z.enum(["manual", "on-turn", "on-verify"])),
2966
+ codecs: z.array(z.enum(["h264-mp4", "vp9-webm"])),
2967
+ reason: CapabilityUnavailableReason.nullable(),
2968
+ }),
2969
+ // The AGENT drives the SAME :0 (xdotool/XTEST + scrot) the human watches; the
2970
+ // human viewer plane is read-only by default (§6). `available` == desktop-
2971
+ // capable backend && computerUseEnabled; `readOnly` reports whether the agent
2972
+ // driver itself is gated to no-op input (v1 default false — the agent clicks).
2973
+ ComputerUse: z.object({
2974
+ available: z.boolean(),
2975
+ readOnly: z.boolean(),
2976
+ reason: CapabilityUnavailableReason.nullable(),
2977
+ }),
2978
+ negotiatedAt: z.string(),
2979
+ });
2980
+ export type SessionCapabilities = z.infer<typeof SessionCapabilities>;
2981
+
2982
+ // ── API-direct viewer attach (P1.4) ─────────────────────────────────────────
2983
+ // A viewer holds the GROUP lease (keeping the box warm while watched). These
2984
+ // shape the in-process attach/heartbeat/detach handlers. The scoped stream
2985
+ // token + the un-redacted-pixel acknowledgment are P3/P4 — here it is the
2986
+ // viewer-HOLDER lifecycle only.
2987
+
2988
+ // POST .../viewers — acquire a viewer holder. An omitted viewerId mints a fresh
2989
+ // one (returned in the response, to carry through heartbeats + detach).
2990
+ //
2991
+ // `desktop` declares intent to attach the UN-REDACTED pixel plane (noVNC). ONLY
2992
+ // that plane carries the consent gate (the un-redacted/shared acknowledgment). A
2993
+ // terminal-only warm attach (`desktop:false`, the default) needs NO consent — a
2994
+ // shell is interactive by nature and the gate is the scoped tunnel URL + stream
2995
+ // token — so it warms the box and mints the pty-ws terminal cell WITHOUT a 409.
2996
+ // Omitted defaults to `false` so a terminal-only client never trips the gate.
2997
+ export const AttachViewerRequest = z.object({
2998
+ viewerId: z.string().uuid().optional(),
2999
+ desktop: z.boolean().optional(),
3000
+ });
3001
+ export type AttachViewerRequest = z.infer<typeof AttachViewerRequest>;
3002
+
3003
+ export const ViewerHolder = z.object({
3004
+ viewerId: z.string().uuid(),
3005
+ sandboxGroupId: z.string().uuid(),
3006
+ liveness: z.enum(["cold", "warming", "warm", "draining"]),
3007
+ // The epoch the viewer is fenced on; echoed back on heartbeats.
3008
+ leaseEpoch: z.number().int().nonnegative(),
3009
+ viewerHeartbeatIntervalMs: z.number().int().positive(),
3010
+ // The desktop pixel tunnel URL the viewer connects to directly; null until
3011
+ // P4 mints it (gated until then).
3012
+ dataPlaneUrl: z.string().nullable(),
3013
+ });
3014
+ export type ViewerHolder = z.infer<typeof ViewerHolder>;
3015
+
3016
+ // POST .../stream-capabilities/acknowledge — record the calling principal's
3017
+ // acknowledgment of the un-redacted pixel plane (P3.2; modules/07-channel-b.md
3018
+ // §6 + addendum E.1). Reuses the acknowledgment machinery — no new endpoint
3019
+ // shape beyond this body, no new permission beyond stream:acknowledge.
3020
+ //
3021
+ // `acknowledgeShared` MUST be true when the box is shared (the group has >1
3022
+ // session): the un-redacted desktop path returns 409 shared_acknowledgment_required
3023
+ // until a shared box is acknowledged WITH the shared-exposure consent. For a
3024
+ // solo box `acknowledgeShared` is irrelevant (the un-redacted ack alone gates).
3025
+ export const AcknowledgeStreamRequest = z.object({
3026
+ // The principal accepts that the desktop pixel plane is un-redacted (can show
3027
+ // cloud creds the agent cat's into a terminal). Always true to record consent;
3028
+ // present for self-documentation + a future explicit withdraw.
3029
+ acknowledgeUnredacted: z.boolean().default(true),
3030
+ // The principal accepts the shared-exposure disclosure: watching this desktop
3031
+ // also shows sibling sessions' agents on the one framebuffer.
3032
+ acknowledgeShared: z.boolean().default(false),
3033
+ });
3034
+ export type AcknowledgeStreamRequest = z.infer<typeof AcknowledgeStreamRequest>;
3035
+
3036
+ export const AcknowledgeStreamResponse = z.object({
3037
+ acknowledged: z.boolean(),
3038
+ acknowledgedShared: z.boolean(),
3039
+ });
3040
+ export type AcknowledgeStreamResponse = z.infer<typeof AcknowledgeStreamResponse>;
3041
+
3042
+ // POST .../viewers/:viewerId/heartbeat — refresh the holder TTL. Epoch-fenced:
3043
+ // a stale-epoch beat (a box re-established under a newer epoch) is rejected.
3044
+ export const ViewerHeartbeatRequest = z.object({
3045
+ leaseEpoch: z.number().int().nonnegative(),
3046
+ });
3047
+ export type ViewerHeartbeatRequest = z.infer<typeof ViewerHeartbeatRequest>;
3048
+
3049
+ export const ViewerHeartbeatResponse = z.object({
3050
+ // false ⇒ the holder was reaped or the epoch is stale; the client re-attaches.
3051
+ alive: z.boolean(),
3052
+ });
3053
+ export type ViewerHeartbeatResponse = z.infer<typeof ViewerHeartbeatResponse>;
3054
+
3055
+ // =============================================================================
3056
+ // Bring-your-own-compute (M5) — enrollment device-flow HTTP contract.
3057
+ //
3058
+ // The HTTP shapes mirror the @opengeni/agent-proto device-flow messages
3059
+ // (DeviceAuthStart*, DeviceAuthPoll*, EnrollmentCredentials) so the Rust agent's
3060
+ // `enroll` command (which runs the flow over HTTP before it has NATS creds)
3061
+ // decodes the SAME field names (the proto's ts-proto JSON is camelCase). The
3062
+ // request bodies additionally carry the consent-relevant fields the dossier brief
3063
+ // mandates (the agent ed25519 pubkey + can-offer-display + requests-screen-control).
3064
+ // =============================================================================
3065
+
3066
+ export const EnrollmentOs = z.enum(["linux", "macos", "windows"]);
3067
+ export type EnrollmentOs = z.infer<typeof EnrollmentOs>;
3068
+ export const EnrollmentArch = z.enum(["x86_64", "aarch64"]);
3069
+ export type EnrollmentArch = z.infer<typeof EnrollmentArch>;
3070
+
3071
+ // POST /enrollments/device/start (agent-side, unauthenticated-at-the-user-level,
3072
+ // rate-limited). The agent presents its ed25519 public key + os/arch + the
3073
+ // requested whole-machine exposure + whether it can offer a display + whether it
3074
+ // requests screen control.
3075
+ export const DeviceEnrollmentStartRequest = z.object({
3076
+ // The agent's ed25519 public key (the machine identity the enrollment binds to).
3077
+ publicKey: z.string().min(1).max(1024),
3078
+ os: EnrollmentOs.default("linux"),
3079
+ arch: EnrollmentArch.default("x86_64"),
3080
+ // Human-friendly machine name (hostname by default).
3081
+ machineName: z.string().min(1).max(256).optional(),
3082
+ // v1 only supports whole-machine; kept explicit so the consent is recorded.
3083
+ exposure: z.literal("whole-machine").default("whole-machine"),
3084
+ // The agent can offer a display (a real screen / Xvfb is available).
3085
+ canOfferDisplay: z.boolean().default(false),
3086
+ // The agent requests screen control (computer-use); the user's allow_screen_control
3087
+ // at approve is the AUTHORITATIVE consent.
3088
+ requestsScreenControl: z.boolean().default(false),
3089
+ // The workspace this machine is enrolling into. The agent is told this at install
3090
+ // (the user picks the workspace, or the install/enroll token carries it). The user
3091
+ // who approves must hold a grant in THIS workspace — that binding is what makes
3092
+ // the (user-unauthenticated) start safe: it cannot grant access to a workspace no
3093
+ // authorized user later approves in.
3094
+ workspaceId: z.string().uuid(),
3095
+ });
3096
+ export type DeviceEnrollmentStartRequest = z.infer<typeof DeviceEnrollmentStartRequest>;
3097
+
3098
+ // The DeviceAuthStart response (field names match the proto's JSON).
3099
+ export const DeviceEnrollmentStartResponse = z.object({
3100
+ deviceCode: z.string(),
3101
+ userCode: z.string(),
3102
+ verificationUri: z.string(),
3103
+ verificationUriComplete: z.string(),
3104
+ intervalSeconds: z.number().int().positive(),
3105
+ expiresInSeconds: z.number().int().positive(),
3106
+ });
3107
+ export type DeviceEnrollmentStartResponse = z.infer<typeof DeviceEnrollmentStartResponse>;
3108
+
3109
+ // POST /enrollments/device/approve (USER-authenticated, workspace-gated). The
3110
+ // LOUD CONSENT step. whole-machine is mandatory (implicit); screen-control is
3111
+ // opt-in per allow_screen_control.
3112
+ export const DeviceEnrollmentApproveRequest = z.object({
3113
+ userCode: z.string().min(1).max(64),
3114
+ allowScreenControl: z.boolean().default(false),
3115
+ });
3116
+ export type DeviceEnrollmentApproveRequest = z.infer<typeof DeviceEnrollmentApproveRequest>;
3117
+
3118
+ export const DeviceEnrollmentApproveResponse = z.object({
3119
+ approved: z.boolean(),
3120
+ enrollmentId: z.string().uuid(),
3121
+ sandboxId: z.string().uuid(),
3122
+ allowScreenControl: z.boolean(),
3123
+ });
3124
+ export type DeviceEnrollmentApproveResponse = z.infer<typeof DeviceEnrollmentApproveResponse>;
3125
+
3126
+ // POST /enrollments/device/poll (agent-side). The poll state machine.
3127
+ export const DeviceEnrollmentPollRequest = z.object({
3128
+ deviceCode: z.string().min(1).max(256),
3129
+ });
3130
+ export type DeviceEnrollmentPollRequest = z.infer<typeof DeviceEnrollmentPollRequest>;
3131
+
3132
+ export const DeviceEnrollmentState = z.enum(["pending", "authorized", "denied", "expired", "disabled"]);
3133
+ export type DeviceEnrollmentState = z.infer<typeof DeviceEnrollmentState>;
3134
+
3135
+ // The EnrollmentCredentials (field names match the proto's JSON). natsAccountCreds
3136
+ // is a PLACEHOLDER — the real per-workspace NATS Account creds binding is
3137
+ // infra-deferred (M4/relay); the bearer + subjectPrefix are the application-tier
3138
+ // identity the agent presents today.
3139
+ export const EnrollmentCredentialsResponse = z.object({
3140
+ agentId: z.string().uuid(),
3141
+ workspaceId: z.string().uuid(),
3142
+ // The signed bearer the agent presents to the control plane (the `oge_` token).
3143
+ bearer: z.string(),
3144
+ // The Account-scoped control-plane subject prefix the agent subscribes to:
3145
+ // agent.<workspaceId>.<agentId>.
3146
+ subjectPrefix: z.string(),
3147
+ // Connect info for the control plane + stream relay (may be empty when not yet
3148
+ // configured for this deployment — the agent surfaces "control plane unconfigured").
3149
+ natsUrls: z.array(z.string()),
3150
+ relayUrl: z.string(),
3151
+ // The agent's PRODUCER token for the relay edge (the `ogr_` token; M8b). Presented
3152
+ // as StreamOpen.token when the agent registers a pty/desktop channel; the relay
3153
+ // verifies it then pairs the producer with the viewer (whose `ogs_` token the
3154
+ // relay also verifies). Empty when the relay-token plane is unconfigured for this
3155
+ // deployment (graceful degrade — the agent then presents an empty token the relay
3156
+ // rejects, surfacing the gap loudly rather than silently producing a dead stream).
3157
+ relayToken: z.string(),
3158
+ // VESTIGIAL (M-AUTH): there is no per-machine NATS Account creds file. The agent
3159
+ // presents the `bearer` above as the NATS connect AUTH-TOKEN; the server's
3160
+ // auth-callout responder validates it and mints a workspace-scoped user JWT. This
3161
+ // field echoes the bearer so a consumer reading it as the connect credential still
3162
+ // works; new consumers should read `bearer` directly.
3163
+ natsAccountCreds: z.string(),
3164
+ // The minisign public key the agent pins for self-update verification.
3165
+ updatePublicKey: z.string(),
3166
+ consentedWholeMachine: z.boolean(),
3167
+ consentedScreenControl: z.boolean(),
3168
+ });
3169
+ export type EnrollmentCredentialsResponse = z.infer<typeof EnrollmentCredentialsResponse>;
3170
+
3171
+ export const DeviceEnrollmentPollResponse = z.object({
3172
+ state: DeviceEnrollmentState,
3173
+ // Present only when state === "authorized".
3174
+ credentials: EnrollmentCredentialsResponse.optional(),
3175
+ });
3176
+ export type DeviceEnrollmentPollResponse = z.infer<typeof DeviceEnrollmentPollResponse>;
3177
+
3178
+ // GET /enrollments — a workspace's machines (the Machines dashboard surface).
3179
+ export const EnrollmentSummary = z.object({
3180
+ id: z.string().uuid(),
3181
+ pubkey: z.string(),
3182
+ exposure: z.literal("whole-machine"),
3183
+ hasDisplay: z.boolean(),
3184
+ allowScreenControl: z.boolean(),
3185
+ status: z.enum(["active", "revoked"]),
3186
+ os: EnrollmentOs,
3187
+ arch: z.string(),
3188
+ lastSeenAt: z.string().nullable(),
3189
+ createdAt: z.string(),
3190
+ revokedAt: z.string().nullable(),
3191
+ });
3192
+ export type EnrollmentSummary = z.infer<typeof EnrollmentSummary>;
3193
+
3194
+ export const ListEnrollmentsResponse = z.object({
3195
+ enrollments: z.array(EnrollmentSummary),
3196
+ });
3197
+ export type ListEnrollmentsResponse = z.infer<typeof ListEnrollmentsResponse>;
3198
+
3199
+ export const RevokeEnrollmentResponse = z.object({
3200
+ revoked: z.boolean(),
3201
+ });
3202
+ export type RevokeEnrollmentResponse = z.infer<typeof RevokeEnrollmentResponse>;
3203
+
3204
+ // =============================================================================
3205
+ // Enrollment UX (self-hosted enrollment UX, design 11): the click-Grant approve
3206
+ // page lookup/deny + the headless enroll-token mint/exchange. These sit beside the
3207
+ // device-flow contracts above and REUSE EnrollmentCredentialsResponse for the
3208
+ // exchange's credential payload (identical shape to the poll authorized branch).
3209
+ // =============================================================================
3210
+
3211
+ // POST /v1/enrollments/device/lookup (USER-authenticated, NO workspace in the
3212
+ // path). The approve page (EnrollmentConsent) needs the machine details for a
3213
+ // user_code WITHOUT consuming the request. The user_code is globally unique among
3214
+ // pending rows; the route resolves its workspace, authorizes (enrollments:read),
3215
+ // and returns the machine details — or 404 (never revealing cross-workspace
3216
+ // existence) when the grant check fails or no live pending row matches.
3217
+ export const DeviceEnrollmentLookupRequest = z.object({
3218
+ userCode: z.string().min(1).max(64),
3219
+ });
3220
+ export type DeviceEnrollmentLookupRequest = z.infer<typeof DeviceEnrollmentLookupRequest>;
3221
+
3222
+ // The presentational machine details the consent screen renders (a subset of the
3223
+ // pending request — NO secrets, NO device_code).
3224
+ export const DeviceEnrollmentLookupMachine = z.object({
3225
+ machineName: z.string().nullable(),
3226
+ os: EnrollmentOs,
3227
+ arch: z.string(),
3228
+ canOfferDisplay: z.boolean(),
3229
+ requestsScreenControl: z.boolean(),
3230
+ });
3231
+ export type DeviceEnrollmentLookupMachine = z.infer<typeof DeviceEnrollmentLookupMachine>;
3232
+
3233
+ export const DeviceEnrollmentLookupResponse = z.object({
3234
+ workspaceId: z.string().uuid(),
3235
+ userCode: z.string(),
3236
+ machine: DeviceEnrollmentLookupMachine,
3237
+ expiresAt: z.string(),
3238
+ });
3239
+ export type DeviceEnrollmentLookupResponse = z.infer<typeof DeviceEnrollmentLookupResponse>;
3240
+
3241
+ // POST /v1/workspaces/:workspaceId/enrollments/device/deny (USER-authenticated,
3242
+ // enrollments:manage). The explicit "no" at the approve page — mirrors approve.
3243
+ export const DeviceEnrollmentDenyRequest = z.object({
3244
+ userCode: z.string().min(1).max(64),
3245
+ });
3246
+ export type DeviceEnrollmentDenyRequest = z.infer<typeof DeviceEnrollmentDenyRequest>;
3247
+
3248
+ export const DeviceEnrollmentDenyResponse = z.object({
3249
+ denied: z.boolean(),
3250
+ });
3251
+ export type DeviceEnrollmentDenyResponse = z.infer<typeof DeviceEnrollmentDenyResponse>;
3252
+
3253
+ // POST /v1/workspaces/:workspaceId/enrollments/token (USER-authenticated,
3254
+ // enrollments:manage). Mints the short-TTL headless enroll token (the `oget_`
3255
+ // token). allowScreenControl bakes the screen-control consent into the token.
3256
+ export const MintEnrollTokenRequest = z.object({
3257
+ allowScreenControl: z.boolean().default(false),
3258
+ });
3259
+ export type MintEnrollTokenRequest = z.infer<typeof MintEnrollTokenRequest>;
3260
+
3261
+ export const MintEnrollTokenResponse = z.object({
3262
+ // The `oget_` token. SECRET — the UI shows it once with a copy-now warning.
3263
+ token: z.string(),
3264
+ expiresAt: z.string(),
3265
+ expiresInSeconds: z.number().int().positive(),
3266
+ });
3267
+ export type MintEnrollTokenResponse = z.infer<typeof MintEnrollTokenResponse>;
3268
+
3269
+ // POST /v1/enrollments/token/exchange (UNAUTHENTICATED — the token IS the auth).
3270
+ // The agent presents the same identity fields it sends to device/start plus the
3271
+ // enroll token. On a valid token the control plane performs the SAME finalize as
3272
+ // approve and returns the IDENTICAL EnrollmentCredentialsResponse shape (so the
3273
+ // agent's existing credential parsing is reused).
3274
+ export const EnrollTokenExchangeRequest = z.object({
3275
+ // The `oget_` enroll token (the auth + the workspace/account/consent grant).
3276
+ token: z.string().min(1),
3277
+ // The agent's ed25519 public key (the machine identity the enrollment binds to).
3278
+ publicKey: z.string().min(1).max(1024),
3279
+ os: EnrollmentOs.default("linux"),
3280
+ arch: EnrollmentArch.default("x86_64"),
3281
+ machineName: z.string().min(1).max(256).optional(),
3282
+ // v1 only supports whole-machine; kept explicit so the consent is recorded.
3283
+ exposure: z.literal("whole-machine").default("whole-machine"),
3284
+ canOfferDisplay: z.boolean().default(false),
3285
+ // The agent's REQUEST; the token's allowScreenControl is the AUTHORITATIVE consent.
3286
+ requestsScreenControl: z.boolean().default(false),
3287
+ });
3288
+ export type EnrollTokenExchangeRequest = z.infer<typeof EnrollTokenExchangeRequest>;
3289
+
3290
+ // The exchange wraps the EXISTING EnrollmentCredentialsResponse — IDENTICAL to the
3291
+ // poll authorized branch's `credentials` (NOT a redefined credential shape).
3292
+ export const EnrollTokenExchangeResponse = z.object({
3293
+ credentials: EnrollmentCredentialsResponse,
3294
+ });
3295
+ export type EnrollTokenExchangeResponse = z.infer<typeof EnrollTokenExchangeResponse>;
3296
+
3297
+ // ── Machines dashboard + per-machine metrics (M10, dossier §10.7) ────────────
3298
+ //
3299
+ // The SHARED data contract M10 (backend) implements + M9 (UI) renders. THE
3300
+ // orchestrator owns this shape; M9 imports these types so the dashboard never
3301
+ // drifts from the API. The fields mirror the agent's MetricsSample wire shape
3302
+ // (`@opengeni/agent-proto`) projected to the dashboard's JSON, plus the derived
3303
+ // machine state matrix (the M3 liveness + the consent/display reasons).
3304
+
3305
+ /**
3306
+ * A point-in-time machine metrics sample as the dashboard reads it. `cpuPct` and
3307
+ * the load averages are 0..N doubles; the byte figures are integers; `gpuUtilPct`
3308
+ * / `gpuMemBytes` are null when no GPU was present at sample time (the wire
3309
+ * contract: absence == not-reported, NEVER a real zero). `runQueue` is the
3310
+ * runnable-count contention signal. `sampledAt` is an ISO-8601 instant.
3311
+ */
3312
+ export const MetricSample = z.object({
3313
+ cpuPct: z.number(),
3314
+ load1: z.number(),
3315
+ load5: z.number(),
3316
+ load15: z.number(),
3317
+ memUsedBytes: z.number().int(),
3318
+ memTotalBytes: z.number().int(),
3319
+ diskUsedBytes: z.number().int(),
3320
+ diskTotalBytes: z.number().int(),
3321
+ gpuUtilPct: z.number().nullable(),
3322
+ gpuMemBytes: z.number().int().nullable(),
3323
+ runQueue: z.number(),
3324
+ sampledAt: z.string(),
3325
+ });
3326
+ export type MetricSample = z.infer<typeof MetricSample>;
3327
+
3328
+ /** The derived dashboard state of a machine. The M3 liveness
3329
+ * (online/reconnecting/offline) plus the enrollment-derived consent/display
3330
+ * reasons (consent_required / display_unavailable) and the in-flight device-flow
3331
+ * (enrolling). */
3332
+ export const MachineState = z.enum([
3333
+ "online",
3334
+ "reconnecting",
3335
+ "offline",
3336
+ "consent_required",
3337
+ "display_unavailable",
3338
+ "enrolling",
3339
+ ]);
3340
+ export type MachineState = z.infer<typeof MachineState>;
3341
+
3342
+ export const MachineKind = z.enum(["modal", "selfhosted"]);
3343
+ export type MachineKind = z.infer<typeof MachineKind>;
3344
+
3345
+ /**
3346
+ * A machine as the Machines dashboard renders it. The workspace's enrolled
3347
+ * selfhosted machines PLUS the session's synthetic Modal group box
3348
+ * (`isSessionGroup: true`). `active` marks the session's currently-active
3349
+ * routing target. `sharedSessionCount` is the lease refcount (how many sessions
3350
+ * share this whole machine). `metrics` is the latest sample, or null when none
3351
+ * has landed yet (just enrolled / offline before a first heartbeat).
3352
+ */
3353
+ export const MachineView = z.object({
3354
+ sandboxId: z.string(),
3355
+ enrollmentId: z.string().nullable(),
3356
+ name: z.string(),
3357
+ kind: MachineKind,
3358
+ state: MachineState,
3359
+ active: z.boolean(),
3360
+ isSessionGroup: z.boolean(),
3361
+ os: z.string(),
3362
+ arch: z.string(),
3363
+ hasDisplay: z.boolean(),
3364
+ allowScreenControl: z.boolean(),
3365
+ sharedSessionCount: z.number().int(),
3366
+ lastSeenAt: z.string().nullable(),
3367
+ metrics: MetricSample.nullable(),
3368
+ });
3369
+ export type MachineView = z.infer<typeof MachineView>;
3370
+
3371
+ /**
3372
+ * GET /v1/workspaces/:ws/machines — the dashboard list. `activeSandboxId` /
3373
+ * `activeEpoch` echo the session's epoch-fenced active-sandbox pointer (null
3374
+ * activeSandboxId == the session's own group box is active).
3375
+ */
3376
+ export const MachinesResponse = z.object({
3377
+ activeSandboxId: z.string().nullable(),
3378
+ activeEpoch: z.number().int(),
3379
+ machines: z.array(MachineView),
3380
+ });
3381
+ export type MachinesResponse = z.infer<typeof MachinesResponse>;
3382
+
3383
+ /**
3384
+ * POST /v1/workspaces/:ws/sessions/:sessionId/active-sandbox — the user-
3385
+ * authenticated swap of a session's active sandbox (the same epoch-fenced
3386
+ * mechanic the M7 `sandbox_swap` MCP tool exposes to the agent). `target` is a
3387
+ * `MachinesResponse` machine's `sandboxId`, or "session"/"default" to swap back
3388
+ * to the session's own group box.
3389
+ */
3390
+ export const SwapActiveSandboxRequest = z.object({
3391
+ target: z.string().min(1),
3392
+ });
3393
+ export type SwapActiveSandboxRequest = z.infer<typeof SwapActiveSandboxRequest>;
3394
+
3395
+ /**
3396
+ * The swap outcome (mirrors the server `FleetSwapResult`). `swapped` is true on a
3397
+ * successful repoint OR a no-op (already pointed there); `reason` carries the
3398
+ * failure detail (unowned/offline target, or a lost epoch fence) when false.
3399
+ */
3400
+ export const SwapActiveSandboxResponse = z.object({
3401
+ swapped: z.boolean(),
3402
+ activeSandboxId: z.string().nullable(),
3403
+ activeEpoch: z.number().int(),
3404
+ reason: z.string().optional(),
3405
+ });
3406
+ export type SwapActiveSandboxResponse = z.infer<typeof SwapActiveSandboxResponse>;
3407
+
3408
+ /**
3409
+ * GET /v1/workspaces/:ws/machines/:enrollmentId/metrics/series?window=1h — the
3410
+ * downsampled (~1/min) history the dashboard time-range reads.
3411
+ */
3412
+ export const MachineMetricsSeriesResponse = z.object({
3413
+ samples: z.array(MetricSample),
3414
+ });
3415
+ export type MachineMetricsSeriesResponse = z.infer<typeof MachineMetricsSeriesResponse>;
3416
+
3417
+ /**
3418
+ * A single host-exposed model + the provider that serves it, as surfaced to
3419
+ * clients (SDK + React composer) by GET /v1/config/client. The wire `api`
3420
+ * ("responses" | "chat") lets a client reason about provider capabilities; the
3421
+ * provider id/label drive the picker's grouping. This mirrors the runtime's
3422
+ * ConfiguredModel (packages/config) projected to the client-safe fields.
3423
+ */
3424
+ export const ClientModel = z.object({
3425
+ id: z.string(),
3426
+ label: z.string(),
3427
+ provider: z.string(), // provider id
3428
+ providerLabel: z.string(),
3429
+ api: z.enum(["responses", "chat"]),
3430
+ contextWindowTokens: z.number().int().positive().optional(),
3431
+ });
3432
+ export type ClientModel = z.infer<typeof ClientModel>;
3433
+
1425
3434
  export const ClientConfig = z.object({
1426
3435
  deploymentRevision: z.string(),
1427
3436
  defaultModel: z.string(),
1428
3437
  allowedModels: z.array(z.string()).min(1),
3438
+ // Richer model list (provider-grouped) for the picker. Defaults to [] for
3439
+ // back-compat: callers that only read allowedModels are unaffected.
3440
+ models: z.array(ClientModel).default([]),
1429
3441
  defaultReasoningEffort: ReasoningEffort,
1430
3442
  allowedReasoningEfforts: z.array(ReasoningEffort).min(1),
1431
3443
  mcpServers: z.array(z.object({
@@ -1438,6 +3450,15 @@ export const ClientConfig = z.object({
1438
3450
  }),
1439
3451
  productAccessMode: ProductAccessMode,
1440
3452
  auth: ClientAuthConfig.default({ mode: "none" }),
3453
+ // Server-wide hint: does this deployment support Channel-A structured services
3454
+ // at all (P4.4). Per-session availability is negotiated on /stream-capabilities
3455
+ // (it depends on the session's pinned backend); this is the coarse on/off the
3456
+ // client uses to decide whether to even attempt the fs/git/terminal panels.
3457
+ structuredServices: z.object({
3458
+ fileSystem: z.boolean(),
3459
+ git: z.boolean(),
3460
+ terminalEvents: z.boolean(),
3461
+ }).default({ fileSystem: false, git: false, terminalEvents: false }),
1441
3462
  });
1442
3463
  export type ClientConfig = z.infer<typeof ClientConfig>;
1443
3464