@opengeni/runtime 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.
Files changed (65) hide show
  1. package/dist/chunk-2PO56VAL.js +3478 -0
  2. package/dist/chunk-2PO56VAL.js.map +1 -0
  3. package/dist/index.d.ts +912 -0
  4. package/dist/index.js +3663 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/sandbox/index.d.ts +1738 -0
  7. package/dist/sandbox/index.js +187 -0
  8. package/dist/sandbox/index.js.map +1 -0
  9. package/package.json +49 -0
  10. package/src/bundled_hashicorp_terraform_skills/LICENSE +373 -0
  11. package/src/bundled_hashicorp_terraform_skills/README.md +18 -0
  12. package/src/bundled_hashicorp_terraform_skills/UPSTREAM_GIT_SHA +1 -0
  13. package/src/bundled_hashicorp_terraform_skills/azure-verified-modules/SKILL.md +613 -0
  14. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +43 -0
  15. package/src/bundled_hashicorp_terraform_skills/refactor-module/SKILL.md +538 -0
  16. package/src/bundled_hashicorp_terraform_skills/social-media-marketing/SKILL.md +35 -0
  17. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/SKILL.md +372 -0
  18. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/references/MANUAL-IMPORT.md +113 -0
  19. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/scripts/list_resources.sh +38 -0
  20. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/SKILL.md +480 -0
  21. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/api-monitoring.md +543 -0
  22. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/component-blocks.md +476 -0
  23. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/deployment-blocks.md +391 -0
  24. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/examples.md +1529 -0
  25. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/linked-stacks.md +187 -0
  26. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/troubleshooting.md +671 -0
  27. package/src/bundled_hashicorp_terraform_skills/terraform-style-guide/SKILL.md +353 -0
  28. package/src/bundled_hashicorp_terraform_skills/terraform-test/SKILL.md +451 -0
  29. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/CI_CD.md +80 -0
  30. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/EXAMPLES.md +314 -0
  31. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/MOCK_PROVIDERS.md +171 -0
  32. package/src/codex-tool-search.ts +267 -0
  33. package/src/context-compaction.ts +538 -0
  34. package/src/history-sanitizer.ts +719 -0
  35. package/src/index.ts +3299 -0
  36. package/src/sandbox/capabilities.ts +69 -0
  37. package/src/sandbox/channel-a.ts +1031 -0
  38. package/src/sandbox/display-stack.ts +231 -0
  39. package/src/sandbox/errors.ts +34 -0
  40. package/src/sandbox/index.ts +832 -0
  41. package/src/sandbox/providers/blaxel.ts +35 -0
  42. package/src/sandbox/providers/cloudflare.ts +24 -0
  43. package/src/sandbox/providers/daytona.ts +34 -0
  44. package/src/sandbox/providers/docker.ts +17 -0
  45. package/src/sandbox/providers/e2b.ts +36 -0
  46. package/src/sandbox/providers/index.ts +107 -0
  47. package/src/sandbox/providers/local.ts +13 -0
  48. package/src/sandbox/providers/modal.ts +55 -0
  49. package/src/sandbox/providers/none.ts +13 -0
  50. package/src/sandbox/providers/runloop.ts +32 -0
  51. package/src/sandbox/providers/selfhosted.ts +96 -0
  52. package/src/sandbox/providers/types.ts +38 -0
  53. package/src/sandbox/providers/vercel.ts +29 -0
  54. package/src/sandbox/recording.ts +286 -0
  55. package/src/sandbox/routing/backend-resolver.ts +189 -0
  56. package/src/sandbox/routing/routing-session.ts +455 -0
  57. package/src/sandbox/select.ts +371 -0
  58. package/src/sandbox/selfhosted/capabilities.ts +255 -0
  59. package/src/sandbox/selfhosted/control-rpc.ts +351 -0
  60. package/src/sandbox/selfhosted/session.ts +930 -0
  61. package/src/sandbox/selfhosted/testing.ts +230 -0
  62. package/src/sandbox/stream-port.ts +185 -0
  63. package/src/sandbox/stream-token.ts +90 -0
  64. package/src/sandbox/terminal-server.ts +203 -0
  65. package/src/sandbox-computer.ts +835 -0
