@opengeni/config 0.2.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/src/index.ts ADDED
@@ -0,0 +1,2206 @@
1
+ import {
2
+ BillingMode,
3
+ CAPABILITY_DESCRIPTORS,
4
+ Entitlements,
5
+ EntitlementsMode,
6
+ ProductAccessMode,
7
+ ReasoningEffort,
8
+ SandboxBackend,
9
+ StaticUsageLimits,
10
+ UsageLimitsMode,
11
+ } from "@opengeni/contracts";
12
+ import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex/constants";
13
+ import { z } from "zod";
14
+
15
+ const envName = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+ const registryId = /^[A-Za-z0-9_-]+$/;
17
+ const EnvBoolean = z.preprocess((value) => {
18
+ if (typeof value !== "string") {
19
+ return value;
20
+ }
21
+ const normalized = value.trim().toLowerCase();
22
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) {
23
+ return true;
24
+ }
25
+ if (["false", "0", "no", "n", "off"].includes(normalized)) {
26
+ return false;
27
+ }
28
+ return value;
29
+ }, z.boolean());
30
+
31
+ export const sandboxPreparationProfiles: Record<string, { env: string[]; hooks: string[] }> = {
32
+ none: {
33
+ env: [],
34
+ hooks: [],
35
+ },
36
+ azure: {
37
+ env: [
38
+ "ARM_CLIENT_ID",
39
+ "ARM_CLIENT_SECRET",
40
+ "ARM_TENANT_ID",
41
+ "ARM_SUBSCRIPTION_ID",
42
+ "AZURE_CLIENT_ID",
43
+ "AZURE_CLIENT_SECRET",
44
+ "AZURE_TENANT_ID",
45
+ "AZURE_SUBSCRIPTION_ID",
46
+ "AZURE_AUTHORITY_HOST",
47
+ ],
48
+ hooks: ["azure-cli-login"],
49
+ },
50
+ github: {
51
+ env: [
52
+ "GH_TOKEN",
53
+ "GITHUB_TOKEN",
54
+ "GIT_AUTHOR_NAME",
55
+ "GIT_AUTHOR_EMAIL",
56
+ "GIT_COMMITTER_NAME",
57
+ "GIT_COMMITTER_EMAIL",
58
+ ],
59
+ hooks: [],
60
+ },
61
+ };
62
+
63
+ /**
64
+ * Placeholder token inside an agent-instructions persona template. The runtime
65
+ * substitutes the non-bypassable CORE (goal-loop ownership + the dynamic
66
+ * workspace-environment block) at this marker. A template that omits the
67
+ * marker still gets the CORE appended after it (a non-bypassable fail-safe),
68
+ * so a white-labelled persona can never drop the goal-loop contract or the
69
+ * environment metadata the agent depends on.
70
+ */
71
+ export const AGENT_INSTRUCTIONS_CORE_PLACEHOLDER = "{{core}}";
72
+
73
+ /**
74
+ * Default per-workspace agent persona template. This is the BRAND + tool-usage
75
+ * opinion (the white-labellable surface): the "You are an OpenGeni workspace
76
+ * agent." identity line, the framing/opinion lines, and the mount-path facts.
77
+ *
78
+ * The CORE that MUST survive any override — the goal-loop ownership line (which
79
+ * names the opengeni__goal_* tools) and the dynamic workspace-environment block
80
+ * — is injected at AGENT_INSTRUCTIONS_CORE_PLACEHOLDER by the runtime, never
81
+ * baked into this overridable string.
82
+ *
83
+ * INVARIANT: with no per-workspace override and an empty environment, the
84
+ * runtime's composed instructions are BYTE-IDENTICAL to the historical
85
+ * hardcoded preamble. The template below is exactly the historical lines 1–11
86
+ * joined by " ", followed by " " + the placeholder. Changing a single
87
+ * character here changes that default; a runtime test pins it.
88
+ */
89
+ export const DEFAULT_AGENT_INSTRUCTIONS = [
90
+ "You are an OpenGeni workspace agent.",
91
+ "Follow the user's task and any enabled pack or skill instructions for the current role.",
92
+ "Work inside the sandbox workspace and use filesystem and shell tools when useful.",
93
+ "Repository resources are mounted under repos/<owner>/<repo>.",
94
+ "File resources are mounted under files/<file-id>/ unless the session specifies another mount path.",
95
+ "Attached files are mounted read-only; copy them before modifying.",
96
+ "Bundled skills are under .agents/ and can include infrastructure, marketing, or other role-specific guidance.",
97
+ "Use Checkov, Terraform, Azure CLI, GitHub CLI, and repository tools when relevant.",
98
+ "When the Azure sandbox preparation profile is enabled and service-principal variables are present, the sandbox is pre-authenticated with normal Azure CLI before work starts.",
99
+ "Treat code-changing work as GitOps work: create a focused branch/commit/PR when GitHub credentials are available; otherwise report exact commands and blockers.",
100
+ "Return concise, factual summaries with files changed, commands run, and remaining blockers.",
101
+ AGENT_INSTRUCTIONS_CORE_PLACEHOLDER,
102
+ ].join(" ");
103
+
104
+ const SettingsSchema = z.object({
105
+ serviceName: z.string().default("opengeni"),
106
+ environment: z.string().default("local"),
107
+ deploymentRevision: z.string().default("dev"),
108
+ databaseUrl: z.string().default("postgres://opengeni:opengeni@127.0.0.1:5432/opengeni"),
109
+ // Step I (§7.8 runtime half). Dedicated Postgres schema for the EMBEDDED
110
+ // topology. Default "" → standalone: no search_path scoping, server default
111
+ // (`public`). When set (e.g. "opengeni"), the db handle + the managed-auth
112
+ // pool send `search_path = "<dbSchema>","opengeni_private","public"` so every
113
+ // query resolves into the dedicated schema with NO query rewrite (SPIKE-1 F1).
114
+ dbSchema: z.string().default(""),
115
+ // Step I (§7.7). RLS posture. "force" (default) = today's FORCE-RLS via the
116
+ // non-owner `opengeni_app` role. "scoped" = the embedded owner-role path (the
117
+ // GUC is still emitted defensively, so the query path is identical).
118
+ rlsStrategy: z.enum(["force", "scoped"]).default("force"),
119
+ natsUrl: z.string().default("nats://127.0.0.1:4222"),
120
+ temporalHost: z.string().default("127.0.0.1:7233"),
121
+ temporalNamespace: z.string().default("default"),
122
+ temporalTaskQueue: z.string().default("opengeni-runs-ts"),
123
+ startupDependencyRetryAttempts: z.coerce.number().int().positive().default(30),
124
+ startupDependencyRetryInitialDelayMs: z.coerce.number().int().positive().default(1000),
125
+ startupDependencyRetryMaxDelayMs: z.coerce.number().int().positive().default(5000),
126
+ observabilityStructuredLogs: EnvBoolean.default(false),
127
+ observabilityMetricsEnabled: EnvBoolean.default(true),
128
+ observabilityOtlpEndpoint: z.string().url().optional(),
129
+ observabilityOtlpHeaders: z.string().default(""),
130
+ publicBaseUrl: z.string().url().optional(),
131
+ // Base URL for the bring-your-own-compute agent release assets the get.<domain>
132
+ // install routes redirect to. Defaults to this repo's GitHub Releases. The route
133
+ // appends `/download/agent-v<ver>/<asset>` (or `/latest/download/<asset>`).
134
+ agentReleasesBaseUrl: z.string().url().default("https://github.com/Cloudgeni-ai/opengeni/releases"),
135
+ productAccessMode: ProductAccessMode.default("local"),
136
+ billingMode: BillingMode.default("disabled"),
137
+ entitlementsMode: EntitlementsMode.default("none"),
138
+ usageLimitsMode: UsageLimitsMode.default("none"),
139
+ staticEntitlementsJson: z.string().default("{}"),
140
+ staticUsageLimitsJson: z.string().default("{}"),
141
+ delegationSecret: z.string().optional(),
142
+ // Sandbox-surfacing scoped stream-token HMAC secret (master-spine §C.3 / I8).
143
+ // When unset, the API falls back to `delegationSecret` (the same HMAC envelope
144
+ // family, `ogs_` vs `ogd_` prefix). REQUIRED-WHEN-DESKTOP, but the absence of
145
+ // BOTH while sandboxDesktopEnabled=true is a GRACEFUL DEGRADE (DesktopStream
146
+ // transport:null + a loud boot warning), NOT a hard boot-fail (I8/OD-8).
147
+ streamTokenSecret: z.string().optional(),
148
+ // The desktop input plane (raw stream:control writes) is OFF in v1: even a
149
+ // holder of stream:control gets 403 until this flips. Keeps stream:control a
150
+ // declared-but-inert permission so later hardening is a flag flip.
151
+ streamControlEnabled: EnvBoolean.default(false),
152
+ environmentsEncryptionKey: z.string().optional(),
153
+ // Session goal guard rails. Goals are designed for runs that legitimately
154
+ // span days, so length is bounded by pathology detection (no-progress
155
+ // streaks, budget exhaustion), never by count. goalMaxAutoContinuations is
156
+ // therefore UNSET by default (no cap); deployments may configure one, and
157
+ // it then acts as a hard ceiling that per-goal overrides can only lower.
158
+ goalMaxAutoContinuations: z.coerce.number().int().positive().optional(),
159
+ goalNoProgressLimit: z.coerce.number().int().positive().default(3),
160
+ // Per-segment ceiling on agent loop turns (model calls) within a single
161
+ // session turn. Effectively unbounded by default for the same reason as
162
+ // above; the graceful max-turns valve (idle + goal continuation, never a
163
+ // session failure) remains as inert safety should a deployment set a cap.
164
+ agentMaxModelCallsPerTurn: z.coerce.number().int().positive().default(1_000_000),
165
+ // Where turn-input conversation history comes from (issue #35):
166
+ // "items" (default) = the session_history_items table (SDK-native,
167
+ // version-stable conversation truth); "run_state" = the legacy serialized
168
+ // RunState blob. Items and the sandbox envelope are dual-written
169
+ // unconditionally; this flag governs the read path only, so flipping back to
170
+ // "run_state" remains a safe rollback at any time.
171
+ sessionHistorySource: z.enum(["run_state", "items"]).default("items"),
172
+ // Provider-aware conversation context management (long-lived sessions
173
+ // otherwise grow unbounded until they overflow the model context window and
174
+ // hard-fail every turn). Resolution (see resolveContextCompactionMode):
175
+ // "auto" (default) -> "server" when openaiProvider === "openai" (the
176
+ // OpenAI platform Responses API honors server-side context_management),
177
+ // else "client" (Azure rejects context_management with a 400, so we run
178
+ // our own client-side compaction).
179
+ // "server" / "client" -> force that path regardless of provider.
180
+ // "off" -> neither path (legacy unbounded growth; escape hatch only).
181
+ contextCompactionMode: z.enum(["auto", "server", "client", "off"]).default("auto"),
182
+ // The model's real context window in tokens. gpt-5.5's true window is
183
+ // 1,050,000; it is absent from the SDK's hardcoded compaction window map (it
184
+ // knows only up to gpt-5.4), so the SDK's DynamicCompactionPolicy would fall
185
+ // back to a wrong 240k. We pass an explicit StaticCompactionPolicy threshold
186
+ // derived from these settings on the server path, and use the same numbers to
187
+ // budget the client path.
188
+ contextWindowTokens: z.coerce.number().int().positive().default(1_050_000),
189
+ // Tokens reserved for model output; subtracted from the window to get the
190
+ // usable input budget B = contextWindowTokens - contextReservedOutputTokens.
191
+ contextReservedOutputTokens: z.coerce.number().int().nonnegative().default(128_000),
192
+ // Server path only: explicit compact_threshold (tokens) handed to the SDK's
193
+ // StaticCompactionPolicy. Defaults to floor(B * contextCompactSoftFraction)
194
+ // when unset.
195
+ contextServerCompactThresholdTokens: z.coerce.number().int().positive().optional(),
196
+ // Client path: compact pre-turn when the last turn's actual input tokens
197
+ // reach softFraction*B; hard-force at hardFraction*B.
198
+ contextCompactSoftFraction: z.coerce.number().positive().max(1).default(0.70),
199
+ contextCompactHardFraction: z.coerce.number().positive().max(1).default(0.85),
200
+ // Client path: tokens of the most recent FULL turns kept verbatim after a
201
+ // compaction (the live working set: recent tool results, not just messages).
202
+ contextKeepRecentTokens: z.coerce.number().int().positive().default(32_000),
203
+ // Client path: token ceiling on the generated summary body.
204
+ contextSummaryMaxTokens: z.coerce.number().int().positive().default(20_000),
205
+ authRequired: EnvBoolean.default(false),
206
+ accessKey: z.string().optional(),
207
+ authAllowHealth: EnvBoolean.default(true),
208
+ authAllowMetrics: EnvBoolean.default(false),
209
+ apiHost: z.string().default("0.0.0.0"),
210
+ apiPort: z.coerce.number().int().positive().default(8000),
211
+ opengeniMcpUrl: z.string().url().optional(),
212
+ corsAllowOriginRegex: z.string().default(String.raw`^https?://(localhost|127\.0\.0\.1)(:\d+)?$`),
213
+ openaiProvider: z.enum(["openai", "azure"]).default("openai"),
214
+ openaiApiKey: z.string().optional(),
215
+ openaiBaseUrl: z.string().optional(),
216
+ openaiModel: z.string().default("gpt-5.5"),
217
+ openaiAllowedModels: z.string().default("gpt-5.5,gpt-5.4,gpt-5.4-mini"),
218
+ modelPricingJson: z.string().default("{}"),
219
+ // Extra (non-built-in) model providers, declared by the host as a JSON
220
+ // provider registry. Each entry carries its own base URL, API key, wire API
221
+ // ("responses" | "chat") and the models it exposes. The models a client may
222
+ // use are the UNION of the built-in provider's allowed models and every
223
+ // registry provider's models. validateSettings parses this at boot so a
224
+ // malformed registry / unresolvable key / id collision fails fast.
225
+ modelProvidersJson: z.string().default("[]"),
226
+ // Codex (ChatGPT) subscription: when enabled, a per-workspace connected
227
+ // subscription is injected as a synthetic "codex-subscription" registry
228
+ // provider whose models route through the ChatGPT backend (@opengeni/codex).
229
+ codexSubscriptionEnabled: EnvBoolean.default(false), // OPENGENI_CODEX_SUBSCRIPTION_ENABLED
230
+ codexProductSku: z.string().optional(), // OPENGENI_CODEX_PRODUCT_SKU (X-OpenAI-Product-Sku, apps only)
231
+ // Progressive connector disclosure (Codex-CLI-style tool_search): on a codex
232
+ // turn, flag the ~217 codex_apps connector tools `defer_loading:true` (dropping
233
+ // their schemas from model context) and add one client-executed tool_search
234
+ // tool that BM25-discloses only the matching connectors. Default OFF — a codex
235
+ // turn is byte-for-byte unchanged until enabled. OPENGENI_CODEX_TOOL_SEARCH_ENABLED
236
+ codexToolSearchEnabled: EnvBoolean.default(false),
237
+ // Multi-account P3 (auto-rotation): an account is "near exhaustion" — ineligible to be
238
+ // rotated TO — when EITHER usage window (5h/weekly) is at/over this percent. Default 90 to
239
+ // match the UI danger flip (UsageBar danger at pct >= 90). OPENGENI_CODEX_ROTATION_NEAR_EXHAUSTION_PCT.
240
+ codexRotationNearExhaustionPct: z.coerce.number().int().min(1).max(100).default(90),
241
+ openaiReasoningEffort: ReasoningEffort.default("low"),
242
+ openaiAllowedReasoningEfforts: z.string().default("low,medium,high,xhigh"),
243
+ openaiResponsesTransport: z.enum(["http", "websocket"]).default("http"),
244
+ // Provider-assigned item ids (rs_/msg_/fc_…) in Responses API input are
245
+ // resolved against the provider's server-side response store. That store is
246
+ // not durable enough to anchor long runs on: a response that streamed fine
247
+ // can be missing from the store on the very next model call, which then
248
+ // fails with 400 "Item with id ... not found". "strip" removes the ids from
249
+ // every model-call input so requests are self-contained — conversation
250
+ // truth already lives client-side in session_history_items. "preserve"
251
+ // keeps the SDK's pass-through behavior.
252
+ openaiProviderItemIds: z.enum(["strip", "preserve"]).default("strip"),
253
+ // With ids stripped the provider cannot resolve prior reasoning server-side,
254
+ // so request reasoning.encrypted_content and send it back with each call:
255
+ // reasoning continuity without depending on provider-side storage.
256
+ openaiReasoningEncryptedContent: EnvBoolean.default(true),
257
+ // Model-call retry budget for transient provider failures (429s and friends).
258
+ // The openai client default of 2 retries is too small for sustained TPM
259
+ // backpressure during long autonomous runs.
260
+ openaiMaxRetries: z.coerce.number().int().nonnegative().default(5),
261
+ // Native hosted web search. The live Azure Responses path executes the
262
+ // hosted web_search tool, so this is provider-unconditional: ON by default
263
+ // on every provider, exposed only so operators can disable it. When true,
264
+ // buildOpenGeniAgent attaches webSearchTool() to the agent's tools — it is
265
+ // merged with the MCP-server tools (getAllTools = [...mcpTools, ...tools])
266
+ // and the sandbox capability tools, never replacing them.
267
+ webSearchEnabled: EnvBoolean.default(true),
268
+ // Deployment-default agent persona template (the white-label surface). The
269
+ // runtime resolves the effective template per turn as
270
+ // per-session-override > per-workspace override > this default, substitutes
271
+ // the non-bypassable CORE at AGENT_INSTRUCTIONS_CORE_PLACEHOLDER (or appends
272
+ // it when the template omits the marker), and uses the result as the agent's
273
+ // instructions. Defaulting to DEFAULT_AGENT_INSTRUCTIONS keeps the composed
274
+ // default byte-identical to the historical hardcoded preamble.
275
+ agentInstructionsTemplate: z.string().default(DEFAULT_AGENT_INSTRUCTIONS),
276
+ azureOpenaiBaseUrl: z.string().optional(),
277
+ azureOpenaiEndpoint: z.string().optional(),
278
+ azureOpenaiDeployment: z.string().optional(),
279
+ azureOpenaiApiVersion: z.string().optional(),
280
+ azureOpenaiApiKey: z.string().optional(),
281
+ azureOpenaiAdToken: z.string().optional(),
282
+ disableOpenaiTracing: EnvBoolean.default(false),
283
+ sandboxBackend: SandboxBackend.default("docker"),
284
+ dockerImage: z.string().default("opengeni-sandbox:local"),
285
+ dockerExposedPorts: z.string().default(""),
286
+ dockerNetwork: z.string().optional(),
287
+ modalAppName: z.string().default("opengeni-sandbox"),
288
+ modalImageRef: z.string().optional(),
289
+ // Modal's hard sandbox lifetime (timeoutMs = this * 1000), counted from each
290
+ // create/resume — it is the BACKSTOP that reclaims a box if the reaper/worker is
291
+ // down, NOT the warm-window controller (that's sandboxIdleGraceMs). It must
292
+ // comfortably exceed reaperPeriod + idleGrace so the reaper terminates a
293
+ // genuinely-idle box FIRST; the boot invariant below enforces that. Default 1h
294
+ // (was 900s/15min): the 15-min drain grace counts from the user's LAST release,
295
+ // but Modal's clock starts at the preceding turn's resume — so a 15-min grace on
296
+ // top of a 900s lifetime would let Modal kill the box mid-warm-window. 3600s
297
+ // leaves ~45min of headroom for the active turn before the warm window opens.
298
+ // Knob: OPENGENI_MODAL_TIMEOUT_SECONDS.
299
+ modalTimeoutSeconds: z.coerce.number().int().positive().default(3600),
300
+ modalTokenId: z.string().optional(),
301
+ modalTokenSecret: z.string().optional(),
302
+ modalEnvironment: z.string().optional(),
303
+ // modal gap-fill: idleTimeoutMs + workspacePersistence were unmapped (module 03 §4.1).
304
+ //
305
+ // CRITICAL (sandbox-file-persistence): when this is UNSET the Modal SDK sends
306
+ // idleTimeoutSecs=undefined, so Modal applies its OWN short server-default idle
307
+ // timeout (~minutes) — and a box between turns sits with NO active connection,
308
+ // so that idle clock runs and Modal idle-reaps the box LONG before OpenGeni's
309
+ // own reaper waits out sandboxIdleGraceMs (15min) to resume+persist+terminate
310
+ // it. The observed failure: every drain logs "drainable box already gone
311
+ // (NotFound on resume)", persistWorkspace() never fires, /workspace is lost.
312
+ // Modal's idle-reap is a SECOND reaper racing OpenGeni's — and it wins. The fix:
313
+ // OpenGeni OWNS box lifecycle via its reaper + the hard modalTimeoutSeconds
314
+ // backstop, so the Modal idle-reap must NOT fire first. We default the effective
315
+ // idle timeout to the hard lifetime (effectiveModalIdleTimeoutSeconds), making
316
+ // the box survive its full warm window so the reaper can snapshot it. Set this
317
+ // explicitly (OPENGENI_MODAL_IDLE_TIMEOUT_SECONDS) only to deliberately idle-reap
318
+ // SOONER than the hard lifetime; the boot invariant forbids a value that would
319
+ // reap before reaperPeriod + idleGrace elapses.
320
+ modalIdleTimeoutSeconds: z.coerce.number().int().positive().optional(),
321
+ // /workspace FILE PERSISTENCE across warm/cold cycles. Defaults to
322
+ // `snapshot_filesystem` so EVERY box is created persistence-capable: the reaper
323
+ // snapshots the live box before it terminates a drained group, and a later
324
+ // cold-restore hydrates a fresh box from that snapshot (sandbox-file-persistence).
325
+ // `snapshot_filesystem` requires the manifest declare NO ephemeralPersistencePaths
326
+ // (buildManifest never sets entry.ephemeral, so it never downgrades to tar). Set
327
+ // OPENGENI_MODAL_WORKSPACE_PERSISTENCE=tar to opt back out (no native snapshot;
328
+ // the reaper persists a tar archive — same store+hydrate plumbing, slower).
329
+ modalWorkspacePersistence: z
330
+ .enum(["tar", "snapshot_filesystem", "snapshot_directory"])
331
+ .default("snapshot_filesystem"),
332
+ // Snapshot GC backstop (sandbox-file-persistence): the reaper keeps ONE latest
333
+ // filesystem snapshot per lease (delete-prior-on-supersede + delete-on-teardown).
334
+ // This is the TTL retention floor for the periodic orphan sweep — a snapshot
335
+ // whose lease is cold and older than this is best-effort deleted so a crashed
336
+ // persist-then-no-restore never leaks a Modal image. 0 disables the TTL sweep
337
+ // (delete-on-supersede/teardown still run). Default 7 days.
338
+ modalSnapshotRetentionSeconds: z.coerce.number().int().nonnegative().default(604_800),
339
+ // Shared desktop toggle: this module reads it for the 6080 port-merge; the
340
+ // owner module (P4.x) acts on it to launch the display stack.
341
+ sandboxDesktopEnabled: EnvBoolean.default(false),
342
+ // Human take-control toggle: when ON (default) the negotiated DesktopStream
343
+ // cell advertises mode "interactive" — the noVNC viewer can drive mouse+keyboard
344
+ // into :0 (x11vnc runs without -viewonly). Turn it OFF for a genuinely read-only
345
+ // deployment: the cell reports mode "read-only" and the client disables the
346
+ // "Take control" affordance. Independent of computerUseReadOnly (the AGENT
347
+ // driver); this gates the HUMAN viewer plane.
348
+ sandboxDesktopInteractive: EnvBoolean.default(true),
349
+ // REAL PTY terminal toggle (P5.t): gates the ttyd pty-ws plane (7681) the API
350
+ // mints over the SAME tunnel as the desktop. Defaults ON — the interactive
351
+ // terminal is a baseline structured-service surface (unlike the heavier desktop
352
+ // pixel plane); a deployment can turn it off to fall back to the read-only
353
+ // sse-events command firehose. The 7681 port-merge tracks sandboxDesktopEnabled
354
+ // (a desktop-capable image is the one that bakes ttyd).
355
+ sandboxTerminalEnabled: EnvBoolean.default(true),
356
+ // The desktop framebuffer geometry the pixel plane advertises + launches the
357
+ // display stack with (P4.2). v1 has no live RANDR resize; a change is a full
358
+ // down→up restart. Defaults match the proven spike geometry (1280x800).
359
+ streamResolutionWidth: z.coerce.number().int().positive().default(1280),
360
+ streamResolutionHeight: z.coerce.number().int().positive().default(800),
361
+ // P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
362
+ // scrot). Gated by sandboxDesktopEnabled + a desktop-capable backend in
363
+ // buildAgentCapabilities; computerUseReadOnly:false is the agent-driver default
364
+ // (it must click/type — the human viewer plane is the read-only one).
365
+ computerUseEnabled: EnvBoolean.default(true),
366
+ computerUseReadOnly: EnvBoolean.default(false),
367
+ // P4.3 recording loop: ffmpeg x11grab of :0 → mp4/webm → @opengeni/storage.
368
+ // recordingMaxBytes caps the in-memory finalize buffer (≤ storage single-PUT);
369
+ // recordingMaxSeconds is the ffmpeg -t hard ceiling (bounds a multi-day turn).
370
+ recordingEnabled: EnvBoolean.default(true),
371
+ recordingDefaultCodec: z.enum(["h264-mp4", "vp9-webm"]).default("h264-mp4"),
372
+ recordingFramerate: z.coerce.number().int().positive().default(15),
373
+ recordingMaxSeconds: z.coerce.number().int().positive().default(600),
374
+ recordingMaxBytes: z.coerce.number().int().positive().default(268_435_456), // 256 MB
375
+ // --- daytona ---
376
+ daytonaApiKey: z.string().optional(),
377
+ daytonaApiUrl: z.string().url().optional(),
378
+ daytonaTarget: z.string().optional(),
379
+ daytonaImage: z.string().optional(),
380
+ daytonaSnapshotName: z.string().optional(),
381
+ daytonaAutoStopInterval: z.coerce.number().int().nonnegative().optional(), // 0 disables idle-kill
382
+ daytonaTimeoutSeconds: z.coerce.number().int().positive().optional(),
383
+ daytonaExposedPortUrlTtlSeconds: z.coerce.number().int().positive().optional(),
384
+ // --- runloop ---
385
+ runloopApiKey: z.string().optional(),
386
+ runloopBaseUrl: z.string().url().optional(),
387
+ runloopBlueprintName: z.string().optional(),
388
+ runloopBlueprintId: z.string().optional(),
389
+ runloopTunnel: EnvBoolean.default(true),
390
+ runloopKeepAliveSeconds: z.coerce.number().int().positive().optional(),
391
+ // --- e2b (SDK reads E2B_API_KEY from env; mirrored for validation + forwarding) ---
392
+ e2bApiKey: z.string().optional(),
393
+ e2bTemplate: z.string().optional(),
394
+ e2bTimeoutSeconds: z.coerce.number().int().positive().optional(),
395
+ e2bTimeoutAction: z.enum(["pause", "kill"]).optional(),
396
+ e2bAllowInternetAccess: EnvBoolean.optional(),
397
+ e2bAutoResume: EnvBoolean.optional(),
398
+ e2bWorkspacePersistence: z.enum(["tar", "snapshot"]).optional(),
399
+ // --- blaxel ---
400
+ blaxelApiKey: z.string().optional(),
401
+ blaxelImage: z.string().optional(),
402
+ blaxelRegion: z.string().optional(),
403
+ blaxelExposedPortPublic: EnvBoolean.optional(), // public vs bl_preview_token
404
+ blaxelExposedPortUrlTtlSeconds: z.coerce.number().int().positive().optional(),
405
+ blaxelMemoryMb: z.coerce.number().int().positive().optional(),
406
+ blaxelTtl: z.string().optional(),
407
+ // --- cloudflare (headless) ---
408
+ cloudflareWorkerUrl: z.string().url().optional(),
409
+ cloudflareApiKey: z.string().optional(),
410
+ // --- vercel (headless) ---
411
+ vercelToken: z.string().optional(),
412
+ vercelProjectId: z.string().optional(),
413
+ vercelTeamId: z.string().optional(),
414
+ vercelRuntime: z.string().optional(),
415
+ // --- sandbox ownership inversion (P1.2 rollout flag, default OFF) ---
416
+ // The keystone flag for the stateless resume-by-id model. When FALSE the
417
+ // agent-turn path is BYTE-FOR-BYTE today's build-and-discard behavior (no
418
+ // lease acquire, no resume-by-id, no non-owned injection). When TRUE the turn
419
+ // activity acquires the group lease, resumes the one box by id from the lease
420
+ // envelope, injects it as a NON-OWNED RunConfig session (the SDK never reaps
421
+ // it — the proven keystone), and releases the holder in finally. Uses
422
+ // EnvBoolean (NOT z.coerce.boolean(), which would coerce "false" -> true and
423
+ // turn the flag ON the moment anyone set the env var to disable it).
424
+ sandboxOwnershipEnabled: EnvBoolean.default(false),
425
+ // --- bring-your-own-compute (selfhosted 11th backend) rollout flag, default OFF ---
426
+ // The keystone flag for the whole selfhosted feature (the enrollment device-flow,
427
+ // the NATS control plane, the relay stream tier). When FALSE the enrollment routes
428
+ // 404 (invisible — the surface does not exist for this deployment) and the
429
+ // selfhosted backend is inert; boot is unaffected. EnvBoolean (NOT
430
+ // z.coerce.boolean(), which coerces "false" -> true). Flipped per-environment via
431
+ // the deploy-staging IaC secret/configmap pattern (dossier §17/§25.1).
432
+ sandboxSelfhostedEnabled: EnvBoolean.default(false),
433
+ // The HMAC secret the control plane signs the enrollment bearer credential with
434
+ // (the `oge_` envelope the agent presents back to the control plane). Optional:
435
+ // when ABSENT and sandboxSelfhostedEnabled is on, the poll route reports the
436
+ // credential plane disabled (graceful degrade, mirrors streamTokenSecret). NEVER
437
+ // logged. Lives in the opengeni-runtime secret (Helm-clobbered configmap avoided).
438
+ enrollmentSigningSecret: z.string().optional(),
439
+ // Connect-info the EnrollmentCredentials hand the agent: the NATS server URL(s)
440
+ // the agent dials for the control plane, and the relay edge base URL for streams.
441
+ // The per-workspace NATS Account creds binding is infra-deferred (M4/relay
442
+ // milestone) — the poll returns these endpoints + a placeholder creds field.
443
+ selfhostedNatsUrl: z.string().optional(),
444
+ selfhostedRelayUrl: z.string().optional(),
445
+ // The HMAC secret the control plane signs the agent's relay PRODUCER token with
446
+ // (the `ogr_` envelope threaded into EnrollmentCredentials.relayToken; M8b/dossier
447
+ // §10.5). The relay verifies the producer token with the SAME secret. Optional:
448
+ // when ABSENT the poll returns an empty relayToken (graceful degrade — the stream
449
+ // plane is simply unavailable until configured). Falls back to streamTokenSecret /
450
+ // delegationSecret (same HMAC family) so a deployment with a stream-token secret
451
+ // needs no second one. NEVER logged. Lives in the opengeni-runtime secret.
452
+ selfhostedRelayTokenSecret: z.string().optional(),
453
+ // The minisign PUBLIC key the agent pins for self-update verification (handed to
454
+ // the agent in EnrollmentCredentials; the SECRET key lives only in CI).
455
+ agentUpdatePublicKey: z.string().optional(),
456
+ // --- NATS auth-callout tenancy boundary (bring-your-own-compute M-AUTH; dossier
457
+ // §10.1 NATS Accounts per workspace + §17 the isolation smoke) -------------
458
+ // nats-server is configured with AUTH CALLOUT: an external agent connects
459
+ // presenting its `oge_` enrollment bearer as the connect auth-token; the server
460
+ // issues an authorization request on $SYS.REQ.USER.AUTH to our responder, which
461
+ // validates the bearer and returns a SIGNED NATS user JWT scoped to pub/sub ONLY
462
+ // `agent.<ws>.>` (+ `_INBOX.>`). That per-subject scope IS the per-workspace
463
+ // isolation. These are deployment-level secrets in the opengeni-runtime secret
464
+ // (Helm-clobbered configmap avoided), all OPTIONAL: when the callout plane is not
465
+ // configured the responder simply does not start (selfhosted agents cannot
466
+ // connect — graceful, never a boot-fail).
467
+ //
468
+ // The callout account SIGNING SEED (`SA...`). Both the user JWT and the
469
+ // authorization-response JWT are signed by this account key; its public key
470
+ // (`A...`) is the `auth_callout.issuer` in the server config. NEVER logged.
471
+ selfhostedNatsCalloutAccountSeed: z.string().optional(),
472
+ // The TARGET ACCOUNT NAME the minted user is placed into (the server-config-mode
473
+ // `auth_callout.account`, e.g. "APP"). The responder writes it as the minted user
474
+ // JWT `aud` so nats-server binds the agent to this account — the SAME account the
475
+ // privileged control plane connects into, so `agent.<ws>.<id>.rpc` request/reply
476
+ // routes. Optional; resolveNatsCalloutConfig defaults it to "APP".
477
+ selfhostedNatsCalloutAccountName: z.string().optional(),
478
+ // The callout RESPONDER's own NATS login (one of the `auth_callout.auth_users`
479
+ // in the AUTH account) — the responder connects with this to subscribe
480
+ // $SYS.REQ.USER.AUTH. Username/password.
481
+ selfhostedNatsCalloutUser: z.string().optional(),
482
+ selfhostedNatsCalloutPassword: z.string().optional(),
483
+ // The PRIVILEGED control-plane login (api/worker): a static account user that may
484
+ // request `agent.*.rpc` + receive its inbox replies. The event bus + the
485
+ // selfhosted control RPC ride THIS connection. Username/password; when unset the
486
+ // bus connects anonymously (local dev / a NATS with no auth_callout).
487
+ selfhostedNatsControlUser: z.string().optional(),
488
+ selfhostedNatsControlPassword: z.string().optional(),
489
+ // --- sandbox lease cadences (cadence invariant validated at boot below) ---
490
+ // reaperPeriod < viewerHolderTTL, and reaperPeriod + idleGrace < the EFFECTIVE
491
+ // box idle timeout (effectiveModalIdleTimeoutSeconds, which defaults to the hard
492
+ // modalTimeoutSeconds). No keep-alive loop: between turns the box survives on its
493
+ // idle timeout — which we pin high enough (via the idle-timeout default) that
494
+ // OpenGeni's reaper, not Modal's idle-reap, governs teardown so /workspace is
495
+ // snapshotted before the box dies (sandbox-file-persistence).
496
+ sandboxLeaseReaperPeriodMs: z.coerce.number().int().positive().default(30_000),
497
+ sandboxViewerHolderTtlMs: z.coerce.number().int().positive().default(90_000),
498
+ // The DRAIN grace: how long a refcount-0 (draining) lease stays WARM before the
499
+ // reaper resume-by-ids the box and terminates it. This is the cost-vs-snappiness
500
+ // dial — when the user navigates away the box keeps refcount 0, but it survives
501
+ // this whole window so a "glanced away then came back" re-arms the SAME warm box
502
+ // (acquireLease re-arms draining->warm; the reaper's BEFORE-terminate re-read
503
+ // skips a re-armed box). Default 15min so a brief detour never cold-creates a
504
+ // fresh EMPTY box; lower it to trade warm cost for a snappier reclaim. Knob:
505
+ // OPENGENI_SANDBOX_IDLE_GRACE_MS.
506
+ sandboxIdleGraceMs: z.coerce.number().int().positive().default(900_000),
507
+ // expires_at refresh window for a held lease (>> the turn 10s heartbeat so a
508
+ // single missed heartbeat never TTL-reaps a live turn). The warming TTL is the
509
+ // window a cold->warming spawner has to commit warm before a reaper resets it.
510
+ sandboxLeaseTtlMs: z.coerce.number().int().positive().default(90_000),
511
+ sandboxLeaseWarmingTtlMs: z.coerce.number().int().positive().default(120_000),
512
+ // --- sandbox warm-time billing (P2.1) ---
513
+ // Per-backend warm rate (usd_micros/sec), like modelPricingJson: an empty {}
514
+ // means warm-cost is not debited (warm-seconds are still metered for audit).
515
+ // Shape: { "modal": 5, "runloop": 4, ... }. Backends absent here meter
516
+ // warm-seconds but accrue NO warm_cost / debit (rate 0).
517
+ sandboxWarmRateMicrosPerSecondJson: z.string().default("{}"),
518
+ // Per-workspace warm cap (cumulative warm-seconds since the start of the UTC
519
+ // month, summed over sandbox.warm_seconds). 0 = unbounded. A workspace over the
520
+ // cap force-drains its VIEWER-ONLY boxes (guarded AND turn_holders=0 — a paying
521
+ // turn is never killed); the reaper then stop()s at refcount 0.
522
+ sandboxMaxWarmSecondsPerWorkspace: z.coerce.number().int().nonnegative().default(0),
523
+ sandboxPreparationProfiles: z.string().default("none"),
524
+ sandboxEnvAllowlist: z.string().default(""),
525
+ objectStorageEndpoint: z.string().url().optional(),
526
+ objectStorageSandboxEndpoint: z.string().url().optional(),
527
+ objectStorageBackend: z.enum(["s3-compatible", "aws-s3", "azure-blob", "gcs"]).default("s3-compatible"),
528
+ objectStorageBucket: z.string().min(1).default("opengeni-files"),
529
+ objectStorageRegion: z.string().min(1).default("us-east-1"),
530
+ objectStorageS3Provider: z.string().min(1).default("Minio"),
531
+ objectStorageAccessKeyId: z.string().optional(),
532
+ objectStorageSecretAccessKey: z.string().optional(),
533
+ objectStorageForcePathStyle: EnvBoolean.default(true),
534
+ objectStorageAzureConnectionString: z.string().optional(),
535
+ objectStorageAzureAccountName: z.string().optional(),
536
+ objectStorageAzureAccountKey: z.string().optional(),
537
+ objectStorageAzureEndpoint: z.string().url().optional(),
538
+ objectStorageGcsProjectId: z.string().optional(),
539
+ objectStorageGcsCredentialsJson: z.string().optional(),
540
+ objectStorageGcsKeyFilename: z.string().optional(),
541
+ objectStorageGcsApiEndpoint: z.string().url().optional(),
542
+ documentParser: z.string().min(1).default("liteparse"),
543
+ documentChunkSize: z.coerce.number().int().positive().default(1200),
544
+ documentChunkOverlap: z.coerce.number().int().nonnegative().default(160),
545
+ documentEmbeddingProvider: z.enum(["openai", "deterministic"]).default("openai"),
546
+ documentEmbeddingModel: z.string().min(1).default("text-embedding-3-large"),
547
+ documentEmbeddingDimensions: z.coerce.number().int().positive().default(3072),
548
+ documentEmbeddingApiKey: z.string().optional(),
549
+ documentEmbeddingBaseUrl: z.string().url().optional(),
550
+ gitAuthorName: z.string().optional(),
551
+ gitAuthorEmail: z.string().optional(),
552
+ gitCommitterName: z.string().optional(),
553
+ gitCommitterEmail: z.string().optional(),
554
+ githubAppManifestBaseUrl: z.string().optional(),
555
+ githubAppManifestStateSecret: z.string().optional(),
556
+ githubAppId: z.string().optional(),
557
+ githubClientId: z.string().optional(),
558
+ githubClientSecret: z.string().optional(),
559
+ githubAppSlug: z.string().optional(),
560
+ githubWebhookSecret: z.string().optional(),
561
+ githubAppPrivateKey: z.string().optional(),
562
+ betterAuthSecret: z.string().optional(),
563
+ betterAuthAllowedHosts: z.string().default(""),
564
+ betterAuthCookieDomain: z.string().optional(),
565
+ betterAuthTrustedOrigins: z.string().default(""),
566
+ resendApiKey: z.string().optional(),
567
+ emailFrom: z.string().default("OpenGeni <auth@mail.opengeni.ai>"),
568
+ stripeSecretKey: z.string().optional(),
569
+ stripePublishableKey: z.string().optional(),
570
+ stripeWebhookSecret: z.string().optional(),
571
+ stripeCreditsProductId: z.string().optional(),
572
+ mcpServers: z.array(z.object({
573
+ id: z.string().min(1).regex(registryId),
574
+ name: z.string().min(1).optional(),
575
+ url: z.string().url(),
576
+ allowedTools: z.array(z.string().min(1)).optional(),
577
+ timeoutMs: z.number().int().positive().optional(),
578
+ cacheToolsList: z.boolean().default(false),
579
+ /**
580
+ * Extra request headers sent to this MCP server (credential injection
581
+ * for workspace-enabled capability MCPs). Populated at runtime from
582
+ * encrypted capability-installation credentials; do not put secrets in
583
+ * OPENGENI_MCP_SERVERS.
584
+ */
585
+ headers: z.record(z.string(), z.string()).optional(),
586
+ })).default([]),
587
+ });
588
+
589
+ export type Settings = z.infer<typeof SettingsSchema>;
590
+ export type McpServerConfig = Settings["mcpServers"][number];
591
+ export type ModelPricing = {
592
+ inputMicrosPerMillionTokens: number;
593
+ cachedInputMicrosPerMillionTokens?: number | undefined;
594
+ outputMicrosPerMillionTokens: number;
595
+ marginBps?: number | undefined;
596
+ };
597
+ export type ModelUsageInput = {
598
+ inputTokens?: number | undefined;
599
+ outputTokens?: number | undefined;
600
+ totalTokens?: number | undefined;
601
+ inputTokensDetails?: Record<string, number> | Array<Record<string, number>> | undefined;
602
+ requestUsageEntries?: ModelUsageInput[] | undefined;
603
+ };
604
+
605
+ export type StaticUsageLimitsConfig = StaticUsageLimits;
606
+ export type EntitlementsConfig = Entitlements;
607
+
608
+ const ModelPricingSchema = z.object({
609
+ inputMicrosPerMillionTokens: z.number().int().nonnegative(),
610
+ cachedInputMicrosPerMillionTokens: z.number().int().nonnegative().optional(),
611
+ outputMicrosPerMillionTokens: z.number().int().nonnegative(),
612
+ marginBps: z.number().int().min(0).max(100_000).optional(),
613
+ });
614
+
615
+ /**
616
+ * Wire API a provider speaks. The built-in OpenAI/Azure provider always uses
617
+ * "responses" (the OpenAI Responses API). Extra registry providers default to
618
+ * "chat" (the broadly compatible /v1/chat/completions surface); Fireworks is
619
+ * wired as "chat" because its beta Responses endpoint echoes input back and
620
+ * silently no-ops hosted tools (see docs/model-providers.md).
621
+ */
622
+ export const ModelProviderApi = z.enum(["responses", "chat"]);
623
+ export type ModelProviderApi = z.infer<typeof ModelProviderApi>;
624
+
625
+ /**
626
+ * Registry provider kind. "api-key" providers carry their own static key/headers;
627
+ * "codex-subscription" providers authenticate per-request with a ChatGPT/Codex
628
+ * subscription token resolved at call time (no static key) — see @opengeni/codex.
629
+ */
630
+ export const RegistryProviderKind = z.enum(["api-key", "codex-subscription"]);
631
+ export type RegistryProviderKind = z.infer<typeof RegistryProviderKind>;
632
+
633
+ /** A single model exposed by a registry provider. */
634
+ const RegistryModelSchema = z.object({
635
+ id: z.string().min(1), // model id sent to the provider, e.g. "accounts/fireworks/models/glm-5p2"
636
+ label: z.string().min(1).optional(), // display name; defaults to id
637
+ contextWindowTokens: z.number().int().positive().optional(),
638
+ reasoningEffort: z.boolean().optional(), // model accepts a reasoning-effort control
639
+ hostedWebSearch: z.boolean().optional(), // provider executes the hosted web_search tool for this model
640
+ pricing: ModelPricingSchema.optional(),
641
+ });
642
+
643
+ /** A non-built-in provider declared by the host via OPENGENI_MODEL_PROVIDERS_JSON. */
644
+ const RegistryProviderSchema = z.object({
645
+ kind: RegistryProviderKind.default("api-key"), // "codex-subscription" => per-request token, no static key
646
+ id: z.string().min(1).regex(registryId), // stable provider id, e.g. "fireworks"
647
+ label: z.string().min(1).optional(),
648
+ api: ModelProviderApi.default("chat"),
649
+ baseUrl: z.string().url(),
650
+ apiKey: z.string().optional(), // inline key (pragmatic) ...
651
+ apiKeyEnv: z.string().optional(), // ... OR name of the env var holding the key (preferred)
652
+ defaultQuery: z.record(z.string(), z.string()).optional(),
653
+ defaultHeaders: z.record(z.string(), z.string()).optional(),
654
+ models: z.array(RegistryModelSchema).min(1),
655
+ });
656
+ export type RegistryProvider = z.infer<typeof RegistryProviderSchema>;
657
+
658
+ /**
659
+ * Runtime-resolved provider (built-in or registry), client-construction-ready.
660
+ * The built-in OpenAI/Azure provider is always present and always "responses";
661
+ * registry providers carry their own base URL / key / wire API. compactionMode
662
+ * is "server" only for the built-in OpenAI platform provider (its Responses API
663
+ * honors server-side context_management) and "client" for everything else.
664
+ */
665
+ export interface ResolvedModelProvider {
666
+ id: string; // "openai" | "azure" | registry id
667
+ label: string;
668
+ kind: RegistryProviderKind; // "api-key" (built-ins + most registry) | "codex-subscription"
669
+ api: ModelProviderApi;
670
+ builtin: boolean;
671
+ baseUrl?: string | undefined;
672
+ apiKey?: string | undefined;
673
+ defaultQuery?: Record<string, string> | undefined;
674
+ defaultHeaders?: Record<string, string> | undefined;
675
+ compactionMode: ContextCompactionMode; // "server" only for built-in OpenAI; "client" otherwise
676
+ }
677
+
678
+ /** A single exposed model + the provider that serves it. */
679
+ export interface ConfiguredModel {
680
+ id: string;
681
+ label: string;
682
+ providerId: string;
683
+ providerLabel: string;
684
+ api: ModelProviderApi;
685
+ contextWindowTokens?: number | undefined;
686
+ reasoningEffort: boolean;
687
+ hostedWebSearch: boolean;
688
+ }
689
+
690
+ export const defaultModelPricing: Record<string, ModelPricing> = {
691
+ "gpt-5.5": {
692
+ inputMicrosPerMillionTokens: 5_000_000,
693
+ cachedInputMicrosPerMillionTokens: 500_000,
694
+ outputMicrosPerMillionTokens: 30_000_000,
695
+ marginBps: 2_500,
696
+ },
697
+ "gpt-5.4": {
698
+ inputMicrosPerMillionTokens: 2_500_000,
699
+ cachedInputMicrosPerMillionTokens: 250_000,
700
+ outputMicrosPerMillionTokens: 15_000_000,
701
+ marginBps: 2_500,
702
+ },
703
+ "gpt-5.4-mini": {
704
+ inputMicrosPerMillionTokens: 750_000,
705
+ cachedInputMicrosPerMillionTokens: 75_000,
706
+ outputMicrosPerMillionTokens: 4_500_000,
707
+ marginBps: 2_500,
708
+ },
709
+ "gpt-5.2": {
710
+ inputMicrosPerMillionTokens: 1_750_000,
711
+ cachedInputMicrosPerMillionTokens: 175_000,
712
+ outputMicrosPerMillionTokens: 14_000_000,
713
+ marginBps: 2_500,
714
+ },
715
+ "gpt-5.2-chat-latest": {
716
+ inputMicrosPerMillionTokens: 1_750_000,
717
+ cachedInputMicrosPerMillionTokens: 175_000,
718
+ outputMicrosPerMillionTokens: 14_000_000,
719
+ marginBps: 2_500,
720
+ },
721
+ "gpt-5.2-codex": {
722
+ inputMicrosPerMillionTokens: 1_750_000,
723
+ cachedInputMicrosPerMillionTokens: 175_000,
724
+ outputMicrosPerMillionTokens: 14_000_000,
725
+ marginBps: 2_500,
726
+ },
727
+ "gpt-5.1": {
728
+ inputMicrosPerMillionTokens: 1_250_000,
729
+ cachedInputMicrosPerMillionTokens: 125_000,
730
+ outputMicrosPerMillionTokens: 10_000_000,
731
+ marginBps: 2_500,
732
+ },
733
+ "gpt-5": {
734
+ inputMicrosPerMillionTokens: 1_250_000,
735
+ cachedInputMicrosPerMillionTokens: 125_000,
736
+ outputMicrosPerMillionTokens: 10_000_000,
737
+ marginBps: 2_500,
738
+ },
739
+ "gpt-5-mini": {
740
+ inputMicrosPerMillionTokens: 250_000,
741
+ cachedInputMicrosPerMillionTokens: 25_000,
742
+ outputMicrosPerMillionTokens: 2_000_000,
743
+ marginBps: 2_500,
744
+ },
745
+ "gpt-5-nano": {
746
+ inputMicrosPerMillionTokens: 50_000,
747
+ cachedInputMicrosPerMillionTokens: 5_000,
748
+ outputMicrosPerMillionTokens: 400_000,
749
+ marginBps: 2_500,
750
+ },
751
+ // Fireworks AI / GLM 5.2 — the first shipped non-OpenAI registry model. A
752
+ // built-in default pricing entry makes managed billing work out of the box
753
+ // for hosts that expose this model via OPENGENI_MODEL_PROVIDERS_JSON without
754
+ // also setting OPENGENI_MODEL_PRICING_JSON.
755
+ "accounts/fireworks/models/glm-5p2": {
756
+ inputMicrosPerMillionTokens: 1_400_000,
757
+ cachedInputMicrosPerMillionTokens: 260_000,
758
+ outputMicrosPerMillionTokens: 4_400_000,
759
+ marginBps: 2_500,
760
+ },
761
+ };
762
+
763
+ // --- backend-gated required-credential table (the single source of truth) ---
764
+ // Each sandbox backend declares ONLY its own required credentials: a deployment
765
+ // configured for `sandboxBackend=modal` must carry the Modal token, but a
766
+ // daytona/e2b/local/none deployment must NOT be forced to set Modal creds (and
767
+ // vice versa). validateSettings() iterates this table for the *active* backend
768
+ // only — so the cred a backend doesn't use is never a boot blocker — and the
769
+ // deployment package mirrors the same table to drive its env-render + the
770
+ // required-env manifest (one table, two consumers).
771
+ //
772
+ // `field` is the parsed Settings key (boot validation reads the typed value);
773
+ // `env` is the OPENGENI_* variable name (deployment renders/requires it). The
774
+ // modal token is a both-or-neither pair handled by an extra refine in
775
+ // validateSettings — this table holds the hard "must be present when active"
776
+ // requirements.
777
+ export type SandboxRequiredEnv = {
778
+ field: keyof Settings;
779
+ env: string;
780
+ };
781
+
782
+ export const SANDBOX_REQUIRED_ENV: Record<z.infer<typeof SandboxBackend>, readonly SandboxRequiredEnv[]> = {
783
+ // docker/local/none need no credentials (local dev container / in-process / off).
784
+ docker: [],
785
+ local: [],
786
+ none: [],
787
+ modal: [
788
+ { field: "modalAppName", env: "OPENGENI_MODAL_APP_NAME" },
789
+ { field: "modalTokenId", env: "OPENGENI_MODAL_TOKEN_ID" },
790
+ { field: "modalTokenSecret", env: "OPENGENI_MODAL_TOKEN_SECRET" },
791
+ ],
792
+ daytona: [
793
+ { field: "daytonaApiKey", env: "OPENGENI_DAYTONA_API_KEY" },
794
+ ],
795
+ runloop: [
796
+ { field: "runloopApiKey", env: "OPENGENI_RUNLOOP_API_KEY" },
797
+ ],
798
+ e2b: [
799
+ { field: "e2bApiKey", env: "OPENGENI_E2B_API_KEY" },
800
+ ],
801
+ blaxel: [
802
+ { field: "blaxelApiKey", env: "OPENGENI_BLAXEL_API_KEY" },
803
+ ],
804
+ cloudflare: [
805
+ { field: "cloudflareWorkerUrl", env: "OPENGENI_CLOUDFLARE_WORKER_URL" },
806
+ ],
807
+ vercel: [
808
+ { field: "vercelToken", env: "OPENGENI_VERCEL_TOKEN" },
809
+ { field: "vercelProjectId", env: "OPENGENI_VERCEL_PROJECT_ID" },
810
+ ],
811
+ // selfhosted needs NO per-box credentials: it is the user's own machine reached
812
+ // over the agent's own enrollment. The enrollment-signing + relay-token secrets
813
+ // are deployment-level (a single runtime secret, not per-active-backend creds),
814
+ // wired in the connectivity/enrollment milestones (M4/M5), not here.
815
+ selfhosted: [],
816
+ };
817
+
818
+ /** The required OPENGENI_* env var names for a backend (for the deployment manifest). */
819
+ export function requiredSandboxEnvForBackend(backend: z.infer<typeof SandboxBackend>): string[] {
820
+ return (SANDBOX_REQUIRED_ENV[backend] ?? []).map((entry) => entry.env);
821
+ }
822
+
823
+ function optional(name: string): string | undefined {
824
+ const value = process.env[name];
825
+ return value && value.trim().length > 0 ? value : undefined;
826
+ }
827
+
828
+ export function getSettings(): Settings {
829
+ const raw = {
830
+ serviceName: optional("OPENGENI_SERVICE_NAME"),
831
+ environment: optional("OPENGENI_ENVIRONMENT"),
832
+ deploymentRevision: optional("OPENGENI_DEPLOYMENT_REVISION") ?? optional("SOURCE_VERSION") ?? optional("GITHUB_SHA"),
833
+ databaseUrl: optional("OPENGENI_DATABASE_URL"),
834
+ dbSchema: optional("OPENGENI_DB_SCHEMA"),
835
+ rlsStrategy: optional("OPENGENI_RLS_STRATEGY"),
836
+ natsUrl: optional("OPENGENI_NATS_URL"),
837
+ temporalHost: optional("OPENGENI_TEMPORAL_HOST"),
838
+ temporalNamespace: optional("OPENGENI_TEMPORAL_NAMESPACE"),
839
+ temporalTaskQueue: optional("OPENGENI_TEMPORAL_TASK_QUEUE"),
840
+ startupDependencyRetryAttempts: optional("OPENGENI_STARTUP_DEPENDENCY_RETRY_ATTEMPTS"),
841
+ startupDependencyRetryInitialDelayMs: optional("OPENGENI_STARTUP_DEPENDENCY_RETRY_INITIAL_DELAY_MS"),
842
+ startupDependencyRetryMaxDelayMs: optional("OPENGENI_STARTUP_DEPENDENCY_RETRY_MAX_DELAY_MS"),
843
+ observabilityStructuredLogs: optional("OPENGENI_OBSERVABILITY_STRUCTURED_LOGS"),
844
+ observabilityMetricsEnabled: optional("OPENGENI_OBSERVABILITY_METRICS_ENABLED"),
845
+ observabilityOtlpEndpoint: optional("OPENGENI_OTEL_EXPORTER_OTLP_ENDPOINT") ?? optional("OTEL_EXPORTER_OTLP_ENDPOINT"),
846
+ observabilityOtlpHeaders: optional("OPENGENI_OTEL_EXPORTER_OTLP_HEADERS") ?? optional("OTEL_EXPORTER_OTLP_HEADERS"),
847
+ publicBaseUrl: optional("OPENGENI_PUBLIC_BASE_URL"),
848
+ agentReleasesBaseUrl: optional("OPENGENI_AGENT_RELEASES_BASE_URL"),
849
+ productAccessMode: optional("OPENGENI_PRODUCT_ACCESS_MODE"),
850
+ billingMode: optional("OPENGENI_BILLING_MODE"),
851
+ entitlementsMode: optional("OPENGENI_ENTITLEMENTS_MODE"),
852
+ usageLimitsMode: optional("OPENGENI_USAGE_LIMITS_MODE"),
853
+ staticEntitlementsJson: optional("OPENGENI_STATIC_ENTITLEMENTS_JSON"),
854
+ staticUsageLimitsJson: optional("OPENGENI_STATIC_USAGE_LIMITS_JSON"),
855
+ delegationSecret: optional("OPENGENI_DELEGATION_SECRET"),
856
+ streamTokenSecret: optional("OPENGENI_STREAM_TOKEN_SECRET"),
857
+ streamControlEnabled: optional("OPENGENI_STREAM_CONTROL_ENABLED"),
858
+ environmentsEncryptionKey: optional("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY"),
859
+ goalMaxAutoContinuations: optional("OPENGENI_GOAL_MAX_AUTO_CONTINUATIONS"),
860
+ goalNoProgressLimit: optional("OPENGENI_GOAL_NO_PROGRESS_LIMIT"),
861
+ agentMaxModelCallsPerTurn: optional("OPENGENI_AGENT_MAX_MODEL_CALLS_PER_TURN"),
862
+ sessionHistorySource: optional("OPENGENI_SESSION_HISTORY_SOURCE"),
863
+ contextCompactionMode: optional("OPENGENI_CONTEXT_COMPACTION_MODE"),
864
+ contextWindowTokens: optional("OPENGENI_CONTEXT_WINDOW_TOKENS"),
865
+ contextReservedOutputTokens: optional("OPENGENI_CONTEXT_RESERVED_OUTPUT_TOKENS"),
866
+ contextServerCompactThresholdTokens: optional("OPENGENI_CONTEXT_SERVER_COMPACT_THRESHOLD_TOKENS"),
867
+ contextCompactSoftFraction: optional("OPENGENI_CONTEXT_COMPACT_SOFT_FRACTION"),
868
+ contextCompactHardFraction: optional("OPENGENI_CONTEXT_COMPACT_HARD_FRACTION"),
869
+ contextKeepRecentTokens: optional("OPENGENI_CONTEXT_KEEP_RECENT_TOKENS"),
870
+ contextSummaryMaxTokens: optional("OPENGENI_CONTEXT_SUMMARY_MAX_TOKENS"),
871
+ authRequired: optional("OPENGENI_AUTH_REQUIRED"),
872
+ accessKey: optional("OPENGENI_ACCESS_KEY"),
873
+ authAllowHealth: optional("OPENGENI_AUTH_ALLOW_HEALTH"),
874
+ authAllowMetrics: optional("OPENGENI_AUTH_ALLOW_METRICS"),
875
+ apiHost: optional("OPENGENI_API_HOST"),
876
+ apiPort: optional("OPENGENI_API_PORT"),
877
+ opengeniMcpUrl: optional("OPENGENI_MCP_URL"),
878
+ corsAllowOriginRegex: optional("OPENGENI_CORS_ALLOW_ORIGIN_REGEX"),
879
+ openaiProvider: optional("OPENGENI_OPENAI_PROVIDER"),
880
+ openaiApiKey: optional("OPENGENI_OPENAI_API_KEY") ?? optional("OPENAI_API_KEY"),
881
+ openaiBaseUrl: optional("OPENGENI_OPENAI_BASE_URL") ?? optional("OPENAI_BASE_URL"),
882
+ openaiModel: optional("OPENGENI_OPENAI_MODEL"),
883
+ openaiAllowedModels: optional("OPENGENI_OPENAI_ALLOWED_MODELS"),
884
+ modelPricingJson: optional("OPENGENI_MODEL_PRICING_JSON"),
885
+ modelProvidersJson: optional("OPENGENI_MODEL_PROVIDERS_JSON"),
886
+ codexSubscriptionEnabled: optional("OPENGENI_CODEX_SUBSCRIPTION_ENABLED"),
887
+ codexToolSearchEnabled: optional("OPENGENI_CODEX_TOOL_SEARCH_ENABLED"),
888
+ codexProductSku: optional("OPENGENI_CODEX_PRODUCT_SKU"),
889
+ codexRotationNearExhaustionPct: optional("OPENGENI_CODEX_ROTATION_NEAR_EXHAUSTION_PCT"),
890
+ openaiReasoningEffort: optional("OPENGENI_OPENAI_REASONING_EFFORT"),
891
+ openaiAllowedReasoningEfforts: optional("OPENGENI_OPENAI_ALLOWED_REASONING_EFFORTS"),
892
+ openaiResponsesTransport: optional("OPENGENI_OPENAI_RESPONSES_TRANSPORT"),
893
+ openaiProviderItemIds: optional("OPENGENI_OPENAI_PROVIDER_ITEM_IDS"),
894
+ openaiReasoningEncryptedContent: optional("OPENGENI_OPENAI_REASONING_ENCRYPTED_CONTENT"),
895
+ openaiMaxRetries: optional("OPENGENI_OPENAI_MAX_RETRIES"),
896
+ webSearchEnabled: optional("OPENGENI_WEB_SEARCH_ENABLED"),
897
+ agentInstructionsTemplate: optional("OPENGENI_AGENT_INSTRUCTIONS_TEMPLATE"),
898
+ azureOpenaiBaseUrl: optional("OPENGENI_AZURE_OPENAI_BASE_URL"),
899
+ azureOpenaiEndpoint: optional("OPENGENI_AZURE_OPENAI_ENDPOINT"),
900
+ azureOpenaiDeployment: optional("OPENGENI_AZURE_OPENAI_DEPLOYMENT"),
901
+ azureOpenaiApiVersion: optional("OPENGENI_AZURE_OPENAI_API_VERSION"),
902
+ azureOpenaiApiKey: optional("OPENGENI_AZURE_OPENAI_API_KEY"),
903
+ azureOpenaiAdToken: optional("OPENGENI_AZURE_OPENAI_AD_TOKEN"),
904
+ disableOpenaiTracing: optional("OPENGENI_DISABLE_OPENAI_TRACING"),
905
+ sandboxBackend: optional("OPENGENI_SANDBOX_BACKEND"),
906
+ dockerImage: optional("OPENGENI_DOCKER_IMAGE"),
907
+ dockerExposedPorts: optional("OPENGENI_DOCKER_EXPOSED_PORTS"),
908
+ dockerNetwork: optional("OPENGENI_DOCKER_NETWORK"),
909
+ modalAppName: optional("OPENGENI_MODAL_APP_NAME"),
910
+ modalImageRef: optional("OPENGENI_MODAL_IMAGE_REF"),
911
+ modalTimeoutSeconds: optional("OPENGENI_MODAL_TIMEOUT_SECONDS"),
912
+ modalTokenId: optional("OPENGENI_MODAL_TOKEN_ID"),
913
+ modalTokenSecret: optional("OPENGENI_MODAL_TOKEN_SECRET"),
914
+ modalEnvironment: optional("OPENGENI_MODAL_ENVIRONMENT"),
915
+ modalIdleTimeoutSeconds: optional("OPENGENI_MODAL_IDLE_TIMEOUT_SECONDS"),
916
+ modalWorkspacePersistence: optional("OPENGENI_MODAL_WORKSPACE_PERSISTENCE"),
917
+ modalSnapshotRetentionSeconds: optional("OPENGENI_MODAL_SNAPSHOT_RETENTION_SECONDS"),
918
+ sandboxDesktopEnabled: optional("OPENGENI_SANDBOX_DESKTOP_ENABLED"),
919
+ sandboxDesktopInteractive: optional("OPENGENI_SANDBOX_DESKTOP_INTERACTIVE"),
920
+ sandboxTerminalEnabled: optional("OPENGENI_SANDBOX_TERMINAL_ENABLED"),
921
+ streamResolutionWidth: optional("OPENGENI_STREAM_RESOLUTION_WIDTH"),
922
+ streamResolutionHeight: optional("OPENGENI_STREAM_RESOLUTION_HEIGHT"),
923
+ computerUseEnabled: optional("OPENGENI_COMPUTER_USE_ENABLED"),
924
+ computerUseReadOnly: optional("OPENGENI_COMPUTER_USE_READONLY"),
925
+ recordingEnabled: optional("OPENGENI_RECORDING_ENABLED"),
926
+ recordingDefaultCodec: optional("OPENGENI_RECORDING_DEFAULT_CODEC"),
927
+ recordingFramerate: optional("OPENGENI_RECORDING_FRAMERATE"),
928
+ recordingMaxSeconds: optional("OPENGENI_RECORDING_MAX_SECONDS"),
929
+ recordingMaxBytes: optional("OPENGENI_RECORDING_MAX_BYTES"),
930
+ daytonaApiKey: optional("OPENGENI_DAYTONA_API_KEY"),
931
+ daytonaApiUrl: optional("OPENGENI_DAYTONA_API_URL"),
932
+ daytonaTarget: optional("OPENGENI_DAYTONA_TARGET"),
933
+ daytonaImage: optional("OPENGENI_DAYTONA_IMAGE"),
934
+ daytonaSnapshotName: optional("OPENGENI_DAYTONA_SNAPSHOT_NAME"),
935
+ daytonaAutoStopInterval: optional("OPENGENI_DAYTONA_AUTO_STOP_INTERVAL"),
936
+ daytonaTimeoutSeconds: optional("OPENGENI_DAYTONA_TIMEOUT_SECONDS"),
937
+ daytonaExposedPortUrlTtlSeconds: optional("OPENGENI_DAYTONA_EXPOSED_PORT_URL_TTL_SECONDS"),
938
+ runloopApiKey: optional("OPENGENI_RUNLOOP_API_KEY"),
939
+ runloopBaseUrl: optional("OPENGENI_RUNLOOP_BASE_URL"),
940
+ runloopBlueprintName: optional("OPENGENI_RUNLOOP_BLUEPRINT_NAME"),
941
+ runloopBlueprintId: optional("OPENGENI_RUNLOOP_BLUEPRINT_ID"),
942
+ runloopTunnel: optional("OPENGENI_RUNLOOP_TUNNEL"),
943
+ runloopKeepAliveSeconds: optional("OPENGENI_RUNLOOP_KEEP_ALIVE_SECONDS"),
944
+ e2bApiKey: optional("OPENGENI_E2B_API_KEY"),
945
+ e2bTemplate: optional("OPENGENI_E2B_TEMPLATE"),
946
+ e2bTimeoutSeconds: optional("OPENGENI_E2B_TIMEOUT_SECONDS"),
947
+ e2bTimeoutAction: optional("OPENGENI_E2B_TIMEOUT_ACTION"),
948
+ e2bAllowInternetAccess: optional("OPENGENI_E2B_ALLOW_INTERNET_ACCESS"),
949
+ e2bAutoResume: optional("OPENGENI_E2B_AUTO_RESUME"),
950
+ e2bWorkspacePersistence: optional("OPENGENI_E2B_WORKSPACE_PERSISTENCE"),
951
+ blaxelApiKey: optional("OPENGENI_BLAXEL_API_KEY"),
952
+ blaxelImage: optional("OPENGENI_BLAXEL_IMAGE"),
953
+ blaxelRegion: optional("OPENGENI_BLAXEL_REGION"),
954
+ blaxelExposedPortPublic: optional("OPENGENI_BLAXEL_EXPOSED_PORT_PUBLIC"),
955
+ blaxelExposedPortUrlTtlSeconds: optional("OPENGENI_BLAXEL_EXPOSED_PORT_URL_TTL_SECONDS"),
956
+ blaxelMemoryMb: optional("OPENGENI_BLAXEL_MEMORY_MB"),
957
+ blaxelTtl: optional("OPENGENI_BLAXEL_TTL"),
958
+ cloudflareWorkerUrl: optional("OPENGENI_CLOUDFLARE_WORKER_URL"),
959
+ cloudflareApiKey: optional("OPENGENI_CLOUDFLARE_API_KEY"),
960
+ vercelToken: optional("OPENGENI_VERCEL_TOKEN"),
961
+ vercelProjectId: optional("OPENGENI_VERCEL_PROJECT_ID"),
962
+ vercelTeamId: optional("OPENGENI_VERCEL_TEAM_ID"),
963
+ vercelRuntime: optional("OPENGENI_VERCEL_RUNTIME"),
964
+ sandboxOwnershipEnabled: optional("OPENGENI_SANDBOX_OWNERSHIP_ENABLED"),
965
+ sandboxSelfhostedEnabled: optional("OPENGENI_SANDBOX_SELFHOSTED_ENABLED"),
966
+ enrollmentSigningSecret: optional("OPENGENI_ENROLLMENT_SIGNING_SECRET"),
967
+ selfhostedNatsUrl: optional("OPENGENI_SELFHOSTED_NATS_URL"),
968
+ selfhostedRelayUrl: optional("OPENGENI_SELFHOSTED_RELAY_URL"),
969
+ selfhostedRelayTokenSecret: optional("OPENGENI_SELFHOSTED_RELAY_TOKEN_SECRET"),
970
+ agentUpdatePublicKey: optional("OPENGENI_AGENT_UPDATE_PUBLIC_KEY"),
971
+ selfhostedNatsCalloutAccountSeed: optional("OPENGENI_SELFHOSTED_NATS_CALLOUT_ACCOUNT_SEED"),
972
+ selfhostedNatsCalloutAccountName: optional("OPENGENI_SELFHOSTED_NATS_CALLOUT_ACCOUNT_NAME"),
973
+ selfhostedNatsCalloutUser: optional("OPENGENI_SELFHOSTED_NATS_CALLOUT_USER"),
974
+ selfhostedNatsCalloutPassword: optional("OPENGENI_SELFHOSTED_NATS_CALLOUT_PASSWORD"),
975
+ selfhostedNatsControlUser: optional("OPENGENI_SELFHOSTED_NATS_CONTROL_USER"),
976
+ selfhostedNatsControlPassword: optional("OPENGENI_SELFHOSTED_NATS_CONTROL_PASSWORD"),
977
+ sandboxLeaseReaperPeriodMs: optional("OPENGENI_SANDBOX_LEASE_REAPER_PERIOD_MS"),
978
+ sandboxViewerHolderTtlMs: optional("OPENGENI_SANDBOX_VIEWER_HOLDER_TTL_MS"),
979
+ sandboxIdleGraceMs: optional("OPENGENI_SANDBOX_IDLE_GRACE_MS"),
980
+ sandboxLeaseTtlMs: optional("OPENGENI_SANDBOX_LEASE_TTL_MS"),
981
+ sandboxLeaseWarmingTtlMs: optional("OPENGENI_SANDBOX_LEASE_WARMING_TTL_MS"),
982
+ sandboxWarmRateMicrosPerSecondJson: optional("OPENGENI_SANDBOX_WARM_RATE_MICROS_PER_SECOND_JSON"),
983
+ sandboxMaxWarmSecondsPerWorkspace: optional("OPENGENI_SANDBOX_MAX_WARM_SECONDS_PER_WORKSPACE"),
984
+ sandboxPreparationProfiles: optional("OPENGENI_SANDBOX_PREPARATION_PROFILES"),
985
+ sandboxEnvAllowlist: optional("OPENGENI_SANDBOX_ENV_ALLOWLIST"),
986
+ objectStorageEndpoint: optional("OPENGENI_OBJECT_STORAGE_ENDPOINT"),
987
+ objectStorageSandboxEndpoint: optional("OPENGENI_OBJECT_STORAGE_SANDBOX_ENDPOINT"),
988
+ objectStorageBackend: optional("OPENGENI_OBJECT_STORAGE_BACKEND"),
989
+ objectStorageBucket: optional("OPENGENI_OBJECT_STORAGE_BUCKET"),
990
+ objectStorageRegion: optional("OPENGENI_OBJECT_STORAGE_REGION"),
991
+ objectStorageS3Provider: optional("OPENGENI_OBJECT_STORAGE_S3_PROVIDER"),
992
+ objectStorageAccessKeyId: optional("OPENGENI_OBJECT_STORAGE_ACCESS_KEY_ID"),
993
+ objectStorageSecretAccessKey: optional("OPENGENI_OBJECT_STORAGE_SECRET_ACCESS_KEY"),
994
+ objectStorageForcePathStyle: optional("OPENGENI_OBJECT_STORAGE_FORCE_PATH_STYLE"),
995
+ objectStorageAzureConnectionString: optional("OPENGENI_OBJECT_STORAGE_AZURE_CONNECTION_STRING"),
996
+ objectStorageAzureAccountName: optional("OPENGENI_OBJECT_STORAGE_AZURE_ACCOUNT_NAME"),
997
+ objectStorageAzureAccountKey: optional("OPENGENI_OBJECT_STORAGE_AZURE_ACCOUNT_KEY"),
998
+ objectStorageAzureEndpoint: optional("OPENGENI_OBJECT_STORAGE_AZURE_ENDPOINT"),
999
+ objectStorageGcsProjectId: optional("OPENGENI_OBJECT_STORAGE_GCS_PROJECT_ID"),
1000
+ objectStorageGcsCredentialsJson: optional("OPENGENI_OBJECT_STORAGE_GCS_CREDENTIALS_JSON"),
1001
+ objectStorageGcsKeyFilename: optional("OPENGENI_OBJECT_STORAGE_GCS_KEY_FILENAME"),
1002
+ objectStorageGcsApiEndpoint: optional("OPENGENI_OBJECT_STORAGE_GCS_API_ENDPOINT"),
1003
+ documentParser: optional("OPENGENI_DOCUMENT_PARSER"),
1004
+ documentChunkSize: optional("OPENGENI_DOCUMENT_CHUNK_SIZE"),
1005
+ documentChunkOverlap: optional("OPENGENI_DOCUMENT_CHUNK_OVERLAP"),
1006
+ documentEmbeddingProvider: optional("OPENGENI_DOCUMENT_EMBEDDING_PROVIDER"),
1007
+ documentEmbeddingModel: optional("OPENGENI_DOCUMENT_EMBEDDING_MODEL"),
1008
+ documentEmbeddingDimensions: optional("OPENGENI_DOCUMENT_EMBEDDING_DIMENSIONS"),
1009
+ documentEmbeddingApiKey: optional("OPENGENI_DOCUMENT_EMBEDDING_API_KEY"),
1010
+ documentEmbeddingBaseUrl: optional("OPENGENI_DOCUMENT_EMBEDDING_BASE_URL"),
1011
+ gitAuthorName: optional("OPENGENI_GIT_AUTHOR_NAME"),
1012
+ gitAuthorEmail: optional("OPENGENI_GIT_AUTHOR_EMAIL"),
1013
+ gitCommitterName: optional("OPENGENI_GIT_COMMITTER_NAME"),
1014
+ gitCommitterEmail: optional("OPENGENI_GIT_COMMITTER_EMAIL"),
1015
+ githubAppManifestBaseUrl: optional("OPENGENI_GITHUB_APP_MANIFEST_BASE_URL"),
1016
+ githubAppManifestStateSecret: optional("OPENGENI_GITHUB_APP_MANIFEST_STATE_SECRET"),
1017
+ githubAppId: optional("OPENGENI_GITHUB_APP_ID"),
1018
+ githubClientId: optional("OPENGENI_GITHUB_CLIENT_ID"),
1019
+ githubClientSecret: optional("OPENGENI_GITHUB_CLIENT_SECRET"),
1020
+ githubAppSlug: optional("OPENGENI_GITHUB_APP_SLUG"),
1021
+ githubWebhookSecret: optional("OPENGENI_GITHUB_WEBHOOK_SECRET"),
1022
+ githubAppPrivateKey: optional("OPENGENI_GITHUB_APP_PRIVATE_KEY"),
1023
+ betterAuthSecret: optional("OPENGENI_BETTER_AUTH_SECRET"),
1024
+ betterAuthAllowedHosts: optional("OPENGENI_BETTER_AUTH_ALLOWED_HOSTS"),
1025
+ betterAuthCookieDomain: optional("OPENGENI_BETTER_AUTH_COOKIE_DOMAIN"),
1026
+ betterAuthTrustedOrigins: optional("OPENGENI_BETTER_AUTH_TRUSTED_ORIGINS"),
1027
+ resendApiKey: optional("OPENGENI_RESEND_API_KEY"),
1028
+ emailFrom: optional("OPENGENI_EMAIL_FROM"),
1029
+ stripeSecretKey: optional("OPENGENI_STRIPE_SECRET_KEY"),
1030
+ stripePublishableKey: optional("OPENGENI_STRIPE_PUBLISHABLE_KEY"),
1031
+ stripeWebhookSecret: optional("OPENGENI_STRIPE_WEBHOOK_SECRET"),
1032
+ stripeCreditsProductId: optional("OPENGENI_STRIPE_CREDITS_PRODUCT_ID"),
1033
+ mcpServers: parseMcpServers(optional("OPENGENI_MCP_SERVERS")),
1034
+ };
1035
+ const parsed = SettingsSchema.parse(raw);
1036
+ const settings = {
1037
+ ...parsed,
1038
+ mcpServers: ensureBuiltInMcpServers(parsed),
1039
+ };
1040
+ validateSettings(settings);
1041
+ return settings;
1042
+ }
1043
+
1044
+ /**
1045
+ * The Modal sandbox idle timeout (seconds) the provider actually passes as
1046
+ * idleTimeoutMs (sandbox-file-persistence). When the operator did not pin
1047
+ * OPENGENI_MODAL_IDLE_TIMEOUT_SECONDS we DEFAULT it to the hard lifetime
1048
+ * (modalTimeoutSeconds): OpenGeni's reaper owns box lifecycle, so Modal's
1049
+ * built-in idle-reap (which would otherwise fire on its short server default and
1050
+ * kill the box BEFORE the reaper can snapshot /workspace) is pushed out to the
1051
+ * hard backstop. An explicit smaller value is honoured (the boot invariant keeps
1052
+ * it above reaperPeriod + idleGrace so a drained box still survives long enough
1053
+ * to be persisted).
1054
+ */
1055
+ export function effectiveModalIdleTimeoutSeconds(settings: Settings): number {
1056
+ return settings.modalIdleTimeoutSeconds ?? settings.modalTimeoutSeconds;
1057
+ }
1058
+
1059
+ export function collectSandboxEnvironment(settings: Settings, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
1060
+ const out: Record<string, string> = {};
1061
+ for (const name of sandboxEnvironmentVariableNames(settings)) {
1062
+ const value = source[name];
1063
+ if (value) {
1064
+ out[name] = value;
1065
+ }
1066
+ }
1067
+ return out;
1068
+ }
1069
+
1070
+ /**
1071
+ * Resolved API key for a registry provider: the inline `apiKey` when present,
1072
+ * else the value of the env var named by `apiKeyEnv`. The preferred form is
1073
+ * `apiKeyEnv` (the secret stays out of OPENGENI_MODEL_PROVIDERS_JSON). Reads
1074
+ * from `source` (defaults to process.env) so callers can resolve against an
1075
+ * explicit environment in tests.
1076
+ */
1077
+ export function resolveProviderApiKey(
1078
+ provider: Pick<RegistryProvider, "apiKey" | "apiKeyEnv">,
1079
+ source: NodeJS.ProcessEnv = process.env,
1080
+ ): string | undefined {
1081
+ if (provider.apiKey) {
1082
+ return provider.apiKey;
1083
+ }
1084
+ if (provider.apiKeyEnv) {
1085
+ const value = source[provider.apiKeyEnv];
1086
+ return value && value.trim().length > 0 ? value : undefined;
1087
+ }
1088
+ return undefined;
1089
+ }
1090
+
1091
+ /** The built-in provider's stable id: "openai" on the OpenAI platform, "azure" on Azure. */
1092
+ function builtinProviderId(settings: Pick<Settings, "openaiProvider">): string {
1093
+ return settings.openaiProvider === "azure" ? "azure" : "openai";
1094
+ }
1095
+
1096
+ function builtinProviderLabel(settings: Pick<Settings, "openaiProvider">): string {
1097
+ return settings.openaiProvider === "azure" ? "Azure OpenAI" : "OpenAI";
1098
+ }
1099
+
1100
+ /**
1101
+ * Every provider a client may route to: the built-in OpenAI/Azure provider
1102
+ * first (id "openai"/"azure", always "responses", compactionMode from
1103
+ * resolveContextCompactionMode), then each registry provider in declaration
1104
+ * order (compactionMode "client"). Client-construction inputs are filled from
1105
+ * the existing flat openai/azure settings for the built-in, and from the
1106
+ * registry entry for the rest. Registry ids may not collide with the built-in
1107
+ * id — validateSettings rejects that at boot.
1108
+ */
1109
+ export function configuredProviders(settings: Settings): ResolvedModelProvider[] {
1110
+ const builtin: ResolvedModelProvider = {
1111
+ id: builtinProviderId(settings),
1112
+ label: builtinProviderLabel(settings),
1113
+ kind: "api-key",
1114
+ api: "responses",
1115
+ builtin: true,
1116
+ compactionMode: resolveContextCompactionMode(settings),
1117
+ };
1118
+ if (settings.openaiProvider === "azure") {
1119
+ builtin.baseUrl = settings.azureOpenaiBaseUrl ?? settings.azureOpenaiEndpoint;
1120
+ builtin.apiKey = settings.azureOpenaiApiKey ?? settings.azureOpenaiAdToken;
1121
+ } else {
1122
+ builtin.baseUrl = settings.openaiBaseUrl;
1123
+ builtin.apiKey = settings.openaiApiKey;
1124
+ }
1125
+ const registry = parseModelProvidersJson(settings.modelProvidersJson).map((provider): ResolvedModelProvider => ({
1126
+ id: provider.id,
1127
+ label: provider.label ?? provider.id,
1128
+ kind: provider.kind,
1129
+ api: provider.api,
1130
+ builtin: false,
1131
+ baseUrl: provider.baseUrl,
1132
+ apiKey: resolveProviderApiKey(provider),
1133
+ defaultQuery: provider.defaultQuery,
1134
+ defaultHeaders: provider.defaultHeaders,
1135
+ compactionMode: "client",
1136
+ }));
1137
+ return [builtin, ...registry];
1138
+ }
1139
+
1140
+ /**
1141
+ * Every model a client may use, the built-in provider's models first
1142
+ * (configuredAllowedModels-from-openai, mapped to "responses" with
1143
+ * hostedWebSearch/contextWindow/reasoningEffort from the flat settings), then
1144
+ * each registry provider's models (label→id, hostedWebSearch/reasoningEffort
1145
+ * default false). De-duplicated by id (first wins) so the default model stays
1146
+ * first and the built-in allow-list takes precedence over registry entries.
1147
+ */
1148
+ export function configuredModels(settings: Settings): ConfiguredModel[] {
1149
+ const builtinId = builtinProviderId(settings);
1150
+ const builtinLabel = builtinProviderLabel(settings);
1151
+ // The built-in (OpenAI/Azure) provider must NEVER claim a registry-namespaced
1152
+ // model id. The worker overwrites settings.openaiModel with the turn's model
1153
+ // (apps/worker agent-turn runSettings) — including a `codex/<slug>` id, or a
1154
+ // registry id like "accounts/fireworks/models/glm-5p2" — so without this
1155
+ // filter the built-in allow-list would emit a `{ id, providerId: <azure> }`
1156
+ // entry that, by the first-wins de-dup below, shadows the real registry /
1157
+ // codex-subscription provider and ships the id to Azure as a deployment name
1158
+ // (opaque DeploymentNotFound 404). A `<provider>/<model>`-namespaced id (it
1159
+ // contains "/") that a registry actually owns is never a valid Azure/OpenAI
1160
+ // deployment name, and a `codex/`-prefixed id never is either — exclude both
1161
+ // from the built-in list. A BARE id a registry merely redeclares (e.g.
1162
+ // "gpt-5.5") is left in place so the built-in still wins it via the first-wins
1163
+ // de-dup below (preserving the documented built-in-precedence contract). When
1164
+ // a codex/ id has NO codex provider injected (no active subscription) it then
1165
+ // resolves to nothing and getModel fails loud with
1166
+ // CodexSubscriptionUnavailableError instead of mis-routing to Azure.
1167
+ const registryOwnedIds = new Set(
1168
+ parseModelProvidersJson(settings.modelProvidersJson).flatMap((provider) => provider.models.map((model) => model.id)),
1169
+ );
1170
+ const isRegistryNamespaced = (id: string): boolean =>
1171
+ id.startsWith(CODEX_MODEL_ID_PREFIX) || (id.includes("/") && registryOwnedIds.has(id));
1172
+ const out: ConfiguredModel[] = uniqueValues([settings.openaiModel, ...splitCsv(settings.openaiAllowedModels)])
1173
+ .filter((id) => !isRegistryNamespaced(id))
1174
+ .map((id) => ({
1175
+ id,
1176
+ label: id,
1177
+ providerId: builtinId,
1178
+ providerLabel: builtinLabel,
1179
+ api: "responses" as const,
1180
+ contextWindowTokens: settings.contextWindowTokens,
1181
+ reasoningEffort: true,
1182
+ hostedWebSearch: settings.webSearchEnabled,
1183
+ }));
1184
+ for (const provider of parseModelProvidersJson(settings.modelProvidersJson)) {
1185
+ const providerLabel = provider.label ?? provider.id;
1186
+ for (const model of provider.models) {
1187
+ out.push({
1188
+ id: model.id,
1189
+ label: model.label ?? model.id,
1190
+ providerId: provider.id,
1191
+ providerLabel,
1192
+ api: provider.api,
1193
+ ...(model.contextWindowTokens === undefined ? {} : { contextWindowTokens: model.contextWindowTokens }),
1194
+ reasoningEffort: model.reasoningEffort ?? false,
1195
+ hostedWebSearch: model.hostedWebSearch ?? false,
1196
+ });
1197
+ }
1198
+ }
1199
+ const seen = new Set<string>();
1200
+ return out.filter((model) => {
1201
+ if (seen.has(model.id)) {
1202
+ return false;
1203
+ }
1204
+ seen.add(model.id);
1205
+ return true;
1206
+ });
1207
+ }
1208
+
1209
+ /**
1210
+ * Allowed model ids in selection order. Reimplemented on top of
1211
+ * configuredModels so it is the union of the built-in allow-list and every
1212
+ * registry provider's ids, de-duplicated. INVARIANT (existing callers + tests
1213
+ * depend on it): settings.openaiModel is always first, then the rest of the
1214
+ * openai allow-list, then registry ids.
1215
+ */
1216
+ export function configuredAllowedModels(settings: Settings): string[] {
1217
+ return configuredModels(settings).map((model) => model.id);
1218
+ }
1219
+
1220
+ /**
1221
+ * Resolve a model string to the provider that serves it and its configured
1222
+ * shape. Returns undefined when the id is not exposed (built-in allow-list nor
1223
+ * any registry provider), so the runtime can fall back to the legacy global
1224
+ * client path.
1225
+ */
1226
+ export function resolveModelProvider(
1227
+ settings: Settings,
1228
+ modelId: string,
1229
+ ): { provider: ResolvedModelProvider; model: ConfiguredModel } | undefined {
1230
+ const model = configuredModels(settings).find((candidate) => candidate.id === modelId);
1231
+ if (!model) {
1232
+ return undefined;
1233
+ }
1234
+ const provider = configuredProviders(settings).find((candidate) => candidate.id === model.providerId);
1235
+ if (!provider) {
1236
+ return undefined;
1237
+ }
1238
+ return { provider, model };
1239
+ }
1240
+
1241
+ /**
1242
+ * Effective per-model pricing. Merge order (later wins):
1243
+ * defaultModelPricing → registry model `pricing` entries (keyed by model id)
1244
+ * → parseModelPricingJson(settings.modelPricingJson) (explicit JSON wins).
1245
+ */
1246
+ export function configuredModelPricing(settings: Settings): Record<string, ModelPricing> {
1247
+ const registry: Record<string, ModelPricing> = {};
1248
+ for (const provider of parseModelProvidersJson(settings.modelProvidersJson)) {
1249
+ for (const model of provider.models) {
1250
+ if (model.pricing) {
1251
+ registry[model.id] = model.pricing;
1252
+ }
1253
+ }
1254
+ }
1255
+ const configured = parseModelPricingJson(settings.modelPricingJson);
1256
+ return {
1257
+ ...defaultModelPricing,
1258
+ ...registry,
1259
+ ...configured,
1260
+ };
1261
+ }
1262
+
1263
+ /**
1264
+ * Resolved conversation-context compaction path for a run.
1265
+ * - "server": let the OpenAI platform Responses API compact server-side (the
1266
+ * SDK emits context_management; we pass the correct gpt-5.5 threshold).
1267
+ * - "client": run OpenGeni's own client-side compaction (Azure and any other
1268
+ * backend that rejects/ignores context_management).
1269
+ * - "off": neither (legacy unbounded growth; escape hatch).
1270
+ *
1271
+ * "auto" maps to "server" on the OpenAI platform provider and "client"
1272
+ * otherwise — Azure's Responses API returns 400 unsupported_parameter for
1273
+ * context_management, so it must never take the server path.
1274
+ */
1275
+ export type ContextCompactionMode = "server" | "client" | "off";
1276
+
1277
+ export function resolveContextCompactionMode(settings: Pick<Settings, "contextCompactionMode" | "openaiProvider">): ContextCompactionMode {
1278
+ switch (settings.contextCompactionMode) {
1279
+ case "server":
1280
+ return "server";
1281
+ case "client":
1282
+ return "client";
1283
+ case "off":
1284
+ return "off";
1285
+ case "auto":
1286
+ default:
1287
+ return settings.openaiProvider === "openai" ? "server" : "client";
1288
+ }
1289
+ }
1290
+
1291
+ /** Usable input-token budget B = window - reserved output. */
1292
+ export function contextInputBudgetTokens(settings: Pick<Settings, "contextWindowTokens" | "contextReservedOutputTokens">): number {
1293
+ return Math.max(0, settings.contextWindowTokens - settings.contextReservedOutputTokens);
1294
+ }
1295
+
1296
+ /**
1297
+ * Server-path compact_threshold (tokens) handed to the SDK's
1298
+ * StaticCompactionPolicy: the explicit override when set, else
1299
+ * floor(B * softFraction). This is what sidesteps the SDK's wrong 240k
1300
+ * fallback for gpt-5.5 (which is absent from its hardcoded window map).
1301
+ */
1302
+ export function contextServerCompactThreshold(settings: Pick<Settings, "contextWindowTokens" | "contextReservedOutputTokens" | "contextServerCompactThresholdTokens" | "contextCompactSoftFraction">): number {
1303
+ if (settings.contextServerCompactThresholdTokens) {
1304
+ return settings.contextServerCompactThresholdTokens;
1305
+ }
1306
+ return Math.floor(contextInputBudgetTokens(settings) * settings.contextCompactSoftFraction);
1307
+ }
1308
+
1309
+ export function configuredStaticUsageLimits(settings: Settings): StaticUsageLimitsConfig {
1310
+ return parseStaticUsageLimitsJson(settings.staticUsageLimitsJson);
1311
+ }
1312
+
1313
+ export function configuredEntitlements(settings: Settings): EntitlementsConfig {
1314
+ if (settings.entitlementsMode === "none") {
1315
+ return {};
1316
+ }
1317
+ const configured = parseStaticEntitlementsJson(settings.staticEntitlementsJson);
1318
+ if (settings.entitlementsMode === "static") {
1319
+ return configured;
1320
+ }
1321
+ return {
1322
+ "managed.auth.email_password": true,
1323
+ "managed.billing.prepaid_credits": settings.billingMode === "stripe",
1324
+ "managed.api_keys": true,
1325
+ "managed.workspaces": true,
1326
+ "managed.github_app": Boolean(settings.githubAppId && settings.githubAppPrivateKey),
1327
+ ...configured,
1328
+ };
1329
+ }
1330
+
1331
+ export function calculateModelUsageCostMicros(settings: Settings, model: string, usage: ModelUsageInput): number {
1332
+ const pricing = configuredModelPricing(settings)[model];
1333
+ if (!pricing) {
1334
+ throw new Error(`Missing model pricing for ${model}`);
1335
+ }
1336
+ const entries = usage.requestUsageEntries && usage.requestUsageEntries.length > 0 ? usage.requestUsageEntries : [usage];
1337
+ const rawCost = entries.reduce((sum, entry) => sum + calculateEntryCostMicros(pricing, entry), 0);
1338
+ const marginBps = pricing.marginBps ?? 0;
1339
+ return Math.ceil(rawCost * (10_000 + marginBps) / 10_000);
1340
+ }
1341
+
1342
+ export function configuredAllowedReasoningEfforts(settings: Settings): Array<z.infer<typeof ReasoningEffort>> {
1343
+ return uniqueValues([settings.openaiReasoningEffort, ...splitCsv(settings.openaiAllowedReasoningEfforts)])
1344
+ .map((value) => ReasoningEffort.parse(value));
1345
+ }
1346
+
1347
+ /**
1348
+ * Decodes OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY (base64, exactly 32 bytes) for
1349
+ * AES-256-GCM workspace environment value encryption. Returns null when unset.
1350
+ * Throws naming only the env var, never echoing its value.
1351
+ */
1352
+ export function environmentsEncryptionKeyBytes(settings: Settings): Uint8Array | null {
1353
+ if (!settings.environmentsEncryptionKey) {
1354
+ return null;
1355
+ }
1356
+ const decoded = Buffer.from(settings.environmentsEncryptionKey, "base64");
1357
+ if (decoded.length !== 32) {
1358
+ throw new Error("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY must be base64 for exactly 32 bytes (generate with: openssl rand -base64 32)");
1359
+ }
1360
+ return new Uint8Array(decoded);
1361
+ }
1362
+
1363
+ /**
1364
+ * The connection `search_path` for OpenGeni's db handles + the managed-auth pool
1365
+ * (Step I, §7.8 runtime half). Returns `undefined` when `dbSchema` is unset
1366
+ * (standalone) so no `search_path` startup parameter is sent and the server
1367
+ * default (`public`) applies — byte-for-byte today's behavior. When `dbSchema`
1368
+ * is set (embedded), returns `"<schema>,opengeni_private,public"` — `public`
1369
+ * stays LAST so `gen_random_uuid()` (pgcrypto) and the `vector` type still
1370
+ * resolve (the SPIKE-1 live footgun). `opengeni_private` is on the path so the
1371
+ * RLS GUC-reader helpers resolve when referenced unqualified.
1372
+ */
1373
+ export function dbSearchPath(settings: Pick<Settings, "dbSchema">): string | undefined {
1374
+ const schema = settings.dbSchema?.trim();
1375
+ if (!schema) {
1376
+ return undefined;
1377
+ }
1378
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) {
1379
+ throw new Error(`OPENGENI_DB_SCHEMA is not a valid Postgres identifier: ${schema}`);
1380
+ }
1381
+ return `${schema},opengeni_private,public`;
1382
+ }
1383
+
1384
+ export function collectGitIdentityEnvironment(settings: Settings): Record<string, string> {
1385
+ return Object.fromEntries(Object.entries({
1386
+ GIT_AUTHOR_NAME: settings.gitAuthorName,
1387
+ GIT_AUTHOR_EMAIL: settings.gitAuthorEmail,
1388
+ GIT_COMMITTER_NAME: settings.gitCommitterName ?? settings.gitAuthorName,
1389
+ GIT_COMMITTER_EMAIL: settings.gitCommitterEmail ?? settings.gitAuthorEmail,
1390
+ }).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0));
1391
+ }
1392
+
1393
+ /**
1394
+ * The STABLE run-scoped sandbox environment: the subset of a run's box-manifest
1395
+ * environment that is IDENTICAL whether the box is first warmed by the worker
1396
+ * TURN or by an API-direct ATTACH (viewer / Channel-A / desktop / terminal). It
1397
+ * is the layered base every cold box must be created with so a later turn's
1398
+ * agent-manifest apply finds an EMPTY environment delta in the SDK's
1399
+ * `validateNoEnvironmentDelta` (which throws "Live sandbox sessions cannot change
1400
+ * manifest environment variables" on ANY key the agent declares that the box's
1401
+ * manifest lacks or carries a different value for).
1402
+ *
1403
+ * Precedence (lowest → highest): deployment allowlist (`collectSandboxEnvironment`)
1404
+ * < git identity (`collectGitIdentityEnvironment`) < the session's attached
1405
+ * workspace environment < the backend-aware HOME default. Reserved-name validation
1406
+ * at write time keeps workspace values from colliding with platform entries.
1407
+ *
1408
+ * DELIBERATELY EXCLUDES the per-run, ROTATING GitHub App installation token
1409
+ * VALUE that `sandboxEnvironmentForRun` mints when a repository resource is
1410
+ * attached: that token is minted FRESH per call, so it is not a stable, attach-
1411
+ * reproducible value and must not be part of the shared base. Under the token-
1412
+ * broker (B1) the token VALUE never rides the manifest at all — it is seeded to a
1413
+ * FILE inside the box (agent-managed, refreshable mid-turn via the `github_token`
1414
+ * MCP tool) and git auth flows through GIT_ASKPASS -> that file. What IS stable and
1415
+ * lives here is the token FILE PATH (`OPENGENI_GIT_TOKEN_FILE`): a constant derived
1416
+ * from HOME, so it appears IDENTICALLY on BOTH the turn AND every attach manifest
1417
+ * (the SDK's per-turn provided-session env delta stays empty even as the token
1418
+ * rotates). The attach surfaces have only the `Session` (no repo resources) and so
1419
+ * never seed a token, but the file-path pointer is harmless (an unwritten file
1420
+ * simply yields no auth); the BLOCKING attach-vs-turn error this helper fixes is
1421
+ * for the common (no-repo) and workspace-environment-attached cases.
1422
+ */
1423
+ export function stableSandboxEnvironmentForRun(
1424
+ settings: Settings,
1425
+ workspaceEnvironment: Record<string, string> = {},
1426
+ ): Record<string, string> {
1427
+ const environment: Record<string, string> = {
1428
+ ...collectSandboxEnvironment(settings),
1429
+ ...collectGitIdentityEnvironment(settings),
1430
+ ...workspaceEnvironment,
1431
+ };
1432
+ // Backend-aware HOME: a provisioned box (docker + every cloud provider) runs the
1433
+ // agent under the descriptor's workspaceRoot. `local` runs in-process as the host
1434
+ // unix user (keep its real $HOME); `none` has no box.
1435
+ const descriptor = CAPABILITY_DESCRIPTORS[settings.sandboxBackend];
1436
+ if (settings.sandboxBackend !== "none" && settings.sandboxBackend !== "local") {
1437
+ environment.HOME ??= descriptor.workspaceRoot;
1438
+ }
1439
+ // TOKEN-BROKER (B1): the STABLE token FILE PATH. A constant derived from the
1440
+ // resolved HOME (falling back to the descriptor workspaceRoot), so it is
1441
+ // parity-safe — it joins the shared base and therefore appears IDENTICALLY on
1442
+ // BOTH the worker-turn manifest AND every API-direct attach manifest, keeping
1443
+ // the SDK's provided-session env delta empty. Only the PATH is stable; the token
1444
+ // VALUE lives exclusively in the file (agent-managed, refreshable mid-turn), never
1445
+ // the manifest env.
1446
+ environment.OPENGENI_GIT_TOKEN_FILE ??= `${environment.HOME ?? descriptor.workspaceRoot}/.opengeni/git-token`;
1447
+ return environment;
1448
+ }
1449
+
1450
+ /**
1451
+ * Whether a resource set carries a GitHub-App-connected repository (installation
1452
+ * + repository ids present) — the SAME predicate the worker turn uses to decide
1453
+ * whether it declares the stable git-auth pointers. Attach surfaces call this so
1454
+ * an attach-warmed cold box carries the IDENTICAL manifest env a later repo turn
1455
+ * declares (env parity — see applyGitAuthPointerEnvironment).
1456
+ */
1457
+ export function hasGitHubRepositorySelection(resources: ReadonlyArray<{ kind: string; githubInstallationId?: unknown; githubRepositoryId?: unknown }>): boolean {
1458
+ const positive = (value: unknown): boolean =>
1459
+ (typeof value === "number" && Number.isInteger(value) && value > 0)
1460
+ || (typeof value === "string" && /^\d+$/.test(value) && Number(value) > 0);
1461
+ return resources.some((resource) => resource.kind === "repository" && positive(resource.githubInstallationId) && positive(resource.githubRepositoryId));
1462
+ }
1463
+
1464
+ /**
1465
+ * TOKEN-BROKER (B1) parity: the STABLE git-auth POINTER environment a
1466
+ * repo-attached run declares — GIT_ASKPASS (a fixed path under HOME; the script
1467
+ * itself is provisioned at box setup), GIT_TERMINAL_PROMPT, and the GitHub-App
1468
+ * bot identity fallbacks. NO rotating value rides here (the token lives in the
1469
+ * file behind the askpass), so the layer is attach-reproducible and MUST be
1470
+ * applied identically by the worker turn (sandboxEnvironmentForRun) AND every
1471
+ * API-direct attach surface that can cold-create the box (viewer attach,
1472
+ * channel-A ops). A box cold-created WITHOUT this layer kills the next repo
1473
+ * turn: the turn's manifest declares these keys, the box's env lacks them, and
1474
+ * the SDK's provided-session guard throws "Live sandbox sessions cannot change
1475
+ * manifest environment variables" (observed live: an open session page's viewer
1476
+ * attach won the cold-create race and the first turn died).
1477
+ *
1478
+ * Mutates and returns `environment`. Identity fallbacks preserve values already
1479
+ * present (the deployment git-identity allowlist wins over the bot identity).
1480
+ */
1481
+ export function applyGitAuthPointerEnvironment(
1482
+ environment: Record<string, string>,
1483
+ identity: { name: string; email: string } | null,
1484
+ ): Record<string, string> {
1485
+ environment.GIT_ASKPASS = `${environment.HOME ?? "/workspace"}/.opengeni/askpass`;
1486
+ environment.GIT_TERMINAL_PROMPT = "0";
1487
+ if (identity) {
1488
+ environment.GIT_AUTHOR_NAME = environment.GIT_AUTHOR_NAME || identity.name;
1489
+ environment.GIT_AUTHOR_EMAIL = environment.GIT_AUTHOR_EMAIL || identity.email;
1490
+ environment.GIT_COMMITTER_NAME = environment.GIT_COMMITTER_NAME || identity.name;
1491
+ environment.GIT_COMMITTER_EMAIL = environment.GIT_COMMITTER_EMAIL || identity.email;
1492
+ }
1493
+ return environment;
1494
+ }
1495
+
1496
+ export type StartupRetryOptions = {
1497
+ attempts?: number;
1498
+ initialDelayMs?: number;
1499
+ maxDelayMs?: number;
1500
+ onRetry?: (event: {
1501
+ label: string;
1502
+ attempt: number;
1503
+ attempts: number;
1504
+ delayMs: number;
1505
+ error: unknown;
1506
+ }) => void;
1507
+ };
1508
+
1509
+ export function startupRetryOptions(settings: Settings): Required<Omit<StartupRetryOptions, "onRetry">> {
1510
+ return {
1511
+ attempts: settings.startupDependencyRetryAttempts,
1512
+ initialDelayMs: settings.startupDependencyRetryInitialDelayMs,
1513
+ maxDelayMs: settings.startupDependencyRetryMaxDelayMs,
1514
+ };
1515
+ }
1516
+
1517
+ export async function retryStartupDependency<T>(
1518
+ label: string,
1519
+ operation: () => Promise<T>,
1520
+ options: StartupRetryOptions = {},
1521
+ ): Promise<T> {
1522
+ const attempts = Math.max(1, Math.floor(options.attempts ?? 30));
1523
+ const initialDelayMs = Math.max(0, Math.floor(options.initialDelayMs ?? 1000));
1524
+ const maxDelayMs = Math.max(initialDelayMs, Math.floor(options.maxDelayMs ?? 5000));
1525
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
1526
+ try {
1527
+ return await operation();
1528
+ } catch (error) {
1529
+ if (attempt >= attempts) {
1530
+ throw error;
1531
+ }
1532
+ const delayMs = Math.min(maxDelayMs, initialDelayMs * 2 ** (attempt - 1));
1533
+ options.onRetry?.({ label, attempt, attempts, delayMs, error });
1534
+ await delay(delayMs);
1535
+ }
1536
+ }
1537
+ throw new Error(`unreachable startup retry state for ${label}`);
1538
+ }
1539
+
1540
+ export function sandboxEnvironmentVariableNames(settings: Settings): string[] {
1541
+ const profiles = sandboxPreparationProfileNames(settings);
1542
+ const names: string[] = [];
1543
+ for (const profile of profiles) {
1544
+ names.push(...sandboxPreparationProfiles[profile]!.env);
1545
+ }
1546
+ names.push(...splitCsv(settings.sandboxEnvAllowlist));
1547
+ return uniqueEnvNames(names, "sandbox env");
1548
+ }
1549
+
1550
+ export function sandboxLifecycleHookIds(settings: Settings): string[] {
1551
+ const ids: string[] = [];
1552
+ for (const profile of sandboxPreparationProfileNames(settings)) {
1553
+ ids.push(...sandboxPreparationProfiles[profile]!.hooks);
1554
+ }
1555
+ return uniqueValues(ids);
1556
+ }
1557
+
1558
+ function sandboxPreparationProfileNames(settings: Settings): string[] {
1559
+ const profiles = splitCsv(settings.sandboxPreparationProfiles).map((value) => value.toLowerCase());
1560
+ if (profiles.includes("none")) {
1561
+ if (profiles.length > 1) {
1562
+ throw new Error("OPENGENI_SANDBOX_PREPARATION_PROFILES cannot combine none with other profiles");
1563
+ }
1564
+ return ["none"];
1565
+ }
1566
+ for (const profile of profiles) {
1567
+ if (!sandboxPreparationProfiles[profile]) {
1568
+ throw new Error(`Unknown sandbox preparation profile ${profile}`);
1569
+ }
1570
+ }
1571
+ return profiles;
1572
+ }
1573
+
1574
+ export function parseExposedPorts(raw: string): number[] {
1575
+ return splitCsv(raw).map((value) => {
1576
+ const port = Number(value);
1577
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1578
+ throw new Error("OPENGENI_DOCKER_EXPOSED_PORTS must contain TCP port numbers");
1579
+ }
1580
+ return port;
1581
+ });
1582
+ }
1583
+
1584
+ export function parseMcpServers(raw: string | undefined): unknown[] | undefined {
1585
+ if (!raw) {
1586
+ return undefined;
1587
+ }
1588
+ try {
1589
+ const parsed = JSON.parse(raw);
1590
+ if (!Array.isArray(parsed)) {
1591
+ throw new Error("value must be a JSON array");
1592
+ }
1593
+ return parsed;
1594
+ } catch (error) {
1595
+ const message = error instanceof Error ? error.message : String(error);
1596
+ throw new Error(`OPENGENI_MCP_SERVERS must be a JSON array: ${message}`);
1597
+ }
1598
+ }
1599
+
1600
+ export function parseModelPricingJson(raw: string): Record<string, ModelPricing> {
1601
+ if (!raw.trim() || raw.trim() === "{}") {
1602
+ return {};
1603
+ }
1604
+ let parsed: unknown;
1605
+ try {
1606
+ parsed = JSON.parse(raw);
1607
+ } catch (error) {
1608
+ const message = error instanceof Error ? error.message : String(error);
1609
+ throw new Error(`OPENGENI_MODEL_PRICING_JSON must be valid JSON: ${message}`);
1610
+ }
1611
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1612
+ throw new Error("OPENGENI_MODEL_PRICING_JSON must be a JSON object keyed by model name");
1613
+ }
1614
+ const out: Record<string, ModelPricing> = {};
1615
+ for (const [model, value] of Object.entries(parsed)) {
1616
+ if (!model.trim()) {
1617
+ throw new Error("OPENGENI_MODEL_PRICING_JSON contains an empty model name");
1618
+ }
1619
+ out[model] = ModelPricingSchema.parse(value);
1620
+ }
1621
+ return out;
1622
+ }
1623
+
1624
+ // --- sandbox warm-rate table (P2.1) ---
1625
+ // Per-backend usd_micros/sec, parsed from sandboxWarmRateMicrosPerSecondJson the
1626
+ // same way model pricing is. An empty {} (the default) means no warm-cost is
1627
+ // debited — warm-seconds are still metered for audit, just at rate 0.
1628
+ export function parseSandboxWarmRateJson(raw: string): Record<string, number> {
1629
+ if (!raw.trim() || raw.trim() === "{}") {
1630
+ return {};
1631
+ }
1632
+ let parsed: unknown;
1633
+ try {
1634
+ parsed = JSON.parse(raw);
1635
+ } catch (error) {
1636
+ const message = error instanceof Error ? error.message : String(error);
1637
+ throw new Error(`OPENGENI_SANDBOX_WARM_RATE_MICROS_PER_SECOND_JSON must be valid JSON: ${message}`);
1638
+ }
1639
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1640
+ throw new Error("OPENGENI_SANDBOX_WARM_RATE_MICROS_PER_SECOND_JSON must be a JSON object keyed by backend name");
1641
+ }
1642
+ const out: Record<string, number> = {};
1643
+ for (const [backend, value] of Object.entries(parsed)) {
1644
+ if (!backend.trim()) {
1645
+ throw new Error("OPENGENI_SANDBOX_WARM_RATE_MICROS_PER_SECOND_JSON contains an empty backend name");
1646
+ }
1647
+ const rate = typeof value === "number" ? value : Number(value);
1648
+ if (!Number.isFinite(rate) || rate < 0) {
1649
+ throw new Error(`OPENGENI_SANDBOX_WARM_RATE_MICROS_PER_SECOND_JSON rate for ${backend} must be a non-negative number`);
1650
+ }
1651
+ out[backend] = rate;
1652
+ }
1653
+ return out;
1654
+ }
1655
+
1656
+ // Resolve the warm rate (usd_micros/sec) for a backend; 0 when the backend has no
1657
+ // configured rate (the box is metered in seconds but not cost-debited).
1658
+ export function sandboxWarmRateMicrosPerSecond(settings: Settings, backend: string): number {
1659
+ const table = parseSandboxWarmRateJson(settings.sandboxWarmRateMicrosPerSecondJson);
1660
+ return table[backend] ?? 0;
1661
+ }
1662
+
1663
+ /**
1664
+ * Parse + validate the extra-provider registry JSON. `[]` (or empty/whitespace)
1665
+ * yields an empty list. Surfaces JSON and zod errors prefixed with the env-var
1666
+ * name so a malformed registry fails fast at boot (validateSettings calls this).
1667
+ */
1668
+ export function parseModelProvidersJson(raw: string): RegistryProvider[] {
1669
+ if (!raw.trim() || raw.trim() === "[]") {
1670
+ return [];
1671
+ }
1672
+ let parsed: unknown;
1673
+ try {
1674
+ parsed = JSON.parse(raw);
1675
+ } catch (error) {
1676
+ const message = error instanceof Error ? error.message : String(error);
1677
+ throw new Error(`OPENGENI_MODEL_PROVIDERS_JSON must be valid JSON: ${message}`);
1678
+ }
1679
+ if (!Array.isArray(parsed)) {
1680
+ throw new Error("OPENGENI_MODEL_PROVIDERS_JSON must be a JSON array of providers");
1681
+ }
1682
+ return parsed.map((entry, index) => {
1683
+ const result = RegistryProviderSchema.safeParse(entry);
1684
+ if (!result.success) {
1685
+ throw new Error(`OPENGENI_MODEL_PROVIDERS_JSON provider[${index}] is invalid: ${result.error.message}`);
1686
+ }
1687
+ return result.data;
1688
+ });
1689
+ }
1690
+
1691
+ export function parseStaticUsageLimitsJson(raw: string): StaticUsageLimitsConfig {
1692
+ if (!raw.trim() || raw.trim() === "{}") {
1693
+ return {};
1694
+ }
1695
+ let parsed: unknown;
1696
+ try {
1697
+ parsed = JSON.parse(raw);
1698
+ } catch (error) {
1699
+ const message = error instanceof Error ? error.message : String(error);
1700
+ throw new Error(`OPENGENI_STATIC_USAGE_LIMITS_JSON must be valid JSON: ${message}`);
1701
+ }
1702
+ return StaticUsageLimits.parse(parsed);
1703
+ }
1704
+
1705
+ export function parseStaticEntitlementsJson(raw: string): EntitlementsConfig {
1706
+ if (!raw.trim() || raw.trim() === "{}") {
1707
+ return {};
1708
+ }
1709
+ let parsed: unknown;
1710
+ try {
1711
+ parsed = JSON.parse(raw);
1712
+ } catch (error) {
1713
+ const message = error instanceof Error ? error.message : String(error);
1714
+ throw new Error(`OPENGENI_STATIC_ENTITLEMENTS_JSON must be valid JSON: ${message}`);
1715
+ }
1716
+ return Entitlements.parse(parsed);
1717
+ }
1718
+
1719
+ function calculateEntryCostMicros(pricing: ModelPricing, entry: ModelUsageInput): number {
1720
+ const inputTokens = positiveInt(entry.inputTokens);
1721
+ const outputTokens = positiveInt(entry.outputTokens);
1722
+ const cachedTokens = Math.min(inputTokens, cachedInputTokens(entry));
1723
+ const uncachedInputTokens = Math.max(0, inputTokens - cachedTokens);
1724
+ const cachedInputRate = pricing.cachedInputMicrosPerMillionTokens ?? pricing.inputMicrosPerMillionTokens;
1725
+ return Math.ceil((uncachedInputTokens * pricing.inputMicrosPerMillionTokens) / 1_000_000)
1726
+ + Math.ceil((cachedTokens * cachedInputRate) / 1_000_000)
1727
+ + Math.ceil((outputTokens * pricing.outputMicrosPerMillionTokens) / 1_000_000);
1728
+ }
1729
+
1730
+ function cachedInputTokens(entry: ModelUsageInput): number {
1731
+ const details = Array.isArray(entry.inputTokensDetails)
1732
+ ? entry.inputTokensDetails
1733
+ : entry.inputTokensDetails
1734
+ ? [entry.inputTokensDetails]
1735
+ : [];
1736
+ let total = 0;
1737
+ for (const detail of details) {
1738
+ total += positiveInt(detail.cached_tokens)
1739
+ + positiveInt(detail.cachedInputTokens)
1740
+ + positiveInt(detail.cached_input_tokens);
1741
+ }
1742
+ return total;
1743
+ }
1744
+
1745
+ function positiveInt(value: unknown): number {
1746
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1747
+ }
1748
+
1749
+ function ensureBuiltInMcpServers(settings: Settings): Settings["mcpServers"] {
1750
+ const existing = settings.mcpServers.filter((server) => server.id !== "opengeni");
1751
+ const firstPartyMcpUrl = firstPartyMcpServerUrl(settings);
1752
+ const firstPartyDocsMcpUrl = firstPartyDocumentsMcpServerUrl(firstPartyMcpUrl);
1753
+ const hasFiles = existing.some((server) => server.id === "files");
1754
+ const hasDocs = existing.some((server) => server.id === "docs");
1755
+ return [
1756
+ {
1757
+ id: "opengeni",
1758
+ name: "OpenGeni",
1759
+ url: firstPartyMcpUrl,
1760
+ // The opengeni server's tools/list response is permission-scoped: it
1761
+ // varies by the calling session's delegated grant (e.g. a manager
1762
+ // session sees sessions_*/environment_* tools that a worker session
1763
+ // does not). The OpenAI Agents SDK caches tools/list in a process-global
1764
+ // map keyed only by the MCP server name, which is identical for every
1765
+ // session in the worker process. Caching here would let the first
1766
+ // session to warm the cache dictate what every later session sees,
1767
+ // regardless of permissions. tools/list is a cheap per-turn call, so we
1768
+ // never cache it. (The files server pins allowedTools to a single
1769
+ // permission-invariant tool and docs is already uncached, so both stay
1770
+ // safe to cache / leave as-is.)
1771
+ cacheToolsList: false,
1772
+ },
1773
+ ...(hasFiles ? [] : [{
1774
+ id: "files",
1775
+ name: "Files",
1776
+ url: firstPartyMcpUrl,
1777
+ allowedTools: ["files_get_download_url"],
1778
+ cacheToolsList: true,
1779
+ }]),
1780
+ ...(hasDocs ? [] : [{
1781
+ id: "docs",
1782
+ name: "Document Search",
1783
+ url: firstPartyDocsMcpUrl,
1784
+ allowedTools: ["search_documents", "fetch_document_chunk", "list_document_bases"],
1785
+ cacheToolsList: false,
1786
+ }]),
1787
+ ...existing,
1788
+ ];
1789
+ }
1790
+
1791
+ /**
1792
+ * The base URL of OpenGeni's own first-party MCP endpoint, as a `{workspaceId}`
1793
+ * template — the SINGLE source of truth for the `opengeniMcpUrl`-or-loopback
1794
+ * decision. Every site that needs the first-party MCP base (config's tool
1795
+ * registry here, and the worker-side `firstPartyMcpServerUrlForRun` /
1796
+ * `firstPartyMcpUrls` in @opengeni/runtime) MUST route through this so the
1797
+ * default lives in exactly one place.
1798
+ *
1799
+ * BINDING CONTRACT (`opengeniMcpUrl`):
1800
+ * - STANDALONE (unset): falls back to the loopback default
1801
+ * `http://127.0.0.1:${apiPort}/v1/workspaces/{workspaceId}/mcp` — the worker
1802
+ * and API are in/next to the same host:port, so loopback resolves the
1803
+ * workspace-scoped MCP. Byte-for-byte today's behavior.
1804
+ * - EMBEDDED / MOUNTED (must set): when OpenGeni's API is mounted as a host
1805
+ * sub-app under a prefix (e.g. `https://host/og/v1/...`), the loopback
1806
+ * default is WRONG — the worker runs in the host process and `127.0.0.1:
1807
+ * ${apiPort}` is not where the mounted, sandbox-routable MCP lives. The host
1808
+ * MUST set `OPENGENI_MCP_URL` to the externally/sandbox-routable base (a
1809
+ * `{workspaceId}` template, or a concrete base that gets re-scoped). This is
1810
+ * the one binding a mounted embed cannot leave unset.
1811
+ */
1812
+ export function firstPartyMcpBaseUrl(settings: Settings): string {
1813
+ return settings.opengeniMcpUrl ?? `http://127.0.0.1:${settings.apiPort}/v1/workspaces/{workspaceId}/mcp`;
1814
+ }
1815
+
1816
+ function firstPartyMcpServerUrl(settings: Settings): string {
1817
+ return firstPartyMcpBaseUrl(settings);
1818
+ }
1819
+
1820
+ function firstPartyDocumentsMcpServerUrl(mcpUrl: string): string {
1821
+ return `${mcpUrl.replace(/\/+$/, "")}/docs`;
1822
+ }
1823
+
1824
+ function validateSettings(settings: Settings): void {
1825
+ if (settings.productAccessMode === "managed") {
1826
+ if (!settings.publicBaseUrl) {
1827
+ throw new Error("OPENGENI_PUBLIC_BASE_URL is required when OPENGENI_PRODUCT_ACCESS_MODE=managed");
1828
+ }
1829
+ if (!settings.betterAuthSecret) {
1830
+ throw new Error("OPENGENI_BETTER_AUTH_SECRET is required when OPENGENI_PRODUCT_ACCESS_MODE=managed");
1831
+ }
1832
+ if (!settings.delegationSecret) {
1833
+ throw new Error("OPENGENI_DELEGATION_SECRET is required when OPENGENI_PRODUCT_ACCESS_MODE=managed");
1834
+ }
1835
+ if (!["local", "test"].includes(settings.environment) && !settings.resendApiKey) {
1836
+ throw new Error("OPENGENI_RESEND_API_KEY is required for managed mode outside local/test");
1837
+ }
1838
+ if (!["local", "test"].includes(settings.environment) && !settings.environmentsEncryptionKey) {
1839
+ throw new Error("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is required for managed mode outside local/test");
1840
+ }
1841
+ }
1842
+ environmentsEncryptionKeyBytes(settings);
1843
+ if (
1844
+ settings.productAccessMode === "configured"
1845
+ && !["local", "test"].includes(settings.environment)
1846
+ && !settings.delegationSecret
1847
+ && !settings.authRequired
1848
+ ) {
1849
+ throw new Error("OPENGENI_PRODUCT_ACCESS_MODE=configured requires OPENGENI_DELEGATION_SECRET or OPENGENI_AUTH_REQUIRED=true outside local/test");
1850
+ }
1851
+ if (settings.billingMode === "stripe") {
1852
+ if (!settings.stripeSecretKey || !settings.stripeWebhookSecret) {
1853
+ throw new Error("OPENGENI_STRIPE_SECRET_KEY and OPENGENI_STRIPE_WEBHOOK_SECRET are required when OPENGENI_BILLING_MODE=stripe");
1854
+ }
1855
+ }
1856
+ if (settings.productAccessMode !== "managed" && settings.billingMode === "stripe") {
1857
+ throw new Error("OPENGENI_BILLING_MODE=stripe requires OPENGENI_PRODUCT_ACCESS_MODE=managed");
1858
+ }
1859
+ if (settings.billingMode === "stripe" || settings.usageLimitsMode === "managed") {
1860
+ const pricing = configuredModelPricing(settings);
1861
+ const missing = configuredAllowedModels(settings).filter((model) => !pricing[model]);
1862
+ if (missing.length > 0) {
1863
+ throw new Error(`Missing model pricing for managed billing model(s): ${missing.join(", ")}. Set OPENGENI_MODEL_PRICING_JSON.`);
1864
+ }
1865
+ }
1866
+ if (settings.usageLimitsMode === "static") {
1867
+ const limits = configuredStaticUsageLimits(settings);
1868
+ if (Object.keys(limits).length === 0) {
1869
+ throw new Error("OPENGENI_STATIC_USAGE_LIMITS_JSON must define at least one cap when OPENGENI_USAGE_LIMITS_MODE=static");
1870
+ }
1871
+ } else {
1872
+ parseStaticUsageLimitsJson(settings.staticUsageLimitsJson);
1873
+ }
1874
+ if (settings.entitlementsMode === "static") {
1875
+ const entitlements = parseStaticEntitlementsJson(settings.staticEntitlementsJson);
1876
+ if (Object.keys(entitlements).length === 0) {
1877
+ throw new Error("OPENGENI_STATIC_ENTITLEMENTS_JSON must define at least one feature when OPENGENI_ENTITLEMENTS_MODE=static");
1878
+ }
1879
+ } else {
1880
+ parseStaticEntitlementsJson(settings.staticEntitlementsJson);
1881
+ }
1882
+ if (settings.authRequired && !settings.accessKey) {
1883
+ throw new Error("OPENGENI_ACCESS_KEY is required when OPENGENI_AUTH_REQUIRED=true");
1884
+ }
1885
+ if (settings.openaiProvider === "azure") {
1886
+ if (!settings.azureOpenaiBaseUrl && !settings.azureOpenaiEndpoint) {
1887
+ throw new Error("Azure OpenAI requires OPENGENI_AZURE_OPENAI_BASE_URL or OPENGENI_AZURE_OPENAI_ENDPOINT");
1888
+ }
1889
+ if (!settings.azureOpenaiBaseUrl && !settings.azureOpenaiDeployment) {
1890
+ throw new Error("Azure OpenAI endpoint mode requires OPENGENI_AZURE_OPENAI_DEPLOYMENT");
1891
+ }
1892
+ if (!settings.azureOpenaiBaseUrl && !settings.azureOpenaiApiVersion) {
1893
+ throw new Error("Azure OpenAI endpoint mode requires OPENGENI_AZURE_OPENAI_API_VERSION");
1894
+ }
1895
+ if (!settings.azureOpenaiApiKey && !settings.azureOpenaiAdToken) {
1896
+ throw new Error("Azure OpenAI requires an API key or AD token");
1897
+ }
1898
+ }
1899
+ // The Modal token is a both-or-neither pair regardless of the active backend
1900
+ // (a half-configured token is always a misconfiguration). This is orthogonal
1901
+ // to the backend-gated required-cred sweep below.
1902
+ if (Boolean(settings.modalTokenId) !== Boolean(settings.modalTokenSecret)) {
1903
+ throw new Error("OPENGENI_MODAL_TOKEN_ID and OPENGENI_MODAL_TOKEN_SECRET must both be set or both omitted");
1904
+ }
1905
+ // Backend-gated required credentials: only the *active* backend's creds are
1906
+ // required. A modal deployment must carry the Modal token; a daytona/e2b/none
1907
+ // deployment must NOT be forced to (and is not). Drives off the single
1908
+ // SANDBOX_REQUIRED_ENV table that the deployment package also mirrors.
1909
+ for (const required of SANDBOX_REQUIRED_ENV[settings.sandboxBackend] ?? []) {
1910
+ const value = settings[required.field];
1911
+ if (value === undefined || value === null || (typeof value === "string" && value.trim().length === 0)) {
1912
+ throw new Error(`${required.env} is required when OPENGENI_SANDBOX_BACKEND=${settings.sandboxBackend}`);
1913
+ }
1914
+ }
1915
+ if (settings.objectStorageBackend === "s3-compatible" || settings.objectStorageBackend === "aws-s3") {
1916
+ if (Boolean(settings.objectStorageAccessKeyId) !== Boolean(settings.objectStorageSecretAccessKey)) {
1917
+ throw new Error("OPENGENI_OBJECT_STORAGE_ACCESS_KEY_ID and OPENGENI_OBJECT_STORAGE_SECRET_ACCESS_KEY must both be set or both omitted");
1918
+ }
1919
+ if (settings.objectStorageBackend === "s3-compatible" && (settings.objectStorageEndpoint || settings.objectStorageSandboxEndpoint) && (!settings.objectStorageAccessKeyId || !settings.objectStorageSecretAccessKey)) {
1920
+ throw new Error("S3-compatible object storage endpoints require OPENGENI_OBJECT_STORAGE_ACCESS_KEY_ID and OPENGENI_OBJECT_STORAGE_SECRET_ACCESS_KEY");
1921
+ }
1922
+ if (settings.objectStorageAzureConnectionString || settings.objectStorageAzureAccountName || settings.objectStorageAzureAccountKey || settings.objectStorageAzureEndpoint) {
1923
+ throw new Error("S3 object storage uses OPENGENI_OBJECT_STORAGE_* S3 settings, not OPENGENI_OBJECT_STORAGE_AZURE_* settings");
1924
+ }
1925
+ if (settings.objectStorageGcsProjectId || settings.objectStorageGcsCredentialsJson || settings.objectStorageGcsKeyFilename || settings.objectStorageGcsApiEndpoint) {
1926
+ throw new Error("S3 object storage uses OPENGENI_OBJECT_STORAGE_* S3 settings, not OPENGENI_OBJECT_STORAGE_GCS_* settings");
1927
+ }
1928
+ } else if (settings.objectStorageBackend === "azure-blob") {
1929
+ if (settings.objectStorageEndpoint || settings.objectStorageSandboxEndpoint || settings.objectStorageAccessKeyId || settings.objectStorageSecretAccessKey) {
1930
+ throw new Error("Azure Blob storage uses OPENGENI_OBJECT_STORAGE_AZURE_* settings, not S3-compatible object storage settings");
1931
+ }
1932
+ if (settings.objectStorageGcsProjectId || settings.objectStorageGcsCredentialsJson || settings.objectStorageGcsKeyFilename || settings.objectStorageGcsApiEndpoint) {
1933
+ throw new Error("Azure Blob storage uses OPENGENI_OBJECT_STORAGE_AZURE_* settings, not OPENGENI_OBJECT_STORAGE_GCS_* settings");
1934
+ }
1935
+ const hasConnectionString = Boolean(settings.objectStorageAzureConnectionString);
1936
+ const hasSharedKey = Boolean(settings.objectStorageAzureAccountName) && Boolean(settings.objectStorageAzureAccountKey);
1937
+ if (!hasConnectionString && !hasSharedKey) {
1938
+ throw new Error("Azure Blob storage requires OPENGENI_OBJECT_STORAGE_AZURE_CONNECTION_STRING or OPENGENI_OBJECT_STORAGE_AZURE_ACCOUNT_NAME plus OPENGENI_OBJECT_STORAGE_AZURE_ACCOUNT_KEY");
1939
+ }
1940
+ } else {
1941
+ if (settings.objectStorageEndpoint || settings.objectStorageSandboxEndpoint || settings.objectStorageAccessKeyId || settings.objectStorageSecretAccessKey) {
1942
+ throw new Error("GCS object storage uses OPENGENI_OBJECT_STORAGE_GCS_* settings, not S3-compatible object storage settings");
1943
+ }
1944
+ if (settings.objectStorageAzureConnectionString || settings.objectStorageAzureAccountName || settings.objectStorageAzureAccountKey || settings.objectStorageAzureEndpoint) {
1945
+ throw new Error("GCS object storage uses OPENGENI_OBJECT_STORAGE_GCS_* settings, not OPENGENI_OBJECT_STORAGE_AZURE_* settings");
1946
+ }
1947
+ if (settings.objectStorageGcsCredentialsJson) {
1948
+ parseGcsCredentialsJson(settings.objectStorageGcsCredentialsJson);
1949
+ }
1950
+ }
1951
+ if (settings.documentChunkOverlap >= settings.documentChunkSize) {
1952
+ throw new Error("OPENGENI_DOCUMENT_CHUNK_OVERLAP must be smaller than OPENGENI_DOCUMENT_CHUNK_SIZE");
1953
+ }
1954
+ parseExposedPorts(settings.dockerExposedPorts);
1955
+ sandboxEnvironmentVariableNames(settings);
1956
+ sandboxLifecycleHookIds(settings);
1957
+ // Fail fast on a malformed warm-rate table (P2.1).
1958
+ parseSandboxWarmRateJson(settings.sandboxWarmRateMicrosPerSecondJson);
1959
+ const serverIds = new Set<string>();
1960
+ for (const server of settings.mcpServers) {
1961
+ if (serverIds.has(server.id)) {
1962
+ throw new Error(`OPENGENI_MCP_SERVERS contains duplicate id ${server.id}`);
1963
+ }
1964
+ serverIds.add(server.id);
1965
+ }
1966
+ // --- sandbox lease cadence invariant (fail fast at boot) ---
1967
+ // reaperPeriod (30s) < viewerHolderTTL (90s), and reaperPeriod + idleGrace must
1968
+ // be strictly less than the provider lifetime (modalTimeoutSeconds*1000):
1969
+ // - the reaper must run more often than the TTL it polices; and
1970
+ // - the reaper must terminate a genuinely-idle box (after the full drain grace,
1971
+ // observed on the NEXT sweep) BEFORE the provider's hard lifetime reclaims it
1972
+ // out from under us — the provider lifetime is the backstop, not the
1973
+ // warm-window controller. idleGrace counts from the user's last release;
1974
+ // the provider clock counts from the preceding resume, so we leave the
1975
+ // active-turn headroom in modalTimeoutSeconds (default 3600s).
1976
+ {
1977
+ const reaperPeriod = settings.sandboxLeaseReaperPeriodMs;
1978
+ const viewerTtl = settings.sandboxViewerHolderTtlMs;
1979
+ const idleGraceMs = settings.sandboxIdleGraceMs;
1980
+ const providerLifetimeMs = settings.modalTimeoutSeconds * 1000;
1981
+ // The EFFECTIVE box lifetime when it sits idle between turns is the Modal IDLE
1982
+ // timeout, NOT the hard lifetime (sandbox-file-persistence): a box with no
1983
+ // active connection is idle-reaped at idleTimeout. effectiveModalIdleTimeout
1984
+ // defaults to the hard lifetime (so the idle-reap never beats the OpenGeni
1985
+ // reaper), but an operator can pin it shorter — the invariants below bind the
1986
+ // reaper cadence + drain grace to the idle timeout (the REAL ceiling), so a
1987
+ // drained box always survives long enough for the reaper to snapshot it.
1988
+ const idleTimeoutMs = effectiveModalIdleTimeoutSeconds(settings) * 1000;
1989
+ if (!(reaperPeriod < viewerTtl)) {
1990
+ throw new Error(
1991
+ `OPENGENI_SANDBOX_LEASE_REAPER_PERIOD_MS (${reaperPeriod}) must be strictly less than `
1992
+ + `OPENGENI_SANDBOX_VIEWER_HOLDER_TTL_MS (${viewerTtl}): the reaper must run more often `
1993
+ + `than the TTL it polices, or stale viewer holders outlive a full reaper period.`);
1994
+ }
1995
+ if (!(idleTimeoutMs <= providerLifetimeMs)) {
1996
+ throw new Error(
1997
+ `OPENGENI_MODAL_IDLE_TIMEOUT_SECONDS*1000 (${idleTimeoutMs}) must not exceed the hard provider `
1998
+ + `lifetime (OPENGENI_MODAL_TIMEOUT_SECONDS*1000 = ${providerLifetimeMs}): the idle timeout is a `
1999
+ + `floor under the hard lifetime, not above it.`);
2000
+ }
2001
+ if (!(viewerTtl < idleTimeoutMs)) {
2002
+ throw new Error(
2003
+ `OPENGENI_SANDBOX_VIEWER_HOLDER_TTL_MS (${viewerTtl}) must be strictly less than the effective box `
2004
+ + `idle timeout (${idleTimeoutMs}): a viewer holder must be reapable before the box idles out from `
2005
+ + `under it (the provider idle-timeout is the backstop).`);
2006
+ }
2007
+ if (!(reaperPeriod + idleGraceMs < idleTimeoutMs)) {
2008
+ throw new Error(
2009
+ `OPENGENI_SANDBOX_LEASE_REAPER_PERIOD_MS + OPENGENI_SANDBOX_IDLE_GRACE_MS `
2010
+ + `(${reaperPeriod} + ${idleGraceMs} = ${reaperPeriod + idleGraceMs}) must be strictly less than the `
2011
+ + `effective box idle timeout (${idleTimeoutMs}): a drained box must SURVIVE its full warm window so `
2012
+ + `the reaper can resume + snapshot /workspace + terminate it on the sweep AFTER the drain grace `
2013
+ + `elapses — Modal's idle-reap must NOT fire first (or /workspace is lost). Raise `
2014
+ + `OPENGENI_MODAL_IDLE_TIMEOUT_SECONDS (defaults to OPENGENI_MODAL_TIMEOUT_SECONDS) or lower `
2015
+ + `OPENGENI_SANDBOX_IDLE_GRACE_MS.`);
2016
+ }
2017
+ }
2018
+ // --- stream-token secret: required-when-desktop, but GRACEFULLY DEGRADE (I8) ---
2019
+ // The desktop pixel plane needs an HMAC secret to mint scoped stream tokens.
2020
+ // It is REQUIRED when desktop is enabled — but per OD-8 a missing secret is NOT
2021
+ // a hard boot-fail: we emit a LOUD warning and the deployment ships with
2022
+ // DesktopStream.transport:null (resolveStreamTokenSecret returns undefined ->
2023
+ // negotiateCapabilities degrades the desktop cell). This keeps a desktop-
2024
+ // configured deployment bootable (headless + Channel-A still work) instead of
2025
+ // crashing the whole API on a missing secret.
2026
+ if (settings.sandboxDesktopEnabled && resolveStreamTokenSecret(settings) === undefined) {
2027
+ console.warn(
2028
+ "[opengeni] OPENGENI_SANDBOX_DESKTOP_ENABLED=true but neither OPENGENI_STREAM_TOKEN_SECRET nor "
2029
+ + "OPENGENI_DELEGATION_SECRET is set: the desktop pixel plane will GRACEFULLY DEGRADE "
2030
+ + "(DesktopStream.transport=null — no scoped stream tokens can be minted). Set "
2031
+ + "OPENGENI_STREAM_TOKEN_SECRET to enable the live desktop stream.",
2032
+ );
2033
+ }
2034
+ // Model provider registry: parse it here so JSON/zod errors surface at boot,
2035
+ // reject a registry id colliding with the built-in provider id (it would
2036
+ // shadow the built-in in configuredProviders), reject duplicate registry
2037
+ // ids, and require a resolvable API key for every registry provider (a
2038
+ // provider with no usable key can never serve a turn). Registry models flow
2039
+ // through configuredAllowedModels, so the managed-billing pricing check above
2040
+ // already covers them.
2041
+ const registryProviders = parseModelProvidersJson(settings.modelProvidersJson);
2042
+ const builtinId = builtinProviderId(settings);
2043
+ const providerIds = new Set<string>();
2044
+ for (const provider of registryProviders) {
2045
+ if (provider.id === builtinId) {
2046
+ throw new Error(`OPENGENI_MODEL_PROVIDERS_JSON provider id ${provider.id} collides with the built-in provider id`);
2047
+ }
2048
+ if (providerIds.has(provider.id)) {
2049
+ throw new Error(`OPENGENI_MODEL_PROVIDERS_JSON contains duplicate provider id ${provider.id}`);
2050
+ }
2051
+ providerIds.add(provider.id);
2052
+ if (!resolveProviderApiKey(provider)) {
2053
+ throw new Error(`OPENGENI_MODEL_PROVIDERS_JSON provider ${provider.id} requires a resolvable API key (set apiKey or apiKeyEnv)`);
2054
+ }
2055
+ }
2056
+ }
2057
+
2058
+ /**
2059
+ * Resolve the secret used to sign/verify scoped stream tokens (master-spine
2060
+ * §C.3). Falls back to `delegationSecret` (the same HMAC envelope family —
2061
+ * `ogs_` vs `ogd_` prefix) so a deployment that already carries a delegation
2062
+ * secret does not need a second one. Returns undefined when neither is set,
2063
+ * which drives the graceful-degrade (DesktopStream.transport:null).
2064
+ */
2065
+ export function resolveStreamTokenSecret(settings: Settings): string | undefined {
2066
+ const explicit = settings.streamTokenSecret?.trim();
2067
+ if (explicit) {
2068
+ return explicit;
2069
+ }
2070
+ const delegation = settings.delegationSecret?.trim();
2071
+ return delegation ? delegation : undefined;
2072
+ }
2073
+
2074
+ /**
2075
+ * True iff the desktop pixel plane must GRACEFULLY DEGRADE because desktop is
2076
+ * enabled but no stream-token secret is resolvable (I8/OD-8). When true,
2077
+ * negotiateCapabilities forces DesktopStream.transport:null.
2078
+ */
2079
+ export function streamTokenDegraded(settings: Settings): boolean {
2080
+ return settings.sandboxDesktopEnabled && resolveStreamTokenSecret(settings) === undefined;
2081
+ }
2082
+
2083
+ /**
2084
+ * Resolve the secret the control plane signs the enrollment bearer credential
2085
+ * with (the `oge_` envelope the agent presents back — M5/dossier §10.2). Falls
2086
+ * back to `delegationSecret` (the same HMAC envelope family) so a deployment that
2087
+ * already carries a delegation secret needs no second one. Returns undefined when
2088
+ * neither is set; when selfhosted is enabled but this is undefined, the poll route
2089
+ * reports the credential plane disabled (graceful degrade, never a 500). NEVER log
2090
+ * the returned value.
2091
+ */
2092
+ export function resolveEnrollmentSigningSecret(settings: Settings): string | undefined {
2093
+ const explicit = settings.enrollmentSigningSecret?.trim();
2094
+ if (explicit) {
2095
+ return explicit;
2096
+ }
2097
+ const delegation = settings.delegationSecret?.trim();
2098
+ return delegation ? delegation : undefined;
2099
+ }
2100
+
2101
+ /**
2102
+ * Resolve the HMAC secret the control plane signs the agent's relay PRODUCER token
2103
+ * with (the `ogr_` envelope; M8b/dossier §10.5). The RELAY verifies the producer
2104
+ * token with the SAME secret (injected into the relay via env). Prefers an explicit
2105
+ * `selfhostedRelayTokenSecret`, then the `streamTokenSecret` (the relay already
2106
+ * needs that one to verify the viewer's `ogs_` token, so a single secret can back
2107
+ * both planes), then `delegationSecret` (same HMAC family). Returns undefined when
2108
+ * none is set — the enrollment poll then returns an empty relayToken (graceful
2109
+ * degrade; the stream plane is unavailable until configured). NEVER log the value.
2110
+ */
2111
+ export function resolveRelayTokenSecret(settings: Settings): string | undefined {
2112
+ const explicit = settings.selfhostedRelayTokenSecret?.trim();
2113
+ if (explicit) {
2114
+ return explicit;
2115
+ }
2116
+ const stream = settings.streamTokenSecret?.trim();
2117
+ if (stream) {
2118
+ return stream;
2119
+ }
2120
+ const delegation = settings.delegationSecret?.trim();
2121
+ return delegation ? delegation : undefined;
2122
+ }
2123
+
2124
+ /**
2125
+ * The resolved NATS auth-callout responder config (M-AUTH). Present only when the
2126
+ * callout plane is FULLY configured: the account signing seed + the responder's own
2127
+ * login. When any piece is missing this returns null and the responder does not
2128
+ * start (selfhosted agents cannot connect — a graceful disabled state, never a boot
2129
+ * crash). The returned `accountSeed` is a secret; NEVER log it.
2130
+ */
2131
+ export interface NatsCalloutConfig {
2132
+ /** The callout account SIGNING seed (`SA...`) — signs the user + response JWTs. */
2133
+ accountSeed: string;
2134
+ /** The target account NAME the user is placed into (the response `aud`). */
2135
+ accountName: string;
2136
+ /** The responder's NATS login (an `auth_callout.auth_users` user). */
2137
+ user: string;
2138
+ password: string;
2139
+ }
2140
+
2141
+ export function resolveNatsCalloutConfig(settings: Settings): NatsCalloutConfig | null {
2142
+ const accountSeed = settings.selfhostedNatsCalloutAccountSeed?.trim();
2143
+ const accountName = settings.selfhostedNatsCalloutAccountName?.trim() || "APP";
2144
+ const user = settings.selfhostedNatsCalloutUser?.trim();
2145
+ const password = settings.selfhostedNatsCalloutPassword?.trim();
2146
+ if (!accountSeed || !user || !password) {
2147
+ return null;
2148
+ }
2149
+ return { accountSeed, accountName, user, password };
2150
+ }
2151
+
2152
+ /**
2153
+ * The PRIVILEGED control-plane NATS login (api/worker). Present only when BOTH a
2154
+ * user and password are set; otherwise null and the bus connects anonymously (local
2155
+ * dev / a NATS without auth_callout). When the callout plane is on, this is the
2156
+ * static account user permitted to request `agent.*.rpc`.
2157
+ */
2158
+ export interface NatsControlPlaneAuth {
2159
+ user: string;
2160
+ password: string;
2161
+ }
2162
+
2163
+ export function resolveNatsControlPlaneAuth(settings: Settings): NatsControlPlaneAuth | null {
2164
+ const user = settings.selfhostedNatsControlUser?.trim();
2165
+ const password = settings.selfhostedNatsControlPassword?.trim();
2166
+ if (!user || !password) {
2167
+ return null;
2168
+ }
2169
+ return { user, password };
2170
+ }
2171
+
2172
+ function splitCsv(raw: string): string[] {
2173
+ return raw.split(",").map((value) => value.trim()).filter(Boolean);
2174
+ }
2175
+
2176
+ function uniqueEnvNames(raw: string[], fieldName: string): string[] {
2177
+ const seen = new Set<string>();
2178
+ const out: string[] = [];
2179
+ for (const name of raw) {
2180
+ if (!envName.test(name)) {
2181
+ throw new Error(`${fieldName} contains invalid variable name ${name}`);
2182
+ }
2183
+ if (!seen.has(name)) {
2184
+ seen.add(name);
2185
+ out.push(name);
2186
+ }
2187
+ }
2188
+ return out;
2189
+ }
2190
+
2191
+ function uniqueValues(raw: string[]): string[] {
2192
+ return [...new Set(raw.filter(Boolean))];
2193
+ }
2194
+
2195
+ function parseGcsCredentialsJson(raw: string): unknown {
2196
+ try {
2197
+ return JSON.parse(raw);
2198
+ } catch (error) {
2199
+ const message = error instanceof Error ? error.message : String(error);
2200
+ throw new Error(`OPENGENI_OBJECT_STORAGE_GCS_CREDENTIALS_JSON must be valid JSON: ${message}`);
2201
+ }
2202
+ }
2203
+
2204
+ function delay(ms: number): Promise<void> {
2205
+ return new Promise((resolve) => setTimeout(resolve, ms));
2206
+ }