@opengeni/runtime 0.6.0 → 0.7.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.
Files changed (44) hide show
  1. package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
  4. package/dist/index.d.ts +293 -121
  5. package/dist/index.js +1819 -786
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2573 -897
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +99 -24
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-BHA2SU3H.js.map +0 -1
  44. package/src/image-history.ts +0 -149
@@ -28,6 +28,8 @@
28
28
  // `@opengeni/db`.
29
29
 
30
30
  import type { ExposedPortEndpoint } from "../stream-port";
31
+ import { SelfhostedControlError } from "../selfhosted/control-rpc";
32
+ import { renderSelfhostedFault } from "../selfhosted/fault-rendering";
31
33
 
32
34
  /** The per-session active-sandbox pointer the proxy re-reads on every op. Mirror
33
35
  * of `@opengeni/db`'s `ActiveSandboxPointer` (structural, so the leaf does not
@@ -74,7 +76,13 @@ export interface RoutableBackendSession {
74
76
  // `event` is kept `unknown` (mirroring the interface's structural style + avoiding
75
77
  // a proto import into the leaf); the SelfhostedSession takes `DesktopInputRequest["event"]`.
76
78
  desktopInput?(event: unknown): Promise<void>;
77
- screenshot?(): Promise<{ png: Uint8Array; width: number; height: number; nativeWidth: number; nativeHeight: number }>;
79
+ screenshot?(): Promise<{
80
+ png: Uint8Array;
81
+ width: number;
82
+ height: number;
83
+ nativeWidth: number;
84
+ nativeHeight: number;
85
+ }>;
78
86
  }
79
87
 
80
88
  /** The resolved active backend for an epoch: the live session + the sandbox id it
@@ -148,10 +156,12 @@ function isFenceError(error: unknown): boolean {
148
156
  if ((error as { fenced?: unknown }).fenced === true) {
149
157
  return true;
150
158
  }
151
- const name = typeof (error as { name?: unknown }).name === "string" ? (error as { name: string }).name : "";
152
- const message = error instanceof Error ? error.message : String((error as { message?: unknown }).message ?? "");
159
+ const name =
160
+ typeof (error as { name?: unknown }).name === "string" ? (error as { name: string }).name : "";
161
+ const message =
162
+ error instanceof Error ? error.message : String((error as { message?: unknown }).message ?? "");
153
163
  const haystack = `${name} ${message}`.toLowerCase();
154
- return haystack.includes("fenced") || haystack.includes("epoch") && haystack.includes("super");
164
+ return haystack.includes("fenced") || (haystack.includes("epoch") && haystack.includes("super"));
155
165
  }
156
166
 
157
167
  /**
@@ -172,9 +182,17 @@ function isFenceError(error: unknown): boolean {
172
182
  export class RoutingSandboxSession implements RoutableBackendSession {
173
183
  private readonly deps: RoutingSandboxSessionDeps;
174
184
  private readonly maxFenceRetries: number;
175
- // The per-epoch resolved-backend cache. Keyed by activeEpoch: a swap bumps the
176
- // epoch, invalidating the cache so the NEXT op re-resolves the new backend.
185
+ // The resolved-backend cache. Keyed by the FULL pointer tuple
186
+ // `(activeEpoch, activeSandboxId)` — NOT the epoch alone. A swap bumps the epoch,
187
+ // but a pointer can also change its target id WITHOUT an epoch bump: the
188
+ // `sessions.active_sandbox_id` FK is `ON DELETE SET NULL`, so a cascade that
189
+ // deletes the pointed-at sandbox row nulls the id at the SAME epoch. Keying on the
190
+ // epoch alone would then keep serving the deleted/stale backend for that epoch
191
+ // (issue #341 §5.2 — a swap-free route to the Shape-3 symptom). Keying on the tuple
192
+ // makes any target change — epoch-bumped OR not — invalidate the cache so the next
193
+ // op re-resolves (and, for a null id, re-resolves the session HOME).
177
194
  private cachedEpoch: number | undefined;
195
+ private cachedSandboxId: string | null | undefined;
178
196
  private cached: ResolvedActiveBackend | undefined;
179
197
  // The last-resolved backend, exposed via the `state` getter (a method-free read
180
198
  // of the active backend's `state`). Updated on every resolve.
@@ -189,7 +207,13 @@ export class RoutingSandboxSession implements RoutableBackendSession {
189
207
  // that cannot serve them. So the constructor assigns them ONLY when the
190
208
  // construction-time default backend actually implements the native surface (below).
191
209
  desktopInput?: (event: unknown) => Promise<void>;
192
- screenshot?: () => Promise<{ png: Uint8Array; width: number; height: number; nativeWidth: number; nativeHeight: number }>;
210
+ screenshot?: () => Promise<{
211
+ png: Uint8Array;
212
+ width: number;
213
+ height: number;
214
+ nativeWidth: number;
215
+ nativeHeight: number;
216
+ }>;
193
217
 
194
218
  constructor(deps: RoutingSandboxSessionDeps) {
195
219
  this.deps = deps;
@@ -252,16 +276,34 @@ export class RoutingSandboxSession implements RoutableBackendSession {
252
276
  */
