@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,3478 @@
|
|
|
1
|
+
// src/sandbox/index.ts
|
|
2
|
+
import { collectSandboxEnvironment, parseExposedPorts } from "@opengeni/config";
|
|
3
|
+
import { DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT7, TERMINAL_STREAM_PORT as TERMINAL_STREAM_PORT2 } from "@opengeni/contracts";
|
|
4
|
+
|
|
5
|
+
// src/sandbox/providers/index.ts
|
|
6
|
+
import { SandboxBackend as SandboxBackend2 } from "@opengeni/contracts";
|
|
7
|
+
|
|
8
|
+
// src/sandbox/capabilities.ts
|
|
9
|
+
import {
|
|
10
|
+
CAPABILITY_DESCRIPTORS,
|
|
11
|
+
DESKTOP_STREAM_PORT,
|
|
12
|
+
SandboxBackend
|
|
13
|
+
} from "@opengeni/contracts";
|
|
14
|
+
function assertDescriptorRegistryInvariants() {
|
|
15
|
+
for (const backend of SandboxBackend.options) {
|
|
16
|
+
const descriptor = CAPABILITY_DESCRIPTORS[backend];
|
|
17
|
+
if (!descriptor) {
|
|
18
|
+
throw new Error(`No CapabilityDescriptor for backend "${backend}"`);
|
|
19
|
+
}
|
|
20
|
+
if (descriptor.backend !== backend) {
|
|
21
|
+
throw new Error(`Descriptor.backend mismatch for "${backend}" (got "${descriptor.backend}")`);
|
|
22
|
+
}
|
|
23
|
+
if (descriptor.capabilities.DesktopStream.available && descriptor.portExposure.kind === "none") {
|
|
24
|
+
throw new Error(`"${backend}" claims DesktopStream but portExposure.kind=none`);
|
|
25
|
+
}
|
|
26
|
+
if (descriptor.capabilities.DesktopStream.available && descriptor.capabilities.DesktopStream.transport === null) {
|
|
27
|
+
throw new Error(`"${backend}" claims DesktopStream but transport is null`);
|
|
28
|
+
}
|
|
29
|
+
if (descriptor.capabilities.DesktopStream.available !== descriptor.capabilities.Recording.available) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`"${backend}" Recording.available (${descriptor.capabilities.Recording.available}) must equal DesktopStream.available (${descriptor.capabilities.DesktopStream.available})`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (descriptor.persistable && descriptor.snapshot.kind === "none") {
|
|
35
|
+
throw new Error(`"${backend}" persistable but snapshot.kind=none`);
|
|
36
|
+
}
|
|
37
|
+
if (descriptor.nativeBucketMount && (descriptor.tier === "dev" || descriptor.tier === "none")) {
|
|
38
|
+
throw new Error(`"${backend}" claims nativeBucketMount on tier=${descriptor.tier}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/sandbox/providers/blaxel.ts
|
|
44
|
+
import { BlaxelSandboxClient } from "@openai/agents-extensions/sandbox/blaxel";
|
|
45
|
+
|
|
46
|
+
// src/sandbox/errors.ts
|
|
47
|
+
var SandboxConfigError = class extends Error {
|
|
48
|
+
backend;
|
|
49
|
+
constructor(backend, message) {
|
|
50
|
+
super(`[sandbox:${backend}] ${message}`);
|
|
51
|
+
this.name = "SandboxConfigError";
|
|
52
|
+
this.backend = backend;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
var SandboxProviderUnavailableError = class extends Error {
|
|
56
|
+
backend;
|
|
57
|
+
constructor(backend) {
|
|
58
|
+
super(`provider ${backend} not available in installed @openai/agents-extensions`);
|
|
59
|
+
this.name = "SandboxProviderUnavailableError";
|
|
60
|
+
this.backend = backend;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// src/sandbox/providers/blaxel.ts
|
|
65
|
+
var blaxelProvider = {
|
|
66
|
+
backend: "blaxel",
|
|
67
|
+
descriptor: CAPABILITY_DESCRIPTORS.blaxel,
|
|
68
|
+
validateCredentials(settings) {
|
|
69
|
+
if (!settings.blaxelApiKey) {
|
|
70
|
+
throw new SandboxConfigError("blaxel", "OPENGENI_BLAXEL_API_KEY is required");
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
build({ settings, environment }) {
|
|
74
|
+
const options = {
|
|
75
|
+
apiKey: settings.blaxelApiKey,
|
|
76
|
+
env: environment
|
|
77
|
+
};
|
|
78
|
+
if (settings.blaxelImage) options.image = settings.blaxelImage;
|
|
79
|
+
if (settings.blaxelRegion) options.region = settings.blaxelRegion;
|
|
80
|
+
if (settings.blaxelExposedPortPublic !== void 0) {
|
|
81
|
+
options.exposedPortPublic = settings.blaxelExposedPortPublic;
|
|
82
|
+
}
|
|
83
|
+
if (settings.blaxelExposedPortUrlTtlSeconds) {
|
|
84
|
+
options.exposedPortUrlTtlS = settings.blaxelExposedPortUrlTtlSeconds;
|
|
85
|
+
}
|
|
86
|
+
if (settings.blaxelMemoryMb) options.memory = settings.blaxelMemoryMb;
|
|
87
|
+
if (settings.blaxelTtl) options.ttl = settings.blaxelTtl;
|
|
88
|
+
return new BlaxelSandboxClient(options);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// src/sandbox/providers/cloudflare.ts
|
|
93
|
+
import { CloudflareSandboxClient } from "@openai/agents-extensions/sandbox/cloudflare";
|
|
94
|
+
var cloudflareProvider = {
|
|
95
|
+
backend: "cloudflare",
|
|
96
|
+
descriptor: CAPABILITY_DESCRIPTORS.cloudflare,
|
|
97
|
+
validateCredentials(settings) {
|
|
98
|
+
if (!settings.cloudflareWorkerUrl) {
|
|
99
|
+
throw new SandboxConfigError("cloudflare", "OPENGENI_CLOUDFLARE_WORKER_URL is required");
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
build({ settings, exposedPorts }) {
|
|
103
|
+
const options = {
|
|
104
|
+
workerUrl: settings.cloudflareWorkerUrl,
|
|
105
|
+
exposedPorts
|
|
106
|
+
};
|
|
107
|
+
if (settings.cloudflareApiKey) options.apiKey = settings.cloudflareApiKey;
|
|
108
|
+
return new CloudflareSandboxClient(options);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/sandbox/providers/daytona.ts
|
|
113
|
+
import { DaytonaSandboxClient } from "@openai/agents-extensions/sandbox/daytona";
|
|
114
|
+
var daytonaProvider = {
|
|
115
|
+
backend: "daytona",
|
|
116
|
+
descriptor: CAPABILITY_DESCRIPTORS.daytona,
|
|
117
|
+
validateCredentials(settings) {
|
|
118
|
+
if (!settings.daytonaApiKey) {
|
|
119
|
+
throw new SandboxConfigError("daytona", "OPENGENI_DAYTONA_API_KEY is required");
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
build({ settings, environment, exposedPorts }) {
|
|
123
|
+
const options = {
|
|
124
|
+
apiKey: settings.daytonaApiKey,
|
|
125
|
+
env: environment,
|
|
126
|
+
exposedPorts
|
|
127
|
+
};
|
|
128
|
+
if (settings.daytonaApiUrl) options.apiUrl = settings.daytonaApiUrl;
|
|
129
|
+
if (settings.daytonaTarget) options.target = settings.daytonaTarget;
|
|
130
|
+
if (settings.daytonaImage) options.image = settings.daytonaImage;
|
|
131
|
+
if (settings.daytonaSnapshotName) options.sandboxSnapshotName = settings.daytonaSnapshotName;
|
|
132
|
+
if (settings.daytonaAutoStopInterval !== void 0) {
|
|
133
|
+
options.autoStopInterval = settings.daytonaAutoStopInterval;
|
|
134
|
+
}
|
|
135
|
+
if (settings.daytonaTimeoutSeconds) options.timeoutSec = settings.daytonaTimeoutSeconds;
|
|
136
|
+
if (settings.daytonaExposedPortUrlTtlSeconds) {
|
|
137
|
+
options.exposedPortUrlTtlS = settings.daytonaExposedPortUrlTtlSeconds;
|
|
138
|
+
}
|
|
139
|
+
return new DaytonaSandboxClient(options);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// src/sandbox/providers/docker.ts
|
|
144
|
+
import { DockerSandboxClient } from "@openai/agents/sandbox/local";
|
|
145
|
+
var dockerProvider = {
|
|
146
|
+
backend: "docker",
|
|
147
|
+
descriptor: CAPABILITY_DESCRIPTORS.docker,
|
|
148
|
+
// Local dev container — no credentials. (The dockerNetwork decoration is
|
|
149
|
+
// applied by the factory, not here: it wraps the constructed client.)
|
|
150
|
+
validateCredentials() {
|
|
151
|
+
},
|
|
152
|
+
build({ settings, exposedPorts }) {
|
|
153
|
+
return new DockerSandboxClient({
|
|
154
|
+
image: settings.dockerImage,
|
|
155
|
+
exposedPorts
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// src/sandbox/providers/e2b.ts
|
|
161
|
+
import { E2BSandboxClient } from "@openai/agents-extensions/sandbox/e2b";
|
|
162
|
+
var e2bProvider = {
|
|
163
|
+
backend: "e2b",
|
|
164
|
+
descriptor: CAPABILITY_DESCRIPTORS.e2b,
|
|
165
|
+
validateCredentials(settings) {
|
|
166
|
+
if (!settings.e2bApiKey) {
|
|
167
|
+
throw new SandboxConfigError("e2b", "OPENGENI_E2B_API_KEY is required");
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
build({ settings, environment, exposedPorts }) {
|
|
171
|
+
const options = {
|
|
172
|
+
env: environment,
|
|
173
|
+
exposedPorts
|
|
174
|
+
};
|
|
175
|
+
if (settings.e2bTemplate) options.template = settings.e2bTemplate;
|
|
176
|
+
if (settings.e2bTimeoutSeconds) options.timeout = settings.e2bTimeoutSeconds;
|
|
177
|
+
if (settings.e2bTimeoutAction) options.timeoutAction = settings.e2bTimeoutAction;
|
|
178
|
+
if (settings.e2bAllowInternetAccess !== void 0) {
|
|
179
|
+
options.allowInternetAccess = settings.e2bAllowInternetAccess;
|
|
180
|
+
}
|
|
181
|
+
if (settings.e2bAutoResume !== void 0) options.autoResume = settings.e2bAutoResume;
|
|
182
|
+
if (settings.e2bWorkspacePersistence) {
|
|
183
|
+
options.workspacePersistence = settings.e2bWorkspacePersistence;
|
|
184
|
+
}
|
|
185
|
+
return new E2BSandboxClient(options);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// src/sandbox/providers/local.ts
|
|
190
|
+
import { UnixLocalSandboxClient } from "@openai/agents/sandbox/local";
|
|
191
|
+
var localProvider = {
|
|
192
|
+
backend: "local",
|
|
193
|
+
descriptor: CAPABILITY_DESCRIPTORS.local,
|
|
194
|
+
// UnixLocalSandboxClient runs in-process — no credentials, no options.
|
|
195
|
+
validateCredentials() {
|
|
196
|
+
},
|
|
197
|
+
build() {
|
|
198
|
+
return new UnixLocalSandboxClient();
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// src/sandbox/providers/modal.ts
|
|
203
|
+
import { ModalImageSelector, ModalSandboxClient } from "@openai/agents-extensions/sandbox/modal";
|
|
204
|
+
import { effectiveModalIdleTimeoutSeconds } from "@opengeni/config";
|
|
205
|
+
var modalProvider = {
|
|
206
|
+
backend: "modal",
|
|
207
|
+
descriptor: CAPABILITY_DESCRIPTORS.modal,
|
|
208
|
+
validateCredentials(settings) {
|
|
209
|
+
if (Boolean(settings.modalTokenId) !== Boolean(settings.modalTokenSecret)) {
|
|
210
|
+
throw new SandboxConfigError(
|
|
211
|
+
"modal",
|
|
212
|
+
"OPENGENI_MODAL_TOKEN_ID and OPENGENI_MODAL_TOKEN_SECRET must both be set or both omitted"
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (!settings.modalAppName) {
|
|
216
|
+
throw new SandboxConfigError("modal", "OPENGENI_MODAL_APP_NAME is required");
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
build({ settings, environment, exposedPorts }) {
|
|
220
|
+
const options = {
|
|
221
|
+
appName: settings.modalAppName,
|
|
222
|
+
timeoutMs: settings.modalTimeoutSeconds * 1e3,
|
|
223
|
+
exposedPorts,
|
|
224
|
+
env: environment
|
|
225
|
+
};
|
|
226
|
+
options.idleTimeoutMs = effectiveModalIdleTimeoutSeconds(settings) * 1e3;
|
|
227
|
+
if (settings.modalWorkspacePersistence) {
|
|
228
|
+
options.workspacePersistence = settings.modalWorkspacePersistence;
|
|
229
|
+
}
|
|
230
|
+
if (settings.modalImageRef) {
|
|
231
|
+
options.image = ModalImageSelector.fromTag(settings.modalImageRef);
|
|
232
|
+
}
|
|
233
|
+
if (settings.modalTokenId) {
|
|
234
|
+
options.tokenId = settings.modalTokenId;
|
|
235
|
+
}
|
|
236
|
+
if (settings.modalTokenSecret) {
|
|
237
|
+
options.tokenSecret = settings.modalTokenSecret;
|
|
238
|
+
}
|
|
239
|
+
if (settings.modalEnvironment) {
|
|
240
|
+
options.environment = settings.modalEnvironment;
|
|
241
|
+
}
|
|
242
|
+
return new ModalSandboxClient(options);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// src/sandbox/providers/none.ts
|
|
247
|
+
var noneProvider = {
|
|
248
|
+
backend: "none",
|
|
249
|
+
descriptor: CAPABILITY_DESCRIPTORS.none,
|
|
250
|
+
// No sandbox: nothing to validate, and build() returns undefined. The factory
|
|
251
|
+
// short-circuits on "none" before calling build, but we keep build honest.
|
|
252
|
+
validateCredentials() {
|
|
253
|
+
},
|
|
254
|
+
build() {
|
|
255
|
+
return void 0;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// src/sandbox/providers/runloop.ts
|
|
260
|
+
import { RunloopSandboxClient } from "@openai/agents-extensions/sandbox/runloop";
|
|
261
|
+
var runloopProvider = {
|
|
262
|
+
backend: "runloop",
|
|
263
|
+
descriptor: CAPABILITY_DESCRIPTORS.runloop,
|
|
264
|
+
validateCredentials(settings) {
|
|
265
|
+
if (!settings.runloopApiKey) {
|
|
266
|
+
throw new SandboxConfigError("runloop", "OPENGENI_RUNLOOP_API_KEY is required");
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
build({ settings, environment, exposedPorts }) {
|
|
270
|
+
const options = {
|
|
271
|
+
apiKey: settings.runloopApiKey,
|
|
272
|
+
env: environment,
|
|
273
|
+
exposedPorts,
|
|
274
|
+
// Tunnel v2: one tunnel for all ports. Defaults to true in our config.
|
|
275
|
+
tunnel: settings.runloopTunnel
|
|
276
|
+
};
|
|
277
|
+
if (settings.runloopBaseUrl) options.baseUrl = settings.runloopBaseUrl;
|
|
278
|
+
if (settings.runloopBlueprintName) options.blueprintName = settings.runloopBlueprintName;
|
|
279
|
+
if (settings.runloopBlueprintId) options.blueprintId = settings.runloopBlueprintId;
|
|
280
|
+
if (settings.runloopKeepAliveSeconds) {
|
|
281
|
+
options.timeouts = { keepAliveTimeoutMs: settings.runloopKeepAliveSeconds * 1e3 };
|
|
282
|
+
}
|
|
283
|
+
return new RunloopSandboxClient(options);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// src/sandbox/selfhosted/control-rpc.ts
|
|
288
|
+
import {
|
|
289
|
+
ControlRequest,
|
|
290
|
+
ControlResponse,
|
|
291
|
+
ErrorCode
|
|
292
|
+
} from "@opengeni/agent-proto";
|
|
293
|
+
function subjectFor(workspaceId, agentId) {
|
|
294
|
+
return `agent.${workspaceId}.${agentId}.rpc`;
|
|
295
|
+
}
|
|
296
|
+
var SelfhostedControlError = class extends Error {
|
|
297
|
+
name = "SelfhostedControlError";
|
|
298
|
+
code;
|
|
299
|
+
reason;
|
|
300
|
+
retryable;
|
|
301
|
+
fenced;
|
|
302
|
+
draining;
|
|
303
|
+
agentOffline;
|
|
304
|
+
osNotFound;
|
|
305
|
+
detail;
|
|
306
|
+
constructor(input) {
|
|
307
|
+
super(input.message);
|
|
308
|
+
this.code = input.code;
|
|
309
|
+
this.reason = input.reason;
|
|
310
|
+
this.retryable = input.retryable;
|
|
311
|
+
this.fenced = input.fenced ?? false;
|
|
312
|
+
this.draining = input.draining ?? false;
|
|
313
|
+
this.agentOffline = input.agentOffline ?? false;
|
|
314
|
+
this.osNotFound = input.osNotFound ?? false;
|
|
315
|
+
this.detail = input.detail ?? {};
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
function agentErrorToControlError(err) {
|
|
319
|
+
const message = err.message || `agent error (${err.code})`;
|
|
320
|
+
const detail = err.detail ?? {};
|
|
321
|
+
switch (err.code) {
|
|
322
|
+
case ErrorCode.ERROR_CODE_AGENT_OFFLINE:
|
|
323
|
+
return new SelfhostedControlError({
|
|
324
|
+
message: message || "the enrolled agent is offline",
|
|
325
|
+
code: err.code,
|
|
326
|
+
reason: "agent_offline",
|
|
327
|
+
retryable: false,
|
|
328
|
+
agentOffline: true,
|
|
329
|
+
detail
|
|
330
|
+
});
|
|
331
|
+
case ErrorCode.ERROR_CODE_TIMEOUT:
|
|
332
|
+
return new SelfhostedControlError({
|
|
333
|
+
message: message || "the enrolled agent did not respond in time",
|
|
334
|
+
code: err.code,
|
|
335
|
+
reason: "agent_reconnecting",
|
|
336
|
+
retryable: true,
|
|
337
|
+
detail
|
|
338
|
+
});
|
|
339
|
+
case ErrorCode.ERROR_CODE_CONSENT_REQUIRED:
|
|
340
|
+
return new SelfhostedControlError({
|
|
341
|
+
message: message || "the op requires consent that has not been granted",
|
|
342
|
+
code: err.code,
|
|
343
|
+
reason: "consent_required",
|
|
344
|
+
retryable: false,
|
|
345
|
+
detail
|
|
346
|
+
});
|
|
347
|
+
case ErrorCode.ERROR_CODE_DRAINING:
|
|
348
|
+
return new SelfhostedControlError({
|
|
349
|
+
message: message || "the agent is draining and cannot accept new work",
|
|
350
|
+
code: err.code,
|
|
351
|
+
reason: null,
|
|
352
|
+
retryable: true,
|
|
353
|
+
draining: true,
|
|
354
|
+
detail
|
|
355
|
+
});
|
|
356
|
+
case ErrorCode.ERROR_CODE_FENCED:
|
|
357
|
+
return new SelfhostedControlError({
|
|
358
|
+
message: message || "a stale op was fenced by the epoch guard; re-resolve and retry",
|
|
359
|
+
code: err.code,
|
|
360
|
+
reason: null,
|
|
361
|
+
retryable: true,
|
|
362
|
+
fenced: true,
|
|
363
|
+
detail
|
|
364
|
+
});
|
|
365
|
+
case ErrorCode.ERROR_CODE_NOT_FOUND:
|
|
366
|
+
return new SelfhostedControlError({
|
|
367
|
+
message: message || "the referenced path or ref does not exist",
|
|
368
|
+
code: err.code,
|
|
369
|
+
reason: null,
|
|
370
|
+
retryable: Boolean(err.retryable),
|
|
371
|
+
osNotFound: true,
|
|
372
|
+
detail
|
|
373
|
+
});
|
|
374
|
+
default:
|
|
375
|
+
return new SelfhostedControlError({
|
|
376
|
+
message,
|
|
377
|
+
code: err.code,
|
|
378
|
+
reason: null,
|
|
379
|
+
retryable: Boolean(err.retryable),
|
|
380
|
+
detail
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function offlineAgentError(message = "no agent responded (offline)") {
|
|
385
|
+
return {
|
|
386
|
+
code: ErrorCode.ERROR_CODE_AGENT_OFFLINE,
|
|
387
|
+
message,
|
|
388
|
+
retryable: false,
|
|
389
|
+
detail: {}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function timeoutAgentError(message = "the agent did not respond in time") {
|
|
393
|
+
return {
|
|
394
|
+
code: ErrorCode.ERROR_CODE_TIMEOUT,
|
|
395
|
+
message,
|
|
396
|
+
retryable: true,
|
|
397
|
+
detail: {}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
var NATS_NO_RESPONDERS_CODE = "503";
|
|
401
|
+
function isNoRespondersError(err) {
|
|
402
|
+
const code = err?.code;
|
|
403
|
+
if (typeof code === "string" && code === NATS_NO_RESPONDERS_CODE) {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
407
|
+
return /no responders|503/i.test(message);
|
|
408
|
+
}
|
|
409
|
+
function isRequestTimeoutError(err) {
|
|
410
|
+
const code = err?.code;
|
|
411
|
+
if (typeof code === "string" && /timeout/i.test(code)) {
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
415
|
+
return /timeout|timed out/i.test(message);
|
|
416
|
+
}
|
|
417
|
+
var NatsControlRpc = class {
|
|
418
|
+
connect;
|
|
419
|
+
connection;
|
|
420
|
+
constructor(connect) {
|
|
421
|
+
this.connect = connect;
|
|
422
|
+
}
|
|
423
|
+
async resolveConnection() {
|
|
424
|
+
if (this.connection === void 0) {
|
|
425
|
+
try {
|
|
426
|
+
this.connection = await this.connect();
|
|
427
|
+
} catch {
|
|
428
|
+
this.connection = null;
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return this.connection ?? null;
|
|
433
|
+
}
|
|
434
|
+
async request(subject, req, opts) {
|
|
435
|
+
const conn = await this.resolveConnection();
|
|
436
|
+
if (!conn) {
|
|
437
|
+
return offlineControlResponse(req.requestId);
|
|
438
|
+
}
|
|
439
|
+
const payload = ControlRequest.encode(req).finish();
|
|
440
|
+
try {
|
|
441
|
+
const reply = await conn.request(subject, payload, { timeout: opts.timeoutMs });
|
|
442
|
+
return ControlResponse.decode(reply.data);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
if (isNoRespondersError(err)) {
|
|
445
|
+
return offlineControlResponse(req.requestId);
|
|
446
|
+
}
|
|
447
|
+
if (isRequestTimeoutError(err)) {
|
|
448
|
+
return timeoutControlResponse(req.requestId);
|
|
449
|
+
}
|
|
450
|
+
this.connection = void 0;
|
|
451
|
+
return offlineControlResponse(req.requestId);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
function offlineControlResponse(requestId) {
|
|
456
|
+
return { requestId, error: offlineAgentError(), result: void 0 };
|
|
457
|
+
}
|
|
458
|
+
function timeoutControlResponse(requestId) {
|
|
459
|
+
return { requestId, error: timeoutAgentError(), result: void 0 };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/sandbox/selfhosted/session.ts
|
|
463
|
+
import {
|
|
464
|
+
FsEntryKind,
|
|
465
|
+
StreamKind
|
|
466
|
+
} from "@opengeni/agent-proto";
|
|
467
|
+
import { DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT2 } from "@opengeni/contracts";
|
|
468
|
+
import { Manifest } from "@openai/agents/sandbox";
|
|
469
|
+
var decoder = new TextDecoder();
|
|
470
|
+
var encoder = new TextEncoder();
|
|
471
|
+
var SELFHOSTED_VIRTUAL_ROOT = "/workspace";
|
|
472
|
+
function toMachinePath(p, workingDir) {
|
|
473
|
+
const base = workingDir.replace(/\/$/, "");
|
|
474
|
+
if (!p || p === SELFHOSTED_VIRTUAL_ROOT) return base;
|
|
475
|
+
if (p.startsWith(`${SELFHOSTED_VIRTUAL_ROOT}/`)) {
|
|
476
|
+
const rel = p.slice(SELFHOSTED_VIRTUAL_ROOT.length + 1);
|
|
477
|
+
return base ? `${base}/${rel}` : rel;
|
|
478
|
+
}
|
|
479
|
+
if (p.startsWith("/")) return p;
|
|
480
|
+
return base ? `${base}/${p}` : p;
|
|
481
|
+
}
|
|
482
|
+
var injectedApplyDiff;
|
|
483
|
+
function setSelfhostedApplyDiff(fn) {
|
|
484
|
+
injectedApplyDiff = fn;
|
|
485
|
+
}
|
|
486
|
+
var SELFHOSTED_DEFAULT_TIMEOUT_MS = 3e4;
|
|
487
|
+
var SELFHOSTED_RELAY_STREAM_PATH = "/stream";
|
|
488
|
+
var SelfhostedSession = class {
|
|
489
|
+
backendId = "selfhosted";
|
|
490
|
+
workspaceId;
|
|
491
|
+
agentId;
|
|
492
|
+
controlRpc;
|
|
493
|
+
relay;
|
|
494
|
+
epoch;
|
|
495
|
+
timeoutMs;
|
|
496
|
+
subject;
|
|
497
|
+
/** The session working directory — the path/cwd base every op is rooted under
|
|
498
|
+
* (see `toMachinePath`). "" by default ⇒ today's workspace_root behavior. */
|
|
499
|
+
workingDir;
|
|
500
|
+
/**
|
|
501
|
+
* The structural `state` slice consumers read. `agentId`/`instanceId` serve the
|
|
502
|
+
* channel-a `readInstanceId` + docker-network decoration (the agentId IS the
|
|
503
|
+
* identity). `manifest` is the slice the @openai/agents SDK reads AND writes per
|
|
504
|
+
* turn (serializeManifestEnvironment / validateProvidedSessionManifestUpdate read
|
|
505
|
+
* `manifest.root` + iterate `manifest.environment`; providedSessionManifest WRITES
|
|
506
|
+
* `state.manifest = next`). It must be a real, MUTABLE Manifest field — when the
|
|
507
|
+
* RoutingSandboxSession proxy resolves THIS as the active backend it returns
|
|
508
|
+
* `session.state` BY REFERENCE, so the SDK's read and write must both land on a
|
|
509
|
+
* well-formed Manifest here (defined `root`, object `environment`). Without it the
|
|
510
|
+
* SDK crashes with `undefined is not an object (evaluating 'current.root')`.
|
|
511
|
+
*
|
|
512
|
+
* `manifest` is intentionally a plain mutable field (not `readonly`) so the SDK's
|
|
513
|
+
* `state.manifest = next` write succeeds. It is NOT part of the persistable state
|
|
514
|
+
* (`serializeSessionState` round-trips `{agentId}` only).
|
|
515
|
+
*
|
|
516
|
+
* `environment` is the SDK `SandboxSessionState.environment` (a `Record<string,
|
|
517
|
+
* string>`). It MUST be present because the GROUP box's client serializes THIS
|
|
518
|
+
* (the active backend's) state at end-of-turn — the non-owned injected session is
|
|
519
|
+
* serialized via the CONFIGURED client (modal in prod), NOT the selfhosted client.
|
|
520
|
+
* Modal's `serializeRemoteSandboxSessionState` does `Object.entries(state.environment)`;
|
|
521
|
+
* an absent field crashes the post-turn RunState serialize with "Object.entries
|
|
522
|
+
* requires that input parameter not be null or undefined". It carries the run's
|
|
523
|
+
* threaded environment (or `{}`). The resulting modal-tagged envelope is inert for
|
|
524
|
+
* selfhosted (resume re-addresses the machine by agentId via the lease pointer,
|
|
525
|
+
* never from this SDK envelope), so its only job is to not crash the serialize.
|
|
526
|
+
*/
|
|
527
|
+
state;
|
|
528
|
+
constructor(deps) {
|
|
529
|
+
this.workspaceId = deps.workspaceId;
|
|
530
|
+
this.agentId = deps.agentId;
|
|
531
|
+
this.controlRpc = deps.controlRpc;
|
|
532
|
+
this.relay = deps.relay;
|
|
533
|
+
this.epoch = deps.epoch ?? 0;
|
|
534
|
+
this.timeoutMs = deps.timeoutMs ?? SELFHOSTED_DEFAULT_TIMEOUT_MS;
|
|
535
|
+
this.subject = subjectFor(deps.workspaceId, deps.agentId);
|
|
536
|
+
this.workingDir = deps.workingDir ?? "";
|
|
537
|
+
this.state = {
|
|
538
|
+
agentId: deps.agentId,
|
|
539
|
+
instanceId: deps.agentId,
|
|
540
|
+
manifest: new Manifest({ root: "/workspace", entries: {}, environment: deps.environment ?? {} }),
|
|
541
|
+
// The SDK `SandboxSessionState.environment` — the run's threaded env (or `{}`).
|
|
542
|
+
// The group client's end-of-turn serialize reads `state.environment` directly
|
|
543
|
+
// (Object.entries), so it must be a defined object, not absent.
|
|
544
|
+
environment: deps.environment ?? {}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/** Issue a control op, decoding the agent's reply or throwing the mapped
|
|
548
|
+
* `SelfhostedControlError` on an AgentError (incl. a synthesized offline /
|
|
549
|
+
* timeout error from the transport). */
|
|
550
|
+
async call(op) {
|
|
551
|
+
const req = {
|
|
552
|
+
requestId: crypto.randomUUID(),
|
|
553
|
+
epoch: this.epoch,
|
|
554
|
+
op
|
|
555
|
+
};
|
|
556
|
+
const res = await this.controlRpc.request(this.subject, req, { timeoutMs: this.timeoutMs });
|
|
557
|
+
if (res.error) {
|
|
558
|
+
throw agentErrorToControlError(res.error);
|
|
559
|
+
}
|
|
560
|
+
if (!res.result) {
|
|
561
|
+
throw agentErrorToControlError({
|
|
562
|
+
code: 7,
|
|
563
|
+
// ERROR_CODE_PROTOCOL — an empty result is a protocol violation
|
|
564
|
+
message: "agent returned an empty control response",
|
|
565
|
+
retryable: false,
|
|
566
|
+
detail: {}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return res.result;
|
|
570
|
+
}
|
|
571
|
+
/** Channel-A `exec`: run a command on the machine and return its output. */
|
|
572
|
+
async exec(args) {
|
|
573
|
+
const execReq = {
|
|
574
|
+
// The agent does NOT shell-interpret unless `shell` — Channel-A passes a
|
|
575
|
+
// single shell command string, so run it through the platform shell.
|
|
576
|
+
command: [args.cmd],
|
|
577
|
+
shell: true,
|
|
578
|
+
// Rewrite a virtual-root cwd ("/workspace[/…]") onto the machine's frame —
|
|
579
|
+
// an absolute "/workspace" would ENOENT on a real machine (see
|
|
580
|
+
// SELFHOSTED_VIRTUAL_ROOT). Empty → the session workingDir (itself "" by
|
|
581
|
+
// default ⇒ the agent runs in its workspace_root).
|
|
582
|
+
cwd: toMachinePath(args.workdir, this.workingDir),
|
|
583
|
+
env: {},
|
|
584
|
+
stdin: new Uint8Array(0),
|
|
585
|
+
timeoutMs: 0
|
|
586
|
+
};
|
|
587
|
+
const result = await this.call({ $case: "exec", exec: execReq });
|
|
588
|
+
if (result.$case !== "exec") {
|
|
589
|
+
throw new Error(`selfhosted exec: unexpected result ${result.$case}`);
|
|
590
|
+
}
|
|
591
|
+
return execResultToChannelA(result.exec);
|
|
592
|
+
}
|
|
593
|
+
// ── The agent-turn provided-session contract (over the SAME NATS primitives) ──
|
|
594
|
+
// These are what the @openai/agents shell/filesystem/skills capabilities call on
|
|
595
|
+
// the ACTIVE session once the routing proxy resolves selfhosted. They reuse the
|
|
596
|
+
// exec/fs ops above; the machine owns its filesystem (materialization is a no-op).
|
|
597
|
+
/** SDK shell capability `execCommand`: run a command and return its stdout (the
|
|
598
|
+
* `exec_command` tool). Selfhosted exec is non-interactive (no PTY) — `tty` is
|
|
599
|
+
* ignored; `supportsPty()` is false so the SDK never offers a stdin session. */
|
|
600
|
+
async execCommand(args) {
|
|
601
|
+
const result = await this.exec({ cmd: args.cmd, workdir: args.workdir, runAs: args.runAs });
|
|
602
|
+
return result.output;
|
|
603
|
+
}
|
|
604
|
+
/** SDK shell capability never calls this (gated on `supportsPty()` which is
|
|
605
|
+
* false), but the surface advertises it. Selfhosted exec has no interactive PTY
|
|
606
|
+
* session over the structured RPC, so a stdin write is unsupported. */
|
|
607
|
+
supportsPty() {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
/** SDK filesystem capability `view_image`: read the image bytes off the machine
|
|
611
|
+
* and wrap them in the tool-output image shape (magic-byte sniff + path fallback,
|
|
612
|
+
* mirroring the SDK's `imageOutputFromBytes`). */
|
|
613
|
+
async viewImage(args) {
|
|
614
|
+
const bytes = await this.readFile({ path: args.path, ...args.runAs ? { runAs: args.runAs } : {} });
|
|
615
|
+
const mediaType = sniffImageMediaType(bytes, args.path);
|
|
616
|
+
if (!mediaType) {
|
|
617
|
+
throw new Error(`selfhosted view_image: unsupported image format for ${args.path}`);
|
|
618
|
+
}
|
|
619
|
+
return { type: "image", image: { data: Uint8Array.from(bytes), mediaType } };
|
|
620
|
+
}
|
|
621
|
+
/** SDK skills/filesystem `pathExists`: whether a path exists on the machine. */
|
|
622
|
+
async pathExists(path, _runAs) {
|
|
623
|
+
const { exists } = await this.statFile({ path });
|
|
624
|
+
return exists;
|
|
625
|
+
}
|
|
626
|
+
/** SDK skills `listDir`: list a directory as `{name, path, type}[]`. */
|
|
627
|
+
async listDir(args) {
|
|
628
|
+
const result = await this.listFiles({ path: args.path });
|
|
629
|
+
return result.fsList.entries.map((entry) => ({
|
|
630
|
+
name: entry.name,
|
|
631
|
+
path: entry.path,
|
|
632
|
+
type: entry.kind === FsEntryKind.FS_ENTRY_KIND_DIRECTORY ? "dir" : entry.kind === FsEntryKind.FS_ENTRY_KIND_FILE ? "file" : "other"
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
/** SDK manifest-delta `materializeEntry`: a NO-OP for selfhosted. Source
|
|
636
|
+
* materialization (cloning repos / staging files into the box) is how cloud
|
|
637
|
+
* providers prepare a fresh box; a bring-your-own machine already owns its
|
|
638
|
+
* filesystem and is prepared by the agent itself, so there is nothing to stage.
|
|
639
|
+
* Present (not absent) so the SDK's provided-session manifest apply path — which
|
|
640
|
+
* requires `applyManifest()` OR `materializeEntry()` when the agent declares
|
|
641
|
+
* entries — is satisfied without error. The selfhosted manifest declares no
|
|
642
|
+
* entries, so in practice this is never invoked with a real entry. */
|
|
643
|
+
async materializeEntry(_args) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
/** SDK filesystem capability `createEditor`: the apply_patch host. Applies V4A
|
|
647
|
+
* diffs over the NATS fs ops (read → applyDiff → write). `applyDiff` is the SDK's
|
|
648
|
+
* own parser, injected by the runtime barrel (the leaf cannot import it). */
|
|
649
|
+
createEditor(runAs) {
|
|
650
|
+
const applyDiff = injectedApplyDiff;
|
|
651
|
+
if (!applyDiff) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
"selfhosted createEditor: applyDiff not injected (the runtime barrel must call setSelfhostedApplyDiff before an agent turn binds the filesystem capability)"
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const pathExists = (path) => this.pathExists(path, runAs);
|
|
657
|
+
const readText = async (path) => decoder.decode(await this.readFile({ path, ...runAs ? { runAs } : {} }));
|
|
658
|
+
const writeText = async (path, content) => {
|
|
659
|
+
await this.writeFile({ path, content, createParents: true });
|
|
660
|
+
};
|
|
661
|
+
const deletePath = async (path) => {
|
|
662
|
+
await this.exec({ cmd: `rm -rf -- ${shellQuote(toMachinePath(path, ""))}`, ...runAs ? { runAs } : {} });
|
|
663
|
+
};
|
|
664
|
+
return {
|
|
665
|
+
async createFile(operation) {
|
|
666
|
+
if (await pathExists(operation.path)) {
|
|
667
|
+
throw new Error(`selfhosted createFile: file already exists: ${operation.path}`);
|
|
668
|
+
}
|
|
669
|
+
await writeText(operation.path, applyDiff("", operation.diff, "create"));
|
|
670
|
+
return {};
|
|
671
|
+
},
|
|
672
|
+
async updateFile(operation) {
|
|
673
|
+
const current = await readText(operation.path);
|
|
674
|
+
const next = applyDiff(current, operation.diff);
|
|
675
|
+
const destination = operation.moveTo ?? operation.path;
|
|
676
|
+
await writeText(destination, next);
|
|
677
|
+
if (operation.moveTo && destination !== operation.path) {
|
|
678
|
+
await deletePath(operation.path);
|
|
679
|
+
}
|
|
680
|
+
return {};
|
|
681
|
+
},
|
|
682
|
+
async deleteFile(operation) {
|
|
683
|
+
await deletePath(operation.path);
|
|
684
|
+
return {};
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/** Channel-A `readFile`: read a file off the machine (binary-safe). */
|
|
689
|
+
async readFile(args) {
|
|
690
|
+
const result = await this.call({
|
|
691
|
+
$case: "fsRead",
|
|
692
|
+
fsRead: {
|
|
693
|
+
path: toMachinePath(args.path, this.workingDir),
|
|
694
|
+
offset: "0",
|
|
695
|
+
length: args.maxBytes ? String(args.maxBytes) : "0"
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
if (result.$case !== "fsRead") {
|
|
699
|
+
throw new Error(`selfhosted readFile: unexpected result ${result.$case}`);
|
|
700
|
+
}
|
|
701
|
+
return result.fsRead.content;
|
|
702
|
+
}
|
|
703
|
+
/** Write a file onto the machine (the fs surface the descriptor advertises). */
|
|
704
|
+
async writeFile(args) {
|
|
705
|
+
const content = typeof args.content === "string" ? encoder.encode(args.content) : args.content;
|
|
706
|
+
const result = await this.call({
|
|
707
|
+
$case: "fsWrite",
|
|
708
|
+
fsWrite: {
|
|
709
|
+
path: toMachinePath(args.path, this.workingDir),
|
|
710
|
+
content,
|
|
711
|
+
createParents: args.createParents ?? true,
|
|
712
|
+
append: args.append ?? false,
|
|
713
|
+
mode: 0
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
if (result.$case !== "fsWrite") {
|
|
717
|
+
throw new Error(`selfhosted writeFile: unexpected result ${result.$case}`);
|
|
718
|
+
}
|
|
719
|
+
return Number(result.fsWrite.bytesWritten);
|
|
720
|
+
}
|
|
721
|
+
/** List a directory on the machine. */
|
|
722
|
+
async listFiles(args) {
|
|
723
|
+
const result = await this.call({
|
|
724
|
+
$case: "fsList",
|
|
725
|
+
fsList: { path: toMachinePath(args.path, this.workingDir), recursive: args.recursive ?? false }
|
|
726
|
+
});
|
|
727
|
+
if (result.$case !== "fsList") {
|
|
728
|
+
throw new Error(`selfhosted listFiles: unexpected result ${result.$case}`);
|
|
729
|
+
}
|
|
730
|
+
return result;
|
|
731
|
+
}
|
|
732
|
+
/** Stat a path on the machine. */
|
|
733
|
+
async statFile(args) {
|
|
734
|
+
const result = await this.call({ $case: "fsStat", fsStat: { path: toMachinePath(args.path, this.workingDir) } });
|
|
735
|
+
if (result.$case !== "fsStat") {
|
|
736
|
+
throw new Error(`selfhosted statFile: unexpected result ${result.$case}`);
|
|
737
|
+
}
|
|
738
|
+
return { exists: result.fsStat.exists };
|
|
739
|
+
}
|
|
740
|
+
// ── Computer-use control plane (the agent drives its OWN screen) ──────────────
|
|
741
|
+
// The CONTROL-PLANE twin of the relay DesktopInput/desktop stream: instead of a
|
|
742
|
+
// human viewer channel, the agent injects synthetic input into — and captures —
|
|
743
|
+
// its own display for the model's computer-use loop. Both route over the SAME
|
|
744
|
+
// `call()` primitive, so a consent/epoch rejection surfaces as the mapped
|
|
745
|
+
// `SelfhostedControlError` exactly like every other op. `NativeDesktopComputer`
|
|
746
|
+
// (sandbox-computer.ts) is the sole consumer.
|
|
747
|
+
/** Computer-use WRITE op: inject one synthetic desktop input event (pointer/key/
|
|
748
|
+
* scroll) on the machine's OWN display. The agent injects via CGEvent (macOS) /
|
|
749
|
+
* XTEST (Linux) and CONSENT-GATES it — an unconsented call never touches the OS
|
|
750
|
+
* and surfaces the mapped control error (ERROR_CODE_CONSENT_REQUIRED) via `call()`. */
|
|
751
|
+
async desktopInput(event) {
|
|
752
|
+
const result = await this.call({ $case: "desktopInput", desktopInput: { event } });
|
|
753
|
+
if (result.$case !== "desktopInput") {
|
|
754
|
+
throw new Error(`selfhosted desktopInput: unexpected result ${result.$case}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/** Computer-use VIEW op: capture a single PNG screenshot of the machine's desktop
|
|
758
|
+
* plus its geometry (via ScreenCaptureKit / x11). NOT consent-gated (a view op —
|
|
759
|
+
* the view/control decoupling), so it works with a display but no screen-control
|
|
760
|
+
* consent. Returns the raw encoded bytes + width/height. */
|
|
761
|
+
async screenshot() {
|
|
762
|
+
const result = await this.call({ $case: "desktopScreenshot", desktopScreenshot: {} });
|
|
763
|
+
if (result.$case !== "desktopScreenshot") {
|
|
764
|
+
throw new Error(`selfhosted screenshot: unexpected result ${result.$case}`);
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
png: result.desktopScreenshot.png,
|
|
768
|
+
width: result.desktopScreenshot.width,
|
|
769
|
+
height: result.desktopScreenshot.height
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
/** A cheap liveness probe — request a Ping on the subject; returns true iff a
|
|
773
|
+
* responder answered (no AgentError). Used by `negotiateSelfhostedCapabilities`.
|
|
774
|
+
* The wire `nonce` is a uint64 (a numeric string), so the default is a random
|
|
775
|
+
* numeric value — NOT a UUID (which would fail proto uint64 encoding). */
|
|
776
|
+
async ping(nonce = randomNonce()) {
|
|
777
|
+
const req = {
|
|
778
|
+
requestId: crypto.randomUUID(),
|
|
779
|
+
epoch: this.epoch,
|
|
780
|
+
op: { $case: "ping", ping: { nonce } }
|
|
781
|
+
};
|
|
782
|
+
const res = await this.controlRpc.request(this.subject, req, { timeoutMs: this.timeoutMs });
|
|
783
|
+
return !res.error && res.result?.$case === "ping";
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Resolve an exposed port to a relay stream endpoint (the viewer/pty plane).
|
|
787
|
+
* Returns the relay URL SHAPE — `{host:relay, port, tls, query:channel-key}` —
|
|
788
|
+
* after asking the agent to ensure a stream channel for the port. M8b wires the
|
|
789
|
+
* real relay tier (the byte pump) behind THIS seam.
|
|
790
|
+
*
|
|
791
|
+
* THE CHANNEL-KEY QUERY (the M8b relay-dial contract, dossier §10.5): the relay
|
|
792
|
+
* routes by `{workspaceId, agentId, port}` — the EXACT `ChannelKey::query` the
|
|
793
|
+
* agent's relay client (`opengeni-agent-stream`) appends when it registers the
|
|
794
|
+
* producer side: `ws=<workspaceId>&agent=<agentId>&port=<port>`. We append the
|
|
795
|
+
* agent-registered `channel=<channelId>` as a correlation hint. So the viewer
|
|
796
|
+
* dials `wss://<relay>/stream?ws=&agent=&port=&channel=` and presents the minted
|
|
797
|
+
* `ogs_` token in-band (NEVER as a URL param) — the relay pairs it with the
|
|
798
|
+
* producer by the routing key.
|
|
799
|
+
*/
|
|
800
|
+
async resolveExposedPort(port) {
|
|
801
|
+
let channel;
|
|
802
|
+
if (port === DESKTOP_STREAM_PORT2) {
|
|
803
|
+
const result = await this.call({
|
|
804
|
+
$case: "desktopEnsure",
|
|
805
|
+
desktopEnsure: { width: 0, height: 0 }
|
|
806
|
+
});
|
|
807
|
+
if (result.$case !== "desktopEnsure") {
|
|
808
|
+
throw new Error(`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`);
|
|
809
|
+
}
|
|
810
|
+
channel = result.desktopEnsure.channel;
|
|
811
|
+
} else {
|
|
812
|
+
const result = await this.call({
|
|
813
|
+
$case: "ptyOpen",
|
|
814
|
+
// Open the terminal in the session workingDir (default "" ⇒ the agent's
|
|
815
|
+
// workspace_root, byte-identical to before). A relative workingDir resolves
|
|
816
|
+
// under workspace_root; an absolute one is used as-is by the agent.
|
|
817
|
+
ptyOpen: { command: [], cwd: this.workingDir, env: {}, cols: 0, rows: 0, term: "xterm-256color" }
|
|
818
|
+
});
|
|
819
|
+
if (result.$case !== "ptyOpen") {
|
|
820
|
+
throw new Error(`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`);
|
|
821
|
+
}
|
|
822
|
+
channel = result.ptyOpen.channel;
|
|
823
|
+
}
|
|
824
|
+
const channelId = channel?.channelId ?? channelKey(this.workspaceId, this.agentId, port);
|
|
825
|
+
const tls = this.relay.tls ?? true;
|
|
826
|
+
const routingQuery = `ws=${encodeURIComponent(this.workspaceId)}&agent=${encodeURIComponent(this.agentId)}&port=${port}&channel=${encodeURIComponent(channelId)}`;
|
|
827
|
+
return {
|
|
828
|
+
host: this.relay.host,
|
|
829
|
+
port: this.relay.port ?? (tls ? 443 : 80),
|
|
830
|
+
tls,
|
|
831
|
+
// The relay's wss route (`/stream`); buildStreamUrl honors `path`.
|
|
832
|
+
path: this.relay.path ?? SELFHOSTED_RELAY_STREAM_PATH,
|
|
833
|
+
query: routingQuery,
|
|
834
|
+
protocol: kindToProtocol(channel?.kind)
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/** Round-trip the persistable state — `{agentId}` ONLY (resume = re-address). */
|
|
838
|
+
async serializeSessionState() {
|
|
839
|
+
return { agentId: this.agentId };
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
var SelfhostedSandboxClient = class {
|
|
843
|
+
backendId = "selfhosted";
|
|
844
|
+
supportsDefaultOptions = false;
|
|
845
|
+
workspaceId;
|
|
846
|
+
relay;
|
|
847
|
+
controlRpcFactory;
|
|
848
|
+
defaultAgentId;
|
|
849
|
+
epoch;
|
|
850
|
+
timeoutMs;
|
|
851
|
+
environment;
|
|
852
|
+
workingDir;
|
|
853
|
+
controlRpcMemo;
|
|
854
|
+
constructor(opts) {
|
|
855
|
+
this.workspaceId = opts.workspaceId;
|
|
856
|
+
this.relay = opts.relay;
|
|
857
|
+
this.controlRpcFactory = opts.controlRpcFactory;
|
|
858
|
+
this.defaultAgentId = opts.agentId;
|
|
859
|
+
this.epoch = opts.epoch;
|
|
860
|
+
this.timeoutMs = opts.timeoutMs;
|
|
861
|
+
this.environment = opts.environment;
|
|
862
|
+
this.workingDir = opts.workingDir;
|
|
863
|
+
}
|
|
864
|
+
controlRpc() {
|
|
865
|
+
if (!this.controlRpcMemo) {
|
|
866
|
+
this.controlRpcMemo = this.controlRpcFactory();
|
|
867
|
+
}
|
|
868
|
+
return this.controlRpcMemo;
|
|
869
|
+
}
|
|
870
|
+
bind(agentId) {
|
|
871
|
+
return new SelfhostedSession({
|
|
872
|
+
workspaceId: this.workspaceId,
|
|
873
|
+
agentId,
|
|
874
|
+
controlRpc: this.controlRpc(),
|
|
875
|
+
relay: this.relay,
|
|
876
|
+
...this.epoch !== void 0 ? { epoch: this.epoch } : {},
|
|
877
|
+
...this.timeoutMs !== void 0 ? { timeoutMs: this.timeoutMs } : {},
|
|
878
|
+
...this.environment !== void 0 ? { environment: this.environment } : {},
|
|
879
|
+
...this.workingDir !== void 0 ? { workingDir: this.workingDir } : {}
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
/** Bind a session to the live agent subject. There is no box to provision. */
|
|
883
|
+
async create(_manifest, _options) {
|
|
884
|
+
const agentId = this.requireAgentId();
|
|
885
|
+
return this.bind(agentId);
|
|
886
|
+
}
|
|
887
|
+
/** Resume = re-address the subject. Identical to create — no provider state. */
|
|
888
|
+
async resume(state, _options) {
|
|
889
|
+
const agentId = readAgentId(state) ?? this.requireAgentId();
|
|
890
|
+
return this.bind(agentId);
|
|
891
|
+
}
|
|
892
|
+
/** Serialize a live session's state → `{agentId}` ONLY. */
|
|
893
|
+
async serializeSessionState(state) {
|
|
894
|
+
const agentId = readAgentId(state) ?? this.requireAgentId();
|
|
895
|
+
return { agentId };
|
|
896
|
+
}
|
|
897
|
+
/** Deserialize `{agentId}` from the persisted envelope. */
|
|
898
|
+
async deserializeSessionState(state) {
|
|
899
|
+
const agentId = readAgentId(state) ?? this.requireAgentId();
|
|
900
|
+
return { agentId };
|
|
901
|
+
}
|
|
902
|
+
/** selfhosted is NOT persistable — there is no owned session state to preserve
|
|
903
|
+
* (the machine is the persistence). The lease never snapshots it. */
|
|
904
|
+
async canPersistOwnedSessionState() {
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
requireAgentId() {
|
|
908
|
+
if (!this.defaultAgentId) {
|
|
909
|
+
throw new Error("selfhosted sandbox client: no agentId bound (create()/resume() need a session state carrying agentId)");
|
|
910
|
+
}
|
|
911
|
+
return this.defaultAgentId;
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
async function buildSelfhostedBackendSession(deps) {
|
|
915
|
+
const client = new SelfhostedSandboxClient({
|
|
916
|
+
workspaceId: deps.workspaceId,
|
|
917
|
+
relay: deps.relay,
|
|
918
|
+
controlRpcFactory: deps.controlRpcFactory,
|
|
919
|
+
agentId: deps.agentId,
|
|
920
|
+
epoch: deps.epoch,
|
|
921
|
+
...deps.timeoutMs !== void 0 ? { timeoutMs: deps.timeoutMs } : {},
|
|
922
|
+
...deps.environment !== void 0 ? { environment: deps.environment } : {},
|
|
923
|
+
...deps.workingDir ? { workingDir: deps.workingDir } : {}
|
|
924
|
+
});
|
|
925
|
+
const session = await client.resume({ agentId: deps.agentId });
|
|
926
|
+
return { client, session };
|
|
927
|
+
}
|
|
928
|
+
function readAgentId(state) {
|
|
929
|
+
if (state && typeof state === "object") {
|
|
930
|
+
const candidate = state.agentId ?? state.providerState?.agentId;
|
|
931
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
932
|
+
return candidate;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return void 0;
|
|
936
|
+
}
|
|
937
|
+
function execResultToChannelA(res) {
|
|
938
|
+
const stdout = decoder.decode(res.stdout);
|
|
939
|
+
const stderr = decoder.decode(res.stderr);
|
|
940
|
+
return {
|
|
941
|
+
output: stdout,
|
|
942
|
+
stdout,
|
|
943
|
+
stderr,
|
|
944
|
+
exitCode: res.exitCode
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function channelKey(workspaceId, agentId, port) {
|
|
948
|
+
return `${workspaceId}:${agentId}:${port}`;
|
|
949
|
+
}
|
|
950
|
+
function shellQuote(value) {
|
|
951
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
952
|
+
}
|
|
953
|
+
function sniffImageMediaType(bytes, path) {
|
|
954
|
+
if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) return "image/png";
|
|
955
|
+
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
956
|
+
if (bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56) return "image/gif";
|
|
957
|
+
if (bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return "image/webp";
|
|
958
|
+
if (bytes[0] === 66 && bytes[1] === 77) return "image/bmp";
|
|
959
|
+
if (bytes[0] === 73 && bytes[1] === 73 && bytes[2] === 42 && bytes[3] === 0 || bytes[0] === 77 && bytes[1] === 77 && bytes[2] === 0 && bytes[3] === 42) return "image/tiff";
|
|
960
|
+
if (looksLikeSvg(bytes)) return "image/svg+xml";
|
|
961
|
+
return mediaTypeFromPath(path);
|
|
962
|
+
}
|
|
963
|
+
function looksLikeSvg(bytes) {
|
|
964
|
+
const prefix = decoder.decode(bytes.subarray(0, Math.min(bytes.byteLength, 512))).trimStart().toLowerCase();
|
|
965
|
+
return prefix.startsWith("<svg") || /^<\?xml[\s\S]*<svg/u.test(prefix);
|
|
966
|
+
}
|
|
967
|
+
function mediaTypeFromPath(path) {
|
|
968
|
+
const p = path?.trim().toLowerCase() ?? "";
|
|
969
|
+
if (p.endsWith(".png")) return "image/png";
|
|
970
|
+
if (p.endsWith(".jpg") || p.endsWith(".jpeg")) return "image/jpeg";
|
|
971
|
+
if (p.endsWith(".gif")) return "image/gif";
|
|
972
|
+
if (p.endsWith(".webp")) return "image/webp";
|
|
973
|
+
if (p.endsWith(".bmp")) return "image/bmp";
|
|
974
|
+
if (p.endsWith(".tif") || p.endsWith(".tiff")) return "image/tiff";
|
|
975
|
+
if (p.endsWith(".svg") || p.endsWith(".svgz")) return "image/svg+xml";
|
|
976
|
+
return void 0;
|
|
977
|
+
}
|
|
978
|
+
function randomNonce() {
|
|
979
|
+
return String(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
|
|
980
|
+
}
|
|
981
|
+
function kindToProtocol(kind) {
|
|
982
|
+
switch (kind) {
|
|
983
|
+
case StreamKind.STREAM_KIND_PTY:
|
|
984
|
+
return "pty";
|
|
985
|
+
case StreamKind.STREAM_KIND_DESKTOP:
|
|
986
|
+
return "vnc";
|
|
987
|
+
default:
|
|
988
|
+
return "raw";
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
function isSelfhostedProviderNotFoundError(_error) {
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// src/sandbox/providers/selfhosted.ts
|
|
996
|
+
function resolveRelayConfig(settings) {
|
|
997
|
+
const raw = settings.selfhostedRelayUrl?.trim();
|
|
998
|
+
if (!raw) {
|
|
999
|
+
return { host: "relay.opengeni.local", port: 443, tls: true, path: "/stream" };
|
|
1000
|
+
}
|
|
1001
|
+
try {
|
|
1002
|
+
const url = new URL(raw.includes("://") ? raw : `wss://${raw}`);
|
|
1003
|
+
const tls = url.protocol === "wss:" || url.protocol === "https:";
|
|
1004
|
+
const port = url.port ? Number(url.port) : tls ? 443 : 80;
|
|
1005
|
+
const path = url.pathname && url.pathname !== "/" ? url.pathname : "/stream";
|
|
1006
|
+
return { host: url.hostname, port, tls, path };
|
|
1007
|
+
} catch {
|
|
1008
|
+
return { host: raw, port: 443, tls: true, path: "/stream" };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function defaultControlRpcFactory() {
|
|
1012
|
+
return new NatsControlRpc(async () => null);
|
|
1013
|
+
}
|
|
1014
|
+
var selfhostedProvider = {
|
|
1015
|
+
backend: "selfhosted",
|
|
1016
|
+
descriptor: CAPABILITY_DESCRIPTORS.selfhosted,
|
|
1017
|
+
/**
|
|
1018
|
+
* No per-box credentials: the machine is reached over the agent's own
|
|
1019
|
+
* enrollment. The enrollment-signing + relay-token secrets are deployment-level
|
|
1020
|
+
* config that lands with the connectivity/enrollment milestones (M4/M5) — and
|
|
1021
|
+
* the whole feature is gated by a `sandboxSelfhostedEnabled` flag (default off)
|
|
1022
|
+
* that does not yet exist in Settings. So validation is LENIENT (no-op) in M3:
|
|
1023
|
+
* boot must never break, and there is nothing per-box to validate. M4/M5 add
|
|
1024
|
+
* the (flag-gated) signing/relay presence checks here behind the same seam.
|
|
1025
|
+
*/
|
|
1026
|
+
validateCredentials() {
|
|
1027
|
+
},
|
|
1028
|
+
/**
|
|
1029
|
+
* Build the registry client. `create()`/`resume()` bind a `SelfhostedSession`
|
|
1030
|
+
* to the agent subject; the per-request `{workspaceId, agentId, controlRpc}`
|
|
1031
|
+
* are supplied by the resume path (the lease's enrollment) — the registry
|
|
1032
|
+
* client carries the relay config + the default (offline-until-M4) ControlRpc
|
|
1033
|
+
* factory and a backendId-correct surface for `assertProviderRegistryInvariants`.
|
|
1034
|
+
*/
|
|
1035
|
+
build({ settings }) {
|
|
1036
|
+
return new SelfhostedSandboxClient({
|
|
1037
|
+
// The workspaceId is bound per-request by the resume path (the API/worker
|
|
1038
|
+
// construct a request-scoped client with the lease's workspace + a live
|
|
1039
|
+
// ControlRpc). The registry-built client is the boot/assertion shape; an
|
|
1040
|
+
// empty workspaceId is fine until a session is bound with a real one.
|
|
1041
|
+
workspaceId: "",
|
|
1042
|
+
relay: resolveRelayConfig(settings),
|
|
1043
|
+
controlRpcFactory: defaultControlRpcFactory
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
// src/sandbox/providers/vercel.ts
|
|
1049
|
+
import { VercelSandboxClient } from "@openai/agents-extensions/sandbox/vercel";
|
|
1050
|
+
var vercelProvider = {
|
|
1051
|
+
backend: "vercel",
|
|
1052
|
+
descriptor: CAPABILITY_DESCRIPTORS.vercel,
|
|
1053
|
+
validateCredentials(settings) {
|
|
1054
|
+
if (!settings.vercelToken) {
|
|
1055
|
+
throw new SandboxConfigError("vercel", "OPENGENI_VERCEL_TOKEN is required");
|
|
1056
|
+
}
|
|
1057
|
+
if (!settings.vercelProjectId) {
|
|
1058
|
+
throw new SandboxConfigError("vercel", "OPENGENI_VERCEL_PROJECT_ID is required");
|
|
1059
|
+
}
|
|
1060
|
+
},
|
|
1061
|
+
build({ settings, environment, exposedPorts }) {
|
|
1062
|
+
const options = {
|
|
1063
|
+
token: settings.vercelToken,
|
|
1064
|
+
projectId: settings.vercelProjectId,
|
|
1065
|
+
env: environment,
|
|
1066
|
+
exposedPorts
|
|
1067
|
+
};
|
|
1068
|
+
if (settings.vercelTeamId) options.teamId = settings.vercelTeamId;
|
|
1069
|
+
if (settings.vercelRuntime) options.runtime = settings.vercelRuntime;
|
|
1070
|
+
return new VercelSandboxClient(options);
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
// src/sandbox/providers/index.ts
|
|
1075
|
+
var PROVIDER_REGISTRY = {
|
|
1076
|
+
docker: dockerProvider,
|
|
1077
|
+
modal: modalProvider,
|
|
1078
|
+
local: localProvider,
|
|
1079
|
+
none: noneProvider,
|
|
1080
|
+
daytona: daytonaProvider,
|
|
1081
|
+
runloop: runloopProvider,
|
|
1082
|
+
e2b: e2bProvider,
|
|
1083
|
+
blaxel: blaxelProvider,
|
|
1084
|
+
cloudflare: cloudflareProvider,
|
|
1085
|
+
vercel: vercelProvider,
|
|
1086
|
+
selfhosted: selfhostedProvider
|
|
1087
|
+
};
|
|
1088
|
+
var ASSERTION_STUB_SETTINGS = {
|
|
1089
|
+
dockerImage: "opengeni-sandbox:local",
|
|
1090
|
+
modalAppName: "opengeni-sandbox",
|
|
1091
|
+
modalTimeoutSeconds: 900,
|
|
1092
|
+
daytonaApiKey: "stub",
|
|
1093
|
+
runloopApiKey: "stub",
|
|
1094
|
+
runloopTunnel: true,
|
|
1095
|
+
e2bApiKey: "stub",
|
|
1096
|
+
blaxelApiKey: "stub",
|
|
1097
|
+
cloudflareWorkerUrl: "https://stub.example.com",
|
|
1098
|
+
vercelToken: "stub",
|
|
1099
|
+
vercelProjectId: "stub"
|
|
1100
|
+
};
|
|
1101
|
+
function assertProviderRegistryInvariants() {
|
|
1102
|
+
assertDescriptorRegistryInvariants();
|
|
1103
|
+
for (const backend of SandboxBackend2.options) {
|
|
1104
|
+
const registration = PROVIDER_REGISTRY[backend];
|
|
1105
|
+
if (registration.backend !== backend) {
|
|
1106
|
+
throw new Error(`PROVIDER_REGISTRY["${backend}"].backend mismatch (got "${registration.backend}")`);
|
|
1107
|
+
}
|
|
1108
|
+
if (registration.descriptor.backend !== backend) {
|
|
1109
|
+
throw new Error(`PROVIDER_REGISTRY["${backend}"].descriptor.backend mismatch (got "${registration.descriptor.backend}")`);
|
|
1110
|
+
}
|
|
1111
|
+
if (backend === "none") {
|
|
1112
|
+
if (registration.descriptor.backendId !== "none") {
|
|
1113
|
+
throw new Error(`"none" descriptor.backendId must be "none" (got "${registration.descriptor.backendId}")`);
|
|
1114
|
+
}
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const client = registration.build({
|
|
1118
|
+
settings: ASSERTION_STUB_SETTINGS,
|
|
1119
|
+
environment: {},
|
|
1120
|
+
exposedPorts: []
|
|
1121
|
+
});
|
|
1122
|
+
const sdkBackendId = client?.backendId;
|
|
1123
|
+
if (typeof sdkBackendId !== "string") {
|
|
1124
|
+
throw new Error(`Provider "${backend}" SDK client has no string backendId`);
|
|
1125
|
+
}
|
|
1126
|
+
if (sdkBackendId !== registration.descriptor.backendId) {
|
|
1127
|
+
throw new Error(
|
|
1128
|
+
`Provider "${backend}" backendId mismatch: descriptor.backendId="${registration.descriptor.backendId}" but SDK client.backendId="${sdkBackendId}"`
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
assertProviderRegistryInvariants();
|
|
1134
|
+
|
|
1135
|
+
// src/sandbox/index.ts
|
|
1136
|
+
import { collectSandboxEnvironment as collectSandboxEnvironment2, parseExposedPorts as parseExposedPorts2 } from "@opengeni/config";
|
|
1137
|
+
|
|
1138
|
+
// src/sandbox/select.ts
|
|
1139
|
+
import {
|
|
1140
|
+
CAPABILITY_DESCRIPTORS as CAPABILITY_DESCRIPTORS2
|
|
1141
|
+
} from "@opengeni/contracts";
|
|
1142
|
+
function selectBackend(backend) {
|
|
1143
|
+
const descriptor = CAPABILITY_DESCRIPTORS2[backend];
|
|
1144
|
+
if (!descriptor) {
|
|
1145
|
+
throw new Error(`Unknown sandbox backend "${backend}"`);
|
|
1146
|
+
}
|
|
1147
|
+
return descriptor;
|
|
1148
|
+
}
|
|
1149
|
+
function backendSupportsOs(descriptor, os) {
|
|
1150
|
+
return descriptor.os.supported.includes(os);
|
|
1151
|
+
}
|
|
1152
|
+
function desktopCapableBackend(backend) {
|
|
1153
|
+
const direct = CAPABILITY_DESCRIPTORS2[backend];
|
|
1154
|
+
if (direct) {
|
|
1155
|
+
return direct.capabilities.DesktopStream.available === true;
|
|
1156
|
+
}
|
|
1157
|
+
for (const descriptor of Object.values(CAPABILITY_DESCRIPTORS2)) {
|
|
1158
|
+
if (descriptor.backendId === backend) {
|
|
1159
|
+
return descriptor.capabilities.DesktopStream.available === true;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
function negotiateCapabilities(ctx) {
|
|
1165
|
+
const descriptor = selectBackend(ctx.backend);
|
|
1166
|
+
const osSupported = backendSupportsOs(descriptor, ctx.os);
|
|
1167
|
+
const negotiatedAt = (ctx.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1168
|
+
const osReason = osSupported ? null : "os_unsupported";
|
|
1169
|
+
const fileSystem = (() => {
|
|
1170
|
+
if (osReason) {
|
|
1171
|
+
return { available: false, readOnly: true, root: descriptor.workspaceRoot, pathSep: "/", treeMode: "lazy", reason: osReason };
|
|
1172
|
+
}
|
|
1173
|
+
const cap = descriptor.capabilities.FileSystem;
|
|
1174
|
+
return {
|
|
1175
|
+
available: cap.available,
|
|
1176
|
+
readOnly: cap.readOnly,
|
|
1177
|
+
root: descriptor.workspaceRoot,
|
|
1178
|
+
pathSep: "/",
|
|
1179
|
+
treeMode: "lazy",
|
|
1180
|
+
reason: cap.available ? null : "backend_unsupported"
|
|
1181
|
+
};
|
|
1182
|
+
})();
|
|
1183
|
+
const terminal = (() => {
|
|
1184
|
+
const cap = descriptor.capabilities.Terminal;
|
|
1185
|
+
if (osReason) {
|
|
1186
|
+
return { transport: null, ptyCapable: false, shell: "/bin/bash", url: null, token: null, expiresAt: null, reason: osReason };
|
|
1187
|
+
}
|
|
1188
|
+
if (!cap.available) {
|
|
1189
|
+
return { transport: null, ptyCapable: false, shell: "/bin/bash", url: null, token: null, expiresAt: null, reason: "backend_unsupported" };
|
|
1190
|
+
}
|
|
1191
|
+
const ptyCapable = cap.pty;
|
|
1192
|
+
let transport = ptyCapable ? "pty-ws" : "sse-events";
|
|
1193
|
+
let reason = null;
|
|
1194
|
+
if (ptyCapable && ctx.terminalEnabled === false) {
|
|
1195
|
+
transport = "sse-events";
|
|
1196
|
+
reason = "disabled_by_policy";
|
|
1197
|
+
} else if (ptyCapable && ctx.liveness === "cold" && !ctx.terminalStream) {
|
|
1198
|
+
transport = "sse-events";
|
|
1199
|
+
reason = "lease_cold";
|
|
1200
|
+
}
|
|
1201
|
+
const minted = transport === "pty-ws" ? ctx.terminalStream : void 0;
|
|
1202
|
+
return {
|
|
1203
|
+
transport,
|
|
1204
|
+
ptyCapable,
|
|
1205
|
+
shell: "/bin/bash",
|
|
1206
|
+
url: minted?.url ?? null,
|
|
1207
|
+
token: minted?.token ?? null,
|
|
1208
|
+
expiresAt: minted?.expiresAt ?? null,
|
|
1209
|
+
reason
|
|
1210
|
+
};
|
|
1211
|
+
})();
|
|
1212
|
+
const git = (() => {
|
|
1213
|
+
const cap = descriptor.capabilities.Git;
|
|
1214
|
+
if (osReason) {
|
|
1215
|
+
return { available: false, repos: [], reason: osReason };
|
|
1216
|
+
}
|
|
1217
|
+
return { available: cap.available, repos: [], reason: cap.available ? null : "backend_unsupported" };
|
|
1218
|
+
})();
|
|
1219
|
+
const desktop = (() => {
|
|
1220
|
+
const cap = descriptor.capabilities.DesktopStream;
|
|
1221
|
+
let reason = null;
|
|
1222
|
+
let available = cap.available;
|
|
1223
|
+
if (osReason) {
|
|
1224
|
+
available = false;
|
|
1225
|
+
reason = osReason;
|
|
1226
|
+
} else if (!cap.available) {
|
|
1227
|
+
available = false;
|
|
1228
|
+
reason = descriptor.tier === "headless" ? "tier_headless" : "backend_unsupported";
|
|
1229
|
+
} else if (!ctx.desktopEnabled) {
|
|
1230
|
+
available = false;
|
|
1231
|
+
reason = "disabled_by_policy";
|
|
1232
|
+
} else if (ctx.streamTokenSecretAvailable === false) {
|
|
1233
|
+
available = false;
|
|
1234
|
+
reason = "disabled_by_policy";
|
|
1235
|
+
} else if (ctx.liveness === "cold" && !ctx.desktopStream) {
|
|
1236
|
+
available = false;
|
|
1237
|
+
reason = "lease_cold";
|
|
1238
|
+
}
|
|
1239
|
+
const shared = available ? Boolean(ctx.shared) : false;
|
|
1240
|
+
const acknowledged = available ? Boolean(ctx.desktopAcknowledged) : false;
|
|
1241
|
+
const minted = available && acknowledged ? ctx.desktopStream : void 0;
|
|
1242
|
+
const selfhostedFrames = ctx.backend === "selfhosted";
|
|
1243
|
+
const interactive = available && !selfhostedFrames && ctx.desktopInteractive !== false;
|
|
1244
|
+
const mode = interactive ? "interactive" : "read-only";
|
|
1245
|
+
return {
|
|
1246
|
+
transport: available ? selfhostedFrames ? "relay-frames" : cap.transport : null,
|
|
1247
|
+
client: available ? selfhostedFrames ? "frames" : "novnc" : null,
|
|
1248
|
+
mode,
|
|
1249
|
+
url: minted?.url ?? null,
|
|
1250
|
+
token: minted?.token ?? null,
|
|
1251
|
+
expiresAt: minted?.expiresAt ?? null,
|
|
1252
|
+
resolution: minted?.resolution ?? [1024, 768],
|
|
1253
|
+
// Desktop pixels are ALWAYS un-redacted when present (the literal
|
|
1254
|
+
// framebuffer); the acknowledgment gate rests on this.
|
|
1255
|
+
unredacted: true,
|
|
1256
|
+
requiresAcknowledgment: available,
|
|
1257
|
+
acknowledged: available ? Boolean(ctx.desktopAcknowledged) : false,
|
|
1258
|
+
// Shared-exposure disclosure (addendum E.1): `shared` when the group has
|
|
1259
|
+
// >1 session; `sharedSessionIds` is the OTHER sessions' ids ONLY (never
|
|
1260
|
+
// their conversation/metadata). Empty/false for a solo box or when the
|
|
1261
|
+
// desktop cell is unavailable.
|
|
1262
|
+
shared,
|
|
1263
|
+
sharedSessionIds: shared ? ctx.sharedSessionIds ?? [] : [],
|
|
1264
|
+
reason
|
|
1265
|
+
};
|
|
1266
|
+
})();
|
|
1267
|
+
const recording = (() => {
|
|
1268
|
+
const cap = descriptor.capabilities.Recording;
|
|
1269
|
+
if (osReason) {
|
|
1270
|
+
return { available: false, modes: [], codecs: [], reason: osReason };
|
|
1271
|
+
}
|
|
1272
|
+
if (!cap.available) {
|
|
1273
|
+
return { available: false, modes: [], codecs: [], reason: descriptor.tier === "headless" ? "tier_headless" : "backend_unsupported" };
|
|
1274
|
+
}
|
|
1275
|
+
if (!ctx.desktopEnabled) {
|
|
1276
|
+
return { available: false, modes: [], codecs: [], reason: "disabled_by_policy" };
|
|
1277
|
+
}
|
|
1278
|
+
return {
|
|
1279
|
+
available: true,
|
|
1280
|
+
modes: ["manual", "on-turn", "on-verify"],
|
|
1281
|
+
codecs: ["h264-mp4", "vp9-webm"],
|
|
1282
|
+
reason: null
|
|
1283
|
+
};
|
|
1284
|
+
})();
|
|
1285
|
+
const computerUse = (() => {
|
|
1286
|
+
const desktopCapable = descriptor.capabilities.DesktopStream.available;
|
|
1287
|
+
const readOnly = ctx.computerUseReadOnly ?? false;
|
|
1288
|
+
if (osReason) {
|
|
1289
|
+
return { available: false, readOnly, reason: osReason };
|
|
1290
|
+
}
|
|
1291
|
+
if (!desktopCapable) {
|
|
1292
|
+
return { available: false, readOnly, reason: descriptor.tier === "headless" ? "tier_headless" : "backend_unsupported" };
|
|
1293
|
+
}
|
|
1294
|
+
if (!ctx.desktopEnabled || ctx.computerUseEnabled === false) {
|
|
1295
|
+
return { available: false, readOnly, reason: "disabled_by_policy" };
|
|
1296
|
+
}
|
|
1297
|
+
return { available: true, readOnly, reason: null };
|
|
1298
|
+
})();
|
|
1299
|
+
return {
|
|
1300
|
+
sessionId: ctx.sessionId,
|
|
1301
|
+
backend: ctx.backend,
|
|
1302
|
+
os: ctx.os,
|
|
1303
|
+
liveness: ctx.liveness,
|
|
1304
|
+
leaseEpoch: ctx.leaseEpoch,
|
|
1305
|
+
viewerHeartbeatIntervalMs: 3e4,
|
|
1306
|
+
FileSystem: fileSystem,
|
|
1307
|
+
Terminal: terminal,
|
|
1308
|
+
Git: git,
|
|
1309
|
+
DesktopStream: desktop,
|
|
1310
|
+
Recording: recording,
|
|
1311
|
+
ComputerUse: computerUse,
|
|
1312
|
+
negotiatedAt
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// src/sandbox/stream-token.ts
|
|
1317
|
+
import {
|
|
1318
|
+
DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT3,
|
|
1319
|
+
StreamTokenPayload,
|
|
1320
|
+
signStreamToken,
|
|
1321
|
+
verifyStreamToken as verifyStreamTokenEnvelope
|
|
1322
|
+
} from "@opengeni/contracts";
|
|
1323
|
+
import { StreamTokenPayload as StreamTokenPayload2 } from "@opengeni/contracts";
|
|
1324
|
+
var STREAM_TOKEN_DEFAULT_TTL_SECONDS = 120;
|
|
1325
|
+
async function mintStreamToken(secret, input) {
|
|
1326
|
+
const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1e3);
|
|
1327
|
+
const ttlSeconds = input.ttlSeconds ?? STREAM_TOKEN_DEFAULT_TTL_SECONDS;
|
|
1328
|
+
const payload = StreamTokenPayload.parse({
|
|
1329
|
+
workspaceId: input.workspaceId,
|
|
1330
|
+
sessionId: input.sessionId,
|
|
1331
|
+
viewerId: input.viewerId,
|
|
1332
|
+
leaseEpoch: input.leaseEpoch,
|
|
1333
|
+
mode: input.mode ?? "view",
|
|
1334
|
+
port: input.port ?? DESKTOP_STREAM_PORT3,
|
|
1335
|
+
exp: nowSeconds + ttlSeconds
|
|
1336
|
+
});
|
|
1337
|
+
return signStreamToken(secret, payload);
|
|
1338
|
+
}
|
|
1339
|
+
async function verifyStreamToken(secret, token, nowSeconds = Math.floor(Date.now() / 1e3)) {
|
|
1340
|
+
return verifyStreamTokenEnvelope(secret, token, nowSeconds);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// src/sandbox/display-stack.ts
|
|
1344
|
+
import { DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT4 } from "@opengeni/contracts";
|
|
1345
|
+
var STREAM_PORT = DESKTOP_STREAM_PORT4;
|
|
1346
|
+
var DISPLAY_STACK_TIMEOUT_MS = 6e4;
|
|
1347
|
+
var DEFAULT_DESKTOP_GEOMETRY = { width: 1280, height: 800, dpi: 96 };
|
|
1348
|
+
var DisplayStackError = class extends Error {
|
|
1349
|
+
exitCode;
|
|
1350
|
+
stage;
|
|
1351
|
+
constructor(exitCode, output) {
|
|
1352
|
+
const stage = exitCode === 11 ? "xvfb" : exitCode === 12 ? "x11vnc" : exitCode === 13 ? "websockify" : "unknown";
|
|
1353
|
+
super(`desktop display stack failed at stage "${stage}" (exit ${exitCode})${output ? `:
|
|
1354
|
+
${output}` : ""}`);
|
|
1355
|
+
this.name = "DisplayStackError";
|
|
1356
|
+
this.exitCode = exitCode;
|
|
1357
|
+
this.stage = stage;
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
var DisplayStackUnsupportedError = class extends Error {
|
|
1361
|
+
constructor(message) {
|
|
1362
|
+
super(message);
|
|
1363
|
+
this.name = "DisplayStackUnsupportedError";
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
function buildDisplayStackScript(options = {}) {
|
|
1367
|
+
const geometry = options.geometry ?? DEFAULT_DESKTOP_GEOMETRY;
|
|
1368
|
+
const port = options.port ?? DESKTOP_STREAM_PORT4;
|
|
1369
|
+
const env = `DESKTOP_W=${geometry.width} DESKTOP_H=${geometry.height} DESKTOP_DPI=${geometry.dpi} STREAM_PORT=${port}`;
|
|
1370
|
+
return `if nc -z 127.0.0.1 ${port} >/dev/null 2>&1 && nc -z 127.0.0.1 5900 >/dev/null 2>&1; then echo "OPENGENI_DESKTOP_UP port=${port} geometry=${geometry.width}x${geometry.height} dpi=${geometry.dpi} (precheck)"; else mkdir -p /tmp/opengeni-desktop && flock -w 45 /tmp/opengeni-desktop/up.outer.lock env ${env} opengeni-desktop-up; fi`;
|
|
1371
|
+
}
|
|
1372
|
+
function execResultOutput(result) {
|
|
1373
|
+
if (typeof result === "string") {
|
|
1374
|
+
return result;
|
|
1375
|
+
}
|
|
1376
|
+
return [result.output, result.stderr, result.stdout].filter((v) => typeof v === "string" && v.length > 0).join("\n");
|
|
1377
|
+
}
|
|
1378
|
+
function execResultExitCode(result) {
|
|
1379
|
+
if (typeof result === "string") {
|
|
1380
|
+
return null;
|
|
1381
|
+
}
|
|
1382
|
+
return typeof result.exitCode === "number" ? result.exitCode : null;
|
|
1383
|
+
}
|
|
1384
|
+
function inferExitFromOutput(output) {
|
|
1385
|
+
if (/OPENGENI_DESKTOP_UP\b/.test(output)) {
|
|
1386
|
+
return 0;
|
|
1387
|
+
}
|
|
1388
|
+
if (/Xvfb failed to come up/.test(output)) {
|
|
1389
|
+
return 11;
|
|
1390
|
+
}
|
|
1391
|
+
if (/x11vnc failed on/.test(output)) {
|
|
1392
|
+
return 12;
|
|
1393
|
+
}
|
|
1394
|
+
if (/websockify failed on/.test(output)) {
|
|
1395
|
+
return 13;
|
|
1396
|
+
}
|
|
1397
|
+
return -1;
|
|
1398
|
+
}
|
|
1399
|
+
async function ensureDisplayStack(session, options = {}) {
|
|
1400
|
+
const s = session;
|
|
1401
|
+
if (typeof s?.exec !== "function" && typeof s?.execCommand !== "function") {
|
|
1402
|
+
throw new DisplayStackUnsupportedError(
|
|
1403
|
+
"provider session cannot run commands (no exec/execCommand) \u2014 desktop tier unavailable"
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
const geometry = options.geometry ?? DEFAULT_DESKTOP_GEOMETRY;
|
|
1407
|
+
const port = options.port ?? DESKTOP_STREAM_PORT4;
|
|
1408
|
+
const timeoutMs = options.timeoutMs ?? DISPLAY_STACK_TIMEOUT_MS;
|
|
1409
|
+
const cmd = buildDisplayStackScript({ geometry, port });
|
|
1410
|
+
const result = typeof s.exec === "function" ? await s.exec({ cmd, yieldTimeMs: timeoutMs, maxOutputTokens: 2e4 }) : await s.execCommand({ cmd, yieldTimeMs: timeoutMs, maxOutputTokens: 2e4 });
|
|
1411
|
+
const output = execResultOutput(result);
|
|
1412
|
+
const exitCode = execResultExitCode(result) ?? inferExitFromOutput(output);
|
|
1413
|
+
if (exitCode !== 0) {
|
|
1414
|
+
throw new DisplayStackError(exitCode, output);
|
|
1415
|
+
}
|
|
1416
|
+
const marker = (output.match(/OPENGENI_DESKTOP_UP[^\n]*/) ?? [""])[0];
|
|
1417
|
+
return { port, geometry, marker };
|
|
1418
|
+
}
|
|
1419
|
+
async function tearDownDisplayStack(session) {
|
|
1420
|
+
const s = session;
|
|
1421
|
+
if (typeof s?.exec === "function") {
|
|
1422
|
+
await s.exec({ cmd: "opengeni-desktop-down", yieldTimeMs: 1e4, maxOutputTokens: 4e3 });
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (typeof s?.execCommand === "function") {
|
|
1426
|
+
await s.execCommand({ cmd: "opengeni-desktop-down", yieldTimeMs: 1e4, maxOutputTokens: 4e3 });
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// src/sandbox/terminal-server.ts
|
|
1431
|
+
import { TERMINAL_STREAM_PORT } from "@opengeni/contracts";
|
|
1432
|
+
var TERMINAL_SERVER_TIMEOUT_MS = 6e4;
|
|
1433
|
+
var TerminalServerError = class extends Error {
|
|
1434
|
+
exitCode;
|
|
1435
|
+
stage;
|
|
1436
|
+
constructor(exitCode, output) {
|
|
1437
|
+
const stage = exitCode === 14 ? "ttyd" : "unknown";
|
|
1438
|
+
super(`terminal server failed at stage "${stage}" (exit ${exitCode})${output ? `:
|
|
1439
|
+
${output}` : ""}`);
|
|
1440
|
+
this.name = "TerminalServerError";
|
|
1441
|
+
this.exitCode = exitCode;
|
|
1442
|
+
this.stage = stage;
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
var TerminalServerUnsupportedError = class extends Error {
|
|
1446
|
+
constructor(message) {
|
|
1447
|
+
super(message);
|
|
1448
|
+
this.name = "TerminalServerUnsupportedError";
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
function buildTerminalServerScript(options = {}) {
|
|
1452
|
+
const port = options.port ?? TERMINAL_STREAM_PORT;
|
|
1453
|
+
return `mkdir -p /tmp/opengeni-terminal && flock -w 30 /tmp/opengeni-terminal/up.outer.lock env TERMINAL_PORT=${port} opengeni-terminal-up`;
|
|
1454
|
+
}
|
|
1455
|
+
function execResultOutput2(result) {
|
|
1456
|
+
if (typeof result === "string") {
|
|
1457
|
+
return result;
|
|
1458
|
+
}
|
|
1459
|
+
return [result.output, result.stderr, result.stdout].filter((v) => typeof v === "string" && v.length > 0).join("\n");
|
|
1460
|
+
}
|
|
1461
|
+
function execResultExitCode2(result) {
|
|
1462
|
+
if (typeof result === "string") {
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
return typeof result.exitCode === "number" ? result.exitCode : null;
|
|
1466
|
+
}
|
|
1467
|
+
function inferExitFromOutput2(output) {
|
|
1468
|
+
if (/OPENGENI_TERMINAL_UP\b/.test(output)) {
|
|
1469
|
+
return 0;
|
|
1470
|
+
}
|
|
1471
|
+
if (/ttyd failed to come up/.test(output)) {
|
|
1472
|
+
return 14;
|
|
1473
|
+
}
|
|
1474
|
+
return -1;
|
|
1475
|
+
}
|
|
1476
|
+
async function ensureTerminalServer(session, options = {}) {
|
|
1477
|
+
const s = session;
|
|
1478
|
+
if (typeof s?.exec !== "function" && typeof s?.execCommand !== "function") {
|
|
1479
|
+
throw new TerminalServerUnsupportedError(
|
|
1480
|
+
"provider session cannot run commands (no exec/execCommand) \u2014 terminal pty-ws unavailable"
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
const port = options.port ?? TERMINAL_STREAM_PORT;
|
|
1484
|
+
const timeoutMs = options.timeoutMs ?? TERMINAL_SERVER_TIMEOUT_MS;
|
|
1485
|
+
const cmd = buildTerminalServerScript({ port });
|
|
1486
|
+
const result = typeof s.exec === "function" ? await s.exec({ cmd, yieldTimeMs: timeoutMs, maxOutputTokens: 2e4 }) : await s.execCommand({ cmd, yieldTimeMs: timeoutMs, maxOutputTokens: 2e4 });
|
|
1487
|
+
const output = execResultOutput2(result);
|
|
1488
|
+
const exitCode = execResultExitCode2(result) ?? inferExitFromOutput2(output);
|
|
1489
|
+
if (exitCode !== 0) {
|
|
1490
|
+
throw new TerminalServerError(exitCode, output);
|
|
1491
|
+
}
|
|
1492
|
+
const marker = (output.match(/OPENGENI_TERMINAL_UP[^\n]*/) ?? [""])[0];
|
|
1493
|
+
return { port, marker };
|
|
1494
|
+
}
|
|
1495
|
+
async function tearDownTerminalServer(session) {
|
|
1496
|
+
const s = session;
|
|
1497
|
+
if (typeof s?.exec === "function") {
|
|
1498
|
+
await s.exec({ cmd: "opengeni-terminal-down", yieldTimeMs: 1e4, maxOutputTokens: 4e3 });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof s?.execCommand === "function") {
|
|
1502
|
+
await s.execCommand({ cmd: "opengeni-terminal-down", yieldTimeMs: 1e4, maxOutputTokens: 4e3 });
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// src/sandbox/stream-port.ts
|
|
1507
|
+
import { DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT5 } from "@opengeni/contracts";
|
|
1508
|
+
var StreamPortUnavailableError = class extends Error {
|
|
1509
|
+
constructor(message, cause) {
|
|
1510
|
+
super(message);
|
|
1511
|
+
this.cause = cause;
|
|
1512
|
+
this.name = "StreamPortUnavailableError";
|
|
1513
|
+
}
|
|
1514
|
+
cause;
|
|
1515
|
+
};
|
|
1516
|
+
var DEFAULT_RESOLUTION = [1280, 800];
|
|
1517
|
+
function buildStreamUrl(endpoint) {
|
|
1518
|
+
if (typeof endpoint.host !== "string" || endpoint.host.length === 0 || typeof endpoint.port !== "number") {
|
|
1519
|
+
throw new StreamPortUnavailableError(
|
|
1520
|
+
`provider returned a malformed exposed-port endpoint (host=${String(endpoint.host)}, port=${String(endpoint.port)})`
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
const tls = endpoint.tls ?? false;
|
|
1524
|
+
const scheme = tls ? "wss" : "ws";
|
|
1525
|
+
const defaultPort = tls ? 443 : 80;
|
|
1526
|
+
const host = endpoint.host.includes(":") && !endpoint.host.startsWith("[") ? `[${endpoint.host}]` : endpoint.host;
|
|
1527
|
+
const rawPath = typeof endpoint.path === "string" && endpoint.path.length > 0 ? endpoint.path : "/";
|
|
1528
|
+
const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
1529
|
+
const origin = endpoint.port === defaultPort ? `${scheme}://${host}` : `${scheme}://${host}:${endpoint.port}`;
|
|
1530
|
+
const authority = `${origin}${path}`;
|
|
1531
|
+
const query = endpoint.query ?? "";
|
|
1532
|
+
return query ? `${authority}?${query}` : authority;
|
|
1533
|
+
}
|
|
1534
|
+
async function exposeStreamPort(session, input) {
|
|
1535
|
+
const s = session;
|
|
1536
|
+
const port = input.port ?? DESKTOP_STREAM_PORT5;
|
|
1537
|
+
if (typeof s?.resolveExposedPort !== "function") {
|
|
1538
|
+
throw new StreamPortUnavailableError(
|
|
1539
|
+
"provider session cannot resolve exposed ports (no resolveExposedPort) \u2014 desktop stream unavailable"
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
let endpoint;
|
|
1543
|
+
try {
|
|
1544
|
+
endpoint = await s.resolveExposedPort(port);
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
throw new StreamPortUnavailableError(
|
|
1547
|
+
`provider failed to resolve the stream port ${port}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1548
|
+
error
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
const url = buildStreamUrl(endpoint);
|
|
1552
|
+
const ttlSeconds = input.ttlSeconds ?? STREAM_TOKEN_DEFAULT_TTL_SECONDS;
|
|
1553
|
+
const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1e3);
|
|
1554
|
+
const token = await mintStreamToken(input.streamTokenSecret, {
|
|
1555
|
+
workspaceId: input.workspaceId,
|
|
1556
|
+
sessionId: input.sessionId,
|
|
1557
|
+
viewerId: input.viewerId,
|
|
1558
|
+
leaseEpoch: input.leaseEpoch,
|
|
1559
|
+
mode: "view",
|
|
1560
|
+
port,
|
|
1561
|
+
ttlSeconds,
|
|
1562
|
+
nowSeconds
|
|
1563
|
+
});
|
|
1564
|
+
return {
|
|
1565
|
+
url,
|
|
1566
|
+
token,
|
|
1567
|
+
expiresAt: new Date((nowSeconds + ttlSeconds) * 1e3).toISOString(),
|
|
1568
|
+
transport: "vnc-ws",
|
|
1569
|
+
client: "novnc",
|
|
1570
|
+
resolution: input.resolution ?? DEFAULT_RESOLUTION,
|
|
1571
|
+
leaseEpoch: input.leaseEpoch
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/sandbox/recording.ts
|
|
1576
|
+
import { DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT6 } from "@opengeni/contracts";
|
|
1577
|
+
var DEFAULT_MAX_SECONDS = 600;
|
|
1578
|
+
var DEFAULT_FRAMERATE = 15;
|
|
1579
|
+
var DEFAULT_MAX_BYTES = 268435456;
|
|
1580
|
+
var DEFAULT_DIMENSIONS = [1280, 800];
|
|
1581
|
+
var STOP_YIELD_MS = 2e4;
|
|
1582
|
+
var EXEC_YIELD_MS = 15e3;
|
|
1583
|
+
function contentTypeForCodec(codec) {
|
|
1584
|
+
return codec === "vp9-webm" ? "video/webm" : "video/mp4";
|
|
1585
|
+
}
|
|
1586
|
+
function extForCodec(codec) {
|
|
1587
|
+
return codec === "vp9-webm" ? "webm" : "mp4";
|
|
1588
|
+
}
|
|
1589
|
+
var RecordingUnavailableError = class extends Error {
|
|
1590
|
+
constructor(message) {
|
|
1591
|
+
super(message);
|
|
1592
|
+
this.name = "RecordingUnavailableError";
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
var RecordingError = class extends Error {
|
|
1596
|
+
constructor(message, reason) {
|
|
1597
|
+
super(message);
|
|
1598
|
+
this.reason = reason;
|
|
1599
|
+
this.name = "RecordingError";
|
|
1600
|
+
}
|
|
1601
|
+
reason;
|
|
1602
|
+
};
|
|
1603
|
+
function shq(s) {
|
|
1604
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1605
|
+
}
|
|
1606
|
+
function resultOutput(result) {
|
|
1607
|
+
if (typeof result === "string") return result;
|
|
1608
|
+
return [result.output, result.stderr, result.stdout].filter((v) => typeof v === "string" && v.length > 0).join("\n");
|
|
1609
|
+
}
|
|
1610
|
+
var DEFAULT_MAX_OUTPUT_TOKENS = 4e3;
|
|
1611
|
+
async function run(session, cmd, runAs, yieldTimeMs = EXEC_YIELD_MS, maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS) {
|
|
1612
|
+
const args = { cmd, ...runAs ? { runAs } : {}, yieldTimeMs, maxOutputTokens };
|
|
1613
|
+
if (typeof session.exec === "function") {
|
|
1614
|
+
return resultOutput(await session.exec(args));
|
|
1615
|
+
}
|
|
1616
|
+
if (typeof session.execCommand === "function") {
|
|
1617
|
+
return resultOutput(await session.execCommand(args));
|
|
1618
|
+
}
|
|
1619
|
+
throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) \u2014 recording unavailable");
|
|
1620
|
+
}
|
|
1621
|
+
function stripExecBanner(raw) {
|
|
1622
|
+
const marker = raw.lastIndexOf("\nOutput:\n");
|
|
1623
|
+
if (marker >= 0) return raw.slice(marker + "\nOutput:\n".length);
|
|
1624
|
+
if (raw.startsWith("Output:\n")) return raw.slice("Output:\n".length);
|
|
1625
|
+
return raw;
|
|
1626
|
+
}
|
|
1627
|
+
async function startRecording(session, input) {
|
|
1628
|
+
const s = session;
|
|
1629
|
+
const codec = input.codec ?? "h264-mp4";
|
|
1630
|
+
const dimensions = input.dimensions ?? DEFAULT_DIMENSIONS;
|
|
1631
|
+
const framerate = input.framerate ?? DEFAULT_FRAMERATE;
|
|
1632
|
+
const maxSeconds = input.maxSeconds ?? DEFAULT_MAX_SECONDS;
|
|
1633
|
+
const display = input.display ?? ":0";
|
|
1634
|
+
const tmp = input.tmpDir ?? "/tmp";
|
|
1635
|
+
const ext = extForCodec(codec);
|
|
1636
|
+
const boxPath = `${tmp}/og-rec-${input.recordingId}.${ext}`;
|
|
1637
|
+
const pidFile = `${tmp}/og-rec-${input.recordingId}.pid`;
|
|
1638
|
+
const logFile = `${tmp}/og-rec-${input.recordingId}.log`;
|
|
1639
|
+
const [w, h] = dimensions;
|
|
1640
|
+
const enc = codec === "vp9-webm" ? `-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1` : `-c:v libx264 -preset veryfast -pix_fmt yuv420p -movflags +faststart`;
|
|
1641
|
+
const ffmpeg = `nohup ffmpeg -hide_banner -loglevel error -f x11grab -draw_mouse 1 -framerate ${framerate} -video_size ${w}x${h} -i ${display}.0 -t ${maxSeconds} ${enc} ${boxPath} </dev/null >${logFile} 2>&1 & echo $! > ${pidFile}`;
|
|
1642
|
+
await run(s, `bash -lc ${shq(ffmpeg)}`, input.runAs);
|
|
1643
|
+
return {
|
|
1644
|
+
recordingId: input.recordingId,
|
|
1645
|
+
codec,
|
|
1646
|
+
boxPath,
|
|
1647
|
+
pidFile,
|
|
1648
|
+
dimensions,
|
|
1649
|
+
framerate,
|
|
1650
|
+
startedAt: Date.now(),
|
|
1651
|
+
display,
|
|
1652
|
+
...input.runAs ? { runAs: input.runAs } : {}
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
async function stopRecording(session, proc) {
|
|
1656
|
+
const s = session;
|
|
1657
|
+
const wait = `kill -INT "$(cat ${proc.pidFile})" 2>/dev/null; for i in $(seq 1 80); do kill -0 "$(cat ${proc.pidFile})" 2>/dev/null || break; sleep 0.1; done`;
|
|
1658
|
+
await run(s, `bash -lc ${shq(wait)}`, proc.runAs, STOP_YIELD_MS).catch(() => void 0);
|
|
1659
|
+
}
|
|
1660
|
+
async function readRecordingBytes(session, proc, maxBytes = DEFAULT_MAX_BYTES) {
|
|
1661
|
+
const s = session;
|
|
1662
|
+
if (typeof s.exec !== "function" && typeof s.execCommand !== "function") {
|
|
1663
|
+
throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) \u2014 recording finalize unavailable");
|
|
1664
|
+
}
|
|
1665
|
+
const sizeOut = (await run(s, `bash -lc ${shq(`stat -c %s ${proc.boxPath} 2>/dev/null || echo MISSING`)}`, proc.runAs)).trim();
|
|
1666
|
+
const sizeLine = sizeOut.split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? "MISSING";
|
|
1667
|
+
if (sizeLine === "MISSING" || sizeLine === "") {
|
|
1668
|
+
throw new RecordingError(`recording file missing on box: ${proc.boxPath}`, "box-death");
|
|
1669
|
+
}
|
|
1670
|
+
const size = Number(sizeLine);
|
|
1671
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
1672
|
+
throw new RecordingError(`recording file empty on box: ${proc.boxPath}`, "ffmpeg-error");
|
|
1673
|
+
}
|
|
1674
|
+
if (size > maxBytes) {
|
|
1675
|
+
throw new RecordingError(`recording ${size}B exceeds max ${maxBytes}B`, "max-bytes-exceeded");
|
|
1676
|
+
}
|
|
1677
|
+
const STOP_YIELD = 6e4;
|
|
1678
|
+
const encoded = stripExecBanner(
|
|
1679
|
+
await run(s, `bash -lc ${shq(`base64 ${proc.boxPath}`)}`, proc.runAs, STOP_YIELD, null)
|
|
1680
|
+
);
|
|
1681
|
+
const base64 = encoded.replace(/\s+/g, "");
|
|
1682
|
+
if (base64.length === 0) {
|
|
1683
|
+
throw new RecordingError(`recording read returned 0 bytes: ${proc.boxPath}`, "ffmpeg-error");
|
|
1684
|
+
}
|
|
1685
|
+
let bytes;
|
|
1686
|
+
try {
|
|
1687
|
+
bytes = Uint8Array.from(Buffer.from(base64, "base64"));
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
throw new RecordingError(`recording base64 decode failed: ${error instanceof Error ? error.message : String(error)}`, "ffmpeg-error");
|
|
1690
|
+
}
|
|
1691
|
+
if (bytes.length === 0) {
|
|
1692
|
+
throw new RecordingError(`recording read returned 0 bytes: ${proc.boxPath}`, "ffmpeg-error");
|
|
1693
|
+
}
|
|
1694
|
+
return {
|
|
1695
|
+
bytes,
|
|
1696
|
+
contentType: contentTypeForCodec(proc.codec),
|
|
1697
|
+
sizeBytes: bytes.length,
|
|
1698
|
+
durationSeconds: Math.max(0, Math.round((Date.now() - proc.startedAt) / 1e3))
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
async function deleteRecordingArtifacts(session, proc) {
|
|
1702
|
+
const s = session;
|
|
1703
|
+
const logFile = proc.boxPath.replace(/\.(mp4|webm)$/, ".log");
|
|
1704
|
+
await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(() => void 0);
|
|
1705
|
+
}
|
|
1706
|
+
function recordingStorageKey(workspaceId, sessionId, recordingId, codec) {
|
|
1707
|
+
return `recordings/${workspaceId}/${sessionId}/${recordingId}.${extForCodec(codec)}`;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// src/sandbox/channel-a.ts
|
|
1711
|
+
var ChannelAValidationError = class extends Error {
|
|
1712
|
+
constructor(message) {
|
|
1713
|
+
super(message);
|
|
1714
|
+
this.name = "ChannelAValidationError";
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
var ChannelAConflictError = class extends Error {
|
|
1718
|
+
constructor(message) {
|
|
1719
|
+
super(message);
|
|
1720
|
+
this.name = "ChannelAConflictError";
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
var ChannelANotFoundError = class extends Error {
|
|
1724
|
+
constructor(message) {
|
|
1725
|
+
super(message);
|
|
1726
|
+
this.name = "ChannelANotFoundError";
|
|
1727
|
+
}
|
|
1728
|
+
};
|
|
1729
|
+
var ChannelAUnsupportedError = class extends Error {
|
|
1730
|
+
constructor(message) {
|
|
1731
|
+
super(message);
|
|
1732
|
+
this.name = "ChannelAUnsupportedError";
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
var NUL = String.fromCharCode(0);
|
|
1736
|
+
var US = String.fromCharCode(31);
|
|
1737
|
+
var RS = String.fromCharCode(30);
|
|
1738
|
+
var SandboxChannelAService = class {
|
|
1739
|
+
session;
|
|
1740
|
+
workspaceRoot;
|
|
1741
|
+
leaseEpoch;
|
|
1742
|
+
revision;
|
|
1743
|
+
emit;
|
|
1744
|
+
runAs;
|
|
1745
|
+
constructor(opts) {
|
|
1746
|
+
this.session = opts.session;
|
|
1747
|
+
this.workspaceRoot = opts.workspaceRoot ?? "";
|
|
1748
|
+
this.leaseEpoch = opts.leaseEpoch ?? 0;
|
|
1749
|
+
this.revision = opts.revision ?? 0;
|
|
1750
|
+
this.emit = opts.emit;
|
|
1751
|
+
this.runAs = opts.runAs;
|
|
1752
|
+
}
|
|
1753
|
+
/** Capability probe — the compact Channel-A projection. */
|
|
1754
|
+
capabilities(repos = []) {
|
|
1755
|
+
const s = this.session;
|
|
1756
|
+
const hasExec = Boolean(s.exec || s.execCommand);
|
|
1757
|
+
const hasFs = Boolean(s.readFile && (s.exec || s.execCommand || s.createEditor));
|
|
1758
|
+
return {
|
|
1759
|
+
FileSystem: { available: hasFs, readOnly: !(s.exec || s.createEditor), root: this.workspaceRoot },
|
|
1760
|
+
Terminal: {
|
|
1761
|
+
events: hasExec,
|
|
1762
|
+
exec: hasExec,
|
|
1763
|
+
pty: { available: Boolean(s.supportsPty?.() && s.writeStdin) }
|
|
1764
|
+
},
|
|
1765
|
+
Git: { available: hasExec, repos }
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
// ════════════════════════════ exec primitive ══════════════════════════════
|
|
1769
|
+
// RAW exec — returns {stdout, stderr, exitCode}. Uses session.exec when present
|
|
1770
|
+
// (the local/docker sessions return raw output); falls back to execCommand +
|
|
1771
|
+
// a banner strip (last resort; banner-truncation can mangle, so exec is always
|
|
1772
|
+
// preferred). Throws ChannelAUnsupportedError when neither exists.
|
|
1773
|
+
async run(args) {
|
|
1774
|
+
const withRunAs = this.runAs ? { ...args, runAs: this.runAs } : args;
|
|
1775
|
+
if (this.session.exec) {
|
|
1776
|
+
const r = await this.session.exec(withRunAs);
|
|
1777
|
+
return {
|
|
1778
|
+
stdout: r.stdout ?? r.output ?? "",
|
|
1779
|
+
stderr: r.stderr ?? "",
|
|
1780
|
+
exitCode: r.exitCode ?? null,
|
|
1781
|
+
...typeof r.sessionId === "number" ? { sessionId: r.sessionId } : {},
|
|
1782
|
+
wallTimeSeconds: r.wallTimeSeconds ?? 0
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
if (this.session.execCommand) {
|
|
1786
|
+
const raw = await this.session.execCommand(withRunAs);
|
|
1787
|
+
const sessionId = parseExecBannerSessionId(raw);
|
|
1788
|
+
return {
|
|
1789
|
+
stdout: stripExecBanner2(raw),
|
|
1790
|
+
stderr: "",
|
|
1791
|
+
exitCode: null,
|
|
1792
|
+
...sessionId !== null ? { sessionId } : {},
|
|
1793
|
+
wallTimeSeconds: 0
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
throw new ChannelAUnsupportedError("the box does not support command execution");
|
|
1797
|
+
}
|
|
1798
|
+
// ════════════════════════════ FileSystem (A2) ═════════════════════════════
|
|
1799
|
+
async fsList(req) {
|
|
1800
|
+
const root = normalizeRelPath(req.path);
|
|
1801
|
+
const findRoot = root === "" ? "." : shellQuote2(root);
|
|
1802
|
+
const depthArg = Math.max(1, req.depth);
|
|
1803
|
+
const hidden = req.includeHidden ? "" : ` -not -path '*/.*'`;
|
|
1804
|
+
const gnuFind = `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -printf '%y\\t%s\\t%T@\\t%m\\t%p\\0' 2>/dev/null`;
|
|
1805
|
+
let { stdout } = await this.run({ cmd: `bash -lc ${shellQuote2(gnuFind)}`, workdir: this.workspaceRoot || void 0 });
|
|
1806
|
+
if (!stdout) {
|
|
1807
|
+
const portableFind = [
|
|
1808
|
+
`find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -print0 2>/dev/null | while IFS= read -r -d '' p; do`,
|
|
1809
|
+
`if [ -d "$p" ]; then t=d; size=0; elif [ -f "$p" ]; then t=f; size=$(wc -c < "$p" | tr -d ' '); elif [ -L "$p" ]; then t=l; size=0; else t=o; size=0; fi;`,
|
|
1810
|
+
`mtime=$(date -r "$p" +%s 2>/dev/null || stat -c %Y "$p" 2>/dev/null || echo 0);`,
|
|
1811
|
+
`mode=$(stat -f %Lp "$p" 2>/dev/null || stat -c %a "$p" 2>/dev/null || echo 0);`,
|
|
1812
|
+
`printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$t" "$size" "$mtime" "$mode" "$p";`,
|
|
1813
|
+
`done`
|
|
1814
|
+
].join(" ");
|
|
1815
|
+
({ stdout } = await this.run({ cmd: `bash -lc ${shellQuote2(portableFind)}`, workdir: this.workspaceRoot || void 0 }));
|
|
1816
|
+
}
|
|
1817
|
+
const entries = stdout.split(NUL).filter((s) => s.length > 0);
|
|
1818
|
+
const rootNode = {
|
|
1819
|
+
name: basename(root) || (root === "" ? "" : root),
|
|
1820
|
+
path: root,
|
|
1821
|
+
type: "dir",
|
|
1822
|
+
sizeBytes: null,
|
|
1823
|
+
mtimeMs: null,
|
|
1824
|
+
mode: null,
|
|
1825
|
+
children: [],
|
|
1826
|
+
truncated: false
|
|
1827
|
+
};
|
|
1828
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
1829
|
+
byPath.set(root, rootNode);
|
|
1830
|
+
let count = 0;
|
|
1831
|
+
let truncated = false;
|
|
1832
|
+
for (const entry of entries) {
|
|
1833
|
+
if (count >= req.maxEntries) {
|
|
1834
|
+
truncated = true;
|
|
1835
|
+
break;
|
|
1836
|
+
}
|
|
1837
|
+
const parts = entry.split(" ");
|
|
1838
|
+
if (parts.length < 5) continue;
|
|
1839
|
+
const [typeChar, sizeStr, mtimeStr, modeStr, ...pathParts] = parts;
|
|
1840
|
+
const rawPath = pathParts.join(" ");
|
|
1841
|
+
const relPath = stripDotSlash(rawPath, root);
|
|
1842
|
+
const node = {
|
|
1843
|
+
name: basename(relPath),
|
|
1844
|
+
path: relPath,
|
|
1845
|
+
type: findTypeToNode(typeChar ?? ""),
|
|
1846
|
+
sizeBytes: typeChar === "d" ? null : safeInt(sizeStr),
|
|
1847
|
+
mtimeMs: mtimeToMs(mtimeStr),
|
|
1848
|
+
mode: safeOctal(modeStr),
|
|
1849
|
+
...typeChar === "d" ? { children: [] } : {},
|
|
1850
|
+
truncated: false
|
|
1851
|
+
};
|
|
1852
|
+
byPath.set(relPath, node);
|
|
1853
|
+
count++;
|
|
1854
|
+
}
|
|
1855
|
+
for (const [path, node] of byPath) {
|
|
1856
|
+
if (path === root) continue;
|
|
1857
|
+
const parentPath = dirnameRel(path, root);
|
|
1858
|
+
const parent = byPath.get(parentPath) ?? rootNode;
|
|
1859
|
+
(parent.children ??= []).push(node);
|
|
1860
|
+
}
|
|
1861
|
+
sortTree(rootNode);
|
|
1862
|
+
return { root: rootNode, revision: this.revision, truncated };
|
|
1863
|
+
}
|
|
1864
|
+
async fsRead(req) {
|
|
1865
|
+
const path = assertSafeRelPath(req.path);
|
|
1866
|
+
if (!this.session.readFile) {
|
|
1867
|
+
return await this.fsReadViaExec(path, req);
|
|
1868
|
+
}
|
|
1869
|
+
let raw;
|
|
1870
|
+
try {
|
|
1871
|
+
raw = await this.session.readFile({ path: this.joinRoot(path), maxBytes: req.maxBytes, ...this.runAs ? { runAs: this.runAs } : {} });
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
if (isWorkspaceEscapeError(error)) {
|
|
1874
|
+
return await this.fsReadViaExec(path, req);
|
|
1875
|
+
}
|
|
1876
|
+
throw new ChannelANotFoundError(`file not found: ${path} (${error instanceof Error ? error.message : String(error)})`);
|
|
1877
|
+
}
|
|
1878
|
+
const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw);
|
|
1879
|
+
return this.shapeRead(path, bytes, req);
|
|
1880
|
+
}
|
|
1881
|
+
/** Read a file by base64-ing it through exec. Binary-safe and — crucially —
|
|
1882
|
+
* NOT subject to the provider's native-readFile workspace-escape validation,
|
|
1883
|
+
* so it can render a symlink whose target lives outside /workspace (the link
|
|
1884
|
+
* node itself is in-workspace). `base64 <path>` follows the symlink. */
|
|
1885
|
+
async fsReadViaExec(path, req) {
|
|
1886
|
+
const abs = this.joinRoot(path);
|
|
1887
|
+
const { stdout, exitCode } = await this.run({ cmd: `base64 ${shellQuote2(abs)} 2>/dev/null | head -c ${Math.ceil(req.maxBytes * 1.4)}` });
|
|
1888
|
+
if (exitCode !== null && exitCode !== 0 && stdout === "") {
|
|
1889
|
+
throw new ChannelANotFoundError(`file not found: ${path}`);
|
|
1890
|
+
}
|
|
1891
|
+
const bytes = Buffer.from(stdout.replace(/\n/g, ""), "base64");
|
|
1892
|
+
return this.shapeRead(path, bytes, req);
|
|
1893
|
+
}
|
|
1894
|
+
shapeRead(path, bytes, req) {
|
|
1895
|
+
const truncated = bytes.byteLength >= req.maxBytes;
|
|
1896
|
+
const isBinary = sniffBinary(bytes);
|
|
1897
|
+
const encoding = req.encoding === "base64" || isBinary ? "base64" : "utf8";
|
|
1898
|
+
const content = encoding === "base64" ? bytes.toString("base64") : bytes.toString("utf8");
|
|
1899
|
+
return {
|
|
1900
|
+
path,
|
|
1901
|
+
encoding,
|
|
1902
|
+
content,
|
|
1903
|
+
sizeBytes: bytes.byteLength,
|
|
1904
|
+
truncated,
|
|
1905
|
+
isBinary,
|
|
1906
|
+
revision: this.revision
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
async fsWrite(req) {
|
|
1910
|
+
const path = assertSafeRelPath(req.path);
|
|
1911
|
+
const abs = this.joinRoot(path);
|
|
1912
|
+
const bytes = req.encoding === "base64" ? Buffer.from(req.content, "base64") : Buffer.from(req.content, "utf8");
|
|
1913
|
+
if (!req.overwrite) {
|
|
1914
|
+
const { exitCode: exitCode2 } = await this.run({ cmd: `test -e ${shellQuote2(abs)}` });
|
|
1915
|
+
if (exitCode2 === 0) {
|
|
1916
|
+
throw new ChannelAConflictError(`path exists and overwrite is false: ${path}`);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
if (req.createParents) {
|
|
1920
|
+
const dir = dirnameAbs(abs);
|
|
1921
|
+
if (dir) await this.run({ cmd: `mkdir -p ${shellQuote2(dir)}` });
|
|
1922
|
+
}
|
|
1923
|
+
const b64 = bytes.toString("base64");
|
|
1924
|
+
const { exitCode, stderr } = await this.run({
|
|
1925
|
+
cmd: `printf %s ${shellQuote2(b64)} | base64 -d > ${shellQuote2(abs)}`
|
|
1926
|
+
});
|
|
1927
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
1928
|
+
if (req.encoding !== "base64" && this.session.createEditor) {
|
|
1929
|
+
const ok2 = await this.tryEditorWrite(abs, req.content);
|
|
1930
|
+
if (!ok2) throw new ChannelAValidationError(`failed to write ${path}: ${stderr || `exit ${exitCode}`}`);
|
|
1931
|
+
} else {
|
|
1932
|
+
throw new ChannelAValidationError(`failed to write ${path}: ${stderr || `exit ${exitCode}`}`);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
this.revision++;
|
|
1936
|
+
await this.emitFsChanged([{ path, kind: "modified", isDir: false, sizeBytes: bytes.byteLength }], "write");
|
|
1937
|
+
return { path, sizeBytes: bytes.byteLength, revision: this.revision };
|
|
1938
|
+
}
|
|
1939
|
+
async tryEditorWrite(absPath, content) {
|
|
1940
|
+
const editor = this.session.createEditor?.(this.runAs);
|
|
1941
|
+
if (!editor?.createFile) return false;
|
|
1942
|
+
try {
|
|
1943
|
+
const diff = content.split("\n").map((line) => `+${line}`).join("\n");
|
|
1944
|
+
await editor.createFile({ type: "create_file", path: absPath, diff });
|
|
1945
|
+
return true;
|
|
1946
|
+
} catch {
|
|
1947
|
+
return false;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
async fsDelete(req) {
|
|
1951
|
+
const path = assertSafeRelPath(req.path);
|
|
1952
|
+
const abs = this.joinRoot(path);
|
|
1953
|
+
const flag = req.recursive ? "-rf" : "-f";
|
|
1954
|
+
const { exitCode, stderr } = await this.run({ cmd: `rm ${flag} ${shellQuote2(abs)}` });
|
|
1955
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
1956
|
+
throw new ChannelAValidationError(`failed to delete ${path}: ${stderr || `exit ${exitCode}`}`);
|
|
1957
|
+
}
|
|
1958
|
+
this.revision++;
|
|
1959
|
+
await this.emitFsChanged([{ path, kind: "deleted", isDir: false, sizeBytes: null }], "write");
|
|
1960
|
+
return { revision: this.revision };
|
|
1961
|
+
}
|
|
1962
|
+
async fsMove(req) {
|
|
1963
|
+
const path = assertSafeRelPath(req.path);
|
|
1964
|
+
const newPath = assertSafeRelPath(req.newPath);
|
|
1965
|
+
const abs = this.joinRoot(path);
|
|
1966
|
+
const newAbs = this.joinRoot(newPath);
|
|
1967
|
+
if (!req.overwrite) {
|
|
1968
|
+
const { exitCode: exitCode2 } = await this.run({ cmd: `test -e ${shellQuote2(newAbs)}` });
|
|
1969
|
+
if (exitCode2 === 0) {
|
|
1970
|
+
throw new ChannelAConflictError(`destination exists and overwrite is false: ${newPath}`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
if (req.createParents) {
|
|
1974
|
+
const dir = dirnameAbs(newAbs);
|
|
1975
|
+
if (dir) await this.run({ cmd: `mkdir -p ${shellQuote2(dir)}` });
|
|
1976
|
+
}
|
|
1977
|
+
const flag = req.overwrite ? "-f " : "";
|
|
1978
|
+
const { exitCode, stderr } = await this.run({
|
|
1979
|
+
cmd: `mv ${flag}${shellQuote2(abs)} ${shellQuote2(newAbs)}`
|
|
1980
|
+
});
|
|
1981
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
1982
|
+
throw new ChannelAValidationError(`failed to move ${path} -> ${newPath}: ${stderr || `exit ${exitCode}`}`);
|
|
1983
|
+
}
|
|
1984
|
+
this.revision++;
|
|
1985
|
+
await this.emitFsChanged(
|
|
1986
|
+
[
|
|
1987
|
+
{ path, kind: "deleted", isDir: false, sizeBytes: null },
|
|
1988
|
+
{ path: newPath, kind: "created", isDir: false, sizeBytes: null }
|
|
1989
|
+
],
|
|
1990
|
+
"write"
|
|
1991
|
+
);
|
|
1992
|
+
return { path, newPath, revision: this.revision };
|
|
1993
|
+
}
|
|
1994
|
+
async fsMkdir(req) {
|
|
1995
|
+
const path = assertSafeRelPath(req.path);
|
|
1996
|
+
const abs = this.joinRoot(path);
|
|
1997
|
+
const flag = req.recursive ? "-p " : "";
|
|
1998
|
+
const { exitCode, stderr } = await this.run({ cmd: `mkdir ${flag}${shellQuote2(abs)}` });
|
|
1999
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
2000
|
+
throw new ChannelAValidationError(`failed to mkdir ${path}: ${stderr || `exit ${exitCode}`}`);
|
|
2001
|
+
}
|
|
2002
|
+
this.revision++;
|
|
2003
|
+
await this.emitFsChanged([{ path, kind: "created", isDir: true, sizeBytes: null }], "write");
|
|
2004
|
+
return { path, revision: this.revision };
|
|
2005
|
+
}
|
|
2006
|
+
// ════════════════════════════ Git (A2, read-only) ═════════════════════════
|
|
2007
|
+
async gitStatus(req) {
|
|
2008
|
+
const repo = this.repoWorkdir(req.path);
|
|
2009
|
+
const inside = await this.run({ cmd: "git rev-parse --is-inside-work-tree 2>/dev/null", workdir: repo });
|
|
2010
|
+
if (inside.stdout.trim() !== "true") {
|
|
2011
|
+
return { isRepo: false, head: null, detached: false, upstream: null, ahead: 0, behind: 0, files: [], revision: this.revision };
|
|
2012
|
+
}
|
|
2013
|
+
const { stdout } = await this.run({ cmd: "git status --porcelain=v2 --branch -z", workdir: repo });
|
|
2014
|
+
return { ...parsePorcelainV2(stdout), revision: this.revision };
|
|
2015
|
+
}
|
|
2016
|
+
async gitDiff(req) {
|
|
2017
|
+
const repo = this.repoWorkdir(req.path);
|
|
2018
|
+
const ctx = req.contextLines;
|
|
2019
|
+
let range = "";
|
|
2020
|
+
if (req.fromRef && req.toRef) range = `${shellQuote2(req.fromRef)} ${shellQuote2(req.toRef)}`;
|
|
2021
|
+
else if (req.fromRef) range = `${shellQuote2(req.fromRef)}`;
|
|
2022
|
+
else if (req.staged) range = "--cached";
|
|
2023
|
+
const pathspec = req.pathspec.length ? ` -- ${req.pathspec.map(shellQuote2).join(" ")}` : "";
|
|
2024
|
+
const numstat = await this.run({ cmd: `git -c core.quotePath=false diff --no-color -z --numstat ${range}${pathspec}`.trim(), workdir: repo });
|
|
2025
|
+
const stats = parseNumstatZ(numstat.stdout);
|
|
2026
|
+
const files = [];
|
|
2027
|
+
for (const stat of stats) {
|
|
2028
|
+
const target = stat.newPath;
|
|
2029
|
+
const fileStatus = stat.binary ? "modified" : "modified";
|
|
2030
|
+
if (stat.binary) {
|
|
2031
|
+
files.push({
|
|
2032
|
+
path: target,
|
|
2033
|
+
oldPath: stat.oldPath,
|
|
2034
|
+
status: fileStatus,
|
|
2035
|
+
isBinary: true,
|
|
2036
|
+
isImage: isImagePath(target),
|
|
2037
|
+
additions: 0,
|
|
2038
|
+
deletions: 0,
|
|
2039
|
+
hunks: [],
|
|
2040
|
+
truncated: false
|
|
2041
|
+
});
|
|
2042
|
+
continue;
|
|
2043
|
+
}
|
|
2044
|
+
const patch = await this.run({
|
|
2045
|
+
cmd: `git -c core.quotePath=false diff --no-color -U${ctx} ${range} -- ${shellQuote2(target)}`.trim(),
|
|
2046
|
+
workdir: repo
|
|
2047
|
+
});
|
|
2048
|
+
const oversized = Buffer.byteLength(patch.stdout, "utf8") > req.maxBytesPerFile;
|
|
2049
|
+
const parsed = oversized ? { hunks: [], status: "modified" } : parseUnifiedPatch(patch.stdout);
|
|
2050
|
+
files.push({
|
|
2051
|
+
path: target,
|
|
2052
|
+
oldPath: stat.oldPath,
|
|
2053
|
+
status: parsed.status,
|
|
2054
|
+
isBinary: false,
|
|
2055
|
+
isImage: isImagePath(target),
|
|
2056
|
+
additions: stat.additions,
|
|
2057
|
+
deletions: stat.deletions,
|
|
2058
|
+
hunks: parsed.hunks,
|
|
2059
|
+
truncated: oversized
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
return { files, revision: this.revision };
|
|
2063
|
+
}
|
|
2064
|
+
async gitLog(req) {
|
|
2065
|
+
const repo = this.repoWorkdir(req.path);
|
|
2066
|
+
const fmt = `%H${US}%h${US}%P${US}%an${US}%ae${US}%at${US}%cn${US}%ce${US}%ct${US}%s${US}%b${RS}`;
|
|
2067
|
+
const pathspec = req.pathspec.length ? ` -- ${req.pathspec.map(shellQuote2).join(" ")}` : "";
|
|
2068
|
+
const { stdout, exitCode } = await this.run({
|
|
2069
|
+
cmd: `git log --format=${shellQuote2(fmt)} -n${req.maxCount + 1} --skip=${req.skip} ${shellQuote2(req.ref)}${pathspec}`,
|
|
2070
|
+
workdir: repo
|
|
2071
|
+
});
|
|
2072
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
2073
|
+
return { commits: [], hasMore: false };
|
|
2074
|
+
}
|
|
2075
|
+
const records = stdout.split(RS).map((r) => r.replace(/^\n/, "")).filter((r) => r.trim().length > 0);
|
|
2076
|
+
const commits = [];
|
|
2077
|
+
for (const rec of records.slice(0, req.maxCount)) {
|
|
2078
|
+
const f = rec.split(US);
|
|
2079
|
+
if (f.length < 11) continue;
|
|
2080
|
+
commits.push({
|
|
2081
|
+
sha: f[0],
|
|
2082
|
+
shortSha: f[1],
|
|
2083
|
+
parents: (f[2] ?? "").trim() ? f[2].trim().split(" ") : [],
|
|
2084
|
+
author: { name: f[3], email: f[4], timestamp: safeInt(f[5]) ?? 0 },
|
|
2085
|
+
committer: { name: f[6], email: f[7], timestamp: safeInt(f[8]) ?? 0 },
|
|
2086
|
+
subject: f[9],
|
|
2087
|
+
body: f.slice(10).join(US),
|
|
2088
|
+
refs: []
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
return { commits, hasMore: records.length > req.maxCount };
|
|
2092
|
+
}
|
|
2093
|
+
async gitShow(req) {
|
|
2094
|
+
const repo = this.repoWorkdir(req.path);
|
|
2095
|
+
if (req.filePath) {
|
|
2096
|
+
const { stdout, exitCode } = await this.run({
|
|
2097
|
+
cmd: `git cat-file blob ${shellQuote2(`${req.ref}:${req.filePath}`)} 2>/dev/null | base64`,
|
|
2098
|
+
workdir: repo
|
|
2099
|
+
});
|
|
2100
|
+
if (exitCode !== null && exitCode !== 0 && stdout.trim() === "") {
|
|
2101
|
+
throw new ChannelANotFoundError(`blob not found: ${req.ref}:${req.filePath}`);
|
|
2102
|
+
}
|
|
2103
|
+
const bytes = Buffer.from(stdout.replace(/\n/g, ""), "base64");
|
|
2104
|
+
const truncated = bytes.byteLength > req.maxBytesPerFile;
|
|
2105
|
+
const clamped = truncated ? bytes.subarray(0, req.maxBytesPerFile) : bytes;
|
|
2106
|
+
const isBinary = sniffBinary(clamped);
|
|
2107
|
+
const encoding = req.encoding === "base64" || isBinary ? "base64" : "utf8";
|
|
2108
|
+
return {
|
|
2109
|
+
commit: null,
|
|
2110
|
+
files: [],
|
|
2111
|
+
blob: { content: encoding === "base64" ? clamped.toString("base64") : clamped.toString("utf8"), encoding, sizeBytes: clamped.byteLength, truncated },
|
|
2112
|
+
revision: this.revision
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
const log = await this.gitLog({ path: req.path, ref: req.ref, maxCount: 1, skip: 0, pathspec: [] });
|
|
2116
|
+
const commit = log.commits[0] ?? null;
|
|
2117
|
+
const diff = await this.gitDiff({ path: req.path, staged: false, fromRef: `${req.ref}^`, toRef: req.ref, pathspec: [], contextLines: 3, maxBytesPerFile: req.maxBytesPerFile });
|
|
2118
|
+
return { commit, files: diff.files, blob: null, revision: this.revision };
|
|
2119
|
+
}
|
|
2120
|
+
/** Detect repo roots within the workspace (for the Git.repos capability). */
|
|
2121
|
+
async detectRepos() {
|
|
2122
|
+
try {
|
|
2123
|
+
const { stdout } = await this.run({ cmd: `find . -maxdepth 3 -name .git -type d 2>/dev/null`, workdir: this.workspaceRoot || void 0 });
|
|
2124
|
+
return stdout.split("\n").map((l) => l.trim()).filter(Boolean).map((g) => dirnameAbs(stripDotSlash(g, "")) || "");
|
|
2125
|
+
} catch {
|
|
2126
|
+
return [];
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
// ════════════════════════ Terminal exec + PTY (A2) ════════════════════════
|
|
2130
|
+
/** Run a bounded command, return buffered stdout/stderr + exit code inline. The
|
|
2131
|
+
* long-running tail (when the process hasn't exited within timeoutMs) keeps
|
|
2132
|
+
* running in-box; if emitStream is set the buffered output is also published as
|
|
2133
|
+
* the agent firehose so other viewers see it. */
|
|
2134
|
+
async terminalExec(req) {
|
|
2135
|
+
const r = await this.run({
|
|
2136
|
+
cmd: req.command,
|
|
2137
|
+
workdir: this.repoWorkdir(req.cwd),
|
|
2138
|
+
yieldTimeMs: req.timeoutMs
|
|
2139
|
+
});
|
|
2140
|
+
const running = r.exitCode === null && typeof r.sessionId === "number";
|
|
2141
|
+
if (req.emitStream && (r.stdout || r.stderr)) {
|
|
2142
|
+
const events = [];
|
|
2143
|
+
const commandId = crypto.randomUUID();
|
|
2144
|
+
if (r.stdout) events.push({ type: "sandbox.command.output.delta", payload: { stream: "stdout", chunk: r.stdout, commandId, seq: 0 } });
|
|
2145
|
+
if (r.stderr) events.push({ type: "sandbox.command.output.delta", payload: { stream: "stderr", chunk: r.stderr, commandId, seq: 1 } });
|
|
2146
|
+
await this.emitEvents(events);
|
|
2147
|
+
}
|
|
2148
|
+
return {
|
|
2149
|
+
stdout: r.stdout,
|
|
2150
|
+
stderr: r.stderr,
|
|
2151
|
+
exitCode: r.exitCode,
|
|
2152
|
+
running,
|
|
2153
|
+
wallTimeSeconds: r.wallTimeSeconds
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
/** Open an interactive PTY: exec the shell with tty:true, yielding the numeric
|
|
2157
|
+
* exec-session id the caller persists (ptyId<->execSessionId) so subsequent
|
|
2158
|
+
* writeStdin can drive it. Returns the supportsInput gate (false when the
|
|
2159
|
+
* backend has no writeStdin). The caller emits terminal.pty.started after it
|
|
2160
|
+
* persists the row. */
|
|
2161
|
+
async ptyOpen(req, ptyId) {
|
|
2162
|
+
const supportsInput = Boolean(this.session.supportsPty?.() && this.session.writeStdin);
|
|
2163
|
+
const shell = req.shell ?? "/bin/bash";
|
|
2164
|
+
const r = await this.run({
|
|
2165
|
+
cmd: shell,
|
|
2166
|
+
workdir: this.repoWorkdir(req.cwd),
|
|
2167
|
+
tty: true,
|
|
2168
|
+
login: true,
|
|
2169
|
+
yieldTimeMs: 250
|
|
2170
|
+
});
|
|
2171
|
+
return {
|
|
2172
|
+
response: { ptyId, streamVia: "sse-events", supportsInput },
|
|
2173
|
+
execSessionId: typeof r.sessionId === "number" ? r.sessionId : null,
|
|
2174
|
+
shell,
|
|
2175
|
+
initialOutput: r.stdout
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
/** Drive an open PTY's stdin. Returns the drained output (the caller publishes
|
|
2179
|
+
* it as terminal.pty.output.delta). Throws ChannelAUnsupportedError when the
|
|
2180
|
+
* backend has no writeStdin. */
|
|
2181
|
+
async ptyWrite(_req, execSessionId, data) {
|
|
2182
|
+
if (!this.session.writeStdin) {
|
|
2183
|
+
throw new ChannelAUnsupportedError("interactive terminal unsupported on this backend");
|
|
2184
|
+
}
|
|
2185
|
+
const out = await this.session.writeStdin({ sessionId: execSessionId, chars: data, yieldTimeMs: 250 });
|
|
2186
|
+
if (isExecSessionLostBanner(out, execSessionId)) {
|
|
2187
|
+
throw new ChannelAConflictError("pty session lost on the live box; reopen the terminal");
|
|
2188
|
+
}
|
|
2189
|
+
return stripExecBanner2(out);
|
|
2190
|
+
}
|
|
2191
|
+
/** Resize an open PTY (SIGWINCH via stty against the exec-session). The SDK has
|
|
2192
|
+
* no resize method; stty in the same tty session updates the geometry. */
|
|
2193
|
+
async ptyResize(req, execSessionId) {
|
|
2194
|
+
if (!this.session.writeStdin) return;
|
|
2195
|
+
await this.session.writeStdin({ sessionId: execSessionId, chars: `stty cols ${req.cols} rows ${req.rows}
|
|
2196
|
+
`, yieldTimeMs: 50 });
|
|
2197
|
+
}
|
|
2198
|
+
/** Close an open PTY: write exit/EOF. The caller marks the row closed + emits
|
|
2199
|
+
* terminal.pty.exited. */
|
|
2200
|
+
async ptyClose(_req, execSessionId) {
|
|
2201
|
+
if (execSessionId !== null && this.session.writeStdin) {
|
|
2202
|
+
try {
|
|
2203
|
+
await this.session.writeStdin({ sessionId: execSessionId, chars: "", yieldTimeMs: 50 });
|
|
2204
|
+
} catch {
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
// ──────────────────────────── helpers ──────────────────────────────────────
|
|
2209
|
+
/** The current FS revision (for the caller to persist/seed). */
|
|
2210
|
+
currentRevision() {
|
|
2211
|
+
return this.revision;
|
|
2212
|
+
}
|
|
2213
|
+
joinRoot(rel) {
|
|
2214
|
+
if (!this.workspaceRoot) return rel === "" ? "." : rel;
|
|
2215
|
+
return rel === "" ? this.workspaceRoot : `${this.workspaceRoot}/${rel}`;
|
|
2216
|
+
}
|
|
2217
|
+
repoWorkdir(rel) {
|
|
2218
|
+
const safe = normalizeRelPath(rel);
|
|
2219
|
+
const joined = this.joinRoot(safe);
|
|
2220
|
+
return joined === "." ? this.workspaceRoot || void 0 : joined;
|
|
2221
|
+
}
|
|
2222
|
+
async emitEvents(events) {
|
|
2223
|
+
if (!this.emit || events.length === 0) return;
|
|
2224
|
+
try {
|
|
2225
|
+
await this.emit(events);
|
|
2226
|
+
} catch {
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
async emitFsChanged(changes, source) {
|
|
2230
|
+
const payload = { changes, source, revision: this.revision, leaseEpoch: this.leaseEpoch };
|
|
2231
|
+
await this.emitEvents([{ type: "fs.changed", payload }]);
|
|
2232
|
+
}
|
|
2233
|
+
/** Re-probe git after a mutation and emit git.changed (best-effort, used by the
|
|
2234
|
+
* worker agent-turn side after FS-mutating tools). */
|
|
2235
|
+
async emitGitChanged(repoPath, reason) {
|
|
2236
|
+
try {
|
|
2237
|
+
const status = await this.gitStatus({ path: repoPath });
|
|
2238
|
+
const payload = {
|
|
2239
|
+
head: status.head,
|
|
2240
|
+
dirty: status.files.length > 0,
|
|
2241
|
+
ahead: status.ahead,
|
|
2242
|
+
behind: status.behind,
|
|
2243
|
+
changedFileCount: status.files.length,
|
|
2244
|
+
reason,
|
|
2245
|
+
revision: this.revision,
|
|
2246
|
+
leaseEpoch: this.leaseEpoch
|
|
2247
|
+
};
|
|
2248
|
+
await this.emitEvents([{ type: "git.changed", payload }]);
|
|
2249
|
+
} catch {
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
};
|
|
2253
|
+
function stripExecBanner2(raw) {
|
|
2254
|
+
const marker = raw.indexOf("\nOutput:\n");
|
|
2255
|
+
if (marker >= 0) return raw.slice(marker + "\nOutput:\n".length);
|
|
2256
|
+
if (raw.startsWith("Output:\n")) return raw.slice("Output:\n".length);
|
|
2257
|
+
return raw;
|
|
2258
|
+
}
|
|
2259
|
+
function isWorkspaceEscapeError(error) {
|
|
2260
|
+
const msg = error instanceof Error ? error.message : String(error ?? "");
|
|
2261
|
+
const lower = msg.toLowerCase();
|
|
2262
|
+
return lower.includes("workspace escape") || lower.includes("remote validation") && lower.includes("escape");
|
|
2263
|
+
}
|
|
2264
|
+
function isExecSessionLostBanner(out, execSessionId) {
|
|
2265
|
+
if (!out) return false;
|
|
2266
|
+
const lower = out.toLowerCase();
|
|
2267
|
+
if (!lower.includes("session not found")) return false;
|
|
2268
|
+
return lower.includes(`session not found: ${execSessionId}`) || !/session not found:\s*\d+/.test(lower);
|
|
2269
|
+
}
|
|
2270
|
+
function parseExecBannerSessionId(raw) {
|
|
2271
|
+
const outputIdx = raw.indexOf("\nOutput:\n");
|
|
2272
|
+
const banner = outputIdx >= 0 ? raw.slice(0, outputIdx) : raw.startsWith("Output:\n") ? "" : raw;
|
|
2273
|
+
const match = banner.match(/Process running with session ID (\d+)/);
|
|
2274
|
+
if (!match) return null;
|
|
2275
|
+
const n = Number.parseInt(match[1], 10);
|
|
2276
|
+
return Number.isFinite(n) ? n : null;
|
|
2277
|
+
}
|
|
2278
|
+
function sniffBinary(bytes) {
|
|
2279
|
+
const n = Math.min(bytes.byteLength, 8192);
|
|
2280
|
+
for (let i = 0; i < n; i++) if (bytes[i] === 0) return true;
|
|
2281
|
+
return false;
|
|
2282
|
+
}
|
|
2283
|
+
function normalizeRelPath(p) {
|
|
2284
|
+
const trimmed = (p ?? "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
2285
|
+
return trimmed;
|
|
2286
|
+
}
|
|
2287
|
+
function assertSafeRelPath(p) {
|
|
2288
|
+
const norm = normalizeRelPath(p);
|
|
2289
|
+
if (norm === "") throw new ChannelAValidationError("path is required");
|
|
2290
|
+
if (p.startsWith("/")) throw new ChannelAValidationError(`absolute paths are not allowed: ${p}`);
|
|
2291
|
+
if (norm.split("/").some((seg) => seg === "..")) throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
|
|
2292
|
+
return norm;
|
|
2293
|
+
}
|
|
2294
|
+
function shellQuote2(s) {
|
|
2295
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
2296
|
+
}
|
|
2297
|
+
function basename(p) {
|
|
2298
|
+
const parts = p.split("/").filter(Boolean);
|
|
2299
|
+
return parts.length ? parts[parts.length - 1] : "";
|
|
2300
|
+
}
|
|
2301
|
+
function dirnameAbs(p) {
|
|
2302
|
+
const idx = p.lastIndexOf("/");
|
|
2303
|
+
return idx > 0 ? p.slice(0, idx) : "";
|
|
2304
|
+
}
|
|
2305
|
+
function dirnameRel(p, root) {
|
|
2306
|
+
const idx = p.lastIndexOf("/");
|
|
2307
|
+
if (idx < 0) return root;
|
|
2308
|
+
return p.slice(0, idx);
|
|
2309
|
+
}
|
|
2310
|
+
function stripDotSlash(rawPath, root) {
|
|
2311
|
+
let p = rawPath.startsWith("./") ? rawPath.slice(2) : rawPath;
|
|
2312
|
+
p = p.replace(/^\/+/, "");
|
|
2313
|
+
if (root && !p.startsWith(`${root}/`) && p !== root) {
|
|
2314
|
+
return root ? `${root}/${p}` : p;
|
|
2315
|
+
}
|
|
2316
|
+
return p;
|
|
2317
|
+
}
|
|
2318
|
+
function findTypeToNode(t) {
|
|
2319
|
+
if (t === "d") return "dir";
|
|
2320
|
+
if (t === "f") return "file";
|
|
2321
|
+
if (t === "l") return "symlink";
|
|
2322
|
+
return "other";
|
|
2323
|
+
}
|
|
2324
|
+
function safeInt(s) {
|
|
2325
|
+
if (s === void 0) return null;
|
|
2326
|
+
const n = Number.parseInt(s, 10);
|
|
2327
|
+
return Number.isFinite(n) ? n : null;
|
|
2328
|
+
}
|
|
2329
|
+
function safeOctal(s) {
|
|
2330
|
+
if (s === void 0) return null;
|
|
2331
|
+
const n = Number.parseInt(s, 8);
|
|
2332
|
+
return Number.isFinite(n) ? n : null;
|
|
2333
|
+
}
|
|
2334
|
+
function mtimeToMs(s) {
|
|
2335
|
+
if (s === void 0) return null;
|
|
2336
|
+
const f = Number.parseFloat(s);
|
|
2337
|
+
return Number.isFinite(f) ? Math.round(f * 1e3) : null;
|
|
2338
|
+
}
|
|
2339
|
+
function sortTree(node) {
|
|
2340
|
+
if (!node.children) return;
|
|
2341
|
+
node.children.sort((a, b) => {
|
|
2342
|
+
if (a.type === "dir" && b.type !== "dir") return -1;
|
|
2343
|
+
if (a.type !== "dir" && b.type === "dir") return 1;
|
|
2344
|
+
return a.name.localeCompare(b.name);
|
|
2345
|
+
});
|
|
2346
|
+
for (const child of node.children) sortTree(child);
|
|
2347
|
+
}
|
|
2348
|
+
function isImagePath(p) {
|
|
2349
|
+
return /\.(png|jpe?g|gif|webp|bmp|ico|svg|tiff?)$/i.test(p);
|
|
2350
|
+
}
|
|
2351
|
+
function parsePorcelainV2(z) {
|
|
2352
|
+
const records = z.split(NUL);
|
|
2353
|
+
let head = null;
|
|
2354
|
+
let upstream = null;
|
|
2355
|
+
let detached = false;
|
|
2356
|
+
let ahead = 0;
|
|
2357
|
+
let behind = 0;
|
|
2358
|
+
const files = [];
|
|
2359
|
+
for (let i = 0; i < records.length; i++) {
|
|
2360
|
+
const rec = records[i];
|
|
2361
|
+
if (rec === "") continue;
|
|
2362
|
+
if (rec.startsWith("# branch.head ")) {
|
|
2363
|
+
const v = rec.slice("# branch.head ".length);
|
|
2364
|
+
if (v === "(detached)") {
|
|
2365
|
+
detached = true;
|
|
2366
|
+
head = null;
|
|
2367
|
+
} else head = v;
|
|
2368
|
+
} else if (rec.startsWith("# branch.upstream ")) {
|
|
2369
|
+
upstream = rec.slice("# branch.upstream ".length);
|
|
2370
|
+
} else if (rec.startsWith("# branch.ab ")) {
|
|
2371
|
+
const m = rec.slice("# branch.ab ".length).match(/\+(\d+)\s+-(\d+)/);
|
|
2372
|
+
if (m) {
|
|
2373
|
+
ahead = Number(m[1]);
|
|
2374
|
+
behind = Number(m[2]);
|
|
2375
|
+
}
|
|
2376
|
+
} else if (rec.startsWith("1 ")) {
|
|
2377
|
+
const fields = rec.split(" ");
|
|
2378
|
+
const xy = fields[1] ?? "..";
|
|
2379
|
+
const path = fields.slice(8).join(" ");
|
|
2380
|
+
files.push(statusFromXY(xy, path, null));
|
|
2381
|
+
} else if (rec.startsWith("2 ")) {
|
|
2382
|
+
const fields = rec.split(" ");
|
|
2383
|
+
const xy = fields[1] ?? "..";
|
|
2384
|
+
const path = fields.slice(9).join(" ");
|
|
2385
|
+
const oldPath = records[i + 1] ?? null;
|
|
2386
|
+
i++;
|
|
2387
|
+
files.push(statusFromXY(xy, path, oldPath));
|
|
2388
|
+
} else if (rec.startsWith("u ")) {
|
|
2389
|
+
const fields = rec.split(" ");
|
|
2390
|
+
const path = fields.slice(10).join(" ");
|
|
2391
|
+
files.push({ path, oldPath: null, index: "conflicted", worktree: "conflicted", isConflicted: true });
|
|
2392
|
+
} else if (rec.startsWith("? ")) {
|
|
2393
|
+
files.push({ path: rec.slice(2), oldPath: null, index: null, worktree: "untracked", isConflicted: false });
|
|
2394
|
+
} else if (rec.startsWith("! ")) {
|
|
2395
|
+
files.push({ path: rec.slice(2), oldPath: null, index: null, worktree: "ignored", isConflicted: false });
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
return { isRepo: true, head, detached, upstream, ahead, behind, files };
|
|
2399
|
+
}
|
|
2400
|
+
function xyCode(c) {
|
|
2401
|
+
switch (c) {
|
|
2402
|
+
case "A":
|
|
2403
|
+
return "added";
|
|
2404
|
+
case "M":
|
|
2405
|
+
return "modified";
|
|
2406
|
+
case "D":
|
|
2407
|
+
return "deleted";
|
|
2408
|
+
case "R":
|
|
2409
|
+
return "renamed";
|
|
2410
|
+
case "C":
|
|
2411
|
+
return "copied";
|
|
2412
|
+
case "T":
|
|
2413
|
+
return "typechange";
|
|
2414
|
+
case "U":
|
|
2415
|
+
return "conflicted";
|
|
2416
|
+
case ".":
|
|
2417
|
+
return null;
|
|
2418
|
+
default:
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
function statusFromXY(xy, path, oldPath) {
|
|
2423
|
+
const x = xy[0] ?? ".";
|
|
2424
|
+
const y = xy[1] ?? ".";
|
|
2425
|
+
return {
|
|
2426
|
+
path,
|
|
2427
|
+
oldPath,
|
|
2428
|
+
index: xyCode(x),
|
|
2429
|
+
worktree: xyCode(y),
|
|
2430
|
+
isConflicted: x === "U" || y === "U"
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
function parseNumstatZ(z) {
|
|
2434
|
+
const fields = z.split(NUL);
|
|
2435
|
+
const out = [];
|
|
2436
|
+
let i = 0;
|
|
2437
|
+
while (i < fields.length) {
|
|
2438
|
+
const head = fields[i];
|
|
2439
|
+
if (head === "") {
|
|
2440
|
+
i++;
|
|
2441
|
+
continue;
|
|
2442
|
+
}
|
|
2443
|
+
const m = head.match(/^(\d+|-)\t(\d+|-)\t(.*)$/s);
|
|
2444
|
+
if (!m) {
|
|
2445
|
+
i++;
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
const addStr = m[1];
|
|
2449
|
+
const delStr = m[2];
|
|
2450
|
+
const pathPart = m[3];
|
|
2451
|
+
const binary = addStr === "-" && delStr === "-";
|
|
2452
|
+
if (pathPart === "") {
|
|
2453
|
+
const oldPath = fields[i + 1] ?? null;
|
|
2454
|
+
const newPath = fields[i + 2] ?? "";
|
|
2455
|
+
out.push({ additions: binary ? 0 : Number(addStr), deletions: binary ? 0 : Number(delStr), binary, oldPath, newPath });
|
|
2456
|
+
i += 3;
|
|
2457
|
+
} else {
|
|
2458
|
+
out.push({ additions: binary ? 0 : Number(addStr), deletions: binary ? 0 : Number(delStr), binary, oldPath: null, newPath: pathPart });
|
|
2459
|
+
i++;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
return out;
|
|
2463
|
+
}
|
|
2464
|
+
function parseUnifiedPatch(patch) {
|
|
2465
|
+
const lines = patch.split("\n");
|
|
2466
|
+
const hunks = [];
|
|
2467
|
+
let status = "modified";
|
|
2468
|
+
let current = null;
|
|
2469
|
+
let oldNo = 0;
|
|
2470
|
+
let newNo = 0;
|
|
2471
|
+
for (const line of lines) {
|
|
2472
|
+
if (line.startsWith("new file mode")) status = "added";
|
|
2473
|
+
else if (line.startsWith("deleted file mode")) status = "deleted";
|
|
2474
|
+
else if (line.startsWith("rename from") || line.startsWith("rename to")) status = "renamed";
|
|
2475
|
+
if (line.startsWith("@@")) {
|
|
2476
|
+
const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
|
|
2477
|
+
if (m) {
|
|
2478
|
+
const oldStart = Number(m[1]);
|
|
2479
|
+
const oldLines = m[2] !== void 0 ? Number(m[2]) : 1;
|
|
2480
|
+
const newStart = Number(m[3]);
|
|
2481
|
+
const newLines = m[4] !== void 0 ? Number(m[4]) : 1;
|
|
2482
|
+
current = { oldStart, oldLines, newStart, newLines, header: (m[5] ?? "").trim(), lines: [] };
|
|
2483
|
+
hunks.push(current);
|
|
2484
|
+
oldNo = oldStart;
|
|
2485
|
+
newNo = newStart;
|
|
2486
|
+
}
|
|
2487
|
+
continue;
|
|
2488
|
+
}
|
|
2489
|
+
if (!current) continue;
|
|
2490
|
+
if (line.startsWith("\\")) continue;
|
|
2491
|
+
const marker = line[0];
|
|
2492
|
+
const text = line.slice(1);
|
|
2493
|
+
if (marker === "+") {
|
|
2494
|
+
current.lines.push({ type: "add", oldNo: null, newNo, text });
|
|
2495
|
+
newNo++;
|
|
2496
|
+
} else if (marker === "-") {
|
|
2497
|
+
current.lines.push({ type: "del", oldNo, newNo: null, text });
|
|
2498
|
+
oldNo++;
|
|
2499
|
+
} else if (marker === " ") {
|
|
2500
|
+
current.lines.push({ type: "context", oldNo, newNo, text });
|
|
2501
|
+
oldNo++;
|
|
2502
|
+
newNo++;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
return { hunks, status };
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// src/sandbox/selfhosted/capabilities.ts
|
|
2509
|
+
var SELFHOSTED_RECONNECT_WINDOW_MS = 3e4;
|
|
2510
|
+
function selfhostedLiveness(input) {
|
|
2511
|
+
const { enrollment } = input;
|
|
2512
|
+
if (!enrollment || enrollment.status !== "active") {
|
|
2513
|
+
return { state: "offline", consented: false, hasDisplay: false };
|
|
2514
|
+
}
|
|
2515
|
+
const consented = enrollment.exposure === "whole-machine" && enrollment.allowScreenControl;
|
|
2516
|
+
const hasDisplay = enrollment.hasDisplay;
|
|
2517
|
+
if (input.probeResponded) {
|
|
2518
|
+
return { state: "online", consented, hasDisplay };
|
|
2519
|
+
}
|
|
2520
|
+
const now = (input.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
2521
|
+
const lastSeen = enrollment.lastSeenAt ? new Date(enrollment.lastSeenAt).getTime() : null;
|
|
2522
|
+
if (lastSeen !== null && now - lastSeen <= SELFHOSTED_RECONNECT_WINDOW_MS) {
|
|
2523
|
+
return { state: "reconnecting", consented, hasDisplay };
|
|
2524
|
+
}
|
|
2525
|
+
return { state: "offline", consented, hasDisplay };
|
|
2526
|
+
}
|
|
2527
|
+
async function negotiateSelfhostedCapabilities(input) {
|
|
2528
|
+
const probeResponded = input.probeResponded ?? (input.session ? await input.session.ping() : false);
|
|
2529
|
+
const liveness = selfhostedLiveness({
|
|
2530
|
+
enrollment: input.enrollment,
|
|
2531
|
+
probeResponded,
|
|
2532
|
+
...input.now ? { now: input.now } : {}
|
|
2533
|
+
});
|
|
2534
|
+
const baseLiveness = liveness.state === "online" ? "warm" : "cold";
|
|
2535
|
+
const base = {
|
|
2536
|
+
sessionId: input.sessionId,
|
|
2537
|
+
backend: "selfhosted",
|
|
2538
|
+
os: input.os ?? "linux",
|
|
2539
|
+
liveness: baseLiveness,
|
|
2540
|
+
leaseEpoch: input.leaseEpoch,
|
|
2541
|
+
desktopEnabled: input.desktopEnabled ?? true,
|
|
2542
|
+
terminalEnabled: input.terminalEnabled ?? true,
|
|
2543
|
+
computerUseEnabled: input.computerUseEnabled ?? true,
|
|
2544
|
+
...input.desktopAcknowledged !== void 0 ? { desktopAcknowledged: input.desktopAcknowledged } : {},
|
|
2545
|
+
...input.shared !== void 0 ? { shared: input.shared } : {},
|
|
2546
|
+
...input.sharedSessionIds !== void 0 ? { sharedSessionIds: input.sharedSessionIds } : {},
|
|
2547
|
+
...input.now ? { now: input.now } : {}
|
|
2548
|
+
};
|
|
2549
|
+
const caps = negotiateCapabilities(base);
|
|
2550
|
+
if (liveness.state !== "online") {
|
|
2551
|
+
const reason = liveness.state === "offline" ? "agent_offline" : "agent_reconnecting";
|
|
2552
|
+
return {
|
|
2553
|
+
...caps,
|
|
2554
|
+
FileSystem: { ...caps.FileSystem, available: false, readOnly: true, reason },
|
|
2555
|
+
Terminal: { ...caps.Terminal, transport: null, url: null, token: null, expiresAt: null, reason },
|
|
2556
|
+
Git: { ...caps.Git, available: false, reason },
|
|
2557
|
+
DesktopStream: {
|
|
2558
|
+
...caps.DesktopStream,
|
|
2559
|
+
transport: null,
|
|
2560
|
+
client: null,
|
|
2561
|
+
mode: "read-only",
|
|
2562
|
+
url: null,
|
|
2563
|
+
token: null,
|
|
2564
|
+
expiresAt: null,
|
|
2565
|
+
requiresAcknowledgment: false,
|
|
2566
|
+
acknowledged: false,
|
|
2567
|
+
shared: false,
|
|
2568
|
+
sharedSessionIds: [],
|
|
2569
|
+
reason
|
|
2570
|
+
},
|
|
2571
|
+
Recording: { ...caps.Recording, available: false, modes: [], codecs: [], reason },
|
|
2572
|
+
ComputerUse: { ...caps.ComputerUse, available: false, reason }
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
if (!liveness.hasDisplay) {
|
|
2576
|
+
const reason = "display_unavailable";
|
|
2577
|
+
return {
|
|
2578
|
+
...caps,
|
|
2579
|
+
DesktopStream: caps.DesktopStream.transport !== null ? {
|
|
2580
|
+
...caps.DesktopStream,
|
|
2581
|
+
transport: null,
|
|
2582
|
+
client: null,
|
|
2583
|
+
mode: "read-only",
|
|
2584
|
+
url: null,
|
|
2585
|
+
token: null,
|
|
2586
|
+
expiresAt: null,
|
|
2587
|
+
requiresAcknowledgment: false,
|
|
2588
|
+
acknowledged: false,
|
|
2589
|
+
shared: false,
|
|
2590
|
+
sharedSessionIds: [],
|
|
2591
|
+
reason
|
|
2592
|
+
} : caps.DesktopStream,
|
|
2593
|
+
Recording: caps.Recording.available ? { ...caps.Recording, available: false, modes: [], codecs: [], reason } : caps.Recording,
|
|
2594
|
+
ComputerUse: caps.ComputerUse.available ? { ...caps.ComputerUse, available: false, reason } : caps.ComputerUse
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
if (!liveness.consented) {
|
|
2598
|
+
return {
|
|
2599
|
+
...caps,
|
|
2600
|
+
DesktopStream: caps.DesktopStream.transport !== null ? { ...caps.DesktopStream, mode: "read-only" } : caps.DesktopStream,
|
|
2601
|
+
ComputerUse: caps.ComputerUse.available ? { ...caps.ComputerUse, available: false, reason: "consent_required" } : caps.ComputerUse
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
return caps;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// src/sandbox/selfhosted/testing.ts
|
|
2608
|
+
import {
|
|
2609
|
+
ErrorCode as ErrorCode2,
|
|
2610
|
+
FsEntryKind as FsEntryKind2
|
|
2611
|
+
} from "@opengeni/agent-proto";
|
|
2612
|
+
var encoder2 = new TextEncoder();
|
|
2613
|
+
var decoder2 = new TextDecoder();
|
|
2614
|
+
var MockAgentResponder = class {
|
|
2615
|
+
online;
|
|
2616
|
+
consented;
|
|
2617
|
+
draining;
|
|
2618
|
+
files = /* @__PURE__ */ new Map();
|
|
2619
|
+
execHandler;
|
|
2620
|
+
hostname;
|
|
2621
|
+
/** Every request seen, for assertion (subject + decoded ControlRequest). */
|
|
2622
|
+
requests = [];
|
|
2623
|
+
constructor(opts = {}) {
|
|
2624
|
+
this.online = opts.online ?? true;
|
|
2625
|
+
this.consented = opts.consented ?? true;
|
|
2626
|
+
this.draining = opts.draining ?? false;
|
|
2627
|
+
this.hostname = opts.hostname ?? "mock-machine";
|
|
2628
|
+
this.execHandler = opts.exec ?? ((req) => defaultEcho(req, this.hostname));
|
|
2629
|
+
for (const [path, content] of Object.entries(opts.files ?? {})) {
|
|
2630
|
+
this.files.set(normalize(path), typeof content === "string" ? encoder2.encode(content) : content);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
/** Flip the responder offline mid-test (a deliberate stop / blip). */
|
|
2634
|
+
setOnline(online) {
|
|
2635
|
+
this.online = online;
|
|
2636
|
+
}
|
|
2637
|
+
/** Read a file the session wrote (test assertion helper). */
|
|
2638
|
+
fileText(path) {
|
|
2639
|
+
const bytes = this.files.get(normalize(path));
|
|
2640
|
+
return bytes ? decoder2.decode(bytes) : void 0;
|
|
2641
|
+
}
|
|
2642
|
+
async request(subject, req, _opts) {
|
|
2643
|
+
this.requests.push({ subject, req });
|
|
2644
|
+
if (!this.online) {
|
|
2645
|
+
return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_AGENT_OFFLINE, "the enrolled agent is offline", false);
|
|
2646
|
+
}
|
|
2647
|
+
if (this.draining) {
|
|
2648
|
+
return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_DRAINING, "the agent is draining", true);
|
|
2649
|
+
}
|
|
2650
|
+
const op = req.op;
|
|
2651
|
+
if (!op) {
|
|
2652
|
+
return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_PROTOCOL, "empty op", false);
|
|
2653
|
+
}
|
|
2654
|
+
switch (op.$case) {
|
|
2655
|
+
case "ping":
|
|
2656
|
+
return ok(req.requestId, { $case: "ping", ping: { nonce: op.ping.nonce, agentMonotonicMs: "0" } });
|
|
2657
|
+
case "exec": {
|
|
2658
|
+
const res = await this.execHandler(op.exec);
|
|
2659
|
+
return ok(req.requestId, { $case: "exec", exec: res });
|
|
2660
|
+
}
|
|
2661
|
+
case "fsRead": {
|
|
2662
|
+
const bytes = this.files.get(normalize(op.fsRead.path));
|
|
2663
|
+
if (!bytes) {
|
|
2664
|
+
return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_NOT_FOUND, `no such file: ${op.fsRead.path}`, false);
|
|
2665
|
+
}
|
|
2666
|
+
const res = { content: bytes, totalSize: String(bytes.length) };
|
|
2667
|
+
return ok(req.requestId, { $case: "fsRead", fsRead: res });
|
|
2668
|
+
}
|
|
2669
|
+
case "fsWrite": {
|
|
2670
|
+
const path = normalize(op.fsWrite.path);
|
|
2671
|
+
const next = op.fsWrite.append ? concat(this.files.get(path) ?? new Uint8Array(0), op.fsWrite.content) : op.fsWrite.content;
|
|
2672
|
+
this.files.set(path, next);
|
|
2673
|
+
const res = { bytesWritten: String(op.fsWrite.content.length) };
|
|
2674
|
+
return ok(req.requestId, { $case: "fsWrite", fsWrite: res });
|
|
2675
|
+
}
|
|
2676
|
+
case "fsList": {
|
|
2677
|
+
const prefix = normalize(op.fsList.path).replace(/\/?$/, "/");
|
|
2678
|
+
const res = {
|
|
2679
|
+
entries: [...this.files.keys()].filter((p) => p.startsWith(prefix)).map((p) => {
|
|
2680
|
+
const bytes = this.files.get(p);
|
|
2681
|
+
const rel = p.slice(prefix.length);
|
|
2682
|
+
return {
|
|
2683
|
+
name: rel.split("/").pop() ?? rel,
|
|
2684
|
+
path: rel,
|
|
2685
|
+
kind: FsEntryKind2.FS_ENTRY_KIND_FILE,
|
|
2686
|
+
size: String(bytes.length),
|
|
2687
|
+
modifiedMs: "0",
|
|
2688
|
+
mode: 420
|
|
2689
|
+
};
|
|
2690
|
+
})
|
|
2691
|
+
};
|
|
2692
|
+
return ok(req.requestId, { $case: "fsList", fsList: res });
|
|
2693
|
+
}
|
|
2694
|
+
case "fsStat": {
|
|
2695
|
+
const bytes = this.files.get(normalize(op.fsStat.path));
|
|
2696
|
+
const res = bytes ? {
|
|
2697
|
+
exists: true,
|
|
2698
|
+
entry: {
|
|
2699
|
+
name: normalize(op.fsStat.path).split("/").pop() ?? "",
|
|
2700
|
+
path: op.fsStat.path,
|
|
2701
|
+
kind: FsEntryKind2.FS_ENTRY_KIND_FILE,
|
|
2702
|
+
size: String(bytes.length),
|
|
2703
|
+
modifiedMs: "0",
|
|
2704
|
+
mode: 420
|
|
2705
|
+
}
|
|
2706
|
+
} : { exists: false, entry: void 0 };
|
|
2707
|
+
return ok(req.requestId, { $case: "fsStat", fsStat: res });
|
|
2708
|
+
}
|
|
2709
|
+
case "desktopEnsure": {
|
|
2710
|
+
return ok(req.requestId, {
|
|
2711
|
+
$case: "desktopEnsure",
|
|
2712
|
+
desktopEnsure: {
|
|
2713
|
+
channel: { channelId: "mock-desktop", workspaceId: "", agentId: "", kind: 1, port: 6080 },
|
|
2714
|
+
display: { id: ":99", width: 1024, height: 768, virtual: true }
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
case "ptyOpen": {
|
|
2719
|
+
return ok(req.requestId, {
|
|
2720
|
+
$case: "ptyOpen",
|
|
2721
|
+
ptyOpen: {
|
|
2722
|
+
ptyId: "mock-pty",
|
|
2723
|
+
channel: { channelId: "mock-pty", workspaceId: "", agentId: "", kind: 1, port: 7681 }
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
default:
|
|
2728
|
+
return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_UNSUPPORTED, `mock does not implement ${op.$case}`, false);
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
};
|
|
2732
|
+
function defaultEcho(req, hostname) {
|
|
2733
|
+
const joined = req.command.join(" ");
|
|
2734
|
+
const stdout = /hostname|HOSTNAME/.test(joined) ? hostname : joined;
|
|
2735
|
+
return {
|
|
2736
|
+
exitCode: 0,
|
|
2737
|
+
stdout: encoder2.encode(`${stdout}
|
|
2738
|
+
`),
|
|
2739
|
+
stderr: new Uint8Array(0),
|
|
2740
|
+
timedOut: false,
|
|
2741
|
+
durationMs: "1"
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
function ok(requestId, result) {
|
|
2745
|
+
return { requestId, error: void 0, result };
|
|
2746
|
+
}
|
|
2747
|
+
function errorResponse(requestId, code, message, retryable) {
|
|
2748
|
+
const error = { code, message, retryable, detail: {} };
|
|
2749
|
+
return { requestId, error, result: void 0 };
|
|
2750
|
+
}
|
|
2751
|
+
function normalize(path) {
|
|
2752
|
+
const trimmed = path.replace(/\/+$/, "");
|
|
2753
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
2754
|
+
}
|
|
2755
|
+
function concat(a, b) {
|
|
2756
|
+
const out = new Uint8Array(a.length + b.length);
|
|
2757
|
+
out.set(a, 0);
|
|
2758
|
+
out.set(b, a.length);
|
|
2759
|
+
return out;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// src/sandbox/routing/routing-session.ts
|
|
2763
|
+
var RoutingUnsupportedError = class extends Error {
|
|
2764
|
+
name = "RoutingUnsupportedError";
|
|
2765
|
+
constructor(op, kind) {
|
|
2766
|
+
super(`the active sandbox (${kind}) does not support "${op}"`);
|
|
2767
|
+
}
|
|
2768
|
+
};
|
|
2769
|
+
function isFenceError(error) {
|
|
2770
|
+
if (!error || typeof error !== "object") {
|
|
2771
|
+
return false;
|
|
2772
|
+
}
|
|
2773
|
+
if (error.fenced === true) {
|
|
2774
|
+
return true;
|
|
2775
|
+
}
|
|
2776
|
+
const name = typeof error.name === "string" ? error.name : "";
|
|
2777
|
+
const message = error instanceof Error ? error.message : String(error.message ?? "");
|
|
2778
|
+
const haystack = `${name} ${message}`.toLowerCase();
|
|
2779
|
+
return haystack.includes("fenced") || haystack.includes("epoch") && haystack.includes("super");
|
|
2780
|
+
}
|
|
2781
|
+
var RoutingSandboxSession = class {
|
|
2782
|
+
deps;
|
|
2783
|
+
maxFenceRetries;
|
|
2784
|
+
// The per-epoch resolved-backend cache. Keyed by activeEpoch: a swap bumps the
|
|
2785
|
+
// epoch, invalidating the cache so the NEXT op re-resolves the new backend.
|
|
2786
|
+
cachedEpoch;
|
|
2787
|
+
cached;
|
|
2788
|
+
// The last-resolved backend, exposed via the `state` getter (a method-free read
|
|
2789
|
+
// of the active backend's `state`). Updated on every resolve.
|
|
2790
|
+
lastResolved;
|
|
2791
|
+
// The native-desktop control-plane ops (self-hosted / macOS). Declared as OPTIONAL
|
|
2792
|
+
// INSTANCE fields — NOT prototype methods — because their PRESENCE is the selection
|
|
2793
|
+
// signal `isNativeDesktopSession` (sandbox-computer.ts) uses to pick the native vs
|
|
2794
|
+
// exec-shelling Computer. If they were unconditional prototype methods, this proxy
|
|
2795
|
+
// would ALWAYS duck-type as native — misclassifying a Modal-fronting proxy (whose
|
|
2796
|
+
// real backend has no native surface) and driving CGEvent/screenshot ops at a box
|
|
2797
|
+
// that cannot serve them. So the constructor assigns them ONLY when the
|
|
2798
|
+
// construction-time default backend actually implements the native surface (below).
|
|
2799
|
+
desktopInput;
|
|
2800
|
+
screenshot;
|
|
2801
|
+
constructor(deps) {
|
|
2802
|
+
this.deps = deps;
|
|
2803
|
+
this.maxFenceRetries = deps.maxFenceRetries ?? 3;
|
|
2804
|
+
const def = deps.defaultResolved?.session;
|
|
2805
|
+
if (typeof def?.desktopInput === "function" && typeof def?.screenshot === "function") {
|
|
2806
|
+
this.desktopInput = (event) => this.dispatch("desktopInput", async (s) => {
|
|
2807
|
+
if (!s.desktopInput) {
|
|
2808
|
+
throw new RoutingUnsupportedError("desktopInput", this.cached?.kind ?? "unknown");
|
|
2809
|
+
}
|
|
2810
|
+
return s.desktopInput(event);
|
|
2811
|
+
});
|
|
2812
|
+
this.screenshot = () => this.dispatch("screenshot", async (s) => {
|
|
2813
|
+
if (!s.screenshot) {
|
|
2814
|
+
throw new RoutingUnsupportedError("screenshot", this.cached?.kind ?? "unknown");
|
|
2815
|
+
}
|
|
2816
|
+
return s.screenshot();
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* A method-free read of the active backend's `state` (best-effort: the last
|
|
2822
|
+
* resolved backend, falling back to the default backend resolved at construction
|
|
2823
|
+
* so this is non-empty BEFORE the first op). Consumers that read `session.state`
|
|
2824
|
+
* (instanceId/decoration) get the active backend's state.
|
|
2825
|
+
*
|
|
2826
|
+
* CRITICAL: this returns the underlying backend's `state` OBJECT BY REFERENCE
|
|
2827
|
+
* (never a fresh `{}` when a backend exists). The @openai/agents SDK both READS
|
|
2828
|
+
* `session.state.manifest` and WRITES `session.state.manifest = nextManifest`
|
|
2829
|
+
* (providedSessionManifest); returning the live object by reference means those
|
|
2830
|
+
* property writes land on the real backend state and persist. Only when NO
|
|
2831
|
+
* backend has been resolved yet (no default seeded, no op dispatched) do we
|
|
2832
|
+
* return an empty object — and that path no longer occurs in the turn wiring,
|
|
2833
|
+
* which always seeds `defaultResolved`.
|
|
2834
|
+
*/
|
|
2835
|
+
get state() {
|
|
2836
|
+
const backendState = (this.lastResolved ?? this.deps.defaultResolved)?.session.state;
|
|
2837
|
+
return backendState ?? {};
|
|
2838
|
+
}
|
|
2839
|
+
/**
|
|
2840
|
+
* Re-read the pointer and resolve the active backend, using the per-epoch cache.
|
|
2841
|
+
* The cache is keyed by `activeEpoch`: if the epoch is unchanged we return the
|
|
2842
|
+
* cached backend; if it moved (a swap) we re-resolve and update the cache. This
|
|
2843
|
+
* is THE per-call re-read that makes a mid-turn swap land on the next op.
|
|
2844
|
+
*/
|
|
2845
|
+
async resolve() {
|
|
2846
|
+
const pointer = await this.deps.readPointer();
|
|
2847
|
+
if (this.cachedEpoch === pointer.activeEpoch && this.cached) {
|
|
2848
|
+
return this.cached;
|
|
2849
|
+
}
|
|
2850
|
+
const fromEpoch = this.cachedEpoch ?? pointer.activeEpoch;
|
|
2851
|
+
const resolved = await this.deps.resolveActiveBackend(pointer);
|
|
2852
|
+
this.cachedEpoch = pointer.activeEpoch;
|
|
2853
|
+
this.cached = resolved;
|
|
2854
|
+
this.lastResolved = resolved;
|
|
2855
|
+
this.deps.onTransition?.({
|
|
2856
|
+
type: this.cachedEpoch !== void 0 && fromEpoch !== pointer.activeEpoch ? "epoch-changed" : "resolved",
|
|
2857
|
+
fromEpoch,
|
|
2858
|
+
toEpoch: pointer.activeEpoch,
|
|
2859
|
+
sandboxId: resolved.sandboxId,
|
|
2860
|
+
kind: resolved.kind
|
|
2861
|
+
});
|
|
2862
|
+
return resolved;
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Dispatch an op to the currently-active backend, retrying on a stale-epoch
|
|
2866
|
+
* fence. The sequence per attempt:
|
|
2867
|
+
* 1. re-read the pointer + resolve the active backend (cached by epoch),
|
|
2868
|
+
* 2. run `fn(activeSession)`,
|
|
2869
|
+
* 3. on a FENCE error (the pointer moved under us / the backend rejected a
|
|
2870
|
+
* stale epoch), INVALIDATE the cache and retry against the re-resolved
|
|
2871
|
+
* active sandbox — up to `maxFenceRetries`.
|
|
2872
|
+
* A non-fence error propagates immediately (it is a real op failure, not a swap
|
|
2873
|
+
* race).
|
|
2874
|
+
*/
|
|
2875
|
+
async dispatch(op, fn) {
|
|
2876
|
+
let attempt = 0;
|
|
2877
|
+
let lastError;
|
|
2878
|
+
while (attempt <= this.maxFenceRetries) {
|
|
2879
|
+
const backend = await this.resolve();
|
|
2880
|
+
try {
|
|
2881
|
+
return await fn(backend.session);
|
|
2882
|
+
} catch (error) {
|
|
2883
|
+
if (!isFenceError(error)) {
|
|
2884
|
+
throw error;
|
|
2885
|
+
}
|
|
2886
|
+
lastError = error;
|
|
2887
|
+
this.cachedEpoch = void 0;
|
|
2888
|
+
this.cached = void 0;
|
|
2889
|
+
this.deps.onTransition?.({
|
|
2890
|
+
type: "fenced-retry",
|
|
2891
|
+
fromEpoch: backend.sandboxId === null ? 0 : 0,
|
|
2892
|
+
toEpoch: 0,
|
|
2893
|
+
sandboxId: backend.sandboxId,
|
|
2894
|
+
kind: backend.kind
|
|
2895
|
+
});
|
|
2896
|
+
attempt += 1;
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
throw lastError ?? new Error(`routing op "${op}" exhausted fence retries`);
|
|
2900
|
+
}
|
|
2901
|
+
// ── The forwarded structural surface ──────────────────────────────────────
|
|
2902
|
+
// Every method is PRESENT on the proxy (the SDK binds presence once) and
|
|
2903
|
+
// dispatches to the active backend at call-time. A missing backend method
|
|
2904
|
+
// degrades via the natural fallback or RoutingUnsupportedError.
|
|
2905
|
+
async exec(args) {
|
|
2906
|
+
return this.dispatch("exec", async (s) => {
|
|
2907
|
+
if (s.exec) {
|
|
2908
|
+
return s.exec(args);
|
|
2909
|
+
}
|
|
2910
|
+
if (s.execCommand) {
|
|
2911
|
+
return s.execCommand(args);
|
|
2912
|
+
}
|
|
2913
|
+
throw new RoutingUnsupportedError("exec", this.cached?.kind ?? "unknown");
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
async execCommand(args) {
|
|
2917
|
+
return this.dispatch("execCommand", async (s) => {
|
|
2918
|
+
if (s.execCommand) {
|
|
2919
|
+
return s.execCommand(args);
|
|
2920
|
+
}
|
|
2921
|
+
if (s.exec) {
|
|
2922
|
+
const r = await s.exec(args);
|
|
2923
|
+
return r.stdout ?? r.output ?? "";
|
|
2924
|
+
}
|
|
2925
|
+
throw new RoutingUnsupportedError("execCommand", this.cached?.kind ?? "unknown");
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
async writeStdin(args) {
|
|
2929
|
+
return this.dispatch("writeStdin", async (s) => {
|
|
2930
|
+
if (!s.writeStdin) {
|
|
2931
|
+
throw new RoutingUnsupportedError("writeStdin", this.cached?.kind ?? "unknown");
|
|
2932
|
+
}
|
|
2933
|
+
return s.writeStdin(args);
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
async readFile(args) {
|
|
2937
|
+
return this.dispatch("readFile", async (s) => {
|
|
2938
|
+
if (!s.readFile) {
|
|
2939
|
+
throw new RoutingUnsupportedError("readFile", this.cached?.kind ?? "unknown");
|
|
2940
|
+
}
|
|
2941
|
+
return s.readFile(args);
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
async writeFile(args) {
|
|
2945
|
+
return this.dispatch("writeFile", async (s) => {
|
|
2946
|
+
if (!s.writeFile) {
|
|
2947
|
+
throw new RoutingUnsupportedError("writeFile", this.cached?.kind ?? "unknown");
|
|
2948
|
+
}
|
|
2949
|
+
return s.writeFile(args);
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
async listDir(args) {
|
|
2953
|
+
return this.dispatch("listDir", async (s) => {
|
|
2954
|
+
if (!s.listDir) {
|
|
2955
|
+
throw new RoutingUnsupportedError("listDir", this.cached?.kind ?? "unknown");
|
|
2956
|
+
}
|
|
2957
|
+
return s.listDir(args);
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
async pathExists(path, runAs) {
|
|
2961
|
+
return this.dispatch("pathExists", async (s) => {
|
|
2962
|
+
if (!s.pathExists) {
|
|
2963
|
+
throw new RoutingUnsupportedError("pathExists", this.cached?.kind ?? "unknown");
|
|
2964
|
+
}
|
|
2965
|
+
return s.pathExists(path, runAs);
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
async viewImage(args) {
|
|
2969
|
+
return this.dispatch("viewImage", async (s) => {
|
|
2970
|
+
if (!s.viewImage) {
|
|
2971
|
+
throw new RoutingUnsupportedError("viewImage", this.cached?.kind ?? "unknown");
|
|
2972
|
+
}
|
|
2973
|
+
return s.viewImage(args);
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
async materializeEntry(args) {
|
|
2977
|
+
return this.dispatch("materializeEntry", async (s) => {
|
|
2978
|
+
if (!s.materializeEntry) {
|
|
2979
|
+
throw new RoutingUnsupportedError("materializeEntry", this.cached?.kind ?? "unknown");
|
|
2980
|
+
}
|
|
2981
|
+
return s.materializeEntry(args);
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
/** PTY support reflects the LAST-resolved backend (a synchronous probe; the SDK
|
|
2985
|
+
* reads it to decide if the terminal is interactive). It cannot re-read the
|
|
2986
|
+
* pointer (synchronous), so it answers from the last resolve — coherent with
|
|
2987
|
+
* the resolve the surrounding op already performed. Defaults false before the
|
|
2988
|
+
* first resolve. */
|
|
2989
|
+
supportsPty() {
|
|
2990
|
+
const s = (this.lastResolved ?? this.deps.defaultResolved)?.session;
|
|
2991
|
+
return Boolean(s?.supportsPty?.());
|
|
2992
|
+
}
|
|
2993
|
+
/** createEditor is a synchronous factory in the SDK surface; it binds to the
|
|
2994
|
+
* last-resolved backend's editor (or the default backend before the first op).
|
|
2995
|
+
* Returns undefined when the active backend has no editor (channel-a falls back
|
|
2996
|
+
* to its exec-based write path). */
|
|
2997
|
+
createEditor(runAs) {
|
|
2998
|
+
return (this.lastResolved ?? this.deps.defaultResolved)?.session.createEditor?.(runAs);
|
|
2999
|
+
}
|
|
3000
|
+
async resolveExposedPort(port) {
|
|
3001
|
+
return this.dispatch("resolveExposedPort", async (s) => {
|
|
3002
|
+
if (!s.resolveExposedPort) {
|
|
3003
|
+
throw new RoutingUnsupportedError("resolveExposedPort", this.cached?.kind ?? "unknown");
|
|
3004
|
+
}
|
|
3005
|
+
return s.resolveExposedPort(port);
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
3008
|
+
/** Serialize the active backend's session state. Used by the resume-by-id seam
|
|
3009
|
+
* to fold the live box onto the lease. Dispatches to the active backend. */
|
|
3010
|
+
async serializeSessionState() {
|
|
3011
|
+
return this.dispatch("serializeSessionState", async (s) => {
|
|
3012
|
+
if (!s.serializeSessionState) {
|
|
3013
|
+
return void 0;
|
|
3014
|
+
}
|
|
3015
|
+
return s.serializeSessionState();
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
3018
|
+
/** Force a resolve (priming the proxy before the first op so `state`/`supportsPty`
|
|
3019
|
+
* read a real backend). Optional — every op resolves lazily anyway. */
|
|
3020
|
+
async prime() {
|
|
3021
|
+
return this.resolve();
|
|
3022
|
+
}
|
|
3023
|
+
};
|
|
3024
|
+
|
|
3025
|
+
// src/sandbox/routing/backend-resolver.ts
|
|
3026
|
+
var ActiveBackendUnresolvableError = class extends Error {
|
|
3027
|
+
name = "ActiveBackendUnresolvableError";
|
|
3028
|
+
constructor(message) {
|
|
3029
|
+
super(message);
|
|
3030
|
+
}
|
|
3031
|
+
};
|
|
3032
|
+
function makeActiveBackendResolver(deps) {
|
|
3033
|
+
return async (pointer) => {
|
|
3034
|
+
if (pointer.activeSandboxId === null) {
|
|
3035
|
+
return { session: deps.defaultBackend, sandboxId: null, kind: deps.defaultKind };
|
|
3036
|
+
}
|
|
3037
|
+
if (deps.pinnedSelfhosted && pointer.activeSandboxId === deps.pinnedSelfhosted.sandboxId && pointer.activeEpoch === deps.pinnedSelfhosted.epoch) {
|
|
3038
|
+
return { session: deps.pinnedSelfhosted.session, sandboxId: pointer.activeSandboxId, kind: "selfhosted" };
|
|
3039
|
+
}
|
|
3040
|
+
const sandbox = await deps.getSandbox(pointer.activeSandboxId);
|
|
3041
|
+
if (!sandbox) {
|
|
3042
|
+
throw new ActiveBackendUnresolvableError(
|
|
3043
|
+
`active sandbox ${pointer.activeSandboxId} not found in workspace ${deps.workspaceId}`
|
|
3044
|
+
);
|
|
3045
|
+
}
|
|
3046
|
+
if (sandbox.kind === "selfhosted") {
|
|
3047
|
+
if (!sandbox.enrollmentId) {
|
|
3048
|
+
throw new ActiveBackendUnresolvableError(
|
|
3049
|
+
`selfhosted sandbox ${sandbox.id} has no enrollment (agent id) to address`
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
const { session } = await buildSelfhostedBackendSession({
|
|
3053
|
+
workspaceId: deps.workspaceId,
|
|
3054
|
+
relay: deps.relay,
|
|
3055
|
+
controlRpcFactory: deps.controlRpcFactory,
|
|
3056
|
+
agentId: sandbox.enrollmentId,
|
|
3057
|
+
epoch: pointer.activeEpoch,
|
|
3058
|
+
...deps.selfhostedTimeoutMs !== void 0 ? { timeoutMs: deps.selfhostedTimeoutMs } : {},
|
|
3059
|
+
// The turn's declared environment → the session's manifest.environment, so
|
|
3060
|
+
// the SDK's per-turn manifest-env delta is empty (no "cannot change manifest
|
|
3061
|
+
// environment variables" throw on a pin-to-vm turn).
|
|
3062
|
+
...deps.environment !== void 0 ? { environment: deps.environment } : {},
|
|
3063
|
+
// The session's working directory (per-session pointer) → the path/cwd base
|
|
3064
|
+
// for this selfhosted backend. Absent/empty ⇒ the default workspace_root.
|
|
3065
|
+
...pointer.workingDir ? { workingDir: pointer.workingDir } : {}
|
|
3066
|
+
});
|
|
3067
|
+
return { session, sandboxId: sandbox.id, kind: "selfhosted" };
|
|
3068
|
+
}
|
|
3069
|
+
if (sandbox.kind === "modal") {
|
|
3070
|
+
if (!deps.establishModalTarget) {
|
|
3071
|
+
throw new ActiveBackendUnresolvableError(
|
|
3072
|
+
`modal swap target ${sandbox.id} cannot be established in this context (no establisher wired)`
|
|
3073
|
+
);
|
|
3074
|
+
}
|
|
3075
|
+
const session = await deps.establishModalTarget(sandbox);
|
|
3076
|
+
return { session, sandboxId: sandbox.id, kind: "modal" };
|
|
3077
|
+
}
|
|
3078
|
+
throw new ActiveBackendUnresolvableError(
|
|
3079
|
+
`unsupported swap target kind "${sandbox.kind}" for sandbox ${sandbox.id}`
|
|
3080
|
+
);
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
// src/sandbox/index.ts
|
|
3085
|
+
function createSandboxClient(settings, environment = collectSandboxEnvironment(settings)) {
|
|
3086
|
+
return createSandboxClientForBackend(settings.sandboxBackend, settings, environment);
|
|
3087
|
+
}
|
|
3088
|
+
function createSandboxClientForBackend(backend, settings, environment = collectSandboxEnvironment(settings)) {
|
|
3089
|
+
const registration = PROVIDER_REGISTRY[backend];
|
|
3090
|
+
if (!registration) {
|
|
3091
|
+
throw new SandboxConfigError(backend, `Unknown sandbox backend "${backend}"`);
|
|
3092
|
+
}
|
|
3093
|
+
if (registration.backend === "none") {
|
|
3094
|
+
return void 0;
|
|
3095
|
+
}
|
|
3096
|
+
registration.validateCredentials(settings);
|
|
3097
|
+
const exposedPorts = parseExposedPorts(settings.dockerExposedPorts);
|
|
3098
|
+
const desktop = registration.descriptor.capabilities.DesktopStream;
|
|
3099
|
+
if (desktop.available && settings.sandboxDesktopEnabled && !registration.descriptor.portExposure.supportsOnDemandPorts && !exposedPorts.includes(DESKTOP_STREAM_PORT7)) {
|
|
3100
|
+
exposedPorts.push(DESKTOP_STREAM_PORT7);
|
|
3101
|
+
}
|
|
3102
|
+
if (desktop.available && settings.sandboxDesktopEnabled && !registration.descriptor.portExposure.supportsOnDemandPorts && !exposedPorts.includes(TERMINAL_STREAM_PORT2)) {
|
|
3103
|
+
exposedPorts.push(TERMINAL_STREAM_PORT2);
|
|
3104
|
+
}
|
|
3105
|
+
const raw = registration.build({ settings, environment, exposedPorts });
|
|
3106
|
+
return registration.backend === "docker" ? withDockerNetwork(raw, settings.dockerNetwork) : raw;
|
|
3107
|
+
}
|
|
3108
|
+
function withDockerNetwork(client, network) {
|
|
3109
|
+
const trimmed = network?.trim();
|
|
3110
|
+
if (!trimmed) {
|
|
3111
|
+
return client;
|
|
3112
|
+
}
|
|
3113
|
+
const wrapSession = async (session) => {
|
|
3114
|
+
const containerId = session.state?.containerId;
|
|
3115
|
+
if (typeof containerId === "string" && containerId.length > 0) {
|
|
3116
|
+
await connectDockerNetwork(trimmed, containerId);
|
|
3117
|
+
}
|
|
3118
|
+
return session;
|
|
3119
|
+
};
|
|
3120
|
+
return {
|
|
3121
|
+
backendId: client.backendId,
|
|
3122
|
+
...client.supportsDefaultOptions !== void 0 ? { supportsDefaultOptions: client.supportsDefaultOptions } : {},
|
|
3123
|
+
...client.create ? { create: async (...args) => await wrapSession(await client.create(...args)) } : {},
|
|
3124
|
+
...client.resume ? { resume: async (state) => await wrapSession(await client.resume(state)) } : {},
|
|
3125
|
+
...client.delete ? { delete: async (state) => await client.delete(state) } : {},
|
|
3126
|
+
...client.serializeSessionState ? { serializeSessionState: async (state, options) => await client.serializeSessionState(state, options) } : {},
|
|
3127
|
+
...client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state) => await client.canPersistOwnedSessionState(state) } : {},
|
|
3128
|
+
...client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state) => await client.canReusePreservedOwnedSession(state) } : {},
|
|
3129
|
+
...client.deserializeSessionState ? { deserializeSessionState: async (state) => await client.deserializeSessionState(state) } : {}
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
async function connectDockerNetwork(network, containerId) {
|
|
3133
|
+
const result = Bun.spawnSync(["docker", "network", "connect", network, containerId], {
|
|
3134
|
+
stdout: "pipe",
|
|
3135
|
+
stderr: "pipe"
|
|
3136
|
+
});
|
|
3137
|
+
if (result.exitCode === 0) {
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
3141
|
+
if (stderr.includes("already exists")) {
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
throw new Error(`Failed to connect Docker sandbox container to network ${network}: ${stderr.trim()}`);
|
|
3145
|
+
}
|
|
3146
|
+
function sandboxStateEntryFromRunState(state) {
|
|
3147
|
+
const sandboxState = state?._sandbox;
|
|
3148
|
+
if (!sandboxState) {
|
|
3149
|
+
return null;
|
|
3150
|
+
}
|
|
3151
|
+
const entry = sandboxState.sessionsByAgent?.[sandboxState.currentAgentKey] ?? (sandboxState.currentAgentKey && sandboxState.sessionState ? {
|
|
3152
|
+
backendId: sandboxState.backendId,
|
|
3153
|
+
currentAgentKey: sandboxState.currentAgentKey,
|
|
3154
|
+
currentAgentName: sandboxState.currentAgentName,
|
|
3155
|
+
sessionState: sandboxState.sessionState
|
|
3156
|
+
} : null);
|
|
3157
|
+
if (!entry || !entry.sessionState) {
|
|
3158
|
+
return null;
|
|
3159
|
+
}
|
|
3160
|
+
return entry;
|
|
3161
|
+
}
|
|
3162
|
+
async function restoredSandboxSessionStateFromEntry(entry, client) {
|
|
3163
|
+
if (!client || !entry || typeof entry !== "object" || !("sessionState" in entry)) {
|
|
3164
|
+
return void 0;
|
|
3165
|
+
}
|
|
3166
|
+
if (entry.backendId && client.backendId !== entry.backendId) {
|
|
3167
|
+
throw new Error("Stored sandbox envelope backend does not match the configured sandbox client");
|
|
3168
|
+
}
|
|
3169
|
+
return await deserializeSandboxSessionStateEnvelope(client, entry.sessionState);
|
|
3170
|
+
}
|
|
3171
|
+
function readWorkspaceArchiveFromEnvelopeSessionState(sessionState) {
|
|
3172
|
+
if (!sessionState || typeof sessionState !== "object") {
|
|
3173
|
+
return void 0;
|
|
3174
|
+
}
|
|
3175
|
+
const b64 = sessionState.workspaceArchive;
|
|
3176
|
+
if (typeof b64 !== "string" || b64.length === 0) {
|
|
3177
|
+
return void 0;
|
|
3178
|
+
}
|
|
3179
|
+
try {
|
|
3180
|
+
return Uint8Array.from(Buffer.from(b64, "base64"));
|
|
3181
|
+
} catch {
|
|
3182
|
+
return void 0;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
var MODAL_SNAPSHOT_REF_PREFIXES = [
|
|
3186
|
+
"MODAL_SANDBOX_FS_SNAPSHOT_V1\n",
|
|
3187
|
+
"MODAL_SANDBOX_DIR_SNAPSHOT_V1\n"
|
|
3188
|
+
];
|
|
3189
|
+
function decodeModalSnapshotId(archive) {
|
|
3190
|
+
let text;
|
|
3191
|
+
try {
|
|
3192
|
+
text = new TextDecoder().decode(archive);
|
|
3193
|
+
} catch {
|
|
3194
|
+
return void 0;
|
|
3195
|
+
}
|
|
3196
|
+
for (const prefix of MODAL_SNAPSHOT_REF_PREFIXES) {
|
|
3197
|
+
if (!text.startsWith(prefix)) {
|
|
3198
|
+
continue;
|
|
3199
|
+
}
|
|
3200
|
+
try {
|
|
3201
|
+
const payload = JSON.parse(text.slice(prefix.length));
|
|
3202
|
+
return typeof payload.snapshot_id === "string" && payload.snapshot_id.length > 0 ? payload.snapshot_id : void 0;
|
|
3203
|
+
} catch {
|
|
3204
|
+
return void 0;
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
return void 0;
|
|
3208
|
+
}
|
|
3209
|
+
async function deletePriorPersistedSnapshot(session, priorArchiveBase64) {
|
|
3210
|
+
if (!priorArchiveBase64) {
|
|
3211
|
+
return void 0;
|
|
3212
|
+
}
|
|
3213
|
+
let bytes;
|
|
3214
|
+
try {
|
|
3215
|
+
bytes = Uint8Array.from(Buffer.from(priorArchiveBase64, "base64"));
|
|
3216
|
+
} catch {
|
|
3217
|
+
return void 0;
|
|
3218
|
+
}
|
|
3219
|
+
const snapshotId = decodeModalSnapshotId(bytes);
|
|
3220
|
+
if (!snapshotId) {
|
|
3221
|
+
return void 0;
|
|
3222
|
+
}
|
|
3223
|
+
const modal = session.modal;
|
|
3224
|
+
const del = modal?.images?.delete;
|
|
3225
|
+
if (typeof del !== "function") {
|
|
3226
|
+
return void 0;
|
|
3227
|
+
}
|
|
3228
|
+
try {
|
|
3229
|
+
await del.call(modal.images, snapshotId);
|
|
3230
|
+
return snapshotId;
|
|
3231
|
+
} catch {
|
|
3232
|
+
return void 0;
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
async function deserializeSandboxSessionStateEnvelope(client, envelope) {
|
|
3236
|
+
if (!envelope || typeof envelope !== "object") {
|
|
3237
|
+
return void 0;
|
|
3238
|
+
}
|
|
3239
|
+
if (!client.deserializeSessionState) {
|
|
3240
|
+
throw new Error("Sandbox client must implement deserializeSessionState() to resume RunState sandbox state");
|
|
3241
|
+
}
|
|
3242
|
+
const state = envelope;
|
|
3243
|
+
return await client.deserializeSessionState({
|
|
3244
|
+
...state.providerState ?? {},
|
|
3245
|
+
manifest: state.manifest,
|
|
3246
|
+
...state.snapshot !== void 0 ? { snapshot: state.snapshot } : {},
|
|
3247
|
+
...state.snapshotFingerprint !== void 0 ? { snapshotFingerprint: state.snapshotFingerprint } : {},
|
|
3248
|
+
...state.snapshotFingerprintVersion !== void 0 ? { snapshotFingerprintVersion: state.snapshotFingerprintVersion } : {},
|
|
3249
|
+
workspaceReady: state.workspaceReady,
|
|
3250
|
+
...state.exposedPorts ? { exposedPorts: structuredClone(state.exposedPorts) } : {}
|
|
3251
|
+
});
|
|
3252
|
+
}
|
|
3253
|
+
function isProviderSandboxNotFoundError(backendId, error) {
|
|
3254
|
+
if (backendId === "selfhosted") {
|
|
3255
|
+
return isSelfhostedProviderNotFoundError(error);
|
|
3256
|
+
}
|
|
3257
|
+
if (!error) {
|
|
3258
|
+
return false;
|
|
3259
|
+
}
|
|
3260
|
+
const status = error.status ?? error.statusCode;
|
|
3261
|
+
if (status === 404) {
|
|
3262
|
+
return true;
|
|
3263
|
+
}
|
|
3264
|
+
const name = typeof error.name === "string" ? error.name : "";
|
|
3265
|
+
const code = typeof error.code === "string" ? error.code : "";
|
|
3266
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : String(error?.message ?? "");
|
|
3267
|
+
const haystack = `${name} ${code} ${message}`.toLowerCase();
|
|
3268
|
+
const goneMarkers = [
|
|
3269
|
+
"not found",
|
|
3270
|
+
"no longer running",
|
|
3271
|
+
"no longer exists",
|
|
3272
|
+
"does not exist",
|
|
3273
|
+
"doesn't exist",
|
|
3274
|
+
"has been terminated",
|
|
3275
|
+
"was terminated",
|
|
3276
|
+
"is terminated",
|
|
3277
|
+
"sandbox terminated",
|
|
3278
|
+
"notfound",
|
|
3279
|
+
"sandbox_not_found",
|
|
3280
|
+
"box no longer running"
|
|
3281
|
+
];
|
|
3282
|
+
if (haystack.includes("already running") || haystack.includes("still running") || haystack.includes("already exists")) {
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3285
|
+
return goneMarkers.some((marker) => haystack.includes(marker));
|
|
3286
|
+
}
|
|
3287
|
+
function readInstanceId(session) {
|
|
3288
|
+
const state = session.state ?? {};
|
|
3289
|
+
const candidate = state.sandboxId ?? state.instanceId ?? state.id ?? state.hostId ?? state.containerId;
|
|
3290
|
+
return typeof candidate === "string" && candidate.length > 0 ? candidate : "";
|
|
3291
|
+
}
|
|
3292
|
+
async function establishSandboxSessionFromEnvelope(settings, envelope, opts) {
|
|
3293
|
+
const envelopeBackend = typeof envelope?.backendId === "string" ? envelope.backendId : void 0;
|
|
3294
|
+
const backend = opts.backendOverride ?? envelopeBackend ?? settings.sandboxBackend;
|
|
3295
|
+
const environment = opts.environment ?? collectSandboxEnvironment(settings);
|
|
3296
|
+
const client = createSandboxClientForBackend(backend, settings, environment);
|
|
3297
|
+
if (!client) {
|
|
3298
|
+
throw new SandboxConfigError(backend, `Cannot establish a sandbox session for backend "${backend}" (no client; sandboxBackend=none?)`);
|
|
3299
|
+
}
|
|
3300
|
+
if (!client.create) {
|
|
3301
|
+
throw new SandboxConfigError(backend, `Sandbox backend "${backend}" does not support create()`);
|
|
3302
|
+
}
|
|
3303
|
+
const createManifest = { environment };
|
|
3304
|
+
const envelopeSessionState = envelope && typeof envelope === "object" ? envelope.sessionState : void 0;
|
|
3305
|
+
const workspaceArchive = readWorkspaceArchiveFromEnvelopeSessionState(envelopeSessionState);
|
|
3306
|
+
const coldRestore = async (resumeFallbackState) => {
|
|
3307
|
+
const restored = await client.create({ manifest: createManifest });
|
|
3308
|
+
if (workspaceArchive) {
|
|
3309
|
+
const hydrate = restored.hydrateWorkspace;
|
|
3310
|
+
if (typeof hydrate === "function") {
|
|
3311
|
+
try {
|
|
3312
|
+
await hydrate.call(restored, workspaceArchive);
|
|
3313
|
+
} catch (hydrateError) {
|
|
3314
|
+
const restoredState2 = restored.state;
|
|
3315
|
+
const clientWithDelete = client;
|
|
3316
|
+
if (typeof clientWithDelete.delete === "function" && restoredState2 !== void 0) {
|
|
3317
|
+
try {
|
|
3318
|
+
await clientWithDelete.delete(restoredState2);
|
|
3319
|
+
} catch {
|
|
3320
|
+
}
|
|
3321
|
+
} else {
|
|
3322
|
+
const sess = restored;
|
|
3323
|
+
try {
|
|
3324
|
+
await (sess.terminate ?? sess.close)?.();
|
|
3325
|
+
} catch {
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
throw hydrateError;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
const restoredState = restored.state;
|
|
3333
|
+
return { client, session: restored, sessionState: restoredState ?? resumeFallbackState, instanceId: readInstanceId(restored), backendId: client.backendId };
|
|
3334
|
+
};
|
|
3335
|
+
const envelopeProviderState = envelopeSessionState && typeof envelopeSessionState === "object" ? envelopeSessionState.providerState : void 0;
|
|
3336
|
+
const hasResumableInstance = Boolean(
|
|
3337
|
+
envelopeProviderState && typeof envelopeProviderState === "object" && (envelopeProviderState.sandboxId || envelopeProviderState.instanceId || envelopeProviderState.id || envelopeProviderState.containerId)
|
|
3338
|
+
);
|
|
3339
|
+
if (hasResumableInstance && envelopeSessionState && client.resume && client.deserializeSessionState) {
|
|
3340
|
+
let resumedState;
|
|
3341
|
+
try {
|
|
3342
|
+
resumedState = await deserializeSandboxSessionStateEnvelope(client, envelopeSessionState);
|
|
3343
|
+
} catch (error) {
|
|
3344
|
+
throw new SandboxConfigError(backend, `Failed to deserialize sandbox resume envelope for backend "${backend}": ${error instanceof Error ? error.message : String(error)}`);
|
|
3345
|
+
}
|
|
3346
|
+
if (resumedState !== void 0) {
|
|
3347
|
+
try {
|
|
3348
|
+
const session = await client.resume(resumedState);
|
|
3349
|
+
return { client, session, sessionState: resumedState, instanceId: readInstanceId(session), backendId: client.backendId };
|
|
3350
|
+
} catch (error) {
|
|
3351
|
+
if (!isProviderSandboxNotFoundError(client.backendId, error)) {
|
|
3352
|
+
throw error;
|
|
3353
|
+
}
|
|
3354
|
+
return await coldRestore(resumedState);
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
return await coldRestore();
|
|
3359
|
+
}
|
|
3360
|
+
async function serializeEstablishedSandboxEnvelope(established) {
|
|
3361
|
+
const client = established.client;
|
|
3362
|
+
if (!client || typeof client.serializeSessionState !== "function") {
|
|
3363
|
+
return null;
|
|
3364
|
+
}
|
|
3365
|
+
if (established.sessionState === void 0 || established.sessionState === null) {
|
|
3366
|
+
return null;
|
|
3367
|
+
}
|
|
3368
|
+
try {
|
|
3369
|
+
const serialized = await client.serializeSessionState(established.sessionState);
|
|
3370
|
+
const flat = serialized;
|
|
3371
|
+
const manifest = flat.manifest;
|
|
3372
|
+
const exposedPorts = flat.configuredExposedPorts ?? flat.exposedPorts;
|
|
3373
|
+
const sessionState = {
|
|
3374
|
+
providerState: flat,
|
|
3375
|
+
...manifest !== void 0 ? { manifest } : {},
|
|
3376
|
+
...exposedPorts !== void 0 ? { exposedPorts } : {},
|
|
3377
|
+
workspaceReady: true
|
|
3378
|
+
};
|
|
3379
|
+
return { backendId: established.backendId, sessionState };
|
|
3380
|
+
} catch {
|
|
3381
|
+
return null;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
export {
|
|
3386
|
+
CAPABILITY_DESCRIPTORS,
|
|
3387
|
+
DESKTOP_STREAM_PORT,
|
|
3388
|
+
assertDescriptorRegistryInvariants,
|
|
3389
|
+
SandboxConfigError,
|
|
3390
|
+
SandboxProviderUnavailableError,
|
|
3391
|
+
subjectFor,
|
|
3392
|
+
SelfhostedControlError,
|
|
3393
|
+
agentErrorToControlError,
|
|
3394
|
+
offlineAgentError,
|
|
3395
|
+
timeoutAgentError,
|
|
3396
|
+
NatsControlRpc,
|
|
3397
|
+
offlineControlResponse,
|
|
3398
|
+
timeoutControlResponse,
|
|
3399
|
+
setSelfhostedApplyDiff,
|
|
3400
|
+
SELFHOSTED_DEFAULT_TIMEOUT_MS,
|
|
3401
|
+
SELFHOSTED_RELAY_STREAM_PATH,
|
|
3402
|
+
SelfhostedSession,
|
|
3403
|
+
SelfhostedSandboxClient,
|
|
3404
|
+
buildSelfhostedBackendSession,
|
|
3405
|
+
isSelfhostedProviderNotFoundError,
|
|
3406
|
+
PROVIDER_REGISTRY,
|
|
3407
|
+
assertProviderRegistryInvariants,
|
|
3408
|
+
selectBackend,
|
|
3409
|
+
backendSupportsOs,
|
|
3410
|
+
desktopCapableBackend,
|
|
3411
|
+
negotiateCapabilities,
|
|
3412
|
+
STREAM_TOKEN_DEFAULT_TTL_SECONDS,
|
|
3413
|
+
mintStreamToken,
|
|
3414
|
+
verifyStreamToken,
|
|
3415
|
+
StreamTokenPayload2 as StreamTokenPayload,
|
|
3416
|
+
STREAM_PORT,
|
|
3417
|
+
DISPLAY_STACK_TIMEOUT_MS,
|
|
3418
|
+
DEFAULT_DESKTOP_GEOMETRY,
|
|
3419
|
+
DisplayStackError,
|
|
3420
|
+
DisplayStackUnsupportedError,
|
|
3421
|
+
buildDisplayStackScript,
|
|
3422
|
+
ensureDisplayStack,
|
|
3423
|
+
tearDownDisplayStack,
|
|
3424
|
+
TERMINAL_STREAM_PORT,
|
|
3425
|
+
TERMINAL_SERVER_TIMEOUT_MS,
|
|
3426
|
+
TerminalServerError,
|
|
3427
|
+
TerminalServerUnsupportedError,
|
|
3428
|
+
buildTerminalServerScript,
|
|
3429
|
+
ensureTerminalServer,
|
|
3430
|
+
tearDownTerminalServer,
|
|
3431
|
+
StreamPortUnavailableError,
|
|
3432
|
+
buildStreamUrl,
|
|
3433
|
+
exposeStreamPort,
|
|
3434
|
+
contentTypeForCodec,
|
|
3435
|
+
extForCodec,
|
|
3436
|
+
RecordingUnavailableError,
|
|
3437
|
+
RecordingError,
|
|
3438
|
+
startRecording,
|
|
3439
|
+
stopRecording,
|
|
3440
|
+
readRecordingBytes,
|
|
3441
|
+
deleteRecordingArtifacts,
|
|
3442
|
+
recordingStorageKey,
|
|
3443
|
+
ChannelAValidationError,
|
|
3444
|
+
ChannelAConflictError,
|
|
3445
|
+
ChannelANotFoundError,
|
|
3446
|
+
ChannelAUnsupportedError,
|
|
3447
|
+
SandboxChannelAService,
|
|
3448
|
+
stripExecBanner2 as stripExecBanner,
|
|
3449
|
+
isWorkspaceEscapeError,
|
|
3450
|
+
isExecSessionLostBanner,
|
|
3451
|
+
parseExecBannerSessionId,
|
|
3452
|
+
assertSafeRelPath,
|
|
3453
|
+
parsePorcelainV2,
|
|
3454
|
+
parseNumstatZ,
|
|
3455
|
+
parseUnifiedPatch,
|
|
3456
|
+
SELFHOSTED_RECONNECT_WINDOW_MS,
|
|
3457
|
+
selfhostedLiveness,
|
|
3458
|
+
negotiateSelfhostedCapabilities,
|
|
3459
|
+
MockAgentResponder,
|
|
3460
|
+
RoutingUnsupportedError,
|
|
3461
|
+
RoutingSandboxSession,
|
|
3462
|
+
ActiveBackendUnresolvableError,
|
|
3463
|
+
makeActiveBackendResolver,
|
|
3464
|
+
createSandboxClient,
|
|
3465
|
+
createSandboxClientForBackend,
|
|
3466
|
+
sandboxStateEntryFromRunState,
|
|
3467
|
+
restoredSandboxSessionStateFromEntry,
|
|
3468
|
+
readWorkspaceArchiveFromEnvelopeSessionState,
|
|
3469
|
+
decodeModalSnapshotId,
|
|
3470
|
+
deletePriorPersistedSnapshot,
|
|
3471
|
+
deserializeSandboxSessionStateEnvelope,
|
|
3472
|
+
isProviderSandboxNotFoundError,
|
|
3473
|
+
establishSandboxSessionFromEnvelope,
|
|
3474
|
+
serializeEstablishedSandboxEnvelope,
|
|
3475
|
+
collectSandboxEnvironment2 as collectSandboxEnvironment,
|
|
3476
|
+
parseExposedPorts2 as parseExposedPorts
|
|
3477
|
+
};
|
|
3478
|
+
//# sourceMappingURL=chunk-2PO56VAL.js.map
|