@axiom-lattice/opensandbox-gateway 0.2.9 → 0.2.11

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @axiom-lattice/opensandbox-gateway@0.2.9 build /home/runner/work/agentic/agentic/packages/opensandbox-gateway
2
+ > @axiom-lattice/opensandbox-gateway@0.2.11 build /home/runner/work/agentic/agentic/packages/opensandbox-gateway
3
3
  > tsup src/index.ts src/cli.ts --format esm --dts --clean --sourcemap
4
4
 
5
5
  CLI Building entry: src/cli.ts, src/index.ts
@@ -10,12 +10,12 @@
10
10
  ESM Build start
11
11
  ESM dist/cli.mjs 245.00 B
12
12
  ESM dist/index.mjs 196.00 B
13
- ESM dist/chunk-M6FKTT7J.mjs 38.24 KB
13
+ ESM dist/chunk-QC7CI5UY.mjs 47.41 KB
14
14
  ESM dist/cli.mjs.map 387.00 B
15
15
  ESM dist/index.mjs.map 71.00 B
16
- ESM dist/chunk-M6FKTT7J.mjs.map 79.90 KB
17
- ESM ⚡️ Build success in 145ms
16
+ ESM dist/chunk-QC7CI5UY.mjs.map 99.42 KB
17
+ ESM ⚡️ Build success in 166ms
18
18
  DTS Build start
19
- DTS ⚡️ Build success in 29026ms
19
+ DTS ⚡️ Build success in 18414ms
20
20
  DTS dist/cli.d.mts 20.00 B
21
- DTS dist/index.d.mts 10.55 KB
21
+ DTS dist/index.d.mts 13.25 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @axiom-lattice/opensandbox-gateway
2
2
 
3
+ ## 0.2.11
4
+
5
+ ### Patch Changes
6
+
7
+ - a19e0aa: Unify the sandbox service base-URL env and treat missing sandbox files as a structured not-found (ADR-174 / ADR-175).
8
+
9
+ The resident plugin deployment runtime now reads `MICROSANDBOX_SERVICE_BASE_URL` only (dropping `MICROSANDBOX_SERVICE_URL`), so a provider-only config also enables `manage_plugin` deploy/undeploy/status; `sandbox-console` reads `VITE_MICROSANDBOX_SERVICE_BASE_URL`. `MICROSANDBOX_SERVICE_URL` no longer has any effect.
10
+
11
+ `opensandbox-gateway` now preserves the sandbox's upstream HTTP status (and surfaces execd's `{ code, message }`) instead of collapsing every failure to `500 "Download failed"`, and `MicrosandboxServiceClient` attaches the HTTP status to request errors; `microsandbox-service` `readFile`/`downloadFile` map missing files to `404 FILE_NOT_FOUND` (matching `deleteFile`). A missing sandbox file (e.g. the plugin-skill manifest) is therefore classified as not-found rather than a hard failure, fixing `Error loading skill content: ... Download failed`.
12
+
13
+ ## 0.2.10
14
+
15
+ ### Patch Changes
16
+
17
+ - 26fecff: task refactor
18
+ - 910bd08: enhance tasks
19
+
3
20
  ## 0.2.9
4
21
 
5
22
  ### Patch Changes
package/README.md CHANGED
@@ -193,10 +193,10 @@ lattice-opensandbox-gateway
193
193
 
194
194
  ## Connecting from Core
195
195
 
196
- Set the same `MICROSANDBOX_SERVICE_URL` used for microsandbox-service:
196
+ Set the same `MICROSANDBOX_SERVICE_BASE_URL` used for microsandbox-service:
197
197
 
198
198
  ```bash
199
- MICROSANDBOX_SERVICE_URL=http://localhost:4002
199
+ MICROSANDBOX_SERVICE_BASE_URL=http://localhost:4002
200
200
  ```
201
201
 
202
202
  The `MicrosandboxRemoteProvider` → `MicrosandboxServiceClient` → HTTP → gateway chain works identically.
@@ -254,7 +254,7 @@ Deploys the full sandbox stack (`opensandbox-server` + `opensandbox-gateway`) wi
254
254
 