@@ -0,0 +1,930 @@
1
+ // `SelfhostedSession` + `SelfhostedSandboxClient` — the NATS-backed structural
2
+ // sandbox surface for the `selfhosted` backend (bring-your-own-compute).
3
+ //
4
+ // The insight (dossier §7): every existing seam (Channel-A exec/fs/git, the
5
+ // viewer's `resolveExposedPort`, computer-use) consumes a provider session
6
+ // STRUCTURALLY — `session.exec ?? session.execCommand`, `session.readFile`,
7
+ // `session.resolveExposedPort`, `session.serializeSessionState`. If the
8
+ // selfhosted client's `create()`/`resume()` return a session presenting that
9
+ // EXACT surface — but backed by `ControlRpc` (request/reply to the agent over
10
+ // `agent.<ws>.<id>.rpc`, encoded via `@opengeni/agent-proto`) instead of a
11
+ // provider SDK — then those seams work UNCHANGED. The agent IS the box.
12
+ //
13
+ // The session depends ONLY on `ControlRpc` + `{workspaceId, agentId}` (+ the
14
+ // relay config for the stream-URL SHAPE). It knows nothing about NATS directly
15
+ // (the M3/M4 seam). `serializeSessionState`/`deserializeSessionState` round-trip
16
+ // `{agentId}` ONLY — resume = re-address the live subject, NO provider state.
17
+
18
+ import {
19
+ ControlRequest,
20
+ ControlResponse,
21
+ FsEntryKind,
22
+ StreamKind,
23
+ type DesktopInputRequest,
24
+ type ExecRequest,
25
+ type ExecResponse,
26
+ type StreamChannel,
27
+ } from "@opengeni/agent-proto";
28
+ import { DESKTOP_STREAM_PORT } from "@opengeni/contracts";
29
+ // `Manifest` from the ALLOWED sandbox-leaf entrypoint (`@openai/agents/sandbox`
30
+ // re-exports `@openai/agents-core/sandbox`, which exports the Manifest class) —
31
+ // NOT the agent-loop `@openai/agents` root the sandbox leaf forbids. The live
32
+ // `state.manifest` slice the @openai/agents SDK reads per turn must be a real
33
+ // Manifest (see the `state` field below); selfhosted exec routes over NATS and
34
+ // does not use the manifest, but the SDK requires it present + well-formed.
35
+ import { Manifest } from "@openai/agents/sandbox";
36
+ import type { ExposedPortEndpoint } from "../stream-port";
37
+ import {
38
+ agentErrorToControlError,
39
+ subjectFor,
40
+ type ControlRpc,
41
+ } from "./control-rpc";
42
+
43
+ const decoder = new TextDecoder();
44
+ const encoder = new TextEncoder();
45
+
46
+ /**
47
+ * The SDK's VIRTUAL sandbox root. The `@openai/agents` agent loop presents the
48
+ * sandbox to the model rooted at this path — it equals `state.manifest.root`,
49
+ * which is held at "/workspace" to match the Modal createManifest root for the
50
+ * provided-session root-delta guard (`validateProvidedSessionManifestUpdate`).
51
+ *
52
+ * On a bring-your-own machine this path DOES NOT EXIST: the machine's real root
53
+ * is the agent's `workspace_root` (reported in Hello, e.g. "/home/jorge/repo").
54
+ * The Rust agent's `resolve_cwd` maps an EMPTY cwd / a RELATIVE path onto its
55
+ * `workspace_root`, but takes an ABSOLUTE path AS-IS. So a virtual-root-anchored
56
+ * path the SDK hands us ("/workspace" or "/workspace/sub", e.g. an exec workdir
57
+ * or a model-relative file the SDK resolved against the manifest root) would hit
58
+ * the machine as a literal absolute "/workspace/…" → `current_dir`/open ENOENT
59
+ * (the live-swap exec crash: `spawn hostname: No such file or directory`).
60
+ *
61
+ * `toMachinePath` rewrites the virtual frame onto the machine's: the root itself
62
+ * → the session `workingDir` (empty by default ⇒ "", so the agent substitutes its
63
+ * workspace_root); a child → `workingDir`-rooted remainder (the agent joins it onto
64
+ * workspace_root). A genuine machine-ABSOLUTE path the model/agent chose ("/tmp/x"),
65
+ * or a real path echoed back by `listDir`, passes through UNTOUCHED. This is the
66
+ * SOLE adapter rule between the SDK's virtual space and the machine's real
67
+ * filesystem; it is applied at every NATS path/cwd boundary below (exec cwd, fs
68
+ * read/write/list/stat, the editor's delete, the terminal's pty cwd). The
69
+ * per-session `workingDir` (default "" ⇒ a byte-identical no-op) is the base.
70
+ */
71
+ const SELFHOSTED_VIRTUAL_ROOT = "/workspace";
72
+
73
+ /**
74
+ * `workingDir` is the session's per-session working directory — the frame's BASE.
75
+ * It is the launch-workspace_root-relative subdir (or an absolute machine path)
76
+ * the agent/terminal/dock operate under. An EMPTY `workingDir` (the default) makes
77
+ * this byte-identical to before: `base === ""`, so every branch returns the
78
+ * original value (empty/virtual → "", virtual-child → its remainder, a relative or
79
+ * absolute path → itself). A trailing slash on `workingDir` is stripped so a join
80
+ * never doubles; relative stays relative and absolute stays absolute otherwise.
81
+ */
82
+ function toMachinePath(p: string | undefined, workingDir: string): string {
83
+ const base = workingDir.replace(/\/$/, "");
84
+ if (!p || p === SELFHOSTED_VIRTUAL_ROOT) return base;
85
+ if (p.startsWith(`${SELFHOSTED_VIRTUAL_ROOT}/`)) {
86
+ const rel = p.slice(SELFHOSTED_VIRTUAL_ROOT.length + 1);
87
+ return base ? `${base}/${rel}` : rel;
88
+ }
89
+ // An ABSOLUTE machine path — a genuine path the model/agent chose ("/tmp/x") or
90
+ // a real path echoed back by `listDir` — points anywhere and passes through
91
+ // UNTOUCHED (the agent's `resolve_cwd` takes an absolute path as-is).
92
+ if (p.startsWith("/")) return p;
93
+ // A BARE-RELATIVE path is the structural Channel-A surface's frame: the file dock
94
+ // joins fs read/list/git sub-paths under an EMPTY workspaceRoot (yielding a bare
95
+ // relative), and a model-supplied relative exec workdir is bare too. Root it under
96
+ // the session working dir so those reads/stats stay in the SAME frame as the dock's
97
+ // working-dir-rooted listing/exec (which run with cwd = workingDir). The SDK agent
98
+ // loop never emits a bare-relative path — it anchors everything at the manifest
99
+ // root ("/workspace/…") — so this only re-homes the structural surface. With an
100
+ // empty workingDir it is a no-op (base === "" ⇒ returns the path unchanged).
101
+ return base ? `${base}/${p}` : p;
102
+ }
103
+
104
+ // ── The agent-turn provided-session contract (@openai/agents-core) ──────────
105
+ // When the routing proxy resolves a selfhosted ACTIVE backend, the @openai/agents
106
+ // agent loop binds its filesystem/shell/skills capabilities to THIS session and
107
+ // calls a richer method set than the Channel-A structural surface: `createEditor`
108
+ // + `viewImage` (filesystem), `execCommand` + `supportsPty` (shell), `pathExists`
109
+ // + `listDir` + `materializeEntry` + `readFile` (skills). The session must present
110
+ // all of them or the turn crashes (e.g. "Filesystem sandbox sessions must provide
111
+ // createEditor()"). These run over the SAME NATS exec/fs primitives; the machine
112
+ // owns its filesystem so source materialization is a no-op.
113
+
114
+ /** The V4A-diff applier the SDK's apply_patch editor uses. The leaf cannot import
115
+ * `@openai/agents`'s `applyDiff` (the agent-loop root the leaf forbids), so the
116
+ * runtime barrel (`packages/runtime/src/index.ts`, which DOES import that root)
117
+ * injects it via `setSelfhostedApplyDiff` at module load. Until injected,
118
+ * `createEditor()` surfaces a clear error rather than a silent wrong-edit. */
119
+ export type SelfhostedApplyDiff = (input: string, diff: string, mode?: "default" | "create") => string;
120
+ let injectedApplyDiff: SelfhostedApplyDiff | undefined;
121
+
122
+ /** Register the SDK's `applyDiff` so `SelfhostedSession.createEditor()` can apply
123
+ * V4A diffs over the NATS fs ops. Called once by the runtime barrel. */
124
+ export function setSelfhostedApplyDiff(fn: SelfhostedApplyDiff): void {
125
+ injectedApplyDiff = fn;
126
+ }
127
+
128
+ /** The structural Editor surface the SDK's filesystem capability consumes (the
129
+ * three apply_patch operations). Mirrors `@openai/agents-core`'s `Editor`. */
130
+ export interface SelfhostedEditor {
131
+ createFile(operation: { path: string; diff: string }, context?: unknown): Promise<{ output?: string } | void>;
132
+ updateFile(operation: { path: string; diff: string; moveTo?: string }, context?: unknown): Promise<{ output?: string } | void>;
133
+ deleteFile(operation: { path: string }, context?: unknown): Promise<{ output?: string } | void>;
134
+ }
135
+
136
+ /** The image tool-output shape the SDK's view_image tool expects (mirror of
137
+ * `ToolOutputImage` — not re-exported by `@openai/agents/sandbox`, so structural). */
138
+ export interface SelfhostedImageOutput {
139
+ type: "image";
140
+ image: { data: Uint8Array; mediaType: string };
141
+ }
142
+
143
+ /** Default control-op timeout. A transient miss surfaces as `agent_reconnecting`
144
+ * (the turn pauses + retries); it is NOT a hard failure. */
145
+ export const SELFHOSTED_DEFAULT_TIMEOUT_MS = 30_000;
146
+
147
+ /** The relay-URL shape config the session needs to build a stream endpoint. M8b
148
+ * wires the real relay deployment behind THIS seam so `buildStreamUrl` works
149
+ * unchanged behind `resolveExposedPort`. */
150
+ export interface SelfhostedRelayConfig {
151
+ /** The relay edge host (no scheme), e.g. "relay.opengeni.ai". */
152
+ host: string;
153
+ /** The relay port. Defaults to 443 (the relay terminates TLS). */
154
+ port?: number;
155
+ /** Whether the relay endpoint is TLS (wss/https). Defaults true. */
156
+ tls?: boolean;
157
+ /** The relay's stream-dial path (the `opengeni-relay` wss route). Defaults to
158
+ * "/stream" — the route the relay listens on (M8b). */
159
+ path?: string;
160
+ }
161
+
162
+ /** The relay's default wss dial path (the `opengeni-relay` server route). */
163
+ export const SELFHOSTED_RELAY_STREAM_PATH = "/stream";
164
+
165
+ export interface SelfhostedSessionDeps {
166
+ workspaceId: string;
167
+ agentId: string;
168
+ controlRpc: ControlRpc;
169
+ relay: SelfhostedRelayConfig;
170
+ /** The lease/active epoch this session is fenced under (echoed on every
171
+ * ControlRequest so the agent can reject a stale op with ERROR_CODE_FENCED).
172
+ * Defaults to 0 (no fence) for the negotiation-only / test path. */
173
+ epoch?: number;
174
+ /** Override the control-op timeout (tests). */
175
+ timeoutMs?: number;
176
+ /**
177
+ * The run's declared sandbox environment — the SAME `Record<string,string>` the
178
+ * worker turn passes to `runtime.buildAgent`'s `sandboxEnvironment` (and that the
179
+ * agent's TARGET manifest, `buildManifest`, carries). The SDK injects this
180
+ * selfhosted session NON-OWNED and applies the agent's manifest as a provided-
181
+ * session delta; `validateNoEnvironmentDelta` throws "Live sandbox sessions cannot
182
+ * change manifest environment variables" on ANY env mismatch. So `state.manifest`'s
183
+ * `environment` MUST EQUAL the turn's environment for the delta to be empty. The
184
+ * selfhosted exec routes over NATS and does NOT consume the env, but the manifest
185
+ * must carry it for parity. Omitted → `{}` (the negotiation-only / test path,
186
+ * which never applies a turn manifest, so there is no delta to validate).
187
+ */
188
+ environment?: Record<string, string>;
189
+ /**
190
+ * The session's working directory — the BASE every path/cwd is rooted under (see
191
+ * `toMachinePath` / SELFHOSTED_VIRTUAL_ROOT). A launch-workspace_root-relative
192
+ * subdir (resolved under workspace_root by the agent's `resolve_cwd`) or an
193
+ * absolute machine path. Omitted/empty (the default) ⇒ "" ⇒ today's behavior
194
+ * exactly (an empty cwd lets the agent substitute its workspace_root).
195
+ */
196
+ workingDir?: string;
197
+ }
198
+
199
+ /** The Channel-A `exec` result shape (a structural superset of the SDK's). */
200
+ export interface SelfhostedExecResult {
201
+ output: string;
202
+ stdout: string;
203
+ stderr: string;
204
+ exitCode: number | null;
205
+ }
206
+
207
+ /** The `exec` args the structural surface accepts (mirrors ChannelAExecArgs). */
208
+ export interface SelfhostedExecArgs {
209
+ cmd: string;
210
+ workdir?: string | undefined;
211
+ shell?: string | undefined;
212
+ login?: boolean | undefined;
213
+ tty?: boolean | undefined;
214
+ runAs?: string | undefined;
215
+ }
216
+
217
+ /**
218
+ * The persistable session state. For selfhosted this is `{agentId}` ONLY — there
219
+ * is NO provider box id, no snapshot, no manifest. Resume re-addresses the live
220
+ * subject; the machine itself is the persistence (`persistable:false`).
221
+ */
222
+ export interface SelfhostedSessionState {
223
+ agentId: string;
224
+ }
225
+
226
+ /**
227
+ * A live selfhosted session — the structural `SandboxSessionLike` surface over a
228
+ * `ControlRpc`. Mirrors Modal's session shape so Channel-A/viewer/computer-use
229
+ * consume it unchanged.
230
+ */
231
+ export class SelfhostedSession {
232
+ readonly backendId = "selfhosted" as const;
233
+ readonly workspaceId: string;
234
+ readonly agentId: string;
235
+ private readonly controlRpc: ControlRpc;
236
+ private readonly relay: SelfhostedRelayConfig;
237
+ private readonly epoch: number;
238
+ private readonly timeoutMs: number;
239
+ private readonly subject: string;
240
+ /** The session working directory — the path/cwd base every op is rooted under
241
+ * (see `toMachinePath`). "" by default ⇒ today's workspace_root behavior. */
242
+ private readonly workingDir: string;
243
+
244
+ /**
245
+ * The structural `state` slice consumers read. `agentId`/`instanceId` serve the
246
+ * channel-a `readInstanceId` + docker-network decoration (the agentId IS the
247
+ * identity). `manifest` is the slice the @openai/agents SDK reads AND writes per
248
+ * turn (serializeManifestEnvironment / validateProvidedSessionManifestUpdate read
249
+ * `manifest.root` + iterate `manifest.environment`; providedSessionManifest WRITES
250
+ * `state.manifest = next`). It must be a real, MUTABLE Manifest field — when the
251
+ * RoutingSandboxSession proxy resolves THIS as the active backend it returns
252
+ * `session.state` BY REFERENCE, so the SDK's read and write must both land on a
253
+ * well-formed Manifest here (defined `root`, object `environment`). Without it the
254
+ * SDK crashes with `undefined is not an object (evaluating 'current.root')`.
255
+ *
256
+ * `manifest` is intentionally a plain mutable field (not `readonly`) so the SDK's
257
+ * `state.manifest = next` write succeeds. It is NOT part of the persistable state
258
+ * (`serializeSessionState` round-trips `{agentId}` only).
259
+ *
260
+ * `environment` is the SDK `SandboxSessionState.environment` (a `Record<string,
261
+ * string>`). It MUST be present because the GROUP box's client serializes THIS
262
+ * (the active backend's) state at end-of-turn — the non-owned injected session is
263
+ * serialized via the CONFIGURED client (modal in prod), NOT the selfhosted client.
264
+ * Modal's `serializeRemoteSandboxSessionState` does `Object.entries(state.environment)`;
265
+ * an absent field crashes the post-turn RunState serialize with "Object.entries
266
+ * requires that input parameter not be null or undefined". It carries the run's
267
+ * threaded environment (or `{}`). The resulting modal-tagged envelope is inert for
268
+ * selfhosted (resume re-addresses the machine by agentId via the lease pointer,
269
+ * never from this SDK envelope), so its only job is to not crash the serialize.
270
+ */
271
+ readonly state: { agentId: string; instanceId: string; manifest: Manifest; environment: Record<string, string> };
272
+
273
+ constructor(deps: SelfhostedSessionDeps) {
274
+ this.workspaceId = deps.workspaceId;
275
+ this.agentId = deps.agentId;
276
+ this.controlRpc = deps.controlRpc;
277
+ this.relay = deps.relay;
278
+ this.epoch = deps.epoch ?? 0;
279
+ this.timeoutMs = deps.timeoutMs ?? SELFHOSTED_DEFAULT_TIMEOUT_MS;
280
+ this.subject = subjectFor(deps.workspaceId, deps.agentId);
281
+ this.workingDir = deps.workingDir ?? "";
282
+ // A valid Manifest mirroring the Modal create-manifest shape (sandbox/index.ts
283
+ // `createManifest`: `new Manifest({ root: "/workspace", environment })`). `root`
284
+ // is "/workspace" to match `buildManifest`'s declared root (the root-delta guard
285
+ // in validateProvidedSessionManifestUpdate). This is the VIRTUAL root the SDK
286
+ // presents to the model; `toMachinePath` (see SELFHOSTED_VIRTUAL_ROOT) rewrites
287
+ // it onto the machine's real `workspace_root` at every exec/fs NATS boundary,
288
+ // so the manifest never needs to carry the machine's true root. `environment`
289
+ // is the run's declared
290
+ // sandbox environment — the SAME object the worker turn threads into the agent's
291
+ // TARGET manifest — so the SDK's per-turn provided-session delta
292
+ // (validateNoEnvironmentDelta) finds NO mismatch. `entries: {}` because the
293
+ // selfhosted machine already owns its filesystem (no SDK materialization; exec
294
+ // routes over NATS). Omitted env (the negotiation-only / test path) defaults to
295
+ // `{}` — no turn manifest is applied there, so there is no delta to validate.
296
+ this.state = {
297
+ agentId: deps.agentId,
298
+ instanceId: deps.agentId,
299
+ manifest: new Manifest({ root: "/workspace", entries: {}, environment: deps.environment ?? {} }),
300
+ // The SDK `SandboxSessionState.environment` — the run's threaded env (or `{}`).
301
+ // The group client's end-of-turn serialize reads `state.environment` directly
302
+ // (Object.entries), so it must be a defined object, not absent.
303
+ environment: deps.environment ?? {},
304
+ };
305
+ }
306
+
307
+ /** Issue a control op, decoding the agent's reply or throwing the mapped
308
+ * `SelfhostedControlError` on an AgentError (incl. a synthesized offline /
309
+ * timeout error from the transport). */
310
+ private async call(op: NonNullable<ControlRequest["op"]>): Promise<NonNullable<ControlResponse["result"]>> {
311
+ const req: ControlRequest = {
312
+ requestId: crypto.randomUUID(),
313
+ epoch: this.epoch,
314
+ op,
315
+ };
316
+ const res = await this.controlRpc.request(this.subject, req, { timeoutMs: this.timeoutMs });
317
+ if (res.error) {
318
+ throw agentErrorToControlError(res.error);
319
+ }
320
+ if (!res.result) {
321
+ throw agentErrorToControlError({
322
+ code: 7, // ERROR_CODE_PROTOCOL — an empty result is a protocol violation
323
+ message: "agent returned an empty control response",
324
+ retryable: false,
325
+ detail: {},
326
+ });
327
+ }
328
+ return res.result;
329
+ }
330
+
331
+ /** Channel-A `exec`: run a command on the machine and return its output. */
332
+ async exec(args: SelfhostedExecArgs): Promise<SelfhostedExecResult> {
333
+ const execReq: ExecRequest = {
334
+ // The agent does NOT shell-interpret unless `shell` — Channel-A passes a
335
+ // single shell command string, so run it through the platform shell.
336
+ command: [args.cmd],
337
+ shell: true,
338
+ // Rewrite a virtual-root cwd ("/workspace[/…]") onto the machine's frame —
339
+ // an absolute "/workspace" would ENOENT on a real machine (see
340
+ // SELFHOSTED_VIRTUAL_ROOT). Empty → the session workingDir (itself "" by
341
+ // default ⇒ the agent runs in its workspace_root).
342
+ cwd: toMachinePath(args.workdir, this.workingDir),
343
+ env: {},
344
+ stdin: new Uint8Array(0),
345
+ timeoutMs: 0,
346
+ };
347
+ const result = await this.call({ $case: "exec", exec: execReq });
348
+ if (result.$case !== "exec") {
349
+ throw new Error(`selfhosted exec: unexpected result ${result.$case}`);
350
+ }
351
+ return execResultToChannelA(result.exec);
352
+ }
353
+
354
+ // ── The agent-turn provided-session contract (over the SAME NATS primitives) ──
355
+ // These are what the @openai/agents shell/filesystem/skills capabilities call on
356
+ // the ACTIVE session once the routing proxy resolves selfhosted. They reuse the
357
+ // exec/fs ops above; the machine owns its filesystem (materialization is a no-op).
358
+
359
+ /** SDK shell capability `execCommand`: run a command and return its stdout (the
360
+ * `exec_command` tool). Selfhosted exec is non-interactive (no PTY) — `tty` is
361
+ * ignored; `supportsPty()` is false so the SDK never offers a stdin session. */
362
+ async execCommand(args: { cmd: string; workdir?: string; runAs?: string }): Promise<string> {
363
+ const result = await this.exec({ cmd: args.cmd, workdir: args.workdir, runAs: args.runAs });
364
+ return result.output;
365
+ }
366
+
367
+ /** SDK shell capability never calls this (gated on `supportsPty()` which is
368
+ * false), but the surface advertises it. Selfhosted exec has no interactive PTY
369
+ * session over the structured RPC, so a stdin write is unsupported. */
370
+ supportsPty(): boolean {
371
+ return false;
372
+ }
373
+
374
+ /** SDK filesystem capability `view_image`: read the image bytes off the machine
375
+ * and wrap them in the tool-output image shape (magic-byte sniff + path fallback,
376
+ * mirroring the SDK's `imageOutputFromBytes`). */
377
+ async viewImage(args: { path: string; runAs?: string }): Promise<SelfhostedImageOutput> {
378
+ const bytes = await this.readFile({ path: args.path, ...(args.runAs ? { runAs: args.runAs } : {}) });
379
+ const mediaType = sniffImageMediaType(bytes, args.path);
380
+ if (!mediaType) {
381
+ throw new Error(`selfhosted view_image: unsupported image format for ${args.path}`);
382
+ }
383
+ return { type: "image", image: { data: Uint8Array.from(bytes), mediaType } };
384
+ }
385
+
386
+ /** SDK skills/filesystem `pathExists`: whether a path exists on the machine. */
387
+ async pathExists(path: string, _runAs?: string): Promise<boolean> {
388
+ const { exists } = await this.statFile({ path });
389
+ return exists;
390
+ }
391
+
392
+ /** SDK skills `listDir`: list a directory as `{name, path, type}[]`. */
393
+ async listDir(args: { path: string; runAs?: string }): Promise<Array<{ name: string; path: string; type: "file" | "dir" | "other" }>> {
394
+ const result = await this.listFiles({ path: args.path });
395
+ return result.fsList.entries.map((entry) => ({
396
+ name: entry.name,
397
+ path: entry.path,
398
+ type:
399
+ entry.kind === FsEntryKind.FS_ENTRY_KIND_DIRECTORY
400
+ ? ("dir" as const)
401
+ : entry.kind === FsEntryKind.FS_ENTRY_KIND_FILE
402
+ ? ("file" as const)
403
+ : ("other" as const),
404
+ }));
405
+ }
406
+
407
+ /** SDK manifest-delta `materializeEntry`: a NO-OP for selfhosted. Source
408
+ * materialization (cloning repos / staging files into the box) is how cloud
409
+ * providers prepare a fresh box; a bring-your-own machine already owns its
410
+ * filesystem and is prepared by the agent itself, so there is nothing to stage.
411
+ * Present (not absent) so the SDK's provided-session manifest apply path — which
412
+ * requires `applyManifest()` OR `materializeEntry()` when the agent declares
413
+ * entries — is satisfied without error. The selfhosted manifest declares no
414
+ * entries, so in practice this is never invoked with a real entry. */
415
+ async materializeEntry(_args: { path: string; entry: unknown; runAs?: string }): Promise<void> {
416
+ return;
417
+ }
418
+
419
+ /** SDK filesystem capability `createEditor`: the apply_patch host. Applies V4A
420
+ * diffs over the NATS fs ops (read → applyDiff → write). `applyDiff` is the SDK's
421
+ * own parser, injected by the runtime barrel (the leaf cannot import it). */
422
+ createEditor(runAs?: string): SelfhostedEditor {
423
+ const applyDiff = injectedApplyDiff;
424
+ if (!applyDiff) {
425
+ throw new Error(
426
+ "selfhosted createEditor: applyDiff not injected (the runtime barrel must call setSelfhostedApplyDiff before an agent turn binds the filesystem capability)",
427
+ );
428
+ }
429
+ const pathExists = (path: string): Promise<boolean> => this.pathExists(path, runAs);
430
+ const readText = async (path: string): Promise<string> =>
431
+ decoder.decode(await this.readFile({ path, ...(runAs ? { runAs } : {}) }));
432
+ const writeText = async (path: string, content: string): Promise<void> => {
433
+ await this.writeFile({ path, content, createParents: true });
434
+ };
435
+ const deletePath = async (path: string): Promise<void> => {
436
+ // No fs-delete op in the proto; remove via the shell (the machine's own rm).
437
+ // The path arg is embedded in the command, and this.exec runs it with the
438
+ // DEFAULT cwd = the session workingDir. So target the path RELATIVE to that
439
+ // cwd: strip the virtual root to its bare remainder (toMachinePath with an
440
+ // EMPTY base) — prefixing workingDir here too would DOUBLE it (the cwd is
441
+ // already workingDir). A non-virtual absolute path passes through and rm
442
+ // uses it as-is; an empty workingDir is byte-identical to before.
443
+ await this.exec({ cmd: `rm -rf -- ${shellQuote(toMachinePath(path, ""))}`, ...(runAs ? { runAs } : {}) });
444
+ };
445
+ return {
446
+ async createFile(operation) {
447
+ if (await pathExists(operation.path)) {
448
+ throw new Error(`selfhosted createFile: file already exists: ${operation.path}`);
449
+ }
450
+ await writeText(operation.path, applyDiff("", operation.diff, "create"));
451
+ return {};
452
+ },
453
+ async updateFile(operation) {
454
+ const current = await readText(operation.path);
455
+ const next = applyDiff(current, operation.diff);
456
+ const destination = operation.moveTo ?? operation.path;
457
+ await writeText(destination, next);
458
+ if (operation.moveTo && destination !== operation.path) {
459
+ await deletePath(operation.path);
460
+ }
461
+ return {};
462
+ },
463
+ async deleteFile(operation) {
464
+ await deletePath(operation.path);
465
+ return {};
466
+ },
467
+ };
468
+ }
469
+
470
+ /** Channel-A `readFile`: read a file off the machine (binary-safe). */
471
+ async readFile(args: { path: string; runAs?: string; maxBytes?: number }): Promise<Uint8Array> {
472
+ const result = await this.call({
473
+ $case: "fsRead",
474
+ fsRead: {
475
+ path: toMachinePath(args.path, this.workingDir),
476
+ offset: "0",
477
+ length: args.maxBytes ? String(args.maxBytes) : "0",
478
+ },
479
+ });
480
+ if (result.$case !== "fsRead") {
481
+ throw new Error(`selfhosted readFile: unexpected result ${result.$case}`);
482
+ }
483
+ return result.fsRead.content;
484
+ }
485
+
486
+ /** Write a file onto the machine (the fs surface the descriptor advertises). */
487
+ async writeFile(args: { path: string; content: string | Uint8Array; createParents?: boolean; append?: boolean }): Promise<number> {
488
+ const content = typeof args.content === "string" ? encoder.encode(args.content) : args.content;
489
+ const result = await this.call({
490
+ $case: "fsWrite",
491
+ fsWrite: {
492
+ path: toMachinePath(args.path, this.workingDir),
493
+ content,
494
+ createParents: args.createParents ?? true,
495
+ append: args.append ?? false,
496
+ mode: 0,
497
+ },
498
+ });
499
+ if (result.$case !== "fsWrite") {
500
+ throw new Error(`selfhosted writeFile: unexpected result ${result.$case}`);
501
+ }
502
+ return Number(result.fsWrite.bytesWritten);
503
+ }
504
+
505
+ /** List a directory on the machine. */
506
+ async listFiles(args: { path: string; recursive?: boolean }): Promise<NonNullable<ControlResponse["result"]> & { $case: "fsList" }> {
507
+ const result = await this.call({
508
+ $case: "fsList",
509
+ fsList: { path: toMachinePath(args.path, this.workingDir), recursive: args.recursive ?? false },
510
+ });
511
+ if (result.$case !== "fsList") {
512
+ throw new Error(`selfhosted listFiles: unexpected result ${result.$case}`);
513
+ }
514
+ return result;
515
+ }
516
+
517
+ /** Stat a path on the machine. */
518
+ async statFile(args: { path: string }): Promise<{ exists: boolean }> {
519
+ const result = await this.call({ $case: "fsStat", fsStat: { path: toMachinePath(args.path, this.workingDir) } });
520
+ if (result.$case !== "fsStat") {
521
+ throw new Error(`selfhosted statFile: unexpected result ${result.$case}`);
522
+ }
523
+ return { exists: result.fsStat.exists };
524
+ }
525
+
526
+ // ── Computer-use control plane (the agent drives its OWN screen) ──────────────
527
+ // The CONTROL-PLANE twin of the relay DesktopInput/desktop stream: instead of a
528
+ // human viewer channel, the agent injects synthetic input into — and captures —
529
+ // its own display for the model's computer-use loop. Both route over the SAME
530
+ // `call()` primitive, so a consent/epoch rejection surfaces as the mapped
531
+ // `SelfhostedControlError` exactly like every other op. `NativeDesktopComputer`
532
+ // (sandbox-computer.ts) is the sole consumer.
533
+
534
+ /** Computer-use WRITE op: inject one synthetic desktop input event (pointer/key/
535
+ * scroll) on the machine's OWN display. The agent injects via CGEvent (macOS) /
536
+ * XTEST (Linux) and CONSENT-GATES it — an unconsented call never touches the OS
537
+ * and surfaces the mapped control error (ERROR_CODE_CONSENT_REQUIRED) via `call()`. */
538
+ async desktopInput(event: DesktopInputRequest["event"]): Promise<void> {
539
+ const result = await this.call({ $case: "desktopInput", desktopInput: { event } });
540
+ if (result.$case !== "desktopInput") {
541
+ throw new Error(`selfhosted desktopInput: unexpected result ${result.$case}`);
542
+ }
543
+ }
544
+
545
+ /** Computer-use VIEW op: capture a single PNG screenshot of the machine's desktop
546
+ * plus its geometry (via ScreenCaptureKit / x11). NOT consent-gated (a view op —
547
+ * the view/control decoupling), so it works with a display but no screen-control
548
+ * consent. Returns the raw encoded bytes + width/height. */
549
+ async screenshot(): Promise<{ png: Uint8Array; width: number; height: number }> {
550
+ const result = await this.call({ $case: "desktopScreenshot", desktopScreenshot: {} });
551
+ if (result.$case !== "desktopScreenshot") {
552
+ throw new Error(`selfhosted screenshot: unexpected result ${result.$case}`);
553
+ }
554
+ return {
555
+ png: result.desktopScreenshot.png,
556
+ width: result.desktopScreenshot.width,
557
+ height: result.desktopScreenshot.height,
558
+ };
559
+ }
560
+
561
+ /** A cheap liveness probe — request a Ping on the subject; returns true iff a
562
+ * responder answered (no AgentError). Used by `negotiateSelfhostedCapabilities`.
563
+ * The wire `nonce` is a uint64 (a numeric string), so the default is a random
564
+ * numeric value — NOT a UUID (which would fail proto uint64 encoding). */
565
+ async ping(nonce = randomNonce()): Promise<boolean> {
566
+ const req: ControlRequest = {
567
+ requestId: crypto.randomUUID(),
568
+ epoch: this.epoch,
569
+ op: { $case: "ping", ping: { nonce } },
570
+ };
571
+ const res = await this.controlRpc.request(this.subject, req, { timeoutMs: this.timeoutMs });
572
+ return !res.error && res.result?.$case === "ping";
573
+ }
574
+
575
+ /**
576
+ * Resolve an exposed port to a relay stream endpoint (the viewer/pty plane).
577
+ * Returns the relay URL SHAPE — `{host:relay, port, tls, query:channel-key}` —
578
+ * after asking the agent to ensure a stream channel for the port. M8b wires the
579
+ * real relay tier (the byte pump) behind THIS seam.
580
+ *
581
+ * THE CHANNEL-KEY QUERY (the M8b relay-dial contract, dossier §10.5): the relay
582
+ * routes by `{workspaceId, agentId, port}` — the EXACT `ChannelKey::query` the
583
+ * agent's relay client (`opengeni-agent-stream`) appends when it registers the
584
+ * producer side: `ws=<workspaceId>&agent=<agentId>&port=<port>`. We append the
585
+ * agent-registered `channel=<channelId>` as a correlation hint. So the viewer
586
+ * dials `wss://<relay>/stream?ws=&agent=&port=&channel=` and presents the minted
587
+ * `ogs_` token in-band (NEVER as a URL param) — the relay pairs it with the
588
+ * producer by the routing key.
589
+ */
590
+ async resolveExposedPort(port: number): Promise<ExposedPortEndpoint> {
591
+ // Ask the agent to ensure a relay PRODUCER channel exists for the port, using the
592
+ // PORT-APPROPRIATE op. The PTY plane (7681) is INDEPENDENT of the desktop display:
593
+ // route it through `ptyOpen` (which spawns/attaches a PTY and NEVER touches X11),
594
+ // and ONLY the desktop framebuffer plane (6080) through `desktopEnsure` (which
595
+ // hard-requires a live virtual display). Earlier M8b used `desktopEnsure` for
596
+ // EVERY port — that wrongly coupled the terminal to the desktop probe, so a
597
+ // headless (or display-degraded) machine could never get a terminal even though
598
+ // `ptyOpen` would have succeeded. The returned channelId is the relay
599
+ // correlation hint; both ops carry a `StreamChannel` on their response.
600
+ let channel: StreamChannel | undefined;
601
+ if (port === DESKTOP_STREAM_PORT) {
602
+ const result = await this.call({
603
+ $case: "desktopEnsure",
604
+ desktopEnsure: { width: 0, height: 0 },
605
+ });
606
+ if (result.$case !== "desktopEnsure") {
607
+ throw new Error(`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`);
608
+ }
609
+ channel = result.desktopEnsure.channel;
610
+ } else {
611
+ // The PTY plane (7681) + any non-desktop stream port. `command: []` => the
612
+ // user's default login shell; the agent's pty_pump bridges the PTY master to
613
+ // the relay channel. Display-INDEPENDENT — works on a headless machine.
614
+ const result = await this.call({
615
+ $case: "ptyOpen",
616
+ // Open the terminal in the session workingDir (default "" ⇒ the agent's
617
+ // workspace_root, byte-identical to before). A relative workingDir resolves
618
+ // under workspace_root; an absolute one is used as-is by the agent.
619
+ ptyOpen: { command: [], cwd: this.workingDir, env: {}, cols: 0, rows: 0, term: "xterm-256color" },
620
+ });
621
+ if (result.$case !== "ptyOpen") {
622
+ throw new Error(`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`);
623
+ }
624
+ channel = result.ptyOpen.channel;
625
+ }
626
+ const channelId = channel?.channelId ?? channelKey(this.workspaceId, this.agentId, port);
627
+ const tls = this.relay.tls ?? true;
628
+ // The routing key the relay pairs producer↔consumer by — IDENTICAL to the
629
+ // agent's `ChannelKey::query` — plus the channel-id correlation hint.
630
+ const routingQuery =
631
+ `ws=${encodeURIComponent(this.workspaceId)}` +
632
+ `&agent=${encodeURIComponent(this.agentId)}` +
633
+ `&port=${port}` +
634
+ `&channel=${encodeURIComponent(channelId)}`;
635
+ return {
636
+ host: this.relay.host,
637
+ port: this.relay.port ?? (tls ? 443 : 80),
638
+ tls,
639
+ // The relay's wss route (`/stream`); buildStreamUrl honors `path`.
640
+ path: this.relay.path ?? SELFHOSTED_RELAY_STREAM_PATH,
641
+ query: routingQuery,
642
+ protocol: kindToProtocol(channel?.kind),
643
+ };
644
+ }
645
+
646
+ /** Round-trip the persistable state — `{agentId}` ONLY (resume = re-address). */
647
+ async serializeSessionState(): Promise<SelfhostedSessionState> {
648
+ return { agentId: this.agentId };
649
+ }
650
+ }
651
+
652
+ /**
653
+ * The selfhosted SDK-client surface the registry builds. `backendId:"selfhosted"`
654
+ * (the resume-fence field asserted against the descriptor). `create()`/`resume()`
655
+ * return a `SelfhostedSession` bound to `{workspaceId, agentId, controlRpc}`.
656
+ *
657
+ * `create()` and `resume()` are IDENTICAL for selfhosted — there is no box to
658
+ * provision (the machine already exists); both just bind a session to the live
659
+ * subject. `serializeSessionState`/`deserializeSessionState` round-trip
660
+ * `{agentId}` only.
661
+ *
662
+ * The `controlRpc` is constructed LAZILY via an injected factory (defaulting to
663
+ * `NatsControlRpc`); a session built before NATS is configured surfaces
664
+ * `agent_offline` on its first op rather than failing at construction.
665
+ */
666
+ export class SelfhostedSandboxClient {
667
+ readonly backendId = "selfhosted" as const;
668
+ readonly supportsDefaultOptions = false;
669
+ private readonly workspaceId: string;
670
+ private readonly relay: SelfhostedRelayConfig;
671
+ private readonly controlRpcFactory: () => ControlRpc;
672
+ private readonly defaultAgentId: string | undefined;
673
+ private readonly epoch: number | undefined;
674
+ private readonly timeoutMs: number | undefined;
675
+ private readonly environment: Record<string, string> | undefined;
676
+ private readonly workingDir: string | undefined;
677
+ private controlRpcMemo: ControlRpc | undefined;
678
+
679
+ constructor(opts: {
680
+ workspaceId: string;
681
+ relay: SelfhostedRelayConfig;
682
+ /** Lazily build the ControlRpc (defaults to NatsControlRpc in the provider). */
683
+ controlRpcFactory: () => ControlRpc;
684
+ /** The agentId a bare create()/resume() (no state) binds to. Optional: the
685
+ * resume path supplies it via deserializeSessionState. */
686
+ agentId?: string;
687
+ epoch?: number;
688
+ timeoutMs?: number;
689
+ /** The run's declared sandbox environment, threaded into every bound session's
690
+ * `state.manifest.environment` so the SDK's per-turn manifest-env delta is
691
+ * empty (validateNoEnvironmentDelta). See SelfhostedSessionDeps.environment.
692
+ * Omitted → `{}` (the negotiation-only path; no turn manifest is applied). */
693
+ environment?: Record<string, string>;
694
+ /** The session working directory threaded into every bound session (the path/
695
+ * cwd base; see SelfhostedSessionDeps.workingDir). Omitted/empty ⇒ the default
696
+ * workspace_root behavior. */
697
+ workingDir?: string;
698
+ }) {
699
+ this.workspaceId = opts.workspaceId;
700
+ this.relay = opts.relay;
701
+ this.controlRpcFactory = opts.controlRpcFactory;
702
+ this.defaultAgentId = opts.agentId;
703
+ this.epoch = opts.epoch;
704
+ this.timeoutMs = opts.timeoutMs;
705
+ this.environment = opts.environment;
706
+ this.workingDir = opts.workingDir;
707
+ }
708
+
709
+ private controlRpc(): ControlRpc {
710
+ if (!this.controlRpcMemo) {
711
+ this.controlRpcMemo = this.controlRpcFactory();
712
+ }
713
+ return this.controlRpcMemo;
714
+ }
715
+
716
+ private bind(agentId: string): SelfhostedSession {
717
+ return new SelfhostedSession({
718
+ workspaceId: this.workspaceId,
719
+ agentId,
720
+ controlRpc: this.controlRpc(),
721
+ relay: this.relay,
722
+ ...(this.epoch !== undefined ? { epoch: this.epoch } : {}),
723
+ ...(this.timeoutMs !== undefined ? { timeoutMs: this.timeoutMs } : {}),
724
+ ...(this.environment !== undefined ? { environment: this.environment } : {}),
725
+ ...(this.workingDir !== undefined ? { workingDir: this.workingDir } : {}),
726
+ });
727
+ }
728
+
729
+ /** Bind a session to the live agent subject. There is no box to provision. */
730
+ async create(_manifest?: unknown, _options?: unknown): Promise<SelfhostedSession> {
731
+ const agentId = this.requireAgentId();
732
+ return this.bind(agentId);
733
+ }
734
+
735
+ /** Resume = re-address the subject. Identical to create — no provider state. */
736
+ async resume(state: SelfhostedSessionState | Record<string, unknown>, _options?: unknown): Promise<SelfhostedSession> {
737
+ const agentId = readAgentId(state) ?? this.requireAgentId();
738
+ return this.bind(agentId);
739
+ }
740
+
741
+ /** Serialize a live session's state → `{agentId}` ONLY. */
742
+ async serializeSessionState(state: SelfhostedSessionState | { agentId?: string } | unknown): Promise<SelfhostedSessionState> {
743
+ const agentId = readAgentId(state) ?? this.requireAgentId();
744
+ return { agentId };
745
+ }
746
+
747
+ /** Deserialize `{agentId}` from the persisted envelope. */
748
+ async deserializeSessionState(state: Record<string, unknown>): Promise<SelfhostedSessionState> {
749
+ const agentId = readAgentId(state) ?? this.requireAgentId();
750
+ return { agentId };
751
+ }
752
+
753
+ /** selfhosted is NOT persistable — there is no owned session state to preserve
754
+ * (the machine is the persistence). The lease never snapshots it. */
755
+ async canPersistOwnedSessionState(): Promise<boolean> {
756
+ return false;
757
+ }
758
+
759
+ private requireAgentId(): string {
760
+ if (!this.defaultAgentId) {
761
+ throw new Error("selfhosted sandbox client: no agentId bound (create()/resume() need a session state carrying agentId)");
762
+ }
763
+ return this.defaultAgentId;
764
+ }
765
+ }
766
+
767
+ /**
768
+ * The dependency shape `buildSelfhostedBackendSession` needs to bind a live
769
+ * selfhosted session to a target machine. A structural superset of the fields the
770
+ * routing resolver (backend-resolver.ts) reads off its deps + pointer, and the
771
+ * fields the WORKER turn's machine-primary establish branch threads in — so a
772
+ * SINGLE build shape is shared by both (never two divergent constructions of the
773
+ * same SelfhostedSandboxClient/resume pair).
774
+ */
775
+ export interface SelfhostedSessionBuild {
776
+ /** The workspace the machine's control-plane subject is scoped to. */
777
+ workspaceId: string;
778
+ /** The enrollment id == the agent id `agent.<ws>.<id>.rpc` addresses. */
779
+ agentId: string;
780
+ /** The relay-URL shape for stream endpoints. */
781
+ relay: SelfhostedRelayConfig;
782
+ /** Lazily build the live ControlRpc (the request-scoped NATS connection). */
783
+ controlRpcFactory: () => ControlRpc;
784
+ /** The lease/active epoch the session is fenced under (echoed on every op). */
785
+ epoch: number;
786
+ /** The run's declared sandbox environment → the session manifest.environment
787
+ * (env-parity; see SelfhostedSessionDeps.environment). */
788
+ environment?: Record<string, string>;
789
+ /** The session working directory (the path/cwd base). Null/absent ⇒ workspace_root. */
790
+ workingDir?: string | null;
791
+ /** Override the control-op timeout (tests). */
792
+ timeoutMs?: number;
793
+ }
794
+
795
+ /**
796
+ * Build a live selfhosted session bound to a target machine: construct a request-
797
+ * scoped `SelfhostedSandboxClient` (fenced under `epoch`, carrying the run's env +
798
+ * working dir) and `resume()` it (= re-address the live subject — no provider box
799
+ * is created). Returns BOTH the client (the OWNED-sandbox client the turn injects,
800
+ * whose `serializeSessionState` round-trips `{agentId}`) and the live session.
801
+ *
802
+ * Shared by:
803
+ * - the routing resolver (backend-resolver.ts) — a swap target, where only the
804
+ * session is needed; and
805
+ * - the worker turn's machine-primary establish branch — where the client is the
806
+ * owned-sandbox client AND the session is the pinned routing default.
807
+ * Factoring it here keeps the two builds identical (no divergence in the fence
808
+ * epoch, env threading, or working-dir base).
809
+ */
810
+ export async function buildSelfhostedBackendSession(
811
+ deps: SelfhostedSessionBuild,
812
+ ): Promise<{ client: SelfhostedSandboxClient; session: SelfhostedSession }> {
813
+ const client = new SelfhostedSandboxClient({
814
+ workspaceId: deps.workspaceId,
815
+ relay: deps.relay,
816
+ controlRpcFactory: deps.controlRpcFactory,
817
+ agentId: deps.agentId,
818
+ epoch: deps.epoch,
819
+ ...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),
820
+ ...(deps.environment !== undefined ? { environment: deps.environment } : {}),
821
+ ...(deps.workingDir ? { workingDir: deps.workingDir } : {}),
822
+ });
823
+ const session = await client.resume({ agentId: deps.agentId });
824
+ return { client, session };
825
+ }
826
+
827
+ function readAgentId(state: unknown): string | undefined {
828
+ if (state && typeof state === "object") {
829
+ const candidate = (state as { agentId?: unknown }).agentId
830
+ ?? ((state as { providerState?: { agentId?: unknown } }).providerState?.agentId);
831
+ if (typeof candidate === "string" && candidate.length > 0) {
832
+ return candidate;
833
+ }
834
+ }
835
+ return undefined;
836
+ }
837
+
838
+ function execResultToChannelA(res: ExecResponse): SelfhostedExecResult {
839
+ const stdout = decoder.decode(res.stdout);
840
+ const stderr = decoder.decode(res.stderr);
841
+ return {
842
+ output: stdout,
843
+ stdout,
844
+ stderr,
845
+ exitCode: res.exitCode,
846
+ };
847
+ }
848
+
849
+ function channelKey(workspaceId: string, agentId: string, port: number): string {
850
+ return `${workspaceId}:${agentId}:${port}`;
851
+ }
852
+
853
+ /** Single-quote a string for POSIX shell (the editor's delete uses the machine's
854
+ * own `rm`). Mirrors the standard `'…'` quoting with `'\''` escaping. */
855
+ function shellQuote(value: string): string {
856
+ return `'${value.replace(/'/g, `'\\''`)}'`;
857
+ }
858
+
859
+ /** Detect an image media type from magic bytes (with a path-extension fallback),
860
+ * mirroring @openai/agents-core's `sniffImageMediaType` so `viewImage` returns the
861
+ * SAME media types the SDK would. Returns undefined for an unrecognized format. */
862
+ function sniffImageMediaType(bytes: Uint8Array, path: string): string | undefined {
863
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
864
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
865
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return "image/gif";
866
+ if (
867
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
868
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
869
+ ) return "image/webp";
870
+ if (bytes[0] === 0x42 && bytes[1] === 0x4d) return "image/bmp";
871
+ if (
872
+ (bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
873
+ (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a)
874
+ ) return "image/tiff";
875
+ if (looksLikeSvg(bytes)) return "image/svg+xml";
876
+ return mediaTypeFromPath(path);
877
+ }
878
+
879
+ function looksLikeSvg(bytes: Uint8Array): boolean {
880
+ const prefix = decoder.decode(bytes.subarray(0, Math.min(bytes.byteLength, 512))).trimStart().toLowerCase();
881
+ return prefix.startsWith("<svg") || /^<\?xml[\s\S]*<svg/u.test(prefix);
882
+ }
883
+
884
+ function mediaTypeFromPath(path: string): string | undefined {
885
+ const p = path?.trim().toLowerCase() ?? "";
886
+ if (p.endsWith(".png")) return "image/png";
887
+ if (p.endsWith(".jpg") || p.endsWith(".jpeg")) return "image/jpeg";
888
+ if (p.endsWith(".gif")) return "image/gif";
889
+ if (p.endsWith(".webp")) return "image/webp";
890
+ if (p.endsWith(".bmp")) return "image/bmp";
891
+ if (p.endsWith(".tif") || p.endsWith(".tiff")) return "image/tiff";
892
+ if (p.endsWith(".svg") || p.endsWith(".svgz")) return "image/svg+xml";
893
+ return undefined;
894
+ }
895
+
896
+ /** A random uint64-safe numeric nonce (the wire `PingRequest.nonce` is a uint64,
897
+ * represented as a numeric string by ts-proto). */
898
+ function randomNonce(): string {
899
+ // 2^53-safe random integer as a decimal string.
900
+ return String(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
901
+ }
902
+
903
+ function kindToProtocol(kind: StreamKind | undefined): string {
904
+ switch (kind) {
905
+ case StreamKind.STREAM_KIND_PTY:
906
+ return "pty";
907
+ case StreamKind.STREAM_KIND_DESKTOP:
908
+ return "vnc";
909
+ default:
910
+ return "raw";
911
+ }
912
+ }
913
+
914
+ /**
915
+ * The selfhosted NotFound discriminator — THE load-bearing safety property
916
+ * (dossier §10.2/§19): for selfhosted, `agent-offline` (no responder) is NEVER a
917
+ * provider NotFound. A user's real machine is not recreatable; if the lease saw
918
+ * agent-offline as NotFound it would cold-create a RIVAL box (a Modal box) for
919
+ * the user's machine. So this ALWAYS returns FALSE for selfhosted — there is no
920
+ * "box gone, recreate it" condition. An OS-level file NotFound is an op-level
921
+ * error the fs layer 404s; it is likewise NOT a session-recreate condition.
922
+ *
923
+ * `establishSandboxSessionFromEnvelope` cold-restores ONLY when the per-backend
924
+ * NotFound discriminator returns true; returning false here guarantees the
925
+ * selfhosted path never cold-creates a rival — the op surfaces agent_offline and
926
+ * the caller backs off / retries.
927
+ */
928
+ export function isSelfhostedProviderNotFoundError(_error: unknown): false {
929
+ return false;
930
+ }