@opengeni/runtime 0.2.3 → 0.3.1

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.
@@ -29,6 +29,7 @@ import type {
29
29
  import { PROVIDER_REGISTRY } from "./providers";
30
30
  import { SandboxConfigError } from "./errors";
31
31
  import { isSelfhostedProviderNotFoundError } from "./selfhosted/session";
32
+ import type { RuntimeMetricsHooks } from "../metrics";
32
33
 
33
34
  // Re-export the config-owned environment/port helpers from the leaf so the
34
35
  // API-direct control plane can pull its full sandbox-construction surface from
@@ -53,6 +54,16 @@ export {
53
54
  type ProviderRegistration,
54
55
  type ProviderConstructionContext,
55
56
  } from "./providers";
57
+ export {
58
+ modalSandboxAttributionEnvironment,
59
+ modalSandboxAttributionTags,
60
+ sweepModalOrphanSandboxes,
61
+ tagModalSandbox,
62
+ terminateModalSandboxById,
63
+ type LiveModalSandboxLeaseAttribution,
64
+ type ModalOrphanSweepResult,
65
+ type ModalSandboxAttribution,
66
+ } from "./providers/modal";
56
67
  export {
57
68
  selectBackend,
58
69
  backendSupportsOs,
@@ -540,6 +551,8 @@ export type EstablishedSandboxSession = {
540
551
  backendId: string;
541
552
  };
542
553
 
554
+ export type SandboxCreatedCallback = (established: EstablishedSandboxSession) => Promise<void>;
555
+
543
556
  // The structural slice we need from a provider SandboxClient to resume by id and
544
557
  // cold-restore. Narrowed (not the full agent-loop SandboxClient) so the leaf
545
558
  // stays agent-loop-free.
@@ -616,6 +629,16 @@ function readInstanceId(session: unknown): string {
616
629
  return typeof candidate === "string" && candidate.length > 0 ? candidate : "";
617
630
  }
618
631
 
632
+ async function terminateCreatedSandbox(client: ResumeCapableClient, session: unknown, sessionState: unknown): Promise<void> {
633
+ const clientWithDelete = client as { delete?: (state: unknown) => Promise<unknown> };
634
+ if (typeof clientWithDelete.delete === "function" && sessionState !== undefined) {
635
+ try { await clientWithDelete.delete(sessionState); } catch { /* best-effort */ }
636
+ return;
637
+ }
638
+ const sess = session as { close?: () => Promise<unknown>; terminate?: () => Promise<unknown>; kill?: () => Promise<unknown> };
639
+ try { await (sess.terminate ?? sess.kill ?? sess.close)?.(); } catch { /* best-effort */ }
640
+ }
641
+
619
642
  /**
620
643
  * Resume the one box by id from its recovery envelope, or cold-restore from the
621
644
  * snapshot when the provider reports it gone. The envelope is the lease's
@@ -633,7 +656,13 @@ function readInstanceId(session: unknown): string {
633
656
  export async function establishSandboxSessionFromEnvelope(
634
657
  settings: Settings,
635
658
  envelope: Record<string, unknown> | null,
636
- opts: { sessionId: string; backendOverride?: SandboxBackend; environment?: Record<string, string> },
659
+ opts: {
660
+ sessionId: string;
661
+ backendOverride?: SandboxBackend;
662
+ environment?: Record<string, string>;
663
+ onSandboxCreated?: SandboxCreatedCallback;
664
+ metrics?: RuntimeMetricsHooks;
665
+ },
637
666
  ): Promise<EstablishedSandboxSession> {
638
667
  const envelopeBackend = typeof envelope?.backendId === "string" ? (envelope.backendId as SandboxBackend) : undefined;
639
668
  const backend = (opts.backendOverride ?? envelopeBackend ?? (settings.sandboxBackend as SandboxBackend));
@@ -679,7 +708,31 @@ export async function establishSandboxSessionFromEnvelope(
679
708
  // SOLE archive-replay seam, shared by the NotFound warm-reattach path AND the
680
709
  // cold-restore branch (b) below.
681
710
  const coldRestore = async (resumeFallbackState?: unknown): Promise<EstablishedSandboxSession> => {
682
- const restored = await client.create!({ manifest: createManifest });
711
+ const createStarted = Date.now();
712
+ let restored: Awaited<ReturnType<NonNullable<typeof client.create>>>;
713
+ try {
714
+ restored = await client.create!({ manifest: createManifest });
715
+ recordSandboxCreateMetric(opts.metrics, client.backendId, "completed", createStarted);
716
+ } catch (error) {
717
+ recordSandboxCreateMetric(opts.metrics, client.backendId, "failed", createStarted);
718
+ throw error;
719
+ }
720
+ let restoredState = (restored as { state?: unknown }).state;
721
+ let established: EstablishedSandboxSession = {
722
+ client,
723
+ session: restored,
724
+ sessionState: restoredState ?? resumeFallbackState,
725
+ instanceId: readInstanceId(restored),
726
+ backendId: client.backendId,
727
+ };
728
+ if (opts.onSandboxCreated) {
729
+ try {
730
+ await opts.onSandboxCreated(established);
731
+ } catch (createCallbackError) {
732
+ await terminateCreatedSandbox(client, restored, restoredState);
733
+ throw createCallbackError;
734
+ }
735
+ }
683
736
  if (workspaceArchive) {
684
737
  const hydrate = (restored as { hydrateWorkspace?: (data: Uint8Array) => Promise<void> }).hydrateWorkspace;
685
738
  if (typeof hydrate === "function") {
@@ -696,21 +749,38 @@ export async function establishSandboxSessionFromEnvelope(
696
749
  // re-throwing so no box leaks. The original error semantics are preserved
697
750
  // (the re-throw propagates to the caller). This mirrors the reaper's
698
751
  // discipline: NEVER leave an orphaned box running.
699
- const restoredState = (restored as { state?: unknown }).state;
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
- }
752
+ await terminateCreatedSandbox(client, restored, restoredState);
708
753
  throw hydrateError;
709
754
  }
755
+ const hydratedState = (restored as { state?: unknown }).state;
756
+ const hydratedInstanceId = readInstanceId(restored);
757
+ if (hydratedInstanceId && hydratedInstanceId !== established.instanceId) {
758
+ established = {
759
+ client,
760
+ session: restored,
761
+ sessionState: hydratedState ?? resumeFallbackState,
762
+ instanceId: hydratedInstanceId,
763
+ backendId: client.backendId,
764
+ };
765
+ if (opts.onSandboxCreated) {
766
+ try {
767
+ await opts.onSandboxCreated(established);
768
+ } catch (createCallbackError) {
769
+ await terminateCreatedSandbox(client, restored, hydratedState);
770
+ throw createCallbackError;
771
+ }
772
+ }
773
+ }
710
774
  }
711
775
  }
712
- const restoredState = (restored as { state?: unknown }).state;
713
- return { client, session: restored, sessionState: restoredState ?? resumeFallbackState, instanceId: readInstanceId(restored), backendId: client.backendId };
776
+ restoredState = (restored as { state?: unknown }).state;
777
+ return {
778
+ client,
779
+ session: restored,
780
+ sessionState: restoredState ?? resumeFallbackState,
781
+ instanceId: readInstanceId(restored),
782
+ backendId: client.backendId,
783
+ };
714
784
  };
715
785
 
716
786
  // Does the envelope carry a RESUMABLE box id (warm reattach), or only a
@@ -770,6 +840,23 @@ export async function establishSandboxSessionFromEnvelope(
770
840
  return await coldRestore();
771
841
  }
772
842
 
843
+ function recordSandboxCreateMetric(
844
+ metrics: RuntimeMetricsHooks | undefined,
845
+ backend: string,
846
+ outcome: "completed" | "failed",
847
+ startedMs: number,
848
+ ): void {
849
+ try {
850
+ metrics?.onSandboxCreate?.({
851
+ backend,
852
+ outcome,
853
+ durationSeconds: Math.max(0, (Date.now() - startedMs) / 1000),
854
+ });
855
+ } catch {
856
+ // Metrics emission must not affect sandbox lifecycle.
857
+ }
858
+ }
859
+
773
860
  // A client that can SERIALIZE a live session state back to the persistable
774
861
  // envelope form (the inverse of deserializeSessionState). Narrowed so the leaf
775
862
  // stays agent-loop-free.
@@ -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
- async screenshot(): Promise<{ png: Uint8Array; width: number; height: number }> {
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: result.desktopScreenshot.png,
556
- width: result.desktopScreenshot.width,
557
- height: result.desktopScreenshot.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