255
255
  ```
256
256
  Core (agent) Docker host
257
- │ MICROSANDBOX_SERVICE_URL │
257
+ │ MICROSANDBOX_SERVICE_BASE_URL │
258
258
  ▼ │
259
259
  opensandbox-gateway ──HTTP──▶ opensandbox-server ──docker.sock──▶ sandbox containers
260
260
  (Node Fastify, :4002) (Python FastAPI, :8080)
@@ -299,7 +299,7 @@ curl -H "Authorization: Bearer $SANDBOX_API_KEY" http://localhost:4002/api/sandb
299
299
  On the agent process, set:
300
300
 
301
301
  ```bash
302
- MICROSANDBOX_SERVICE_URL=http://localhost:4002
302
+ MICROSANDBOX_SERVICE_BASE_URL=http://localhost:4002
303
303
  MICROSANDBOX_API_KEY=<same as SANDBOX_API_KEY>
304
304
  MICROSANDBOX_IMAGE=kioko12520/sandbox:0.1.0 # optional, dynamic sandbox image
305
305
  ```
@@ -69,7 +69,7 @@ curl -X POST "http://localhost:4002/api/files/read" \
69
69
  ## Connect the agent (Core)
70
70
 
71
71
  ```bash
72
- MICROSANDBOX_SERVICE_URL=http://localhost:4002
72
+ MICROSANDBOX_SERVICE_BASE_URL=http://localhost:4002
73
73
  MICROSANDBOX_API_KEY=<same as SANDBOX_API_KEY>
74
74
  MICROSANDBOX_IMAGE=kioko12520/sandbox:0.1.0
75
75
  ```
package/deploy/README.md CHANGED
@@ -14,7 +14,7 @@ Self-contained Docker Compose deployment for the Axiom Lattice OpenSandbox sandb
14
14
 
15
15
  ```
16
16
  Core (agent) Docker host
17
- │ MICROSANDBOX_SERVICE_URL │
17
+ │ MICROSANDBOX_SERVICE_BASE_URL │
18
18
  ▼ ▼
19
19
  opensandbox-gateway ──HTTP──▶ opensandbox-server ──docker.sock──▶ sandbox containers
20
20
  (Node Fastify, :4002) (Python FastAPI, :8080)
@@ -40,7 +40,7 @@ curl http://localhost:4002/health # gateway
40
40
  Then on the agent process:
41
41
 
42
42
  ```bash
43
- MICROSANDBOX_SERVICE_URL=http://localhost:4002
43
+ MICROSANDBOX_SERVICE_BASE_URL=http://localhost:4002
44
44
  MICROSANDBOX_API_KEY=<same as SANDBOX_API_KEY>
45
45
  MICROSANDBOX_IMAGE=kioko12520/sandbox:0.1.0 # optional; dynamic business sandbox image
46
46
  ```
@@ -1,7 +1,7 @@
1
1
  // src/services/OpenSandboxRuntimeService.ts
2
2
  import { ConnectionConfig, Sandbox } from "@alibaba-group/opensandbox";
3
3
  import { SandboxManager } from "@alibaba-group/opensandbox";
4
- import { SandboxException } from "@alibaba-group/opensandbox";
4
+ import { SandboxApiException, SandboxException } from "@alibaba-group/opensandbox";
5
5
 
6
6
  // src/lib/errors.ts