253
277
  private async resolve(): Promise<ResolvedActiveBackend> {
254
278
  const pointer = await this.deps.readPointer();
255
- if (this.cachedEpoch === pointer.activeEpoch && this.cached) {
279
+ if (
280
+ this.cachedEpoch === pointer.activeEpoch &&
281
+ this.cachedSandboxId === pointer.activeSandboxId &&
282
+ this.cached
283
+ ) {
256
284
  return this.cached;
257
285
  }
258
286
  const fromEpoch = this.cachedEpoch ?? pointer.activeEpoch;
259
287
  const resolved = await this.deps.resolveActiveBackend(pointer);
288
+ // Re-entrancy guard: a resolver that returns THIS proxy as the active backend
289
+ // makes every op (exec/readFile/…) dispatch back into resolve() -> the same
290
+ // backend -> forever (a silent async infinite recursion that HANGS the turn,
291
+ // not a stack overflow). Fail loud instead — a wiring bug must surface as a
292
+ // legible error, never a hung turn.
293
+ if ((resolved.session as unknown) === this) {
294
+ throw new Error(
295
+ "RoutingSandboxSession.resolveActiveBackend returned the proxy itself as the active backend (re-entrancy) — the resolver must return the underlying box session, not the routing proxy.",
296
+ );
297
+ }
260
298
  this.cachedEpoch = pointer.activeEpoch;
299
+ this.cachedSandboxId = pointer.activeSandboxId;
261
300
  this.cached = resolved;
262
301
  this.lastResolved = resolved;
263
302
  this.deps.onTransition?.({
264
- type: this.cachedEpoch !== undefined && fromEpoch !== pointer.activeEpoch ? "epoch-changed" : "resolved",
303
+ type:
304
+ this.cachedEpoch !== undefined && fromEpoch !== pointer.activeEpoch
305
+ ? "epoch-changed"
306
+ : "resolved",
265
307
  fromEpoch,
266
308
  toEpoch: pointer.activeEpoch,
267
309
  sandboxId: resolved.sandboxId,
@@ -281,7 +323,10 @@ export class RoutingSandboxSession implements RoutableBackendSession {
281
323
  * A non-fence error propagates immediately (it is a real op failure, not a swap
282
324
  * race).
283
325
  */
284
- private async dispatch<T>(op: string, fn: (session: RoutableBackendSession) => Promise<T>): Promise<T> {
326
+ private async dispatch<T>(
327
+ op: string,
328
+ fn: (session: RoutableBackendSession) => Promise<T>,
329
+ ): Promise<T> {
285
330
  let attempt = 0;
286
331
  let lastError: unknown;
287
332
  while (attempt <= this.maxFenceRetries) {
@@ -297,6 +342,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
297
342
  // active sandbox (the fenced-retry role). Bounded by maxFenceRetries.
298
343
  lastError = error;
299
344
  this.cachedEpoch = undefined;
345
+ this.cachedSandboxId = undefined;
300
346
  this.cached = undefined;
301
347
  this.deps.onTransition?.({
302
348
  type: "fenced-retry",
@@ -313,6 +359,31 @@ export class RoutingSandboxSession implements RoutableBackendSession {
313
359
  throw lastError ?? new Error(`routing op "${op}" exhausted fence retries`);
314
360
  }
315
361
 
362
+ /**
363
+ * The failure-visibility boundary for the `exec_command` SDK capability tool — the
364
+ * dominant fault surface, and the one whose thrown `SelfhostedControlError` reaches
365
+ * the model wrapped by the SDK's generic tool-error function as
366
+ * "…Please try again. Error: …" — actively wrong for a machine that is offline, a
367
+ * consent that is not granted, or an oversized reply. Since the SDK closure-captures
368
+ * its `errorFunction` (there is no seam to attach one to its internally-built tools),
369
+ * we render the fault into the doctrine's four fields HERE and return it as the
370
+ * tool's string result — legible, in-band, and free of the misleading wrapper.
371
+ * (`apply_patch` already surfaces `error.message` via the SDK's own catch; the
372
+ * skills / `view_image` tools consume their session methods internally, so they are
373
+ * rendered elsewhere or left to their own SDK renderers — not this string boundary.)
374
+ *
375
+ * A FENCE error is re-thrown, NEVER rendered: `dispatch` already retries it against
376
+ * a re-resolved backend, and a fence that escapes retries is a routing condition the
377
+ * turn handles, not a model-facing fault. Any non-selfhosted error (a Modal fault, a
378
+ * `RoutingUnsupportedError`) is re-thrown unchanged.
379
+ */
380
+ private renderSelfhostedFaultOrThrow(error: unknown): string {
381
+ if (error instanceof SelfhostedControlError && !error.fenced) {
382
+ return renderSelfhostedFault(error);
383
+ }
384
+ throw error;
385
+ }
386
+
316
387
  // ── The forwarded structural surface ──────────────────────────────────────
317
388
  // Every method is PRESENT on the proxy (the SDK binds presence once) and
318
389
  // dispatches to the active backend at call-time. A missing backend method
@@ -332,16 +403,22 @@ export class RoutingSandboxSession implements RoutableBackendSession {
332
403
  }
333
404
 
334
405
  async execCommand(args: unknown): Promise<string> {
335
- return this.dispatch("execCommand", async (s) => {
336
- if (s.execCommand) {
337
- return s.execCommand(args);
338
- }
339
- if (s.exec) {
340
- const r = (await s.exec(args)) as { stdout?: string; output?: string };
341
- return r.stdout ?? r.output ?? "";
342
- }
343
- throw new RoutingUnsupportedError("execCommand", this.cached?.kind ?? "unknown");
344
- });
406
+ try {
407
+ return await this.dispatch("execCommand", async (s) => {
408
+ if (s.execCommand) {
409
+ return s.execCommand(args);
410
+ }
411
+ if (s.exec) {
412
+ const r = (await s.exec(args)) as { stdout?: string; output?: string };
413
+ return r.stdout ?? r.output ?? "";
414
+ }
415
+ throw new RoutingUnsupportedError("execCommand", this.cached?.kind ?? "unknown");
416
+ });
417
+ } catch (error) {
418
+ // Render a terminal selfhosted fault as the tool's result (four fields, correct
419
+ // verdict) instead of letting the SDK mislabel it "Please try again".
420
+ return this.renderSelfhostedFaultOrThrow(error);
421
+ }
345
422
  }
346
423
 
347
424
  async writeStdin(args: unknown): Promise<string> {
@@ -417,12 +494,39 @@ export class RoutingSandboxSession implements RoutableBackendSession {
417
494
  return Boolean(s?.supportsPty?.());
418
495
  }
419
496
 
420
- /** createEditor is a synchronous factory in the SDK surface; it binds to the
421
- * last-resolved backend's editor (or the default backend before the first op).
422
- * Returns undefined when the active backend has no editor (channel-a falls back
423
- * to its exec-based write path). */
497
+ /** createEditor is a synchronous factory in the SDK surface. The SDK's filesystem
498
+ * capability calls it ONCE at tool-BIND time — `FilesystemCapability.tools()`,
499
+ * every turn, before any tool runs — and throws "Filesystem sandbox sessions must
500
+ * provide createEditor()" if it returns falsy. When a backend is already resolved
501
+ * (eager/selfhosted routing) we bind to its editor directly, byte-for-byte as
502
+ * before. But under LAZY provisioning the backend is not established yet
503
+ * (defaultResolved is the synthetic unprovisioned session with no editor), so a
504
+ * direct delegate returns undefined and every lazy turn would die at bind. Return a
505
+ * LAZY EDITOR PROXY instead: a non-null editor whose async ops resolve the active
506
+ * backend (establishing the box on first use, via `dispatch`) and delegate to its
507
+ * real editor — mirroring how this proxy defers exec/readFile. */
424
508
  createEditor(runAs?: string): unknown {
425
- return (this.lastResolved ?? this.deps.defaultResolved)?.session.createEditor?.(runAs);
509
+ const eager = (this.lastResolved ?? this.deps.defaultResolved)?.session.createEditor?.(runAs);
510
+ if (eager) {
511
+ return eager;
512
+ }
513
+ const op =
514
+ (name: "createFile" | "updateFile" | "deleteFile") =>
515
+ (operation: unknown, context?: unknown): Promise<unknown> =>
516
+ this.dispatch(`editor.${name}`, async (s) => {
517
+ const editor = s.createEditor?.(runAs) as
518
+ | Record<string, (operation: unknown, context?: unknown) => Promise<unknown>>
519
+ | undefined;
520
+ if (!editor?.[name]) {
521
+ throw new RoutingUnsupportedError(`editor.${name}`, this.cached?.kind ?? "unknown");
522
+ }
523
+ return editor[name](operation, context);
524
+ });
525
+ return {
526
+ createFile: op("createFile"),
527
+ updateFile: op("updateFile"),
528
+ deleteFile: op("deleteFile"),
529
+ };
426
530
  }
427
531
 
428
532
  async resolveExposedPort(port: number): Promise<ExposedPortEndpoint> {
@@ -165,7 +165,14 @@ export function negotiateCapabilities(ctx: NegotiationContext): SessionCapabilit
165
165
 
166
166
  const fileSystem = (() => {
167
167
  if (osReason) {
168
- return { available: false, readOnly: true, root: descriptor.workspaceRoot, pathSep: "/" as const, treeMode: "lazy" as const, reason: osReason };
168
+ return {
169
+ available: false,
170
+ readOnly: true,
171
+ root: descriptor.workspaceRoot,
172
+ pathSep: "/" as const,
173
+ treeMode: "lazy" as const,
174
+ reason: osReason,
175
+ };
169
176
  }
170
177
  const cap = descriptor.capabilities.FileSystem;
171
178
  return {
@@ -181,10 +188,26 @@ export function negotiateCapabilities(ctx: NegotiationContext): SessionCapabilit
181
188
  const terminal = (() => {
182
189
  const cap = descriptor.capabilities.Terminal;
183
190
  if (osReason) {
184
- return { transport: null, ptyCapable: false, shell: "/bin/bash", url: null, token: null, expiresAt: null, reason: osReason };
191
+ return {
192
+ transport: null,
193
+ ptyCapable: false,
194
+ shell: "/bin/bash",
195
+ url: null,
196
+ token: null,
197
+ expiresAt: null,
198
+ reason: osReason,
199
+ };
185
200
  }
186
201
  if (!cap.available) {
187
- return { transport: null, ptyCapable: false, shell: "/bin/bash", url: null, token: null, expiresAt: null, reason: "backend_unsupported" as const };
202
+ return {
203
+ transport: null,
204
+ ptyCapable: false,
205
+ shell: "/bin/bash",
206
+ url: null,
207
+ token: null,
208
+ expiresAt: null,
209
+ reason: "backend_unsupported" as const,
210
+ };
188
211
  }
189
212
  // The REAL PTY (ttyd pty-ws) rides the SAME tunnel as the desktop, so it is
190
213
  // gated identically: a real-PTY backend (cap.pty), the terminal policy toggle
@@ -231,7 +254,11 @@ export function negotiateCapabilities(ctx: NegotiationContext): SessionCapabilit
231
254
  if (osReason) {
232
255
  return { available: false, repos: [], reason: osReason };
233
256
  }
234
- return { available: cap.available, repos: [], reason: cap.available ? null : ("backend_unsupported" as const) };
257
+ return {
258
+ available: cap.available,
259
+ repos: [],
260
+ reason: cap.available ? null : ("backend_unsupported" as const),
261
+ };
235
262
  })();
236
263
 
237
264
  const desktop = (() => {
@@ -317,14 +344,32 @@ export function negotiateCapabilities(ctx: NegotiationContext): SessionCapabilit
317
344
  const recording = (() => {
318
345
  const cap = descriptor.capabilities.Recording;
319
346
  if (osReason) {
320
- return { available: false, modes: [] as ("manual" | "on-turn" | "on-verify")[], codecs: [] as ("h264-mp4" | "vp9-webm")[], reason: osReason };
347
+ return {
348
+ available: false,
349
+ modes: [] as ("manual" | "on-turn" | "on-verify")[],
350
+ codecs: [] as ("h264-mp4" | "vp9-webm")[],
351
+ reason: osReason,
352
+ };
321
353
  }
322
354
  if (!cap.available) {
323
- return { available: false, modes: [] as ("manual" | "on-turn" | "on-verify")[], codecs: [] as ("h264-mp4" | "vp9-webm")[], reason: descriptor.tier === "headless" ? ("tier_headless" as const) : ("backend_unsupported" as const) };
355
+ return {
356
+ available: false,
357
+ modes: [] as ("manual" | "on-turn" | "on-verify")[],
358
+ codecs: [] as ("h264-mp4" | "vp9-webm")[],
359
+ reason:
360
+ descriptor.tier === "headless"
361
+ ? ("tier_headless" as const)
362
+ : ("backend_unsupported" as const),
363
+ };
324
364
  }
325
365
  // Recording feasibility tracks desktop; policy-gate it the same way.
326
366
  if (!ctx.desktopEnabled) {
327
- return { available: false, modes: [] as ("manual" | "on-turn" | "on-verify")[], codecs: [] as ("h264-mp4" | "vp9-webm")[], reason: "disabled_by_policy" as const };
367
+ return {
368
+ available: false,
369
+ modes: [] as ("manual" | "on-turn" | "on-verify")[],
370
+ codecs: [] as ("h264-mp4" | "vp9-webm")[],
371
+ reason: "disabled_by_policy" as const,
372
+ };
328
373
  }
329
374
  return {
330
375
  available: true,
@@ -345,7 +390,14 @@ export function negotiateCapabilities(ctx: NegotiationContext): SessionCapabilit
345
390
  return { available: false, readOnly, reason: osReason };
346
391
  }
347
392
  if (!desktopCapable) {
348
- return { available: false, readOnly, reason: descriptor.tier === "headless" ? ("tier_headless" as const) : ("backend_unsupported" as const) };
393
+ return {
394
+ available: false,
395
+ readOnly,
396
+ reason:
397
+ descriptor.tier === "headless"
398
+ ? ("tier_headless" as const)
399
+ : ("backend_unsupported" as const),
400
+ };
349
401
  }
350
402
  if (!ctx.desktopEnabled || ctx.computerUseEnabled === false) {
351
403
  return { available: false, readOnly, reason: "disabled_by_policy" as const };
@@ -44,6 +44,13 @@ export interface SelfhostedEnrollment {
44
44
  allowScreenControl: boolean;
45
45
  hasDisplay: boolean;
46
46
  lastSeenAt: string | null;
47
+ /** An un-cleared clean going-offline marker (the machine announced a typed
48
+ * GoingOffline). When set, the derivation reads the machine OFFLINE immediately,
49
+ * regardless of a still-fresh `lastSeenAt`. NULL ⇒ no pending goodbye. */
50
+ wentOfflineAt: string | null;
51
+ /** The typed reason string of the pending goodbye (rides alongside
52
+ * `wentOfflineAt`; NULL when there is no un-cleared marker). */
53
+ wentOfflineReason: string | null;
47
54
  }
48
55
 
49
56
  /** The derived liveness state of a selfhosted machine (the online/offline/
@@ -72,6 +79,8 @@ export const SELFHOSTED_RECONNECT_WINDOW_MS = 30_000;
72
79
  * (stale / never seen).
73
80
  *
74
81
  * - no enrollment / revoked → offline (the machine isn't enrolled).
82
+ * - un-cleared clean goodbye → offline (an announced GoingOffline beats
83
+ * last_seen aging AND a lingering probe).
75
84
  * - probe responded → online.
76
85
  * - probe missed, lastSeenAt recent → reconnecting (a transient blip).
77
86
  * - probe missed, lastSeenAt stale → offline.
@@ -89,6 +98,15 @@ export function selfhostedLiveness(input: {
89
98
  }
90
99
  const consented = enrollment.exposure === "whole-machine" && enrollment.allowScreenControl;
91
100
  const hasDisplay = enrollment.hasDisplay;
101
+ // A CLEAN going-offline the machine announced takes precedence over BOTH
102
+ // last_seen aging and a lingering probe: a machine that said "I'm stopping" must
103
+ // read offline immediately (so no new work is routed at it) until a NEWER
104
+ // liveness signal — a reconnect Hello or a fresher heartbeat — clears the marker
105
+ // in the row. `status: "revoked"` above still trumps this (a revoked machine is
106
+ // offline regardless). This is the #348 lease-waits-on-dead-detect fix.
107
+ if (enrollment.wentOfflineAt) {
108
+ return { state: "offline", consented, hasDisplay };
109
+ }
92
110
  if (input.probeResponded) {
93
111
  return { state: "online", consented, hasDisplay };
94
112
  }
@@ -130,8 +148,11 @@ export interface SelfhostedNegotiationInput {
130
148
  * with the online/offline/reconnecting/consent_required/display_unavailable cells
131
149
  * correctly decided. Async because it issues the liveness probe.
132
150
  */
133
- export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiationInput): Promise<SessionCapabilities> {
134
- const probeResponded = input.probeResponded ?? (input.session ? await input.session.ping() : false);
151
+ export async function negotiateSelfhostedCapabilities(
152
+ input: SelfhostedNegotiationInput,
153
+ ): Promise<SessionCapabilities> {
154
+ const probeResponded =
155
+ input.probeResponded ?? (input.session ? await input.session.ping() : false);
135
156
  const liveness = selfhostedLiveness({
136
157
  enrollment: input.enrollment,
137
158
  probeResponded,
@@ -142,7 +163,8 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
142
163
  // descriptor-shaped cells (FS/Terminal/Git/Desktop) negotiate as on a warm box
143
164
  // when online, and a cold box when not reachable (no live tunnel). The
144
165
  // selfhosted overlay below then stamps the selfhosted-specific reasons.
145
- const baseLiveness: NegotiationContext["liveness"] = liveness.state === "online" ? "warm" : "cold";
166
+ const baseLiveness: NegotiationContext["liveness"] =
167
+ liveness.state === "online" ? "warm" : "cold";
146
168
  const base: NegotiationContext = {
147
169
  sessionId: input.sessionId,
148
170
  backend: "selfhosted",
@@ -152,7 +174,9 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
152
174
  desktopEnabled: input.desktopEnabled ?? true,
153
175
  terminalEnabled: input.terminalEnabled ?? true,
154
176
  computerUseEnabled: input.computerUseEnabled ?? true,
155
- ...(input.desktopAcknowledged !== undefined ? { desktopAcknowledged: input.desktopAcknowledged } : {}),
177
+ ...(input.desktopAcknowledged !== undefined
178
+ ? { desktopAcknowledged: input.desktopAcknowledged }
179
+ : {}),
156
180
  ...(input.shared !== undefined ? { shared: input.shared } : {}),
157
181
  ...(input.sharedSessionIds !== undefined ? { sharedSessionIds: input.sharedSessionIds } : {}),
158
182
  ...(input.now ? { now: input.now } : {}),
@@ -166,11 +190,19 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
166
190
  // is the dominant degrade (like os_unsupported): an offline/reconnecting agent
167
191
  // knocks out every reachable capability with the single coherent reason.
168
192
  if (liveness.state !== "online") {
169
- const reason: CapabilityUnavailableReason = liveness.state === "offline" ? "agent_offline" : "agent_reconnecting";
193
+ const reason: CapabilityUnavailableReason =
194
+ liveness.state === "offline" ? "agent_offline" : "agent_reconnecting";
170
195
  return {
171
196
  ...caps,
172
197
  FileSystem: { ...caps.FileSystem, available: false, readOnly: true, reason },
173
- Terminal: { ...caps.Terminal, transport: null, url: null, token: null, expiresAt: null, reason },
198
+ Terminal: {
199
+ ...caps.Terminal,
200
+ transport: null,
201
+ url: null,
202
+ token: null,
203
+ expiresAt: null,
204
+ reason,
205
+ },
174
206
  Git: { ...caps.Git, available: false, reason },
175
207
  DesktopStream: {
176
208
  ...caps.DesktopStream,
@@ -209,22 +241,23 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
209
241
  const reason: CapabilityUnavailableReason = "display_unavailable";
210
242
  return {
211
243
  ...caps,
212
- DesktopStream: caps.DesktopStream.transport !== null
213
- ? {
214
- ...caps.DesktopStream,
215
- transport: null,
216
- client: null,
217
- mode: "read-only",
218
- url: null,
219
- token: null,
220
- expiresAt: null,
221
- requiresAcknowledgment: false,
222
- acknowledged: false,
223
- shared: false,
224
- sharedSessionIds: [],
225
- reason,
226
- }
227
- : caps.DesktopStream,
244
+ DesktopStream:
245
+ caps.DesktopStream.transport !== null
246
+ ? {
247
+ ...caps.DesktopStream,
248
+ transport: null,
249
+ client: null,
250
+ mode: "read-only",
251
+ url: null,
252
+ token: null,
253
+ expiresAt: null,
254
+ requiresAcknowledgment: false,
255
+ acknowledged: false,
256
+ shared: false,
257
+ sharedSessionIds: [],
258
+ reason,
259
+ }
260
+ : caps.DesktopStream,
228
261
  Recording: caps.Recording.available
229
262
  ? { ...caps.Recording, available: false, modes: [], codecs: [], reason }
230
263
  : caps.Recording,
@@ -240,9 +273,10 @@ export async function negotiateSelfhostedCapabilities(input: SelfhostedNegotiati
240
273
  // so no input is forwarded even if the base offered an interactive mode.
241
274
  return {
242
275
  ...caps,
243
- DesktopStream: caps.DesktopStream.transport !== null
244
- ? { ...caps.DesktopStream, mode: "read-only" }
245
- : caps.DesktopStream,
276
+ DesktopStream:
277
+ caps.DesktopStream.transport !== null
278
+ ? { ...caps.DesktopStream, mode: "read-only" }
279
+ : caps.DesktopStream,
246
280
  ComputerUse: caps.ComputerUse.available
247
281
  ? { ...caps.ComputerUse, available: false, reason: "consent_required" }
248
282
  : caps.ComputerUse,