@opengeni/runtime 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-2PO56VAL.js → chunk-D5KU3QUC.js} +240 -23
- package/dist/chunk-D5KU3QUC.js.map +1 -0
- package/dist/index.d.ts +106 -178
- package/dist/index.js +427 -161
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +54 -6
- package/dist/sandbox/index.js +11 -1
- package/package.json +3 -3
- package/src/context-compaction.ts +217 -348
- package/src/image-history.ts +149 -0
- package/src/index.ts +195 -38
- package/src/sandbox/display-stack.ts +96 -12
- package/src/sandbox/index.ts +72 -12
- package/src/sandbox/providers/modal.ts +225 -0
- package/src/sandbox/routing/routing-session.ts +2 -2
- package/src/sandbox/selfhosted/session.ts +21 -5
- package/src/sandbox-computer.ts +88 -26
- package/dist/chunk-2PO56VAL.js.map +0 -1
|
@@ -24,11 +24,41 @@ import { DESKTOP_STREAM_PORT } from "@opengeni/contracts";
|
|
|
24
24
|
export { DESKTOP_STREAM_PORT };
|
|
25
25
|
export const STREAM_PORT = DESKTOP_STREAM_PORT;
|
|
26
26
|
|
|
27
|
-
// The whole-stack launch is bounded by the readiness gates inside the script
|
|
28
|
-
// (four loops of 50 * 0.1s = ~5s each, ~20s worst case) PLUS
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
27
|
+
// The whole-stack launch is bounded by the readiness gates inside the up-script
|
|
28
|
+
// (four loops of 50 * 0.1s = ~5s each, ~20s worst case) PLUS the PAINTABLE-FRAME
|
|
29
|
+
// gate we append (up to ~30s of scrot probing) PLUS first-boot XFCE/dbus + font-cache
|
|
30
|
+
// warm-up on a cold gVisor box. 90s gives headroom over the spike's observed ~5-10s
|
|
31
|
+
// warm path AND the cold-box paint warm-up without masking a genuine wedge.
|
|
32
|
+
export const DISPLAY_STACK_TIMEOUT_MS = 90_000;
|
|
33
|
+
|
|
34
|
+
// PAINTABLE-FRAME gate: poll scrot up to this many times, this many seconds apart,
|
|
35
|
+
// waiting for an actually-PAINTED frame before declaring the stack "up" (~30s worst case).
|
|
36
|
+
const PAINT_PROBE_ATTEMPTS = 150;
|
|
37
|
+
const PAINT_PROBE_INTERVAL_S = 0.2;
|
|
38
|
+
|
|
39
|
+
// The paint FLOOR (bytes): a scrot at/above this size is a real painted desktop; below
|
|
40
|
+
// it, the root is still unpainted and the frame would read as "blank" to the model.
|
|
41
|
+
//
|
|
42
|
+
// WHY A SIZE FLOOR, NOT NON-EMPTINESS (the bug this fixes): the old gate only checked
|
|
43
|
+
// `[ -s frame.png ]` (non-empty). But an UNPAINTED root is never zero-byte — a fresh
|
|
44
|
+
// Xvfb draws either the `-retro` weave stipple or (with `-retro` dropped) solid black,
|
|
45
|
+
// and scrot happily encodes that as a small-but-non-empty PNG. So the old gate passed
|
|
46
|
+
// the instant the VNC ports bound — MEASURED at ~1.4s (fast runc host) to several
|
|
47
|
+
// seconds (cold gVisor) BEFORE xfdesktop finishes its first wallpaper paint — handing
|
|
48
|
+
// the model the pre-paint frame. That pre-paint frame is exactly the "blank/black"
|
|
49
|
+
// screenshot that 400s the model and blanks the human viewer.
|
|
50
|
+
//
|
|
51
|
+
// The sizes are unambiguous and were measured on the canonical desktop image (1280x800)
|
|
52
|
+
// under runc — both the current staging image and a fresh local build:
|
|
53
|
+
// painted XFCE desktop (blue-gradient wallpaper + panel + icons): ~210-222 KB
|
|
54
|
+
// `-retro` stipple root (unpainted, current image): ~17 KB
|
|
55
|
+
// solid-black root (unpainted, after we drop `-retro`): ~13.5 KB
|
|
56
|
+
// 60 KB sits ~3.5x above every unpainted state and ~3.5x below a real paint — a wide,
|
|
57
|
+
// unambiguous margin. It holds against BOTH the currently-deployed `-retro` image and
|
|
58
|
+
// the `-retro`-dropped image this change ships, so the runtime gate is correct before
|
|
59
|
+
// AND after the image rebuild lands. (Assumes the default ~1280x800 geometry; a larger
|
|
60
|
+
// framebuffer only scales the painted frame further above the floor.)
|
|
61
|
+
const PAINT_MIN_BYTES = 60_000;
|
|
32
62
|
|
|
33
63
|
/** Desktop geometry for the framebuffer. v1 has no live RANDR: a resolution
|
|
34
64
|
* change is a full down -> up restart (a separate op). */
|
|
@@ -41,15 +71,25 @@ export type DesktopGeometry = {
|
|
|
41
71
|
export const DEFAULT_DESKTOP_GEOMETRY: DesktopGeometry = { width: 1280, height: 800, dpi: 96 };
|
|
42
72
|
|
|
43
73
|
/** Thrown when a stage of the launch script failed. exitCode 11/12/13 map to
|
|
44
|
-
* Xvfb / x11vnc / websockify respectively (the stage that died)
|
|
45
|
-
*
|
|
74
|
+
* Xvfb / x11vnc / websockify respectively (the stage that died); 14 is the
|
|
75
|
+
* PAINTABLE-FRAME gate (ports listening but scrot still yields an empty frame —
|
|
76
|
+
* the display is up but not actually painting). Degradation is surfaced as a
|
|
77
|
+
* value to viewers by the caller; this error is for diagnostics. */
|
|
46
78
|
export class DisplayStackError extends Error {
|
|
47
79
|
readonly exitCode: number;
|
|
48
|
-
readonly stage: "xvfb" | "x11vnc" | "websockify" | "unknown";
|
|
80
|
+
readonly stage: "xvfb" | "x11vnc" | "websockify" | "paint" | "unknown";
|
|
49
81
|
|
|
50
82
|
constructor(exitCode: number, output: string) {
|
|
51
83
|
const stage =
|
|
52
|
-
exitCode === 11
|
|
84
|
+
exitCode === 11
|
|
85
|
+
? "xvfb"
|
|
86
|
+
: exitCode === 12
|
|
87
|
+
? "x11vnc"
|
|
88
|
+
: exitCode === 13
|
|
89
|
+
? "websockify"
|
|
90
|
+
: exitCode === 14
|
|
91
|
+
? "paint"
|
|
92
|
+
: "unknown";
|
|
53
93
|
super(`desktop display stack failed at stage "${stage}" (exit ${exitCode})${output ? `:\n${output}` : ""}`);
|
|
54
94
|
this.name = "DisplayStackError";
|
|
55
95
|
this.exitCode = exitCode;
|
|
@@ -125,15 +165,52 @@ export function buildDisplayStackScript(options: EnsureDisplayStackOptions = {})
|
|
|
125
165
|
// flock -w bounds the wait so a wedged holder can't deadlock the caller; the
|
|
126
166
|
// up-script itself ALSO takes the same lock (belt + braces) so this works even
|
|
127
167
|
// against an older image that predates the wrapper.
|
|
128
|
-
|
|
168
|
+
//
|
|
169
|
+
// PAINTABLE-FRAME GATE (the completion criterion): the up-script's readiness gates
|
|
170
|
+
// only assert that Xvfb answers xdpyinfo and that x11vnc:5900 + websockify:PORT are
|
|
171
|
+
// LISTENING — NOT that the display actually PAINTS. On a stone-cold gVisor box (the
|
|
172
|
+
// machine→sandbox swap-recovery turn always hits one), Xvfb answers and the VNC ports
|
|
173
|
+
// bind ~1.4s (fast host) to several seconds BEFORE xfdesktop finishes its first
|
|
174
|
+
// wallpaper paint. In that window a scrot yields a small UNPAINTED frame (the -retro
|
|
175
|
+
// stipple or a solid-black root) — never zero-byte — which is exactly the "blank/black"
|
|
176
|
+
// screenshot that 400s the model and blanks the human viewer. (VERIFIED locally: the
|
|
177
|
+
// real xfdesktop backdrop window maps at full 1280x800 the whole time; the render is
|
|
178
|
+
// never structurally broken — it is purely this pre-paint capture race.)
|
|
179
|
+
//
|
|
180
|
+
// We therefore chain a real scrot probe as the completion gate: after the up-script
|
|
181
|
+
// reports success, poll scrot until it produces an actually-PAINTED frame — a PNG at or
|
|
182
|
+
// above PAINT_MIN_BYTES, not merely NON-EMPTY (the old `[ -s ]` check passed on the
|
|
183
|
+
// ~17 KB pre-paint stipple immediately; that WAS the bug) — bounded ~30s, and only THEN
|
|
184
|
+
// let the command exit 0. If it never paints we exit 14 so the caller sees a typed
|
|
185
|
+
// DisplayStackError("paint") — an HONEST failure the worker can degrade + log, rather
|
|
186
|
+
// than a false "up" that hands the model an unpainted image. `-ac` on Xvfb disables
|
|
187
|
+
// access control so this root-side scrot reaches :0. Runs on a pre-check hit too (cheap
|
|
188
|
+
// — an already-up display paints on the first probe). Lives in the runtime-built script
|
|
189
|
+
// (not the baked image up-script) so it ships with the worker/api, no image rebuild —
|
|
190
|
+
// and its size floor holds against the currently-deployed image too.
|
|
191
|
+
const bringUp =
|
|
129
192
|
`if nc -z 127.0.0.1 ${port} >/dev/null 2>&1 && nc -z 127.0.0.1 5900 >/dev/null 2>&1; then ` +
|
|
130
193
|
`echo "OPENGENI_DESKTOP_UP port=${port} geometry=${geometry.width}x${geometry.height} dpi=${geometry.dpi} (precheck)"; ` +
|
|
131
194
|
`else ` +
|
|
132
195
|
`mkdir -p /tmp/opengeni-desktop && ` +
|
|
133
196
|
`flock -w 45 /tmp/opengeni-desktop/up.outer.lock ` +
|
|
134
197
|
`env ${env} opengeni-desktop-up; ` +
|
|
135
|
-
`fi
|
|
136
|
-
|
|
198
|
+
`fi`;
|
|
199
|
+
const paintProbe =
|
|
200
|
+
`p=/tmp/opengeni-desktop/paint-probe.png; ` +
|
|
201
|
+
`for i in $(seq 1 ${PAINT_PROBE_ATTEMPTS}); do ` +
|
|
202
|
+
// Capture, then measure the PNG byte-size. `wc -c < "$p"` yields a bare integer; a
|
|
203
|
+
// failed scrot leaves sz=0. A frame at/above PAINT_MIN_BYTES is a real painted desktop.
|
|
204
|
+
`if DISPLAY=:0 scrot -o "$p" >/dev/null 2>&1; then sz=$(wc -c < "$p" 2>/dev/null || echo 0); else sz=0; fi; ` +
|
|
205
|
+
`rm -f "$p"; ` +
|
|
206
|
+
`if [ "$sz" -ge ${PAINT_MIN_BYTES} ]; then break; fi; ` +
|
|
207
|
+
// NOTE: NOT_PAINTING goes to STDOUT (not stderr): Modal is execCommand-only, so the
|
|
208
|
+
// caller infers the outcome by string-matching the output — stdout is always captured.
|
|
209
|
+
// ($sz is bare shell here — no ${} braces — so JS leaves it for bash to expand.)
|
|
210
|
+
`if [ "$i" = "${PAINT_PROBE_ATTEMPTS}" ]; then echo "OPENGENI_DESKTOP_NOT_PAINTING scrot below ${PAINT_MIN_BYTES}B after warmup (last=$sz)"; exit 14; fi; ` +
|
|
211
|
+
`sleep ${PAINT_PROBE_INTERVAL_S}; ` +
|
|
212
|
+
`done`;
|
|
213
|
+
return `mkdir -p /tmp/opengeni-desktop; { ${bringUp} ; } && { ${paintProbe} ; }`;
|
|
137
214
|
}
|
|
138
215
|
|
|
139
216
|
function execResultOutput(result: ExecResultLike | string): string {
|
|
@@ -157,6 +234,13 @@ function execResultExitCode(result: ExecResultLike | string): number | null {
|
|
|
157
234
|
// bare string), we infer success from the OPENGENI_DESKTOP_UP marker and infer
|
|
158
235
|
// the failing stage from the stage-failure message the script prints to stderr.
|
|
159
236
|
function inferExitFromOutput(output: string): number {
|
|
237
|
+
// Check the PAINTABLE-FRAME failure FIRST: on that path the up-script already
|
|
238
|
+
// printed OPENGENI_DESKTOP_UP (bring-up succeeded) and THEN the paint gate failed,
|
|
239
|
+
// so both markers are present — the NOT_PAINTING one is the authoritative outcome.
|
|
240
|
+
// (Modal is execCommand-only, so this string-inference path is the live one.)
|
|
241
|
+
if (/OPENGENI_DESKTOP_NOT_PAINTING/.test(output)) {
|
|
242
|
+
return 14;
|
|
243
|
+
}
|
|
160
244
|
if (/OPENGENI_DESKTOP_UP\b/.test(output)) {
|
|
161
245
|
return 0;
|
|
162
246
|
}
|
package/src/sandbox/index.ts
CHANGED
|
@@ -53,6 +53,16 @@ export {
|
|
|
53
53
|
type ProviderRegistration,
|
|
54
54
|
type ProviderConstructionContext,
|
|
55
55
|
} from "./providers";
|
|
56
|
+
export {
|
|
57
|
+
modalSandboxAttributionEnvironment,
|
|
58
|
+
modalSandboxAttributionTags,
|
|
59
|
+
sweepModalOrphanSandboxes,
|
|
60
|
+
tagModalSandbox,
|
|
61
|
+
terminateModalSandboxById,
|
|
62
|
+
type LiveModalSandboxLeaseAttribution,
|
|
63
|
+
type ModalOrphanSweepResult,
|
|
64
|
+
type ModalSandboxAttribution,
|
|
65
|
+
} from "./providers/modal";
|
|
56
66
|
export {
|
|
57
67
|
selectBackend,
|
|
58
68
|
backendSupportsOs,
|
|
@@ -540,6 +550,8 @@ export type EstablishedSandboxSession = {
|
|
|
540
550
|
backendId: string;
|
|
541
551
|
};
|
|
542
552
|
|
|
553
|
+
export type SandboxCreatedCallback = (established: EstablishedSandboxSession) => Promise<void>;
|
|
554
|
+
|
|
543
555
|
// The structural slice we need from a provider SandboxClient to resume by id and
|
|
544
556
|
// cold-restore. Narrowed (not the full agent-loop SandboxClient) so the leaf
|
|
545
557
|
// stays agent-loop-free.
|
|
@@ -616,6 +628,16 @@ function readInstanceId(session: unknown): string {
|
|
|
616
628
|
return typeof candidate === "string" && candidate.length > 0 ? candidate : "";
|
|
617
629
|
}
|
|
618
630
|
|
|
631
|
+
async function terminateCreatedSandbox(client: ResumeCapableClient, session: unknown, sessionState: unknown): Promise<void> {
|
|
632
|
+
const clientWithDelete = client as { delete?: (state: unknown) => Promise<unknown> };
|
|
633
|
+
if (typeof clientWithDelete.delete === "function" && sessionState !== undefined) {
|
|
634
|
+
try { await clientWithDelete.delete(sessionState); } catch { /* best-effort */ }
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const sess = session as { close?: () => Promise<unknown>; terminate?: () => Promise<unknown>; kill?: () => Promise<unknown> };
|
|
638
|
+
try { await (sess.terminate ?? sess.kill ?? sess.close)?.(); } catch { /* best-effort */ }
|
|
639
|
+
}
|
|
640
|
+
|
|
619
641
|
/**
|
|
620
642
|
* Resume the one box by id from its recovery envelope, or cold-restore from the
|
|
621
643
|
* snapshot when the provider reports it gone. The envelope is the lease's
|
|
@@ -633,7 +655,12 @@ function readInstanceId(session: unknown): string {
|
|
|
633
655
|
export async function establishSandboxSessionFromEnvelope(
|
|
634
656
|
settings: Settings,
|
|
635
657
|
envelope: Record<string, unknown> | null,
|
|
636
|
-
opts: {
|
|
658
|
+
opts: {
|
|
659
|
+
sessionId: string;
|
|
660
|
+
backendOverride?: SandboxBackend;
|
|
661
|
+
environment?: Record<string, string>;
|
|
662
|
+
onSandboxCreated?: SandboxCreatedCallback;
|
|
663
|
+
},
|
|
637
664
|
): Promise<EstablishedSandboxSession> {
|
|
638
665
|
const envelopeBackend = typeof envelope?.backendId === "string" ? (envelope.backendId as SandboxBackend) : undefined;
|
|
639
666
|
const backend = (opts.backendOverride ?? envelopeBackend ?? (settings.sandboxBackend as SandboxBackend));
|
|
@@ -680,6 +707,22 @@ export async function establishSandboxSessionFromEnvelope(
|
|
|
680
707
|
// cold-restore branch (b) below.
|
|
681
708
|
const coldRestore = async (resumeFallbackState?: unknown): Promise<EstablishedSandboxSession> => {
|
|
682
709
|
const restored = await client.create!({ manifest: createManifest });
|
|
710
|
+
let restoredState = (restored as { state?: unknown }).state;
|
|
711
|
+
let established: EstablishedSandboxSession = {
|
|
712
|
+
client,
|
|
713
|
+
session: restored,
|
|
714
|
+
sessionState: restoredState ?? resumeFallbackState,
|
|
715
|
+
instanceId: readInstanceId(restored),
|
|
716
|
+
backendId: client.backendId,
|
|
717
|
+
};
|
|
718
|
+
if (opts.onSandboxCreated) {
|
|
719
|
+
try {
|
|
720
|
+
await opts.onSandboxCreated(established);
|
|
721
|
+
} catch (createCallbackError) {
|
|
722
|
+
await terminateCreatedSandbox(client, restored, restoredState);
|
|
723
|
+
throw createCallbackError;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
683
726
|
if (workspaceArchive) {
|
|
684
727
|
const hydrate = (restored as { hydrateWorkspace?: (data: Uint8Array) => Promise<void> }).hydrateWorkspace;
|
|
685
728
|
if (typeof hydrate === "function") {
|
|
@@ -696,21 +739,38 @@ export async function establishSandboxSessionFromEnvelope(
|
|
|
696
739
|
// re-throwing so no box leaks. The original error semantics are preserved
|
|
697
740
|
// (the re-throw propagates to the caller). This mirrors the reaper's
|
|
698
741
|
// discipline: NEVER leave an orphaned box running.
|
|
699
|
-
|
|
700
|
-
const clientWithDelete = client as { delete?: (state: unknown) => Promise<unknown> };
|
|
701
|
-
if (typeof clientWithDelete.delete === "function" && restoredState !== undefined) {
|
|
702
|
-
try { await clientWithDelete.delete(restoredState); } catch { /* best-effort; re-throw the hydrate error below */ }
|
|
703
|
-
} else {
|
|
704
|
-
// No delete() — try a session-level close/terminate as a fallback.
|
|
705
|
-
const sess = restored as { close?: () => Promise<unknown>; terminate?: () => Promise<unknown> };
|
|
706
|
-
try { await (sess.terminate ?? sess.close)?.(); } catch { /* best-effort */ }
|
|
707
|
-
}
|
|
742
|
+
await terminateCreatedSandbox(client, restored, restoredState);
|
|
708
743
|
throw hydrateError;
|
|
709
744
|
}
|
|
745
|
+
const hydratedState = (restored as { state?: unknown }).state;
|
|
746
|
+
const hydratedInstanceId = readInstanceId(restored);
|
|
747
|
+
if (hydratedInstanceId && hydratedInstanceId !== established.instanceId) {
|
|
748
|
+
established = {
|
|
749
|
+
client,
|
|
750
|
+
session: restored,
|
|
751
|
+
sessionState: hydratedState ?? resumeFallbackState,
|
|
752
|
+
instanceId: hydratedInstanceId,
|
|
753
|
+
backendId: client.backendId,
|
|
754
|
+
};
|
|
755
|
+
if (opts.onSandboxCreated) {
|
|
756
|
+
try {
|
|
757
|
+
await opts.onSandboxCreated(established);
|
|
758
|
+
} catch (createCallbackError) {
|
|
759
|
+
await terminateCreatedSandbox(client, restored, hydratedState);
|
|
760
|
+
throw createCallbackError;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
710
764
|
}
|
|
711
765
|
}
|
|
712
|
-
|
|
713
|
-
return {
|
|
766
|
+
restoredState = (restored as { state?: unknown }).state;
|
|
767
|
+
return {
|
|
768
|
+
client,
|
|
769
|
+
session: restored,
|
|
770
|
+
sessionState: restoredState ?? resumeFallbackState,
|
|
771
|
+
instanceId: readInstanceId(restored),
|
|
772
|
+
backendId: client.backendId,
|
|
773
|
+
};
|
|
714
774
|
};
|
|
715
775
|
|
|
716
776
|
// Does the envelope carry a RESUMABLE box id (warm reattach), or only a
|
|
@@ -1,9 +1,53 @@
|
|
|
1
1
|
import { ModalImageSelector, ModalSandboxClient } from "@openai/agents-extensions/sandbox/modal";
|
|
2
2
|
import { effectiveModalIdleTimeoutSeconds } from "@opengeni/config";
|
|
3
|
+
import type { Settings } from "@opengeni/config";
|
|
3
4
|
import { CAPABILITY_DESCRIPTORS } from "../capabilities";
|
|
4
5
|
import { SandboxConfigError } from "../errors";
|
|
5
6
|
import type { ProviderRegistration } from "./types";
|
|
6
7
|
|
|
8
|
+
const MODAL_ORPHAN_SWEEP_LIMIT = 50;
|
|
9
|
+
const MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 30 * 60_000;
|
|
10
|
+
|
|
11
|
+
export type ModalSandboxAttribution = {
|
|
12
|
+
leaseId: string;
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
sandboxGroupId: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type LiveModalSandboxLeaseAttribution = ModalSandboxAttribution & {
|
|
18
|
+
instanceId: string | null;
|
|
19
|
+
liveness?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ModalOrphanSweepTermination = {
|
|
23
|
+
sandboxId: string;
|
|
24
|
+
reason: "stale_attribution" | "unattributed";
|
|
25
|
+
tags: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ModalOrphanSweepResult = {
|
|
29
|
+
examined: number;
|
|
30
|
+
terminated: ModalOrphanSweepTermination[];
|
|
31
|
+
skipped: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function modalSandboxAttributionEnvironment(input: ModalSandboxAttribution): Record<string, string> {
|
|
35
|
+
return {
|
|
36
|
+
OPENGENI_SANDBOX_LEASE_ID: input.leaseId,
|
|
37
|
+
OPENGENI_SANDBOX_GROUP_ID: input.sandboxGroupId,
|
|
38
|
+
OPENGENI_WORKSPACE_ID: input.workspaceId,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function modalSandboxAttributionTags(input: ModalSandboxAttribution): Record<string, string> {
|
|
43
|
+
return {
|
|
44
|
+
opengeni: "true",
|
|
45
|
+
opengeni_lease_id: input.leaseId,
|
|
46
|
+
opengeni_workspace_id: input.workspaceId,
|
|
47
|
+
opengeni_sandbox_group_id: input.sandboxGroupId,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
7
51
|
export const modalProvider: ProviderRegistration = {
|
|
8
52
|
backend: "modal",
|
|
9
53
|
descriptor: CAPABILITY_DESCRIPTORS.modal,
|
|
@@ -23,8 +67,13 @@ export const modalProvider: ProviderRegistration = {
|
|
|
23
67
|
const options: NonNullable<ConstructorParameters<typeof ModalSandboxClient>[0]> = {
|
|
24
68
|
appName: settings.modalAppName,
|
|
25
69
|
timeoutMs: settings.modalTimeoutSeconds * 1000,
|
|
70
|
+
sandboxCreateTimeoutS: Math.ceil(settings.sandboxWarmingTimeoutMs / 1000),
|
|
26
71
|
exposedPorts,
|
|
27
72
|
env: environment,
|
|
73
|
+
// The Modal JS SDK's sandbox default command already sleeps until timeout
|
|
74
|
+
// or explicit termination. Do not let the Agents extension stamp a separate
|
|
75
|
+
// hardcoded sleep command; OPENGENI_MODAL_TIMEOUT_SECONDS owns lifetime.
|
|
76
|
+
useSleepCmd: false,
|
|
28
77
|
};
|
|
29
78
|
// gap-fill (module 03 §4.1): these SDK options were previously unmapped.
|
|
30
79
|
// ALWAYS pin idleTimeoutMs (sandbox-file-persistence): an UNSET idle timeout
|
|
@@ -53,3 +102,179 @@ export const modalProvider: ProviderRegistration = {
|
|
|
53
102
|
return new ModalSandboxClient(options);
|
|
54
103
|
},
|
|
55
104
|
};
|
|
105
|
+
|
|
106
|
+
type ModalModule = typeof import("modal");
|
|
107
|
+
type ModalClientLike = InstanceType<ModalModule["ModalClient"]>;
|
|
108
|
+
|
|
109
|
+
function modalClientOptions(settings: Settings): ConstructorParameters<ModalModule["ModalClient"]>[0] {
|
|
110
|
+
return {
|
|
111
|
+
...(settings.modalTokenId ? { tokenId: settings.modalTokenId } : {}),
|
|
112
|
+
...(settings.modalTokenSecret ? { tokenSecret: settings.modalTokenSecret } : {}),
|
|
113
|
+
...(settings.modalEnvironment ? { environment: settings.modalEnvironment } : {}),
|
|
114
|
+
...(settings.modalTimeoutSeconds ? { timeoutMs: settings.modalTimeoutSeconds * 1000 } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function createModalClient(settings: Settings): Promise<ModalClientLike> {
|
|
119
|
+
const modal = await import("modal");
|
|
120
|
+
return new modal.ModalClient(modalClientOptions(settings));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function tagModalSandbox(
|
|
124
|
+
settings: Settings,
|
|
125
|
+
sandboxId: string,
|
|
126
|
+
attribution: ModalSandboxAttribution,
|
|
127
|
+
): Promise<boolean> {
|
|
128
|
+
if (!sandboxId) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const modal = await createModalClient(settings);
|
|
132
|
+
try {
|
|
133
|
+
const sandbox = await modal.sandboxes.fromId(sandboxId);
|
|
134
|
+
await sandbox.setTags(modalSandboxAttributionTags(attribution));
|
|
135
|
+
return true;
|
|
136
|
+
} finally {
|
|
137
|
+
modal.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function terminateModalSandboxById(settings: Settings, sandboxId: string): Promise<boolean> {
|
|
142
|
+
if (!sandboxId) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const modal = await createModalClient(settings);
|
|
146
|
+
try {
|
|
147
|
+
const sandbox = await modal.sandboxes.fromId(sandboxId);
|
|
148
|
+
await sandbox.terminate();
|
|
149
|
+
return true;
|
|
150
|
+
} finally {
|
|
151
|
+
modal.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type ModalSandboxInfo = {
|
|
156
|
+
id: string;
|
|
157
|
+
createdAt?: number;
|
|
158
|
+
tags?: Array<{ tagName?: string; tagValue?: string }>;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
type ModalCpListClient = ModalClientLike & {
|
|
162
|
+
cpClient: {
|
|
163
|
+
sandboxList(input: {
|
|
164
|
+
appId?: string;
|
|
165
|
+
beforeTimestamp?: number;
|
|
166
|
+
environmentName?: string;
|
|
167
|
+
includeFinished?: boolean;
|
|
168
|
+
tags?: Array<{ tagName: string; tagValue: string }>;
|
|
169
|
+
}): Promise<{ sandboxes?: ModalSandboxInfo[] }>;
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
function tagsFromInfo(info: ModalSandboxInfo): Record<string, string> {
|
|
174
|
+
const tags: Record<string, string> = {};
|
|
175
|
+
for (const tag of info.tags ?? []) {
|
|
176
|
+
if (typeof tag.tagName === "string" && typeof tag.tagValue === "string") {
|
|
177
|
+
tags[tag.tagName] = tag.tagValue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return tags;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sandboxCreatedAtMs(info: ModalSandboxInfo): number | null {
|
|
184
|
+
if (typeof info.createdAt !== "number" || !Number.isFinite(info.createdAt) || info.createdAt <= 0) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
// Modal protobuf timestamps in this SDK are seconds as doubles.
|
|
188
|
+
return info.createdAt < 10_000_000_000 ? Math.floor(info.createdAt * 1000) : Math.floor(info.createdAt);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function attributionKey(input: Pick<ModalSandboxAttribution, "leaseId" | "workspaceId" | "sandboxGroupId">): string {
|
|
192
|
+
return `${input.workspaceId}:${input.sandboxGroupId}:${input.leaseId}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function sweepModalOrphanSandboxes(
|
|
196
|
+
settings: Settings,
|
|
197
|
+
liveLeases: LiveModalSandboxLeaseAttribution[],
|
|
198
|
+
options: {
|
|
199
|
+
now?: Date;
|
|
200
|
+
maxTerminations?: number;
|
|
201
|
+
unattributedGraceMs?: number;
|
|
202
|
+
client?: ModalClientLike;
|
|
203
|
+
} = {},
|
|
204
|
+
): Promise<ModalOrphanSweepResult> {
|
|
205
|
+
const nowMs = options.now?.getTime() ?? Date.now();
|
|
206
|
+
const maxTerminations = options.maxTerminations ?? MODAL_ORPHAN_SWEEP_LIMIT;
|
|
207
|
+
const unattributedGraceMs = options.unattributedGraceMs ?? MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS;
|
|
208
|
+
const liveByAttribution = new Map(liveLeases.map((lease) => [attributionKey(lease), lease]));
|
|
209
|
+
const ownedClient = options.client ? null : await createModalClient(settings);
|
|
210
|
+
const modal = (options.client ?? ownedClient)! as ModalCpListClient;
|
|
211
|
+
try {
|
|
212
|
+
const app = await modal.apps.fromName(settings.modalAppName, {
|
|
213
|
+
createIfMissing: false,
|
|
214
|
+
...(settings.modalEnvironment ? { environment: settings.modalEnvironment } : {}),
|
|
215
|
+
});
|
|
216
|
+
const appId = app.appId;
|
|
217
|
+
if (!appId) {
|
|
218
|
+
return { examined: 0, terminated: [], skipped: 0 };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let examined = 0;
|
|
222
|
+
let skipped = 0;
|
|
223
|
+
const terminated: ModalOrphanSweepTermination[] = [];
|
|
224
|
+
let beforeTimestamp: number | undefined;
|
|
225
|
+
while (terminated.length < maxTerminations) {
|
|
226
|
+
const response = await modal.cpClient.sandboxList({
|
|
227
|
+
appId,
|
|
228
|
+
...(beforeTimestamp !== undefined ? { beforeTimestamp } : {}),
|
|
229
|
+
includeFinished: false,
|
|
230
|
+
...(settings.modalEnvironment ? { environmentName: settings.modalEnvironment } : {}),
|
|
231
|
+
tags: [],
|
|
232
|
+
});
|
|
233
|
+
const sandboxes = response.sandboxes ?? [];
|
|
234
|
+
if (sandboxes.length === 0) {
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
for (const info of sandboxes) {
|
|
238
|
+
examined += 1;
|
|
239
|
+
const tags = tagsFromInfo(info);
|
|
240
|
+
const leaseId = tags.opengeni_lease_id;
|
|
241
|
+
const workspaceId = tags.opengeni_workspace_id;
|
|
242
|
+
const sandboxGroupId = tags.opengeni_sandbox_group_id;
|
|
243
|
+
let reason: ModalOrphanSweepTermination["reason"] | null = null;
|
|
244
|
+
if (leaseId && workspaceId && sandboxGroupId) {
|
|
245
|
+
const live = liveByAttribution.get(attributionKey({ leaseId, workspaceId, sandboxGroupId }));
|
|
246
|
+
if (!live || (live.instanceId && live.instanceId !== info.id)) {
|
|
247
|
+
reason = "stale_attribution";
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
const createdAtMs = sandboxCreatedAtMs(info);
|
|
251
|
+
if (createdAtMs !== null && nowMs - createdAtMs >= unattributedGraceMs) {
|
|
252
|
+
reason = "unattributed";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!reason) {
|
|
257
|
+
skipped += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const sandbox = await modal.sandboxes.fromId(info.id);
|
|
262
|
+
await sandbox.terminate();
|
|
263
|
+
terminated.push({ sandboxId: info.id, reason, tags });
|
|
264
|
+
} catch {
|
|
265
|
+
skipped += 1;
|
|
266
|
+
}
|
|
267
|
+
if (terminated.length >= maxTerminations) {
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
beforeTimestamp = sandboxes[sandboxes.length - 1]?.createdAt;
|
|
272
|
+
if (beforeTimestamp === undefined) {
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { examined, terminated, skipped };
|
|
277
|
+
} finally {
|
|
278
|
+
ownedClient?.close();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -74,7 +74,7 @@ export interface RoutableBackendSession {
|
|
|
74
74
|
// `event` is kept `unknown` (mirroring the interface's structural style + avoiding
|
|
75
75
|
// a proto import into the leaf); the SelfhostedSession takes `DesktopInputRequest["event"]`.
|
|
76
76
|
desktopInput?(event: unknown): Promise<void>;
|
|
77
|
-
screenshot?(): Promise<{ png: Uint8Array; width: number; height: number }>;
|
|
77
|
+
screenshot?(): Promise<{ png: Uint8Array; width: number; height: number; nativeWidth: number; nativeHeight: number }>;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** The resolved active backend for an epoch: the live session + the sandbox id it
|
|
@@ -189,7 +189,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
189
189
|
// that cannot serve them. So the constructor assigns them ONLY when the
|
|
190
190
|
// construction-time default backend actually implements the native surface (below).
|
|
191
191
|
desktopInput?: (event: unknown) => Promise<void>;
|
|
192
|
-
screenshot?: () => Promise<{ png: Uint8Array; width: number; height: number }>;
|
|
192
|
+
screenshot?: () => Promise<{ png: Uint8Array; width: number; height: number; nativeWidth: number; nativeHeight: number }>;
|
|
193
193
|
|
|
194
194
|
constructor(deps: RoutingSandboxSessionDeps) {
|
|
195
195
|
this.deps = deps;
|
|
@@ -545,16 +545,32 @@ export class SelfhostedSession {
|
|
|
545
545
|
/** Computer-use VIEW op: capture a single PNG screenshot of the machine's desktop
|
|
546
546
|
* plus its geometry (via ScreenCaptureKit / x11). NOT consent-gated (a view op —
|
|
547
547
|
* the view/control decoupling), so it works with a display but no screen-control
|
|
548
|
-
* consent. Returns the raw encoded bytes + width/height
|
|
549
|
-
|
|
548
|
+
* consent. Returns the raw encoded bytes + the ENCODED width/height, plus the
|
|
549
|
+
* NATIVE (pre-downscale) geometry: when the agent had to downscale the PNG to fit
|
|
550
|
+
* the transport's max payload, `nativeWidth`/`nativeHeight` carry the original
|
|
551
|
+
* capture size so the computer-use layer can scale model clicks (in encoded-pixel
|
|
552
|
+
* space) back to native pixels. An older agent leaves them 0 → read as "same as
|
|
553
|
+
* width/height" (no downscale). */
|
|
554
|
+
async screenshot(): Promise<{
|
|
555
|
+
png: Uint8Array;
|
|
556
|
+
width: number;
|
|
557
|
+
height: number;
|
|
558
|
+
nativeWidth: number;
|
|
559
|
+
nativeHeight: number;
|
|
560
|
+
}> {
|
|
550
561
|
const result = await this.call({ $case: "desktopScreenshot", desktopScreenshot: {} });
|
|
551
562
|
if (result.$case !== "desktopScreenshot") {
|
|
552
563
|
throw new Error(`selfhosted screenshot: unexpected result ${result.$case}`);
|
|
553
564
|
}
|
|
565
|
+
const s = result.desktopScreenshot;
|
|
566
|
+
// Back-compat: an agent predating the native-geometry fields sends 0 → treat the
|
|
567
|
+
// encoded geometry AS the native geometry (scale factor 1.0, no coordinate shift).
|
|
554
568
|
return {
|
|
555
|
-
png:
|
|
556
|
-
width:
|
|
557
|
-
height:
|
|
569
|
+
png: s.png,
|
|
570
|
+
width: s.width,
|
|
571
|
+
height: s.height,
|
|
572
|
+
nativeWidth: s.nativeWidth || s.width,
|
|
573
|
+
nativeHeight: s.nativeHeight || s.height,
|
|
558
574
|
};
|
|
559
575
|
}
|
|
560
576
|
|