7
7
  var HttpError = class extends Error {
@@ -57,6 +57,37 @@ function getEnvNumber(key, fallback) {
57
57
  const v = process.env[key];
58
58
  return v ? Number(v) : fallback;
59
59
  }
60
+ function isHttpStatus(value) {
61
+ return typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599;
62
+ }
63
+ function mapSandboxErrorCodeToStatus(code) {
64
+ switch (code) {
65
+ case "NOT_FOUND":
66
+ case "FILE_NOT_FOUND":
67
+ return 404;
68
+ case "FORBIDDEN":
69
+ return 403;
70
+ case "CONFLICT":
71
+ return 409;
72
+ case "INVALID_REQUEST":
73
+ case "INVALID_ARGUMENT":
74
+ return 400;
75
+ default:
76
+ return 500;
77
+ }
78
+ }
79
+ function parseUpstreamError(rawBody) {
80
+ if (typeof rawBody !== "string" || rawBody.length === 0) return {};
81
+ try {
82
+ const parsed = JSON.parse(rawBody);
83
+ return {
84
+ code: typeof parsed.code === "string" ? parsed.code : void 0,
85
+ message: typeof parsed.message === "string" ? parsed.message : void 0
86
+ };
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
60
91
  var OpenSandboxRuntimeService = class {
61
92
  constructor() {
62
93
  this.cache = /* @__PURE__ */ new Map();
@@ -166,7 +197,10 @@ var OpenSandboxRuntimeService = class {
166
197
  const sandbox = await Sandbox.create({
167
198
  connectionConfig: this.connectionConfig,
168
199
  image: input.image ?? this.defaultImage,
169
- timeoutSeconds: this.defaultTimeout,
200
+ // `null` means no expiry; `undefined` falls back to the service default.
201
+ // Parity with microsandbox: lifecycle is governed by the server TTL here
202
+ // (there is no local idle sweeper), so `null` already means "never expire".
203
+ timeoutSeconds: input.timeoutSeconds === null ? null : input.timeoutSeconds ?? this.defaultTimeout,
170
204
  readyTimeoutSeconds: 1800,
171
205
  resource: {
172
206
  cpu: String(input.cpus ?? this.defaultCpus),
@@ -175,7 +209,7 @@ var OpenSandboxRuntimeService = class {
175
209
  env: input.env ?? {},
176
210
  metadata: { [NAME_METADATA_KEY]: name },
177
211
  volumes: this.mapVolumes(input.volumes),
178
- entrypoint: ["tail", "-f", "/dev/null"]
212
+ entrypoint: input.entrypoint ?? ["tail", "-f", "/dev/null"]
179
213
  });
180
214
  const info = await sandbox.getInfo();
181
215
  const entry = {
@@ -190,10 +224,12 @@ var OpenSandboxRuntimeService = class {
190
224
  wrapError(err, code, message) {
191
225
  if (err instanceof HttpError) throw err;
192
226
  if (err instanceof SandboxException) {
227
+ const upstream = err instanceof SandboxApiException ? parseUpstreamError(err.rawBody) : {};
228
+ const status = err instanceof SandboxApiException && isHttpStatus(err.statusCode) ? err.statusCode : mapSandboxErrorCodeToStatus(upstream.code ?? err.error.code);
193
229
  throw new HttpError(
194
- err.error.code === "NOT_FOUND" ? 404 : err.error.code === "FORBIDDEN" ? 403 : err.error.code === "CONFLICT" ? 409 : err.error.code === "INVALID_REQUEST" ? 400 : 500,
195
- err.error.code,
196
- err.error.message ?? message
230
+ status,
231
+ upstream.code ?? err.error.code,
232
+ upstream.message ?? err.error.message ?? message
197
233
  );
198
234
  }
199
235
  throw new HttpError(500, code, message);
@@ -268,6 +304,64 @@ var OpenSandboxRuntimeService = class {
268
304
  }
269
305
  return { name, status: "unknown" };
270
306
  }
307
+ /**
308
+ * Resolve the relative proxy path for a guest port.
309
+ *
310
+ * The returned path is content-addressed to this service (never the sandbox
311
+ * host) so callers compose `${MICROSANDBOX_SERVICE_BASE_URL}${endpoint}` exactly
312
+ * like the microsandbox-service contract. The sandbox must exist, but the
313
+ * guest port is resolved on demand by {@link resolveProxyTarget}.
314
+ *
315
+ * @param name - Sandbox name
316
+ * @param port - Guest port
317
+ * @returns The relative proxy path
318
+ * @throws {HttpError} 404 `SANDBOX_NOT_FOUND` when the sandbox does not exist
319
+ */
320
+ async getEndpoint(name, port) {
321
+ await this.resolveEntry(name);
322
+ return { endpoint: `/api/sandboxes/${encodeURIComponent(name)}/proxy/${port}` };
323
+ }
324
+ /**
325
+ * Resolve the absolute upstream URL the sandbox proxy dials for a guest port.
326
+ *
327
+ * The SDK endpoint is scheme-less (e.g. `localhost:44772`); a missing scheme
328
+ * is prefixed with `http://`, while an already-absolute endpoint is preserved.
329
+ * Endpoint-specific headers returned by the SDK are surfaced so the proxy can
330
+ * forward them upstream.
331
+ *
332
+ * @param name - Sandbox name
333
+ * @param port - Guest port
334
+ * @returns The absolute upstream target and any required headers
335
+ * @throws {HttpError} 404 `SANDBOX_NOT_FOUND` when the sandbox does not exist
336
+ */
337
+ async resolveProxyTarget(name, port) {
338
+ const entry = await this.resolveEntry(name);
339
+ try {
340
+ const endpoint = await entry.sandbox.getEndpoint(port);
341
+ const target = endpoint.endpoint.includes("://") ? endpoint.endpoint : `http://${endpoint.endpoint}`;
342
+ return { target, headers: endpoint.headers };
343
+ } catch (err) {
344
+ throw this.wrapError(
345
+ err,
346
+ "ENDPOINT_NOT_FOUND",
347
+ `Endpoint for port ${port} not found on '${name}'`
348
+ );
349
+ }
350
+ }
351
+ /**
352
+ * Resolve a cached sandbox entry, recovering it from server metadata when the
353
+ * local cache is cold.
354
+ *
355
+ * @param name - Sandbox name
356
+ * @throws {HttpError} 404 `SANDBOX_NOT_FOUND` when no sandbox matches the name
357
+ */
358
+ async resolveEntry(name) {
359
+ const entry = this.cache.get(name) ?? await this.findByNameViaMetadata(name);
360
+ if (!entry) {
361
+ throw new HttpError(404, "SANDBOX_NOT_FOUND", `Sandbox '${name}' not found`);
362
+ }
363
+ return entry;
364
+ }
271
365
  resolvePath(path3) {
272
366
  if (path3 === "~" || path3 === "~/") return "/";
273
367
  if (path3.startsWith("~/")) return `/${path3.slice(2)}`;
@@ -575,6 +669,9 @@ function registerImageRoutes(app, imageService) {
575
669
  app.delete("/api/images/detail", controller.deleteImage);
576
670
  }
577
671
 
672
+ // src/routes/sandbox.ts
673
+ import { Transform } from "stream";
674
+
578
675
  // src/schemas/sandbox.ts
579
676
  import z2 from "zod";
580
677
  var bindMountSchema = z2.object({
@@ -601,7 +698,17 @@ var ensureSandboxSchema = z2.object({
601
698
  cpus: z2.number().int().positive().optional(),
602
699
  memoryMib: z2.number().int().positive().optional(),
603
700
  env: z2.record(z2.string()).optional(),
604
- volumes: z2.record(volumeSchema).optional()
701
+ volumes: z2.record(volumeSchema).optional(),
702
+ /** Main process command; replaces the default `tail -f /dev/null`. */
703
+ entrypoint: z2.array(z2.string().min(1)).nonempty().optional(),
704
+ /** Absolute lifetime in seconds; `null` means no expiry. */
705
+ timeoutSeconds: z2.number().int().positive().nullable().optional(),
706
+ /** Guest ports to publish (resolved on demand by the server proxy). */
707
+ exposePorts: z2.array(z2.number().int().positive().max(65535)).optional()
708
+ });
709
+ var sandboxEndpointParamsSchema = z2.object({
710
+ name: z2.string().min(1),
711
+ port: z2.coerce.number().int().positive().max(65535)
605
712
  });
606
713
  var sandboxNameParamsSchema = z2.object({
607
714
  name: z2.string().min(1)
@@ -652,6 +759,90 @@ var sandboxLogsSchema = z2.object({
652
759
  sources: z2.array(z2.string()).optional()
653
760
  });
654
761
 
762
+ // src/lib/proxy.ts
763
+ import http from "http";
764
+ var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
765
+ "connection",
766
+ "keep-alive",
767
+ "proxy-authenticate",
768
+ "proxy-authorization",
769
+ "te",
770
+ "trailer",
771
+ "transfer-encoding",
772
+ "upgrade"
773
+ ]);
774
+ function isHopByHop(header) {
775
+ return HOP_BY_HOP_HEADERS.has(header) || header.startsWith("proxy-");
776
+ }
777
+ function buildProxyHeaders(incoming, requestPath) {
778
+ void requestPath;
779
+ const headers = {};
780
+ for (const [rawKey, rawValue] of Object.entries(incoming)) {
781
+ if (rawValue === void 0) continue;
782
+ const key = rawKey.toLowerCase();
783
+ if (isHopByHop(key)) continue;
784
+ if (key === "host" || key === "content-length") continue;
785
+ if (key === "authorization") continue;
786
+ headers[key] = Array.isArray(rawValue) ? rawValue.join(", ") : rawValue;
787
+ }
788
+ return headers;
789
+ }
790
+ function parseTarget(target) {
791
+ const url = new URL(target.includes("://") ? target : `http://${target}`);
792
+ const basePath = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
793
+ return {
794
+ hostname: url.hostname,
795
+ port: url.port.length > 0 ? Number(url.port) : url.protocol === "https:" ? 443 : 80,
796
+ basePath
797
+ };
798
+ }
799
+ function toProxyError(error) {
800
+ if (error instanceof HttpError) return error;
801
+ const message = error instanceof Error ? error.message : String(error);
802
+ return new HttpError(502, "PROXY_ERROR", message);
803
+ }
804
+ function filterResponseHeaders(headers) {
805
+ const filtered = {};
806
+ for (const [rawKey, rawValue] of Object.entries(headers)) {
807
+ if (rawValue === void 0) continue;
808
+ const key = rawKey.toLowerCase();
809
+ if (isHopByHop(key)) continue;
810
+ filtered[key] = rawValue;
811
+ }
812
+ return filtered;
813
+ }
814
+ function pipeRequestUpstream(options) {
815
+ const { target, method, path: path3, headers, body, reply } = options;
816
+ const { hostname, port, basePath } = parseTarget(target);
817
+ return new Promise((resolve, reject) => {
818
+ const upstream = http.request(
819
+ { hostname, port, method, path: `${basePath}${path3}`, headers },
820
+ (upstreamResponse) => {
821
+ try {
822
+ reply.code(upstreamResponse.statusCode ?? 502);
823
+ reply.headers(filterResponseHeaders(upstreamResponse.headers));
824
+ reply.send(upstreamResponse);
825
+ resolve();
826
+ } catch (error) {
827
+ upstreamResponse.destroy();
828
+ reject(toProxyError(error));
829
+ }
830
+ }
831
+ );
832
+ upstream.on("error", (error) => reject(toProxyError(error)));
833
+ body.on("error", (error) => {
834
+ upstream.destroy();
835
+ reject(toProxyError(error));
836
+ });
837
+ const source = body;
838
+ if (source.readableEnded === true || source.destroyed === true) {
839
+ upstream.end();
840
+ } else {
841
+ body.pipe(upstream);
842
+ }
843
+ });
844
+ }
845
+
655
846
  // src/controllers/sandbox.ts
656
847
  function createSandboxController(runtimeService) {
657
848
  return {
@@ -687,6 +878,48 @@ function createSandboxController(runtimeService) {
687
878
  getStatus: async (request, reply) => {
688
879
  return reply.send(ok(await runtimeService.getStatus(request.params.name)));
689
880
  },
881
+ getEndpoint: async (request, reply) => {
882
+ const parsed = sandboxEndpointParamsSchema.safeParse(request.params ?? {});
883
+ if (!parsed.success) {
884
+ throw new HttpError(400, "INVALID_REQUEST", parsed.error.message);
885
+ }
886
+ return reply.send(ok(await runtimeService.getEndpoint(parsed.data.name, parsed.data.port)));
887
+ },
888
+ /**
889
+ * Stream a request through to a sandbox's guest port.
890
+ *
891
+ * The caller authenticates to this service with its `Authorization` header;
892
+ * {@link buildProxyHeaders} strips that service key so it is not forwarded
893
+ * to the (unauthenticated) sandbox host. Endpoint headers returned by the
894
+ * SDK are merged on top so routing/credential headers reach the sandbox.
895
+ * The live request stream (body-limited by the scoped parser, or the raw
896
+ * request for bodyless methods) is piped upstream without buffering.
897
+ *
898
+ * @throws {HttpError} 404 `SANDBOX_NOT_FOUND` when the sandbox does not exist,
899
+ * or 413 `PAYLOAD_TOO_LARGE` when the streaming body exceeds `bodyLimit`.
900
+ */
901
+ proxyRequest: async (request, reply) => {
902
+ const { name, port } = request.params;
903
+ const rest = request.params["*"] ?? "";
904
+ const resolved = await runtimeService.resolveProxyTarget(name, Number(port));
905
+ const path3 = `/${rest}`;
906
+ const headers = {
907
+ ...buildProxyHeaders(
908
+ request.headers,
909
+ path3
910
+ ),
911
+ ...resolved.headers ?? {}
912
+ };
913
+ const body = request.body ?? request.raw;
914
+ await pipeRequestUpstream({
915
+ target: resolved.target,
916
+ method: request.method,
917
+ path: path3,
918
+ headers,
919
+ body,
920
+ reply
921
+ });
922
+ },
690
923
  getSandboxLogs: async (request, reply) => {
691
924
  const body = sandboxLogsSchema.parse(request.body ?? {});
692
925
  return reply.send(ok(await runtimeService.getSandboxLogs(request.params.name, body)));
@@ -750,6 +983,7 @@ function registerSandboxRoutes(app, runtimeService) {
750
983
  app.post("/api/sandboxes/:name/kill", controller.killSandbox);
751
984
  app.delete("/api/sandboxes/:name", controller.deleteSandbox);
752
985
  app.get("/api/sandboxes/:name/status", controller.getStatus);
986
+ app.get("/api/sandboxes/:name/endpoints/:port", controller.getEndpoint);
753
987
  app.post("/api/sandboxes/:name/logs", controller.getSandboxLogs);
754
988
  app.post("/api/files/read", controller.readFile);
755
989
  app.post("/api/files/write", controller.writeFile);
@@ -760,6 +994,26 @@ function registerSandboxRoutes(app, runtimeService) {
760
994
  app.post("/api/files/upload", controller.uploadFile);
761
995
  app.get("/api/files/download", controller.downloadFile);
762
996
  app.post("/api/shell/exec", controller.execCommand);
997
+ app.register(async (instance) => {
998
+ const bodyLimit = instance.initialConfig.bodyLimit ?? 100 * 1024 * 1024;
999
+ instance.removeAllContentTypeParsers();
1000
+ instance.addContentTypeParser("*", (_request, payload, done) => {
1001
+ let received = 0;
1002
+ const limiter = new Transform({
1003
+ transform(chunk, _encoding, callback) {
1004
+ received += chunk.length;
1005
+ if (received > bodyLimit) {
1006
+ callback(new HttpError(413, "PAYLOAD_TOO_LARGE", "Request body is too large"));
1007
+ return;
1008
+ }
1009
+ callback(null, chunk);
1010
+ }
1011
+ });
1012
+ payload.pipe(limiter);
1013
+ done(null, limiter);
1014
+ });
1015
+ instance.all("/api/sandboxes/:name/proxy/:port/*", controller.proxyRequest);
1016
+ });
763
1017
  }
764
1018
 
765
1019
  // src/controllers/volume-fs.ts
@@ -986,9 +1240,9 @@ async function configureSwagger(app) {
986
1240
 
987
1241
  // src/app.ts
988
1242
  async function buildApp({
1243
+ runtimeService = new OpenSandboxRuntimeService(),
989
1244
  apiKey
990
1245
  } = {}) {
991
- const runtimeService = new OpenSandboxRuntimeService();
992
1246
  const imageService = new ImageService();
993
1247
  const app = fastify({
994
1248
  logger: false,
@@ -1123,4 +1377,4 @@ export {
1123
1377
  buildApp,
1124
1378
  startServer
1125
1379
  };
1126
- //# sourceMappingURL=chunk-M6FKTT7J.mjs.map
1380
+ //# sourceMappingURL=chunk-QC7CI5UY.mjs.map