@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.
- package/dist/chunk-2PO56VAL.js +3478 -0
- package/dist/chunk-2PO56VAL.js.map +1 -0
- package/dist/index.d.ts +912 -0
- package/dist/index.js +3663 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox/index.d.ts +1738 -0
- package/dist/sandbox/index.js +187 -0
- package/dist/sandbox/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bundled_hashicorp_terraform_skills/LICENSE +373 -0
- package/src/bundled_hashicorp_terraform_skills/README.md +18 -0
- package/src/bundled_hashicorp_terraform_skills/UPSTREAM_GIT_SHA +1 -0
- package/src/bundled_hashicorp_terraform_skills/azure-verified-modules/SKILL.md +613 -0
- package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +43 -0
- package/src/bundled_hashicorp_terraform_skills/refactor-module/SKILL.md +538 -0
- package/src/bundled_hashicorp_terraform_skills/social-media-marketing/SKILL.md +35 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/SKILL.md +372 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/references/MANUAL-IMPORT.md +113 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/scripts/list_resources.sh +38 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/SKILL.md +480 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/api-monitoring.md +543 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/component-blocks.md +476 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/deployment-blocks.md +391 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/examples.md +1529 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/linked-stacks.md +187 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/troubleshooting.md +671 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-style-guide/SKILL.md +353 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/SKILL.md +451 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/CI_CD.md +80 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/EXAMPLES.md +314 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/MOCK_PROVIDERS.md +171 -0
- package/src/codex-tool-search.ts +267 -0
- package/src/context-compaction.ts +538 -0
- package/src/history-sanitizer.ts +719 -0
- package/src/index.ts +3299 -0
- package/src/sandbox/capabilities.ts +69 -0
- package/src/sandbox/channel-a.ts +1031 -0
- package/src/sandbox/display-stack.ts +231 -0
- package/src/sandbox/errors.ts +34 -0
- package/src/sandbox/index.ts +832 -0
- package/src/sandbox/providers/blaxel.ts +35 -0
- package/src/sandbox/providers/cloudflare.ts +24 -0
- package/src/sandbox/providers/daytona.ts +34 -0
- package/src/sandbox/providers/docker.ts +17 -0
- package/src/sandbox/providers/e2b.ts +36 -0
- package/src/sandbox/providers/index.ts +107 -0
- package/src/sandbox/providers/local.ts +13 -0
- package/src/sandbox/providers/modal.ts +55 -0
- package/src/sandbox/providers/none.ts +13 -0
- package/src/sandbox/providers/runloop.ts +32 -0
- package/src/sandbox/providers/selfhosted.ts +96 -0
- package/src/sandbox/providers/types.ts +38 -0
- package/src/sandbox/providers/vercel.ts +29 -0
- package/src/sandbox/recording.ts +286 -0
- package/src/sandbox/routing/backend-resolver.ts +189 -0
- package/src/sandbox/routing/routing-session.ts +455 -0
- package/src/sandbox/select.ts +371 -0
- package/src/sandbox/selfhosted/capabilities.ts +255 -0
- package/src/sandbox/selfhosted/control-rpc.ts +351 -0
- package/src/sandbox/selfhosted/session.ts +930 -0
- package/src/sandbox/selfhosted/testing.ts +230 -0
- package/src/sandbox/stream-port.ts +185 -0
- package/src/sandbox/stream-token.ts +90 -0
- package/src/sandbox/terminal-server.ts +203 -0
- 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
|
+
}
|