@opengeni/config 0.11.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -9,12 +9,14 @@ import {
9
9
  ReasoningEffort,
10
10
  SandboxBackend,
11
11
  SessionMcpApprovalPolicy,
12
+ SEEDANCE_2_5_MODEL_ID,
12
13
  StaticUsageLimits,
13
14
  TurnExecutionPolicyV1,
14
15
  UsageLimitsMode,
15
16
  type TurnExecutionLatencyModeSourceV1,
16
17
  type TurnExecutionModelSourceV1,
17
18
  type TurnExecutionReasoningSourceV1,
19
+ type VideoGenerationResolution,
18
20
  } from "@opengeni/contracts";
19
21
  import { CODEX_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS } from "@opengeni/codex";
20
22
  import {
@@ -31,6 +33,24 @@ import { z } from "zod";
31
33
 
32
34
  const envName = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
35
  const registryId = /^[A-Za-z0-9_-]+$/;
36
+
37
+ // Archive capture claims are also the admission/teardown fence around a
38
+ // provider snapshot. Keep a real settlement window after the provider request;
39
+ // a configured request timeout may never consume the entire durable claim.
40
+ export const SANDBOX_ARCHIVE_CAPTURE_MAX_TIMEOUT_MS = 60 * 60_000;
41
+ export const SANDBOX_ARCHIVE_CAPTURE_SETTLEMENT_GRACE_MS = 10_000;
42
+ export const SANDBOX_SNAPSHOT_MAX_TIMEOUT_MS =
43
+ SANDBOX_ARCHIVE_CAPTURE_MAX_TIMEOUT_MS - SANDBOX_ARCHIVE_CAPTURE_SETTLEMENT_GRACE_MS;
44
+ // Admission waits are observational: successful capture/teardown returns as
45
+ // soon as the DB fence clears. This ceiling only covers the unhealthy path. It
46
+ // allows one scheduled inventory and one complete successor claim without
47
+ // turning a recoverable lifecycle transition into a visible caller error. A
48
+ // dead holder's TTL is deliberately NOT included: admission cannot accelerate
49
+ // that proof, and the turn moves to durable recovery if holder quiescence takes
50
+ // longer than this observational wait. The outer cap remains an explicit
51
+ // request-resource boundary; successful transitions return immediately.
52
+ export const SANDBOX_LIFECYCLE_TRANSITION_MAX_WAIT_MS = 60 * 60_000;
53
+ export const SANDBOX_LIFECYCLE_RETRY_HANDOFF_GRACE_MS = 10_000;
34
54
  const EnvBoolean = z.preprocess((value) => {
35
55
  if (typeof value !== "string") {
36
56
  return value;
@@ -214,6 +234,12 @@ const SettingsSchema = z.object({
214
234
  turnWorkerMaxConcurrentTurns: z.coerce.number().int().positive().max(2_000).default(16),
215
235
  turnWorkerTargetCpuUsage: z.coerce.number().positive().max(1).default(0.8),
216
236
  turnWorkerTargetMemoryUsage: z.coerce.number().positive().max(0.8).default(0.75),
237
+ // Admission and emergency recovery are deliberately separate control loops.
238
+ // The Temporal tuner stops polling at the lower target; only genuine danger
239
+ // may invoke the disruptive graceful-drain fallback.
240
+ turnWorkerEmergencyMemoryUsage: z.coerce.number().min(0.85).max(0.95).default(0.9),
241
+ turnWorkerMemoryGuardIntervalMs: z.coerce.number().int().min(1_000).max(60_000).default(5_000),
242
+ turnWorkerMemoryGuardSustainMs: z.coerce.number().int().min(5_000).max(300_000).default(30_000),
217
243
  observabilityStructuredLogs: EnvBoolean.default(false),
218
244
  observabilityMetricsEnabled: EnvBoolean.default(true),
219
245
  observabilityOtlpEndpoint: z.string().url().optional(),
@@ -249,7 +275,13 @@ const SettingsSchema = z.object({
249
275
  agentStableVersion: z
250
276
  .string()
251
277
  .regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u)
252
- .default("0.1.9"),
278
+ .default("0.1.14"),
279
+ // Optional independent beta-channel pointer. When unset, the beta update
280
+ // manifest route is unavailable rather than silently serving stable.
281
+ agentBetaVersion: z
282
+ .string()
283
+ .regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u)
284
+ .optional(),
253
285
  productAccessMode: ProductAccessMode.default("local"),
254
286
  billingMode: BillingMode.default("disabled"),
255
287
  entitlementsMode: EntitlementsMode.default("none"),
@@ -267,8 +299,7 @@ const SettingsSchema = z.object({
267
299
  // holder of stream:control gets 403 until this flips. Keeps stream:control a
268
300
  // declared-but-inert permission so later hardening is a flag flip.
269
301
  streamControlEnabled: EnvBoolean.default(false),
270
- toolspaceEnabled: EnvBoolean.default(false),
271
- toolspaceMaxCallsPerTurn: z.coerce.number().int().positive().default(200),
302
+ codemodeMaxCallsPerTurn: z.coerce.number().int().positive().default(200),
272
303
  // Optional release-coherent bootstrap hint for custom rigs/connected machines
273
304
  // that do not carry the stock-image ogtool binary. Exact stable versions only:
274
305
  // the agent must never guess a tag or silently install `latest`.
@@ -281,11 +312,15 @@ const SettingsSchema = z.object({
281
312
  integrationsStateSecret: z.string().optional(),
282
313
  integrationsAllowPrivateNetworkTargets: EnvBoolean.default(false),
283
314
  integrationsOauthClientsJson: z.string().default("{}"),
315
+ gmailRestAdapterEnabled: EnvBoolean.default(false),
284
316
  slackClientId: z.string().optional(),
285
317
  slackClientSecret: z.string().optional(),
286
318
  slackSigningSecret: z.string().optional(),
287
319
  googleDriveClientId: z.string().optional(),
288
320
  googleDriveClientSecret: z.string().optional(),
321
+ googleDriveWorkspaceEventsEnabled: EnvBoolean.optional(),
322
+ atlassianClientId: z.string().optional(),
323
+ atlassianClientSecret: z.string().optional(),
289
324
  // Undefined is meaningful: the migration boundary persists the product
290
325
  // default of 3 when no deployment override is supplied.
291
326
  maxNestedAgentDepth: z.coerce.number().int().nonnegative().max(MAX_NESTED_AGENT_DEPTH).optional(),
@@ -359,6 +394,45 @@ const SettingsSchema = z.object({
359
394
  // Gateway models below are added to the managed-credit catalog. Workspace
360
395
  // Gateway keys use the encrypted connection broker and never this secret.
361
396
  vercelAiGatewayApiKey: z.string().optional(),
397
+ /** Image adapter route; native hosted providers ignore this model. */
398
+ imageGenerationModel: z.string().trim().min(1).max(256).default("openai/gpt-image-2"),
399
+ /** Durable video generation uses the workspace-owned Gateway credential. */
400
+ videoGenerationPollIntervalMs: z.coerce.number().int().min(1_000).max(60_000).default(5_000),
401
+ videoGenerationRecoveryDeadlineMs: z.coerce
402
+ .number()
403
+ .int()
404
+ .min(60_000)
405
+ .max(24 * 60 * 60_000)
406
+ .default(2 * 60 * 60_000),
407
+ videoGenerationReferenceUrlTtlSeconds: z.coerce
408
+ .number()
409
+ .int()
410
+ .min(300)
411
+ .max(6 * 60 * 60)
412
+ .default(60 * 60),
413
+ videoGenerationMaxConcurrentPerWorkspace: z.coerce.number().int().min(1).max(16).default(2),
414
+ videoGenerationWorkspaceQuotaBytes: z.coerce
415
+ .number()
416
+ .int()
417
+ .positive()
418
+ .max(Number.MAX_SAFE_INTEGER)
419
+ .default(20 * 1024 * 1024 * 1024),
420
+ videoGenerationTempDirectory: z.string().trim().min(1).max(1_024).default("/tmp/opengeni-video"),
421
+ videoGenerationFfprobePath: z.string().trim().min(1).max(1_024).default("ffprobe"),
422
+ // OpenGeni's customer price, not a claim about the provider's delayed cost report.
423
+ // The durable operation freezes the exact resulting price before provider submit.
424
+ videoGenerationCredit480pMicrosPerSecond: z.coerce
425
+ .number()
426
+ .int()
427
+ .positive()
428
+ .max(10_000_000)
429
+ .default(155_000),
430
+ videoGenerationCredit720pMicrosPerSecond: z.coerce
431
+ .number()
432
+ .int()
433
+ .positive()
434
+ .max(10_000_000)
435
+ .default(350_000),
362
436
  // Native composer voice input (browser MediaRecorder → API transcription).
363
437
  // Provider credentials stay server-side; ClientConfig only projects availability
364
438
  // and hard ceilings. Selection happens once before audio is sent — never retry
@@ -449,6 +523,11 @@ const SettingsSchema = z.object({
449
523
  // compatibility diagnosis.
450
524
  // OPENGENI_CODEX_TOOL_SEARCH_ENABLED
451
525
  codexToolSearchEnabled: EnvBoolean.default(true),
526
+ // Provider-neutral progressive disclosure for direct OpenAI/Azure native
527
+ // client search and ordinary-function generic dispatch. Kept separate from
528
+ // the Codex rollout so an emergency Codex opt-out cannot disable every model.
529
+ // OPENGENI_LAZY_TOOL_SEARCH_ENABLED
530
+ lazyToolSearchEnabled: EnvBoolean.default(true),
452
531
  // credential allocator atomic, workspace-local credential allocation. Default OFF is a
453
532
  // deliberate rolling-deploy fence: migrate + roll every worker first, then
454
533
  // enable. Turning it off restores the legacy sticky selector without a schema
@@ -506,6 +585,13 @@ const SettingsSchema = z.object({
506
585
  disableOpenaiTracing: EnvBoolean.default(false),
507
586
  sandboxBackend: SandboxBackend.default("docker"),
508
587
  dockerImage: z.string().default("opengeni-sandbox:local"),
588
+ // Explicit deployment contract: the configured base sandbox image contains
589
+ // the verified, self-contained native artifact runtime at its fixed image
590
+ // paths. Disabled by default so arbitrary/custom provider images never make
591
+ // document/spreadsheet/presentation skills appear when their runtime is
592
+ // absent. Per-pack/per-rig image overrides fail closed in the worker even
593
+ // when this base-image contract is enabled.
594
+ sandboxArtifactRuntimeEnabled: EnvBoolean.default(false),
509
595
  dockerExposedPorts: z.string().default(""),
510
596
  dockerNetwork: z.string().optional(),
511
597
  // When the worker itself runs in a container and talks to a host Docker daemon,
@@ -565,17 +651,16 @@ const SettingsSchema = z.object({
565
651
  // SOONER than the hard lifetime; the boot invariant forbids a value that would
566
652
  // reap before reaperPeriod + idleGrace elapses.
567
653
  modalIdleTimeoutSeconds: z.coerce.number().int().positive().optional(),
568
- // /workspace FILE PERSISTENCE across warm/cold cycles. Defaults to
569
- // `snapshot_filesystem` so EVERY box is created persistence-capable: the reaper
570
- // snapshots the live box before it terminates a drained group, and a later
571
- // cold-restore hydrates a fresh box from that snapshot (sandbox-file-persistence).
572
- // `snapshot_filesystem` requires the manifest declare NO ephemeralPersistencePaths
573
- // (buildManifest never sets entry.ephemeral, so it never downgrades to tar). Set
574
- // OPENGENI_MODAL_WORKSPACE_PERSISTENCE=tar to opt back out (no native snapshot;
575
- // the reaper persists a tar archive — same store+hydrate plumbing, slower).
654
+ // /workspace FILE PERSISTENCE across warm/cold cycles. Directory snapshots
655
+ // preserve only the durable user workspace, so provider recovery does not
656
+ // restore an entire machine image or replace the selected rig/base image.
657
+ // Existing serialized sessions retain their original persistence mode and
658
+ // remain recoverable; this default governs newly created Modal sandboxes.
659
+ // `snapshot_filesystem` remains available for explicit compatibility and
660
+ // immutable rig-image materialization. `tar` is the portable fallback.
576
661
  modalWorkspacePersistence: z
577
662
  .enum(["tar", "snapshot_filesystem", "snapshot_directory"])
578
- .default("snapshot_filesystem"),
663
+ .default("snapshot_directory"),
579
664
  // Shared desktop toggle: this module reads it for the 6080 port-merge; the
580
665
  // owner module (P4.x) acts on it to launch the display stack.
581
666
  sandboxDesktopEnabled: EnvBoolean.default(false),
@@ -700,10 +785,13 @@ const SettingsSchema = z.object({
700
785
  // the deploy-staging IaC secret/configmap pattern.
701
786
  sandboxSelfhostedEnabled: EnvBoolean.default(false),
702
787
  // Gates the op-stream (streaming exec) transport to Connected Machines. The
703
- // runner must ALSO advertise Capabilities.op_stream; default off, and legacy
704
- // request/reply exec is the permanent fallback. EnvBoolean (NOT
788
+ // runner must ALSO advertise Capabilities.op_stream. Streaming is the default
789
+ // because it is the only transport that can keep a command alive without an
790
+ // arbitrary request/reply wall while still supporting replay and cancellation.
791
+ // Older runners remain usable when an explicit positive exec timeout is set.
792
+ // EnvBoolean (NOT
705
793
  // z.coerce.boolean(), which coerces "false" -> true).
706
- agentOpStreamEnabled: EnvBoolean.default(false),
794
+ agentOpStreamEnabled: EnvBoolean.default(true),
707
795
  // The HMAC secret the control plane signs the enrollment bearer credential with
708
796
  // (the `oge_` envelope the agent presents back to the control plane). Optional:
709
797
  // when ABSENT and sandboxSelfhostedEnabled is on, the poll route reports the
@@ -761,23 +849,15 @@ const SettingsSchema = z.object({
761
849
  selfhostedNatsControlUser: z.string().optional(),
762
850
  selfhostedNatsControlPassword: z.string().optional(),
763
851
  // --- selfhosted (Connected Machine) control/exec op deadlines ---------------
764
- // The control plane splits its op deadline in two. CONTROL ops (ping / fs / git /
765
- // desktop / pty) must stay responsive so a machine's liveness is never masked by a
766
- // slow op, so they use the short control timeout. EXEC gets its OWN, larger budget:
767
- // a real command (compile, test run, dependency install) routinely outlives the
768
- // control timeout, and before the split a long command was killed at the ~30s
769
- // control wall. The agent kills the exec child at this deadline; the wire waits
770
- // slightly longer (SELFHOSTED_EXEC_REPLY_GRACE_MS) for the typed timed-out reply.
771
- //
772
- // The exec default is a DELIBERATELY MODEST 2min (not 5): the agent-side admission
773
- // pool is (until a later agent release) a FLAT 8 permits with no per-class split,
774
- // so 8 slow execs holding a permit for 5 minutes would blanket-DRAIN every fs/git
775
- // op — shipping the amplifier before the class-aware-admission fix. 2min still
776
- // clears the large majority of the observed >30s exec tail; genuinely long jobs run
777
- // in the background (see the exec-deadline hint) or raise the knob per deployment.
778
- // Knobs: OPENGENI_SANDBOX_SELFHOSTED_EXEC_TIMEOUT_MS (default 2min) and
852
+ // CONTROL ops (ping / fs / git / desktop / pty) keep a short request timeout so
853
+ // liveness failures surface promptly. EXEC duration is a different concern: by
854
+ // default it is unbounded (0), exactly like a command launched by an unrestricted
855
+ // local agent. The op-stream transport keeps control liveness, replay, and explicit
856
+ // cancellation independent of command duration. A deployment may opt into a hard
857
+ // process deadline by setting a positive value; 0 never schedules a process kill.
858
+ // Knobs: OPENGENI_SANDBOX_SELFHOSTED_EXEC_TIMEOUT_MS (default 0 = none) and
779
859
  // OPENGENI_SANDBOX_SELFHOSTED_CONTROL_TIMEOUT_MS (default 30s).
780
- sandboxSelfhostedExecTimeoutMs: z.coerce.number().int().positive().default(120_000),
860
+ sandboxSelfhostedExecTimeoutMs: z.coerce.number().int().nonnegative().default(0),
781
861
  sandboxSelfhostedControlTimeoutMs: z.coerce.number().int().positive().default(30_000),
782
862
  // --- sandbox lease cadences (cadence invariant validated at boot below) ---
783
863
  // reaperPeriod < viewerHolderTTL, and reaperPeriod + idleGrace < the EFFECTIVE
@@ -788,6 +868,10 @@ const SettingsSchema = z.object({
788
868
  // snapshotted before the box dies (sandbox-file-persistence).
789
869
  sandboxLeaseReaperPeriodMs: z.coerce.number().int().positive().default(30_000),
790
870
  sandboxViewerHolderTtlMs: z.coerce.number().int().positive().default(90_000),
871
+ // A BrowserSession controller refreshes its durable resource and exact
872
+ // interaction lease holder together. This longer crash horizon tolerates API
873
+ // replacement while still releasing a placement whose controller died.
874
+ sandboxInteractionHolderTtlMs: z.coerce.number().int().positive().default(180_000),
791
875
  // The DRAIN grace: how long a refcount-0 (draining) lease stays WARM before the
792
876
  // reaper resume-by-ids the box and terminates it. This is the cost-vs-snappiness
793
877
  // dial — when the user navigates away the box keeps refcount 0, but it survives
@@ -813,7 +897,12 @@ const SettingsSchema = z.object({
813
897
  // graceful shutdown, or become permission to GC an older archive. Timeout is
814
898
  // treated exactly like a failed best-effort snapshot. Knob:
815
899
  // OPENGENI_SANDBOX_SNAPSHOT_TIMEOUT_MS. Default 60s.
816
- sandboxSnapshotTimeoutMs: z.coerce.number().int().positive().default(60_000),
900
+ sandboxSnapshotTimeoutMs: z.coerce
901
+ .number()
902
+ .int()
903
+ .positive()
904
+ .max(SANDBOX_SNAPSHOT_MAX_TIMEOUT_MS)
905
+ .default(60_000),
817
906
  // Begin a controlled snapshot/quiesce/drain/rematerialize transition this far
818
907
  // ahead of a finite provider deadline. Modal's 24h creation clock cannot be
819
908
  // extended; the logical sandbox outlives it by moving to one successor box.
@@ -823,13 +912,12 @@ const SettingsSchema = z.object({
823
912
  // an operator deliberately wants more rotation headroom; the boot invariant
824
913
  // still requires it to remain below the provider lifetime.
825
914
  sandboxRotationLeadMs: z.coerce.number().int().positive().default(3_600_000),
826
- // Bound each global reaper pass so a rollout that discovers many legacy boxes
827
- // with unknown creation clocks cannot create a provider/API thundering herd.
828
- // One is the safe admission default: the reaper services provider transitions
829
- // sequentially, so claiming a wider batch would fence boxes before the same
830
- // sweep can service them. Larger fleets may raise this only as an explicit,
831
- // observed deployment choice.
832
- sandboxRotationBatchSize: z.coerce.number().int().positive().max(500).default(1),
915
+ // Bound provider-deadline rotation admission independently of execution.
916
+ // Every admitted box receives its own durable drain child; the control worker
917
+ // limits provider I/O to 32 concurrent activities. Matching that bound avoids
918
+ // both the old one-box-per-tick deadline backlog and an unbounded provider/API
919
+ // burst. Operators may tune this for a differently-sized worker pool.
920
+ sandboxRotationBatchSize: z.coerce.number().int().positive().max(500).default(32),
833
921
  // expires_at refresh window for a held lease (>> the turn 10s heartbeat so a
834
922
  // single missed heartbeat never TTL-reaps a live turn). The warming TTL is the
835
923
  // window a cold->warming spawner has to commit warm before a reaper resets it.
@@ -1134,6 +1222,13 @@ export type ModelUsageInput = {
1134
1222
  requestUsageEntries?: ModelUsageInput[] | undefined;
1135
1223
  };
1136
1224
 
1225
+ export type ModelUsageCostBreakdown = {
1226
+ /** Provider-rate cost basis for the exact usage, before OpenGeni margin. */
1227
+ providerCostMicros: number;
1228
+ /** OpenGeni credit price after configured margin and latency-mode multiplier. */
1229
+ creditCostMicros: number;
1230
+ };
1231
+
1137
1232
  export type StaticUsageLimitsConfig = StaticUsageLimits;
1138
1233
  export type EntitlementsConfig = Entitlements;
1139
1234
 
@@ -1205,6 +1300,10 @@ export const ModelCapabilitiesV1Schema = z
1205
1300
  webSearch: CapabilityStateV1Schema,
1206
1301
  xSearch: CapabilityStateV1Schema,
1207
1302
  codeExecution: CapabilityStateV1Schema,
1303
+ imageGeneration: CapabilityStateV1Schema.default({
1304
+ upstream: "unknown",
1305
+ runnable: false,
1306
+ }),
1208
1307
  }),
1209
1308
  inputModalities: z.array(ModelModalityV1).min(1),
1210
1309
  /** Exact MIME types accepted as typed `input_file`; `text/*` is allowed. */
@@ -1741,6 +1840,9 @@ export function getSettings(): Settings {
1741
1840
  turnWorkerMaxConcurrentTurns: optional("OPENGENI_TURN_WORKER_MAX_CONCURRENT_TURNS"),
1742
1841
  turnWorkerTargetCpuUsage: optional("OPENGENI_TURN_WORKER_TARGET_CPU_USAGE"),
1743
1842
  turnWorkerTargetMemoryUsage: optional("OPENGENI_TURN_WORKER_TARGET_MEMORY_USAGE"),
1843
+ turnWorkerEmergencyMemoryUsage: optional("OPENGENI_TURN_WORKER_EMERGENCY_MEMORY_USAGE"),
1844
+ turnWorkerMemoryGuardIntervalMs: optional("OPENGENI_TURN_WORKER_MEMORY_GUARD_INTERVAL_MS"),
1845
+ turnWorkerMemoryGuardSustainMs: optional("OPENGENI_TURN_WORKER_MEMORY_GUARD_SUSTAIN_MS"),
1744
1846
  observabilityStructuredLogs: optional("OPENGENI_OBSERVABILITY_STRUCTURED_LOGS"),
1745
1847
  observabilityMetricsEnabled: optional("OPENGENI_OBSERVABILITY_METRICS_ENABLED"),
1746
1848
  observabilityOtlpEndpoint:
@@ -1757,6 +1859,7 @@ export function getSettings(): Settings {
1757
1859
  webBaseUrl: optional("OPENGENI_WEB_BASE_URL"),
1758
1860
  agentReleasesBaseUrl: optional("OPENGENI_AGENT_RELEASES_BASE_URL"),
1759
1861
  agentStableVersion: optional("OPENGENI_AGENT_STABLE_VERSION"),
1862
+ agentBetaVersion: optional("OPENGENI_AGENT_BETA_VERSION"),
1760
1863
  productAccessMode: optional("OPENGENI_PRODUCT_ACCESS_MODE"),
1761
1864
  billingMode: optional("OPENGENI_BILLING_MODE"),
1762
1865
  entitlementsMode: optional("OPENGENI_ENTITLEMENTS_MODE"),
@@ -1766,8 +1869,7 @@ export function getSettings(): Settings {
1766
1869
  delegationSecret: optional("OPENGENI_DELEGATION_SECRET"),
1767
1870
  streamTokenSecret: optional("OPENGENI_STREAM_TOKEN_SECRET"),
1768
1871
  streamControlEnabled: optional("OPENGENI_STREAM_CONTROL_ENABLED"),
1769
- toolspaceEnabled: optional("OPENGENI_TOOLSPACE_ENABLED"),
1770
- toolspaceMaxCallsPerTurn: optional("OPENGENI_TOOLSPACE_MAX_CALLS_PER_TURN"),
1872
+ codemodeMaxCallsPerTurn: optional("OPENGENI_CODEMODE_MAX_CALLS_PER_TURN"),
1771
1873
  ogtoolPackageSpec: optional("OPENGENI_OGTOOL_PACKAGE_SPEC"),
1772
1874
  environmentsEncryptionKey: optional("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY"),
1773
1875
  integrationsEnabled: optional("OPENGENI_INTEGRATIONS_ENABLED"),
@@ -1776,11 +1878,15 @@ export function getSettings(): Settings {
1776
1878
  "OPENGENI_INTEGRATIONS_ALLOW_PRIVATE_NETWORK_TARGETS",
1777
1879
  ),
1778
1880
  integrationsOauthClientsJson: optional("OPENGENI_INTEGRATIONS_OAUTH_CLIENTS_JSON"),
1881
+ gmailRestAdapterEnabled: optional("OPENGENI_GMAIL_REST_ADAPTER_ENABLED"),
1779
1882
  slackClientId: optional("OPENGENI_SLACK_CLIENT_ID"),
1780
1883
  slackClientSecret: optional("OPENGENI_SLACK_CLIENT_SECRET"),
1781
1884
  slackSigningSecret: optional("OPENGENI_SLACK_SIGNING_SECRET"),
1782
1885
  googleDriveClientId: optional("OPENGENI_GOOGLE_DRIVE_CLIENT_ID"),
1783
1886
  googleDriveClientSecret: optional("OPENGENI_GOOGLE_DRIVE_CLIENT_SECRET"),
1887
+ googleDriveWorkspaceEventsEnabled: optional("OPENGENI_GOOGLE_DRIVE_WORKSPACE_EVENTS_ENABLED"),
1888
+ atlassianClientId: optional("OPENGENI_ATLASSIAN_CLIENT_ID"),
1889
+ atlassianClientSecret: optional("OPENGENI_ATLASSIAN_CLIENT_SECRET"),
1784
1890
  maxNestedAgentDepth: optional("OPENGENI_MAX_NESTED_AGENT_DEPTH"),
1785
1891
  socialOauthClientsJson: optional("OPENGENI_SOCIAL_OAUTH_CLIENTS_JSON"),
1786
1892
  goalMaxAutoContinuations: optional("OPENGENI_GOAL_MAX_AUTO_CONTINUATIONS"),
@@ -1807,6 +1913,24 @@ export function getSettings(): Settings {
1807
1913
  openaiModel: optional("OPENGENI_OPENAI_MODEL"),
1808
1914
  openaiAllowedModels: optional("OPENGENI_OPENAI_ALLOWED_MODELS"),
1809
1915
  vercelAiGatewayApiKey: optional("OPENGENI_VERCEL_AI_GATEWAY_API_KEY"),
1916
+ imageGenerationModel: optional("OPENGENI_IMAGE_GENERATION_MODEL"),
1917
+ videoGenerationPollIntervalMs: optional("OPENGENI_VIDEO_GENERATION_POLL_INTERVAL_MS"),
1918
+ videoGenerationRecoveryDeadlineMs: optional("OPENGENI_VIDEO_GENERATION_RECOVERY_DEADLINE_MS"),
1919
+ videoGenerationReferenceUrlTtlSeconds: optional(
1920
+ "OPENGENI_VIDEO_GENERATION_REFERENCE_URL_TTL_SECONDS",
1921
+ ),
1922
+ videoGenerationMaxConcurrentPerWorkspace: optional(
1923
+ "OPENGENI_VIDEO_GENERATION_MAX_CONCURRENT_PER_WORKSPACE",
1924
+ ),
1925
+ videoGenerationWorkspaceQuotaBytes: optional("OPENGENI_VIDEO_GENERATION_WORKSPACE_QUOTA_BYTES"),
1926
+ videoGenerationTempDirectory: optional("OPENGENI_VIDEO_GENERATION_TEMP_DIRECTORY"),
1927
+ videoGenerationFfprobePath: optional("OPENGENI_VIDEO_GENERATION_FFPROBE_PATH"),
1928
+ videoGenerationCredit480pMicrosPerSecond: optional(
1929
+ "OPENGENI_VIDEO_GENERATION_CREDIT_480P_MICROS_PER_SECOND",
1930
+ ),
1931
+ videoGenerationCredit720pMicrosPerSecond: optional(
1932
+ "OPENGENI_VIDEO_GENERATION_CREDIT_720P_MICROS_PER_SECOND",
1933
+ ),
1810
1934
  voiceInputMaxDurationSeconds: optional("OPENGENI_VOICE_INPUT_MAX_DURATION_SECONDS"),
1811
1935
  voiceInputMaxSizeBytes: optional("OPENGENI_VOICE_INPUT_MAX_SIZE_BYTES"),
1812
1936
  voiceInputResumableEnabled: optional("OPENGENI_VOICE_INPUT_RESUMABLE_ENABLED"),
@@ -1838,6 +1962,7 @@ export function getSettings(): Settings {
1838
1962
  codexSubscriptionEnabled: optional("OPENGENI_CODEX_SUBSCRIPTION_ENABLED"),
1839
1963
  codexConnectedAppsEnabled: optional("OPENGENI_CODEX_CONNECTED_APPS_ENABLED"),
1840
1964
  codexToolSearchEnabled: optional("OPENGENI_CODEX_TOOL_SEARCH_ENABLED"),
1965
+ lazyToolSearchEnabled: optional("OPENGENI_LAZY_TOOL_SEARCH_ENABLED"),
1841
1966
  codexCredentialLeasingEnabled: optional("OPENGENI_CODEX_CREDENTIAL_LEASING_ENABLED"),
1842
1967
  codexFleetPolicyShadowEnabled: optional("OPENGENI_CODEX_FLEET_POLICY_SHADOW_ENABLED"),
1843
1968
  codexProductSku: optional("OPENGENI_CODEX_PRODUCT_SKU"),
@@ -1858,6 +1983,7 @@ export function getSettings(): Settings {
1858
1983
  disableOpenaiTracing: optional("OPENGENI_DISABLE_OPENAI_TRACING"),
1859
1984
  sandboxBackend: optional("OPENGENI_SANDBOX_BACKEND"),
1860
1985
  dockerImage: optional("OPENGENI_DOCKER_IMAGE"),
1986
+ sandboxArtifactRuntimeEnabled: optional("OPENGENI_SANDBOX_ARTIFACT_RUNTIME_ENABLED"),
1861
1987
  dockerExposedPorts: optional("OPENGENI_DOCKER_EXPOSED_PORTS"),
1862
1988
  dockerNetwork: optional("OPENGENI_DOCKER_NETWORK"),
1863
1989
  dockerWorkspaceBaseDir: optional("OPENGENI_DOCKER_WORKSPACE_BASE_DIR"),
@@ -1940,6 +2066,7 @@ export function getSettings(): Settings {
1940
2066
  sandboxSelfhostedControlTimeoutMs: optional("OPENGENI_SANDBOX_SELFHOSTED_CONTROL_TIMEOUT_MS"),
1941
2067
  sandboxLeaseReaperPeriodMs: optional("OPENGENI_SANDBOX_LEASE_REAPER_PERIOD_MS"),
1942
2068
  sandboxViewerHolderTtlMs: optional("OPENGENI_SANDBOX_VIEWER_HOLDER_TTL_MS"),
2069
+ sandboxInteractionHolderTtlMs: optional("OPENGENI_SANDBOX_INTERACTION_HOLDER_TTL_MS"),
1943
2070
  sandboxIdleGraceMs: optional("OPENGENI_SANDBOX_IDLE_GRACE_MS"),
1944
2071
  sandboxSnapshotIntervalMs: optional("OPENGENI_SANDBOX_SNAPSHOT_INTERVAL_MS"),
1945
2072
  sandboxSnapshotTimeoutMs: optional("OPENGENI_SANDBOX_SNAPSHOT_TIMEOUT_MS"),
@@ -2070,8 +2197,20 @@ export function sandboxArchiveCaptureTimeoutMs(
2070
2197
  settings: Pick<Settings, "sandboxSnapshotTimeoutMs">,
2071
2198
  ): number {
2072
2199
  return Math.min(
2073
- 60 * 60_000,
2074
- Math.max(settings.sandboxSnapshotTimeoutMs + 30_000, settings.sandboxSnapshotTimeoutMs * 2),
2200
+ SANDBOX_ARCHIVE_CAPTURE_MAX_TIMEOUT_MS,
2201
+ settings.sandboxSnapshotTimeoutMs + SANDBOX_ARCHIVE_CAPTURE_SETTLEMENT_GRACE_MS,
2202
+ );
2203
+ }
2204
+
2205
+ export function sandboxLifecycleTransitionWaitMs(
2206
+ settings: Pick<Settings, "sandboxSnapshotTimeoutMs" | "sandboxLeaseReaperPeriodMs">,
2207
+ ): number {
2208
+ const captureTimeoutMs = sandboxArchiveCaptureTimeoutMs(settings);
2209
+ return Math.min(
2210
+ SANDBOX_LIFECYCLE_TRANSITION_MAX_WAIT_MS,
2211
+ settings.sandboxLeaseReaperPeriodMs +
2212
+ captureTimeoutMs +
2213
+ SANDBOX_LIFECYCLE_RETRY_HANDOFF_GRACE_MS,
2075
2214
  );
2076
2215
  }
2077
2216
 
@@ -2334,7 +2473,12 @@ function normalizeCapabilities(capabilities: ModelCapabilitiesV1): ModelCapabili
2334
2473
 
2335
2474
  function legacyModelCapabilities(
2336
2475
  settings: Settings,
2337
- input: { reasoningEffort: boolean; hostedWebSearch: boolean; vision?: boolean },
2476
+ input: {
2477
+ reasoningEffort: boolean;
2478
+ hostedWebSearch: boolean;
2479
+ hostedImageGeneration?: boolean;
2480
+ vision?: boolean;
2481
+ },
2338
2482
  ): ModelCapabilitiesV1 {
2339
2483
  const reasoningEfforts = input.reasoningEffort ? configuredAllowedReasoningEfforts(settings) : [];
2340
2484
  return normalizeCapabilities({
@@ -2354,6 +2498,10 @@ function legacyModelCapabilities(
2354
2498
  },
2355
2499
  xSearch: { upstream: "unknown", runnable: false },
2356
2500
  codeExecution: { upstream: "unknown", runnable: false },
2501
+ imageGeneration: {
2502
+ upstream: input.hostedImageGeneration ? "supported" : "unknown",
2503
+ runnable: input.hostedImageGeneration ?? false,
2504
+ },
2357
2505
  },
2358
2506
  inputModalities: input.vision ? ["text", "image"] : ["text"],
2359
2507
  inputFileMediaTypes: [
@@ -2586,6 +2734,35 @@ function builtinPromptCachingForModel(
2586
2734
  : undefined;
2587
2735
  }
2588
2736
 
2737
+ /** Reviewed direct-OpenAI text models that accept the hosted image tool. */
2738
+ function builtinHostedImageGenerationForModel(settings: Settings, modelId: string): boolean {
2739
+ return (
2740
+ settings.openaiProvider === "openai" &&
2741
+ isDirectOpenAiApiBaseUrl(settings.openaiBaseUrl) &&
2742
+ ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].includes(modelId)
2743
+ );
2744
+ }
2745
+
2746
+ /** Undefined and the exact public OpenAI v1 endpoint are the same direct route. */
2747
+ export function isDirectOpenAiApiBaseUrl(baseUrl: string | undefined): boolean {
2748
+ if (baseUrl === undefined) return true;
2749
+ try {
2750
+ const parsed = new URL(baseUrl);
2751
+ return (
2752
+ parsed.protocol === "https:" &&
2753
+ parsed.hostname === "api.openai.com" &&
2754
+ parsed.port === "" &&
2755
+ parsed.username === "" &&
2756
+ parsed.password === "" &&
2757
+ parsed.search === "" &&
2758
+ parsed.hash === "" &&
2759
+ parsed.pathname.replace(/\/+$/, "") === "/v1"
2760
+ );
2761
+ } catch {
2762
+ return false;
2763
+ }
2764
+ }
2765
+
2589
2766
  /**
2590
2767
  * Map OpenGeni latency mode to the provider `service_tier` wire value.
2591
2768
  * Azure and Codex ChatGPT accept `priority`; OpenAI API accepts `fast` (alias of priority).
@@ -3026,6 +3203,7 @@ export function configuredModels(settings: Settings): ConfiguredModel[] {
3026
3203
  ...legacyModelCapabilities(settings, {
3027
3204
  reasoningEffort: true,
3028
3205
  hostedWebSearch: settings.webSearchEnabled,
3206
+ hostedImageGeneration: builtinHostedImageGenerationForModel(settings, id),
3029
3207
  vision: id.startsWith("gpt-5.6-"),
3030
3208
  }),
3031
3209
  ...(builtinPromptCachingForModel(id)
@@ -3422,6 +3600,15 @@ export function calculateModelUsageCostMicros(
3422
3600
  usage: ModelUsageInput,
3423
3601
  options?: { latencyMode?: LatencyMode },
3424
3602
  ): number {
3603
+ return calculateModelUsageCostBreakdown(settings, model, usage, options).creditCostMicros;
3604
+ }
3605
+
3606
+ export function calculateModelUsageCostBreakdown(
3607
+ settings: Settings,
3608
+ model: string,
3609
+ usage: ModelUsageInput,
3610
+ options?: { latencyMode?: LatencyMode },
3611
+ ): ModelUsageCostBreakdown {
3425
3612
  const schedule = configuredModelPricingSchedules(settings)[model];
3426
3613
  if (!schedule) {
3427
3614
  throw new Error(`Missing model pricing for ${model}`);
@@ -3438,10 +3625,12 @@ export function calculateModelUsageCostMicros(
3438
3625
  (rawCostByPricing.get(pricing) ?? 0) + calculateEntryCostMicros(pricing, entry),
3439
3626
  );
3440
3627
  }
3441
- let total = 0;
3628
+ let providerCostMicros = 0;
3629
+ let creditCostMicros = 0;
3442
3630
  for (const [pricing, rawCost] of rawCostByPricing) {
3443
3631
  const marginBps = pricing.marginBps ?? 0;
3444
- total += Math.ceil((rawCost * (10_000 + marginBps)) / 10_000);
3632
+ providerCostMicros += rawCost;
3633
+ creditCostMicros += Math.ceil((rawCost * (10_000 + marginBps)) / 10_000);
3445
3634
  }
3446
3635
  const latencyMode = options?.latencyMode ?? "standard";
3447
3636
  if (latencyMode !== "standard") {
@@ -3454,10 +3643,11 @@ export function calculateModelUsageCostMicros(
3454
3643
  (mode) => mode.id === latencyMode && mode.runnable,
3455
3644
  )?.billingMultiplierBps;
3456
3645
  if (multiplierBps && multiplierBps > 0) {
3457
- total = Math.ceil((total * multiplierBps) / 10_000);
3646
+ providerCostMicros = Math.ceil((providerCostMicros * multiplierBps) / 10_000);
3647
+ creditCostMicros = Math.ceil((creditCostMicros * multiplierBps) / 10_000);
3458
3648
  }
3459
3649
  }
3460
- return total;
3650
+ return { providerCostMicros, creditCostMicros };
3461
3651
  }
3462
3652
 
3463
3653
  /**
@@ -3471,6 +3661,16 @@ export function calculateGatewayReportedCostMicros(
3471
3661
  inferenceCostUsd: string,
3472
3662
  options?: { inputTokens?: number },
3473
3663
  ): number {
3664
+ return calculateGatewayReportedCostBreakdown(settings, model, inferenceCostUsd, options)
3665
+ .creditCostMicros;
3666
+ }
3667
+
3668
+ export function calculateGatewayReportedCostBreakdown(
3669
+ settings: Settings,
3670
+ model: string,
3671
+ inferenceCostUsd: string,
3672
+ options?: { inputTokens?: number },
3673
+ ): ModelUsageCostBreakdown {
3474
3674
  const schedule = configuredModelPricingSchedules(settings)[model];
3475
3675
  if (!schedule) {
3476
3676
  throw new Error(`Missing model pricing for ${model}`);
@@ -3483,14 +3683,52 @@ export function calculateGatewayReportedCostMicros(
3483
3683
  const fraction = match[2] ?? "";
3484
3684
  const decimalDigits = BigInt(`${match[1]}${fraction}`);
3485
3685
  const decimalScale = 10n ** BigInt(fraction.length);
3686
+ const providerNumerator = decimalDigits * 1_000_000n;
3687
+ const providerMicros = (providerNumerator + decimalScale - 1n) / decimalScale;
3486
3688
  const marginBps = BigInt(10_000 + (pricing.marginBps ?? 0));
3487
- const numerator = decimalDigits * 1_000_000n * marginBps;
3689
+ const numerator = providerNumerator * marginBps;
3488
3690
  const denominator = decimalScale * 10_000n;
3489
- const micros = (numerator + denominator - 1n) / denominator;
3490
- if (micros > BigInt(Number.MAX_SAFE_INTEGER)) {
3691
+ const creditMicros = (numerator + denominator - 1n) / denominator;
3692
+ if (
3693
+ providerMicros > BigInt(Number.MAX_SAFE_INTEGER) ||
3694
+ creditMicros > BigInt(Number.MAX_SAFE_INTEGER)
3695
+ ) {
3491
3696
  throw new Error("AI Gateway inference cost exceeds the supported billing range");
3492
3697
  }
3493
- return Number(micros);
3698
+ return {
3699
+ providerCostMicros: Number(providerMicros),
3700
+ creditCostMicros: Number(creditMicros),
3701
+ };
3702
+ }
3703
+
3704
+ /**
3705
+ * Exact OpenGeni product price frozen before a managed video request starts.
3706
+ * Gateway reporting is delayed for asynchronous video, so this deliberately
3707
+ * does not masquerade as provider-reported cost.
3708
+ */
3709
+ export function calculateVideoGenerationCreditCostMicros(
3710
+ settings: Settings,
3711
+ input: {
3712
+ modelId: string;
3713
+ resolution: VideoGenerationResolution;
3714
+ durationSeconds: number;
3715
+ },
3716
+ ): number {
3717
+ if (input.modelId !== SEEDANCE_2_5_MODEL_ID) {
3718
+ throw new Error(`Missing video generation credit pricing for ${input.modelId}`);
3719
+ }
3720
+ if (!Number.isSafeInteger(input.durationSeconds) || input.durationSeconds < 1) {
3721
+ throw new Error("Video generation duration is invalid for credit pricing");
3722
+ }
3723
+ const rate =
3724
+ input.resolution === "480p"
3725
+ ? settings.videoGenerationCredit480pMicrosPerSecond
3726
+ : settings.videoGenerationCredit720pMicrosPerSecond;
3727
+ const cost = rate * input.durationSeconds;
3728
+ if (!Number.isSafeInteger(cost) || cost <= 0 || cost > 1_000_000_000) {
3729
+ throw new Error("Video generation credit price exceeds the supported range");
3730
+ }
3731
+ return cost;
3494
3732
  }
3495
3733
 
3496
3734
  export function configuredAllowedReasoningEfforts(
@@ -3704,24 +3942,13 @@ export function stableSandboxEnvironmentForRun(
3704
3942
  environment.OPENGENI_GIT_CLI_WRAPPER_DIR ??= `${home}/.opengeni/bin`;
3705
3943
  environment.PATH = prependPathEntry(environment.PATH, environment.OPENGENI_GIT_CLI_WRAPPER_DIR);
3706
3944
  }
3707
- if (settings.toolspaceEnabled) {
3708
- // Connected Machines do not share one control-plane-known home path. Keep a
3709
- // stable shell-resolved pointer in the manifest; runtime expands this trusted
3710
- // marker against the machine's own HOME for seed, renewal, and every command.
3711
- // Never derive it from the selfhosted descriptor root (`/`), which would try
3712
- // to write `/.opengeni` as an ordinary machine user.
3713
- environment.OPENGENI_TOOLSPACE_TOKEN_FILE ??=
3714
- settings.sandboxBackend === "selfhosted"
3715
- ? "$HOME/.opengeni/toolspace-token"
3716
- : `${environment.HOME ?? descriptor.workspaceRoot}/.opengeni/toolspace-token`;
3945
+ if (settings.sandboxBackend !== "selfhosted" && resolveFirstPartyDelegationSecret(settings)) {
3946
+ environment.OPENGENI_CODEMODE_TOKEN_FILE ??= `${environment.HOME ?? descriptor.workspaceRoot}/.opengeni/codemode-token`;
3717
3947
  if (settings.ogtoolPackageSpec) {
3718
3948
  environment.OPENGENI_OGTOOL_PACKAGE_SPEC ??= settings.ogtoolPackageSpec;
3719
3949
  }
3720
3950
  if (options.workspaceId) {
3721
- environment.OPENGENI_TOOLSPACE_URL ??= firstPartyMcpWorkspaceUrl(
3722
- settings,
3723
- options.workspaceId,
3724
- );
3951
+ environment.OPENGENI_CODEMODE_URL ??= codemodeWorkspaceUrl(settings, options.workspaceId);
3725
3952
  }
3726
3953
  }
3727
3954
  return environment;
@@ -4226,6 +4453,7 @@ function ensureBuiltInMcpServers(settings: Settings): Settings["mcpServers"] {
4226
4453
  "search_documents",
4227
4454
  "fetch_document_chunk",
4228
4455
  "list_document_bases",
4456
+ "list_indexed_documents",
4229
4457
  "knowledge_search",
4230
4458
  "knowledge_fetch",
4231
4459
  "memory_search",
@@ -4278,6 +4506,15 @@ export function firstPartyMcpWorkspaceUrl(settings: Settings, workspaceId: strin
4278
4506
  return url.toString();
4279
4507
  }
4280
4508
 
4509
+ export function codemodeWorkspaceUrl(settings: Settings, workspaceId: string): string {
4510
+ const url = new URL(firstPartyMcpWorkspaceUrl(settings, workspaceId));
4511
+ if (!url.pathname.endsWith("/mcp")) {
4512
+ throw new Error("First-party MCP URL cannot be projected to the Codemode endpoint");
4513
+ }
4514
+ url.pathname = `${url.pathname.slice(0, -4)}/codemode`;
4515
+ return url.toString();
4516
+ }
4517
+
4281
4518
  function firstPartyMcpServerUrl(settings: Settings): string {
4282
4519
  return firstPartyMcpBaseUrl(settings);
4283
4520
  }
@@ -4292,9 +4529,6 @@ function firstPartyFilesMcpServerUrl(mcpUrl: string): string {
4292
4529
 
4293
4530
  function validateSettings(settings: Settings): void {
4294
4531
  temporalConnectionOptions(settings);
4295
- if (settings.toolspaceEnabled && !settings.delegationSecret) {
4296
- throw new Error("OPENGENI_DELEGATION_SECRET is required when OPENGENI_TOOLSPACE_ENABLED=true");
4297
- }
4298
4532
  if (settings.productAccessMode === "managed") {
4299
4533
  if (!settings.publicBaseUrl) {
4300
4534
  throw new Error(
@@ -4348,11 +4582,6 @@ function validateSettings(settings: Settings): void {
4348
4582
  );
4349
4583
  }
4350
4584
  if (settings.slackClientId) {
4351
- if (!settings.slackSigningSecret) {
4352
- throw new Error(
4353
- "OPENGENI_SLACK_SIGNING_SECRET is required when the OpenGeni Slack app is configured",
4354
- );
4355
- }
4356
4585
  if (!settings.publicBaseUrl) {
4357
4586
  throw new Error(
4358
4587
  "OPENGENI_PUBLIC_BASE_URL is required when the OpenGeni Slack app is configured",
@@ -4397,6 +4626,31 @@ function validateSettings(settings: Settings): void {
4397
4626
  );
4398
4627
  }
4399
4628
  }
4629
+ if (Boolean(settings.atlassianClientId) !== Boolean(settings.atlassianClientSecret)) {
4630
+ throw new Error(
4631
+ "OPENGENI_ATLASSIAN_CLIENT_ID and OPENGENI_ATLASSIAN_CLIENT_SECRET must be configured together",
4632
+ );
4633
+ }
4634
+ if (settings.atlassianClientId) {
4635
+ if (!settings.publicBaseUrl) {
4636
+ throw new Error(
4637
+ "OPENGENI_PUBLIC_BASE_URL is required when the Atlassian integration is configured",
4638
+ );
4639
+ }
4640
+ if (
4641
+ !settings.publicBaseUrl.startsWith("https://") &&
4642
+ !["local", "test"].includes(settings.environment)
4643
+ ) {
4644
+ throw new Error(
4645
+ "OPENGENI_PUBLIC_BASE_URL must use https when the Atlassian integration is configured outside local/test",
4646
+ );
4647
+ }
4648
+ if (!settings.integrationsStateSecret) {
4649
+ throw new Error(
4650
+ "OPENGENI_INTEGRATIONS_STATE_SECRET is required when the Atlassian integration is configured",
4651
+ );
4652
+ }
4653
+ }
4400
4654
  parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
4401
4655
  parseSocialOauthClientsJson(settings.socialOauthClientsJson);
4402
4656
  if (
@@ -4620,6 +4874,7 @@ function validateSettings(settings: Settings): void {
4620
4874
  {
4621
4875
  const reaperPeriod = settings.sandboxLeaseReaperPeriodMs;
4622
4876
  const viewerTtl = settings.sandboxViewerHolderTtlMs;
4877
+ const interactionTtl = settings.sandboxInteractionHolderTtlMs;
4623
4878
  const idleGraceMs = settings.sandboxIdleGraceMs;
4624
4879
  const providerLifetimeMs = settings.modalTimeoutSeconds * 1000;
4625
4880
  const rotationLeadMs = settings.sandboxRotationLeadMs;
@@ -4638,6 +4893,13 @@ function validateSettings(settings: Settings): void {
4638
4893
  `than the TTL it polices, or stale viewer holders outlive a full reaper period.`,
4639
4894
  );
4640
4895
  }
4896
+ if (!(reaperPeriod < interactionTtl)) {
4897
+ throw new Error(
4898
+ `OPENGENI_SANDBOX_LEASE_REAPER_PERIOD_MS (${reaperPeriod}) must be strictly less than ` +
4899
+ `OPENGENI_SANDBOX_INTERACTION_HOLDER_TTL_MS (${interactionTtl}): the reaper must run ` +
4900
+ `more often than the controller-heartbeat horizon.`,
4901
+ );
4902
+ }
4641
4903
  if (!(idleTimeoutMs <= providerLifetimeMs)) {
4642
4904
  throw new Error(
4643
4905
  `OPENGENI_MODAL_IDLE_TIMEOUT_SECONDS*1000 (${idleTimeoutMs}) must not exceed the hard provider ` +
@@ -4651,10 +4913,15 @@ function validateSettings(settings: Settings): void {
4651
4913
  `OPENGENI_MODAL_TIMEOUT_SECONDS*1000 (${providerLifetimeMs}).`,
4652
4914
  );
4653
4915
  }
4654
- if (!(rotationLeadMs > settings.sandboxSnapshotTimeoutMs + 2 * reaperPeriod)) {
4916
+ // This is provider-hard-deadline headroom, not a retry delay. Rotation is
4917
+ // admitted immediately before the same sweep's drain inventory, so only the
4918
+ // worst-case time until that sweep plus the complete durable capture window
4919
+ // is required. No second schedule period belongs in the availability path.
4920
+ const captureTimeoutMs = sandboxArchiveCaptureTimeoutMs(settings);
4921
+ if (!(rotationLeadMs > captureTimeoutMs + reaperPeriod)) {
4655
4922
  throw new Error(
4656
- `OPENGENI_SANDBOX_ROTATION_LEAD_MS (${rotationLeadMs}) must exceed the snapshot timeout ` +
4657
- `plus two reaper periods (${settings.sandboxSnapshotTimeoutMs + 2 * reaperPeriod}).`,
4923
+ `OPENGENI_SANDBOX_ROTATION_LEAD_MS (${rotationLeadMs}) must exceed the durable capture ` +
4924
+ `timeout plus one reaper period (${captureTimeoutMs + reaperPeriod}).`,
4658
4925
  );
4659
4926
  }
4660
4927
  if (!(viewerTtl < idleTimeoutMs)) {
@@ -4664,6 +4931,13 @@ function validateSettings(settings: Settings): void {
4664
4931
  `under it (the provider idle-timeout is the backstop).`,
4665
4932
  );
4666
4933
  }
4934
+ if (!(interactionTtl < idleTimeoutMs)) {
4935
+ throw new Error(
4936
+ `OPENGENI_SANDBOX_INTERACTION_HOLDER_TTL_MS (${interactionTtl}) must be strictly less than ` +
4937
+ `the effective box idle timeout (${idleTimeoutMs}): a dead browser controller must be ` +
4938
+ `reapable before the provider reclaims its placement.`,
4939
+ );
4940
+ }
4667
4941
  if (!(reaperPeriod + idleGraceMs < idleTimeoutMs)) {
4668
4942
  throw new Error(
4669
4943
  `OPENGENI_SANDBOX_LEASE_REAPER_PERIOD_MS + OPENGENI_SANDBOX_IDLE_GRACE_MS ` +