@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.
- package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
- package/dist/index.d.ts +293 -121
- package/dist/index.js +1819 -786
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2573 -897
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +99 -24
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-BHA2SU3H.js.map +0 -1
- 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<{
|
|
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 =
|
|
152
|
-
|
|
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
|
|
176
|
-
//
|
|
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<{
|
|
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 (
|
|
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:
|
|
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>(
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
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
|
-
|
|
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> {
|
package/src/sandbox/select.ts
CHANGED
|
@@ -165,7 +165,14 @@ export function negotiateCapabilities(ctx: NegotiationContext): SessionCapabilit
|
|
|
165
165
|
|
|
166
166
|
const fileSystem = (() => {
|
|
167
167
|
if (osReason) {
|
|
168
|
-
return {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
134
|
-
|
|
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"] =
|
|
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
|
|
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 =
|
|
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: {
|
|
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:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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:
|
|
244
|
-
|
|
245
|
-
|
|
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,
|