@lizard-build/sdk 0.1.48 → 0.1.51

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/index.js CHANGED
@@ -548,7 +548,9 @@ var Process = class {
548
548
  envs: opts?.envs,
549
549
  user: opts?.user,
550
550
  workdir: opts?.workdir,
551
- timeoutMs: opts?.timeoutMs
551
+ timeoutMs: opts?.timeoutMs,
552
+ // The pid event is opt-in: older CLIs print any unknown data line as output.
553
+ pidEvent: opts?.onPid ? true : void 0
552
554
  }),
553
555
  signal: AbortSignal.timeout(opts?.timeoutMs ?? 6e4)
554
556
  });
@@ -556,11 +558,6 @@ var Process = class {
556
558
  if (!streaming) return res.json();
557
559
  return this.consumeStream(res, opts);
558
560
  }
559
- /**
560
- * Read an SSE exec stream, handing each line to the caller as it arrives while
561
- * still accumulating the full result. Events are `{stream, line}` for output and
562
- * `{exitCode}` at the end.
563
- */
564
561
  /**
565
562
  * List the processes this sandbox is currently running.
566
563
  *
@@ -584,8 +581,8 @@ var Process = class {
584
581
  /**
585
582
  * Signal a running process. Defaults to `SIGTERM`.
586
583
  *
587
- * The signal goes to the process group, so a shell's children die with it —
588
- * killing `sh -c 'sleep 100'` otherwise leaves the sleep running.
584
+ * The signal goes to the whole process tree, so a shell's children die with
585
+ * it — killing `sh -c 'sleep 100'` otherwise leaves the sleep running.
589
586
  *
590
587
  * The pid comes from {@link list}, or from the `pid` event at the start of a
591
588
  * streaming `exec`.
@@ -604,6 +601,11 @@ var Process = class {
604
601
  });
605
602
  if (!res.ok) await handleApiError(res);
606
603
  }
604
+ /**
605
+ * Read an SSE exec stream, handing each line to the caller as it arrives while
606
+ * still accumulating the full result. Events are `{pid}` first (when asked for
607
+ * with `pidEvent`), `{stream, line}` for output and `{exitCode}` at the end.
608
+ */
607
609
  async consumeStream(res, opts) {
608
610
  const reader = res.body?.getReader();
609
611
  if (!reader) return { stdout: "", stderr: "", exitCode: 0 };
@@ -654,33 +656,37 @@ var Fs = class {
654
656
  /**
655
657
  * Write a file into the sandbox filesystem.
656
658
  *
657
- * Creates parent directories automatically if they don't exist.
658
- * Byte input must contain valid UTF-8; arbitrary binary uploads are unsupported.
659
+ * Creates parent directories automatically if they don't exist. A string is
660
+ * written as UTF-8; bytes (`Uint8Array`, `Buffer` or `ArrayBuffer`) are written
661
+ * exactly as given, so binary files round-trip with {@link readBytes}.
662
+ *
663
+ * With `opts.user` the file is written as (and owned by) that user.
659
664
  *
660
665
  * @example
661
666
  * ```ts
662
667
  * await sandbox.fs.write('/app/index.js', 'console.log("hello")')
663
668
  * ```
664
669
  *
665
- * @example Write UTF-8 bytes:
670
+ * @example Write binary data:
666
671
  * ```ts
667
- * await sandbox.fs.write('/app/notes.txt', new TextEncoder().encode('hello'))
672
+ * await sandbox.fs.write('/app/logo.png', await readFile('logo.png'))
668
673
  * ```
669
674
  */
670
675
  async write(path, data, opts) {
671
- const content = typeof data === "string" ? data : new TextDecoder("utf-8", { fatal: true }).decode(data);
676
+ const body = typeof data === "string" ? { path, content: data, user: opts?.user } : { path, content: bytesToBase64(data instanceof ArrayBuffer ? new Uint8Array(data) : data), encoding: "base64", user: opts?.user };
672
677
  const res = await fetch(`${this.config.apiUrl}/api/sandboxes/${this.sandboxId}/files`, {
673
678
  method: "POST",
674
679
  headers: this.config.headers,
675
- body: JSON.stringify({ path, content, user: opts?.user })
680
+ body: JSON.stringify(body)
676
681
  });
677
682
  if (!res.ok) await handleApiError(res);
678
683
  }
679
684
  /**
680
685
  * Read a file from the sandbox filesystem.
681
686
  *
682
- * @returns The file contents as a UTF-8 string.
687
+ * @returns The file contents as a UTF-8 string. Use {@link readBytes} for binary files.
683
688
  *
689
+ * Throws `NotFoundError` if the path does not exist.
684
690
  * @example
685
691
  * ```ts
686
692
  * const content = await sandbox.fs.read('/app/index.js')
@@ -748,9 +754,7 @@ var Fs = class {
748
754
  * console.log(info.size, info.type)
749
755
  * ```
750
756
  *
751
- * Throws `NotFoundError` if the path does not exist. Sandboxes created before
752
- * this shipped run a guest agent without it and throw `LizardError` (501) —
753
- * recreate the sandbox to use it.
757
+ * Throws `NotFoundError` if the path does not exist.
754
758
  */
755
759
  async stat(path, opts) {
756
760
  const url = new URL(`${this.config.apiUrl}/api/sandboxes/${this.sandboxId}/files/stat`);
@@ -767,9 +771,6 @@ var Fs = class {
767
771
  * ```ts
768
772
  * await sandbox.fs.move('/tmp/build.log', '/app/logs/build.log')
769
773
  * ```
770
- *
771
- * Sandboxes created before this shipped run a guest agent without it and throw
772
- * `LizardError` (501) — recreate the sandbox to use it.
773
774
  */
774
775
  async move(from, to) {
775
776
  const res = await fetch(`${this.config.apiUrl}/api/sandboxes/${this.sandboxId}/files/move`, {
@@ -798,8 +799,7 @@ var Fs = class {
798
799
  * }, 1000)
799
800
  * ```
800
801
  *
801
- * Sandboxes created before this shipped run a guest agent without it and throw
802
- * `LizardError` (501) — recreate the sandbox to use it.
802
+ * Not available on Firecracker sandboxes yet: the call throws `LizardError` (501).
803
803
  */
804
804
  async watch(path, opts) {
805
805
  const res = await fetch(`${this.config.apiUrl}/api/sandboxes/${this.sandboxId}/files/watch`, {
@@ -845,16 +845,24 @@ var Watcher = class {
845
845
  if (!res.ok) await handleApiError(res);
846
846
  }
847
847
  };
848
+ function bytesToBase64(bytes) {
849
+ if (typeof Buffer !== "undefined") return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
850
+ let s = "";
851
+ for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
852
+ return btoa(s);
853
+ }
848
854
 
849
855
  // src/sandbox/desktop.ts
850
856
  var BUTTONS = { left: "1", middle: "2", right: "3" };
851
857
  var SCROLL_BUTTONS = { up: "4", down: "5", left: "6", right: "7" };
858
+ var SCREENSHOT_PATH = /^\/tmp\/lizard-screenshot-[A-Za-z0-9_-]+\.png$/;
852
859
  var TYPE_DELAY_MS = 12;
853
860
  var Desktop = class {
854
- constructor(sandboxId, config, process2) {
861
+ constructor(sandboxId, config, process2, fs) {
855
862
  this.sandboxId = sandboxId;
856
863
  this.config = config;
857
864
  this.process = process2;
865
+ this.fs = fs;
858
866
  }
859
867
  get client() {
860
868
  return new PlatformClient(this.config);
@@ -891,11 +899,17 @@ var Desktop = class {
891
899
  * ```
892
900
  */
893
901
  async screenshot() {
894
- const r = await this.run(["lizard-desktop", "screenshot"]);
895
- if (r.truncated) {
896
- throw new LizardError("Screenshot is larger than the exec output limit; start the desktop at a smaller size");
902
+ const r = await this.run(["lizard-desktop", "screenshot", "--path"]);
903
+ const path = r.stdout.split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? "";
904
+ if (!SCREENSHOT_PATH.test(path)) {
905
+ throw new LizardError(`Screenshot returned an unexpected path: ${path.slice(0, 200)}`);
906
+ }
907
+ try {
908
+ return await this.fs.readBytes(path);
909
+ } finally {
910
+ await this.fs.remove(path).catch(() => {
911
+ });
897
912
  }
898
- return decodeBase64(r.stdout.trim());
899
913
  }
900
914
  /** Click at (x, y). */
901
915
  async click(x, y, opts = {}) {
@@ -990,12 +1004,6 @@ function coord(n) {
990
1004
  if (!Number.isFinite(n) || n < 0) throw new LizardError(`Invalid screen coordinate: ${n}`);
991
1005
  return String(Math.round(n));
992
1006
  }
993
- function decodeBase64(b64) {
994
- if (!b64 || !/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) {
995
- throw new LizardError("Screenshot returned no image");
996
- }
997
- return new Uint8Array(Buffer.from(b64, "base64"));
998
- }
999
1007
 
1000
1008
  // src/sandbox/client.ts
1001
1009
  var SandboxClient = class {
@@ -1098,7 +1106,7 @@ var Sandbox = class extends SandboxClient {
1098
1106
  this.connectionConfig = new ConnectionConfig(opts);
1099
1107
  this.fs = new Fs(this.sandboxId, this.connectionConfig);
1100
1108
  this.process = new Process(this.sandboxId, this.connectionConfig);
1101
- this.desktop = new Desktop(this.sandboxId, this.connectionConfig, this.process);
1109
+ this.desktop = new Desktop(this.sandboxId, this.connectionConfig, this.process, this.fs);
1102
1110
  }
1103
1111
  static async create(templateOrOpts, opts) {
1104
1112
  const template = typeof templateOrOpts === "string" ? templateOrOpts : templateOrOpts?.template ?? this.defaultTemplate;
@@ -1143,11 +1151,17 @@ var Sandbox = class extends SandboxClient {
1143
1151
  async kill(opts) {
1144
1152
  return SandboxClient.killSandbox(this.sandboxId, this.resolveOpts(opts));
1145
1153
  }
1146
- /** Queue a CRIU checkpoint and stop compute. Use waitForStatus('paused') before resuming. */
1154
+ /**
1155
+ * Pause the sandbox: its memory, running processes and files are kept and
1156
+ * compute stops. Wait with `waitForStatus('paused')` before resuming.
1157
+ */
1147
1158
  async pause(opts) {
1148
1159
  return SandboxClient.pauseSandbox(this.sandboxId, this.resolveOpts(opts));
1149
1160
  }
1150
- /** Queue restoration of saved memory and files. Use waitForStatus('running') before executing. */
1161
+ /**
1162
+ * Resume a paused sandbox exactly where it stopped, processes included. Wait
1163
+ * with `waitForStatus('running')` before running commands.
1164
+ */
1151
1165
  async resume(opts) {
1152
1166
  return SandboxClient.resumeSandbox(this.sandboxId, this.resolveOpts(opts));
1153
1167
  }
@@ -1180,33 +1194,82 @@ var Sandbox = class extends SandboxClient {
1180
1194
  return SandboxClient.setTimeoutSandbox(this.sandboxId, timeoutMs, this.resolveOpts(opts));
1181
1195
  }
1182
1196
  /**
1183
- * Register a public HTTPS route and return its hostname without a scheme.
1197
+ * Publish a port on a public HTTPS hostname and return the hostname without a scheme.
1184
1198
  *
1185
- * Useful for accessing HTTP servers started inside the sandbox from your
1186
- * agent or tests without additional tunneling.
1199
+ * The port is private: requests need {@link accessToken} (set by this call) as
1200
+ * the `X-Lizard-Access-Token` header, or `?lizard_token=<token>` in a browser.
1201
+ * {@link exposePort} returns the token and a ready-made browser URL together.
1187
1202
  *
1188
1203
  * @example
1189
1204
  * ```ts
1190
1205
  * // Start an HTTP server on port 3000 before exposing it.
1191
1206
  * const hostname = await sandbox.getHost(3000)
1192
- * console.log(`https://${hostname}`)
1207
+ * const res = await fetch(`https://${hostname}/`, {
1208
+ * headers: { 'X-Lizard-Access-Token': sandbox.accessToken! },
1209
+ * })
1193
1210
  * ```
1194
1211
  */
1195
1212
  async getHost(port, opts) {
1196
- const { hostname } = await SandboxClient.exposeSandboxPort(this.sandboxId, port, this.resolveOpts(opts));
1197
- return hostname;
1213
+ return (await this.exposePort(port, opts)).hostname;
1214
+ }
1215
+ /**
1216
+ * Publish a port and return everything needed to reach it: the hostname, a
1217
+ * browser `url` that carries the token, and the `accessToken` for API clients.
1218
+ *
1219
+ * @example
1220
+ * ```ts
1221
+ * const { url, hostname, accessToken } = await sandbox.exposePort(3000)
1222
+ * console.log(url) // open in a browser
1223
+ * await fetch(`https://${hostname}/api`, { headers: { 'X-Lizard-Access-Token': accessToken } })
1224
+ * ```
1225
+ */
1226
+ async exposePort(port, opts) {
1227
+ const exposed = await SandboxClient.exposeSandboxPort(this.sandboxId, port, this.resolveOpts(opts));
1228
+ if (exposed.accessToken) this.accessToken = exposed.accessToken;
1229
+ return exposed;
1198
1230
  }
1199
- /** Fork is unsupported on Kubernetes; the backend returns HTTP 501. */
1200
- fork(opts = {}) {
1201
- return new PlatformClient(this.resolveOpts()).post(`/api/sandboxes/${this.sandboxId}/fork`, { count: opts.count ?? 1, timeoutMs: opts.timeoutMs ?? 0 });
1231
+ /**
1232
+ * Clone this running sandbox `count` times (1-10). Each fork is a copy of the
1233
+ * microVM at this instant -- memory, running processes and files -- and is
1234
+ * billed like its source. The source keeps running.
1235
+ *
1236
+ * Returns one entry per requested fork, in order: `{ sandbox, info }`, or
1237
+ * `{ error }` for a copy that could not be made (e.g. the account's sandbox
1238
+ * limit). A sandbox with a volume attached cannot be forked (`ConflictError`, 409).
1239
+ *
1240
+ * @param opts.timeoutMs Lifetime of each fork; defaults to the source's timeout.
1241
+ *
1242
+ * @example
1243
+ * ```ts
1244
+ * const forks = await sandbox.fork({ count: 2 })
1245
+ * for (const f of forks) if (f.sandbox) await f.sandbox.process.exec('echo hi')
1246
+ * ```
1247
+ */
1248
+ async fork(opts = {}) {
1249
+ const result = await new PlatformClient(this.resolveOpts()).post(
1250
+ `/api/sandboxes/${this.sandboxId}/fork`,
1251
+ { count: opts.count ?? 1, timeoutMs: opts.timeoutMs ?? 0 }
1252
+ );
1253
+ const Ctor = this.constructor;
1254
+ return (Array.isArray(result) ? result : []).map((entry) => {
1255
+ const id = entry.sandbox?.sandboxId ?? entry.sandbox?.id;
1256
+ if (!entry.sandbox || !id) return { error: entry.error ?? "fork failed" };
1257
+ return { sandbox: new Ctor({ ...this.resolveOpts(), sandboxId: id }), info: { ...entry.sandbox, sandboxId: id } };
1258
+ });
1202
1259
  }
1203
- /** Capture memory and workspace files. Disconnect active clients before capturing. */
1260
+ /**
1261
+ * Save this sandbox -- memory, running processes and files -- as a private
1262
+ * snapshot. A Firecracker snapshot is `ready` as soon as this returns (about 2 s);
1263
+ * start copies of it with {@link Sandbox.restore} (about 0.4 s each). The source
1264
+ * keeps running. `poolSize` applies to container sandboxes only.
1265
+ */
1204
1266
  snapshot(name = `Snapshot ${this.sandboxId}`, opts = {}) {
1205
1267
  return new PlatformClient(this.resolveOpts()).post(`/api/sandboxes/${this.sandboxId}/snapshot`, { name, poolSize: opts.poolSize ?? 5 });
1206
1268
  }
1207
1269
  unexpose(port) {
1208
1270
  return new PlatformClient(this.resolveOpts()).delete(`/api/sandboxes/${this.sandboxId}/expose/${port}`);
1209
1271
  }
1272
+ /** Stream the sandbox's logs. Not available on Firecracker sandboxes yet (`LizardError`, 501). */
1210
1273
  logs(opts = {}) {
1211
1274
  return new PlatformClient(this.resolveOpts()).events(query(`/api/sandboxes/${this.sandboxId}/logs`, { tail: opts.tail }), { signal: opts.signal });
1212
1275
  }
@@ -1216,7 +1279,7 @@ var Sandbox = class extends SandboxClient {
1216
1279
  static getSnapshot(snapshotId, opts) {
1217
1280
  return new PlatformClient(opts ?? {}).get(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}`);
1218
1281
  }
1219
- /** Start a sandbox from an available warm copy in the snapshot's project and region. */
1282
+ /** Start a new sandbox from a snapshot, in the snapshot's project and region. */
1220
1283
  static async restore(snapshotId, opts) {
1221
1284
  const client = new PlatformClient(opts ?? {});
1222
1285
  const snapshot = await this.getSnapshot(snapshotId, opts);
@@ -1231,7 +1294,7 @@ var Sandbox = class extends SandboxClient {
1231
1294
  static setSnapshotWarmPool(snapshotId, poolSize, opts) {
1232
1295
  return new PlatformClient(opts ?? {}).patch(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}`, { poolSize });
1233
1296
  }
1234
- /** Release idle warm copies; retain saved state and existing claimed sandboxes. */
1297
+ /** Release idle warm copies (container snapshots); retain saved state and existing claimed sandboxes. */
1235
1298
  static pauseSnapshot(snapshotId, opts) {
1236
1299
  return new PlatformClient(opts ?? {}).post(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}/pause`, {});
1237
1300
  }
@@ -1770,7 +1833,7 @@ var Volume = class _Volume {
1770
1833
  * "the scratch disk for this task" no longer has to store an id between runs.
1771
1834
  *
1772
1835
  * An existing volume is returned as-is — `sizeGb` applies only to a fresh create,
1773
- * so an existing volume keeps its size. Call {@link Volume.resize} to change it.
1836
+ * so an existing volume keeps its size.
1774
1837
  */
1775
1838
  static async getOrCreate(projectId, name, opts) {
1776
1839
  const config = new ConnectionConfig(opts);
@@ -1813,11 +1876,15 @@ var Volume = class _Volume {
1813
1876
  /**
1814
1877
  * Resize a volume, by name or by id, to `sizeGb`.
1815
1878
  *
1816
- * The change is in place and online: the size is a quota, so no data is copied,
1817
- * it completes in well under a second, and a sandbox that has the volume mounted
1818
- * keeps running and sees the new size immediately. Both growing and shrinking are
1819
- * allowed, but a shrink must leave at least 10% of the new size free — otherwise
1820
- * it throws `ConflictError` with `code: 'volume_too_full_to_shrink'`.
1879
+ * **Firecracker volumes cannot be resized yet**: volumes created for the default
1880
+ * Firecracker sandboxes throw `LizardError` (400) with `code: 'volume_not_resizable'`.
1881
+ * Create the volume at the size you need, or copy the data to a new, larger one.
1882
+ *
1883
+ * On a container volume the change is in place and online: the size is a quota,
1884
+ * so no data is copied and a mounted sandbox sees the new size immediately. Both
1885
+ * growing and shrinking are allowed, but a shrink must leave at least 10% of the
1886
+ * new size free — otherwise it throws `ConflictError` with
1887
+ * `code: 'volume_too_full_to_shrink'`.
1821
1888
  *
1822
1889
  * Other failures carry a `code` too: `invalid_volume_size`, `volume_not_resizable`,
1823
1890
  * `volume_resize_in_progress`, `volume_not_provisioned`,
@@ -1843,7 +1910,7 @@ var Volume = class _Volume {
1843
1910
  if (!res.ok) await handleApiError(res);
1844
1911
  return res.json();
1845
1912
  }
1846
- /** Resize this volume in place — see the static {@link Volume.resize}. */
1913
+ /** Resize this volume — see the static {@link Volume.resize} (not supported for Firecracker volumes yet). */
1847
1914
  async resize(projectId, sizeGb) {
1848
1915
  return _Volume.resize(projectId, this.volumeId, sizeGb, {
1849
1916
  apiKey: this.config.apiKey,
@@ -1966,7 +2033,7 @@ var Execution = class {
1966
2033
  this.stderr = "";
1967
2034
  /** Rich output items (images, JSON, HTML, plain values). */
1968
2035
  this.results = [];
1969
- /** Monotonically increasing counter for this context. */
2036
+ /** Monotonically increasing counter for this context (the Python kernel's `In[n]`). */
1970
2037
  this.executionCount = 0;
1971
2038
  }
1972
2039
  get success() {
@@ -1977,25 +2044,317 @@ var Execution = class {
1977
2044
  }
1978
2045
  };
1979
2046
 
2047
+ // src/code-interpreter/runner.ts
2048
+ var RUNNER_SOURCE = String.raw`# Lizard code runner: executes code for CodeSandbox.run_code / runCode inside the sandbox.
2049
+ # Python runs in a persistent Jupyter kernel per context when the template has one
2050
+ # (the interpreter template does); everything else runs as a fresh process.
2051
+ # Events are JSON lines on stdout; an event over 24000 chars is sent as "part" lines.
2052
+ import base64, json, os, re, shutil, signal, subprocess, sys, threading, time
2053
+ ROOT = "/tmp/.lizard-code"
2054
+ LANGS = {"python": "python3", "javascript": "node", "bash": "bash"}
2055
+ ALIASES = {"py": "python", "python3": "python", "js": "javascript", "node": "javascript", "sh": "bash"}
2056
+ ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
2057
+ LOCK = threading.Lock()
2058
+
2059
+
2060
+ def emit(ev):
2061
+ s = json.dumps(ev)
2062
+ with LOCK:
2063
+ if len(s) <= 24000:
2064
+ print(s, flush=True)
2065
+ return
2066
+ n = (len(s) + 23999) // 24000
2067
+ for i in range(n):
2068
+ print(json.dumps({"type": "part", "data": s[i * 24000:(i + 1) * 24000], "end": i == n - 1}), flush=True)
2069
+
2070
+
2071
+ def fail(msg):
2072
+ emit({"type": "fatal", "message": msg})
2073
+ sys.exit(3)
2074
+
2075
+
2076
+ def meta_path(ctx):
2077
+ return os.path.join(ROOT, ctx, "meta.json")
2078
+
2079
+
2080
+ def load(ctx):
2081
+ try:
2082
+ with open(meta_path(ctx)) as f:
2083
+ return json.load(f)
2084
+ except OSError:
2085
+ return None
2086
+
2087
+
2088
+ def save(m):
2089
+ os.makedirs(os.path.join(ROOT, m["id"]), exist_ok=True)
2090
+ with open(meta_path(m["id"]), "w") as f:
2091
+ json.dump(m, f)
2092
+
2093
+
2094
+ def alive(pid):
2095
+ try:
2096
+ os.kill(pid, 0)
2097
+ except OSError:
2098
+ return False
2099
+ try:
2100
+ with open("/proc/%d/stat" % pid) as f:
2101
+ return f.read().split(") ")[-1][:1] != "Z"
2102
+ except OSError:
2103
+ return False
2104
+
2105
+
2106
+ def kernel_pid(m):
2107
+ try:
2108
+ with open(os.path.join(ROOT, m["id"], "kernel.pid")) as f:
2109
+ return int(f.read().strip())
2110
+ except (OSError, ValueError):
2111
+ return 0
2112
+
2113
+
2114
+ def stop_kernel(m):
2115
+ pid = kernel_pid(m)
2116
+ if pid and alive(pid):
2117
+ try:
2118
+ os.killpg(pid, signal.SIGKILL)
2119
+ except OSError:
2120
+ pass
2121
+ for name in ("kernel.pid", "kernel.json"):
2122
+ try:
2123
+ os.remove(os.path.join(ROOT, m["id"], name))
2124
+ except OSError:
2125
+ pass
2126
+
2127
+
2128
+ def start_kernel(m):
2129
+ d = os.path.join(ROOT, m["id"])
2130
+ conn = os.path.join(d, "kernel.json")
2131
+ stop_kernel(m)
2132
+ os.makedirs(m["cwd"], exist_ok=True)
2133
+ log = open(os.path.join(d, "kernel.log"), "ab")
2134
+ p = subprocess.Popen([sys.executable, "-m", "ipykernel_launcher", "-f", conn], cwd=m["cwd"],
2135
+ stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True,
2136
+ env=dict(os.environ, PYTHONUNBUFFERED="1"))
2137
+ with open(os.path.join(d, "kernel.pid"), "w") as f:
2138
+ f.write(str(p.pid))
2139
+ end = time.time() + 30
2140
+ while time.time() < end:
2141
+ if os.path.exists(conn) and os.path.getsize(conn) > 0:
2142
+ return
2143
+ if p.poll() is not None:
2144
+ break
2145
+ time.sleep(0.05)
2146
+ with open(os.path.join(d, "kernel.log"), "rb") as f:
2147
+ tail = f.read()[-2000:].decode("utf-8", "replace")
2148
+ fail("python kernel failed to start: " + (tail or "no output"))
2149
+
2150
+
2151
+ def has_kernel():
2152
+ try:
2153
+ import jupyter_client, ipykernel # noqa: F401
2154
+ return True
2155
+ except ImportError:
2156
+ return False
2157
+
2158
+
2159
+ def client(m):
2160
+ from jupyter_client import BlockingKernelClient
2161
+ if not (kernel_pid(m) and alive(kernel_pid(m))):
2162
+ start_kernel(m)
2163
+ kc = BlockingKernelClient()
2164
+ kc.load_connection_file(os.path.join(ROOT, m["id"], "kernel.json"))
2165
+ kc.start_channels()
2166
+ kc.wait_for_ready(timeout=30)
2167
+ return kc
2168
+
2169
+
2170
+ def shell_reply(kc, msg_id, timeout):
2171
+ end = time.time() + timeout
2172
+ while time.time() < end:
2173
+ try:
2174
+ r = kc.get_shell_msg(timeout=max(0.01, end - time.time()))
2175
+ except Exception:
2176
+ return None
2177
+ if r["parent_header"].get("msg_id") == msg_id:
2178
+ return r
2179
+ return None
2180
+
2181
+
2182
+ def silent(kc, code):
2183
+ shell_reply(kc, kc.execute(code, silent=True, store_history=False), 10)
2184
+
2185
+
2186
+ BEST = ("image/png", "image/jpeg", "image/svg+xml", "text/html", "text/markdown", "application/json", "text/latex", "text/plain")
2187
+
2188
+
2189
+ def run_python(m, code, envs, timeout):
2190
+ kc = client(m)
2191
+ if envs:
2192
+ silent(kc, "import os as _lz_os\n_lz_saved = {k: _lz_os.environ.get(k) for k in %r}\n_lz_os.environ.update(%r)" % (list(envs), envs))
2193
+ msg_id = kc.execute(code, store_history=True, allow_stdin=False)
2194
+ end = time.time() + timeout
2195
+ timed_out = False
2196
+ while True:
2197
+ left = end - time.time()
2198
+ if left <= 0:
2199
+ timed_out = True
2200
+ break
2201
+ try:
2202
+ msg = kc.get_iopub_msg(timeout=left)
2203
+ except Exception:
2204
+ continue
2205
+ if msg["parent_header"].get("msg_id") != msg_id:
2206
+ continue
2207
+ t, c = msg["msg_type"], msg["content"]
2208
+ if t == "stream":
2209
+ emit({"type": "stderr" if c.get("name") == "stderr" else "stdout", "data": c.get("text", "")})
2210
+ elif t in ("execute_result", "display_data"):
2211
+ data = c.get("data", {})
2212
+ mime = next((k for k in BEST if k in data), next(iter(data), None))
2213
+ if mime:
2214
+ v = data[mime]
2215
+ emit({"type": "result", "mime": mime, "data": v if isinstance(v, str) else json.dumps(v)})
2216
+ elif t == "error":
2217
+ emit({"type": "error", "name": c.get("ename", "Error"), "message": c.get("evalue", ""),
2218
+ "traceback": ANSI.sub("", "\n".join(c.get("traceback", [])))})
2219
+ elif t == "status" and c.get("execution_state") == "idle":
2220
+ break
2221
+ count = 0
2222
+ if timed_out:
2223
+ pid = kernel_pid(m)
2224
+ if pid:
2225
+ try:
2226
+ os.kill(pid, signal.SIGINT)
2227
+ except OSError:
2228
+ pass
2229
+ emit({"type": "error", "name": "TimeoutError", "message": "execution timed out after %gs; the kernel was interrupted" % timeout, "traceback": ""})
2230
+ else:
2231
+ r = shell_reply(kc, msg_id, 10)
2232
+ count = (r or {}).get("content", {}).get("execution_count") or 0
2233
+ if envs and not timed_out:
2234
+ silent(kc, "for _lz_k, _lz_v in _lz_saved.items():\n _lz_os.environ.pop(_lz_k, None) if _lz_v is None else _lz_os.environ.__setitem__(_lz_k, _lz_v)")
2235
+ kc.stop_channels()
2236
+ emit({"type": "done", "execution_count": count})
2237
+
2238
+
2239
+ def run_process(m, code, envs, timeout):
2240
+ interp = LANGS[m["language"]]
2241
+ if shutil.which(interp) is None:
2242
+ emit({"type": "error", "name": "LanguageUnavailable", "message": "%s is not installed in this sandbox" % interp, "traceback": ""})
2243
+ emit({"type": "done", "execution_count": 0})
2244
+ return
2245
+ d = os.path.join(ROOT, m["id"])
2246
+ src = os.path.join(d, "cell" + {"node": ".js", "bash": ".sh"}.get(interp, ".py"))
2247
+ with open(src, "w") as f:
2248
+ f.write(code)
2249
+ os.makedirs(m["cwd"], exist_ok=True)
2250
+ p = subprocess.Popen([interp, src], cwd=m["cwd"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
2251
+ stderr=subprocess.PIPE, env=dict(os.environ, **(envs or {})), start_new_session=True)
2252
+ err_tail = []
2253
+
2254
+ def pump(stream, kind):
2255
+ for line in iter(stream.readline, b""):
2256
+ text = line.decode("utf-8", "replace")
2257
+ if kind == "stderr":
2258
+ err_tail.append(text)
2259
+ del err_tail[:-50]
2260
+ emit({"type": kind, "data": text})
2261
+
2262
+ ts = [threading.Thread(target=pump, args=(p.stdout, "stdout")), threading.Thread(target=pump, args=(p.stderr, "stderr"))]
2263
+ for t in ts:
2264
+ t.start()
2265
+ try:
2266
+ rc = p.wait(timeout=timeout)
2267
+ except subprocess.TimeoutExpired:
2268
+ os.killpg(p.pid, signal.SIGKILL)
2269
+ rc = None
2270
+ for t in ts:
2271
+ t.join(5)
2272
+ m["count"] = m.get("count", 0) + 1
2273
+ save(m)
2274
+ if rc is None:
2275
+ emit({"type": "error", "name": "TimeoutError", "message": "execution timed out after %gs" % timeout, "traceback": ""})
2276
+ elif rc != 0:
2277
+ emit({"type": "error", "name": "ExitError", "message": "exited with code %d" % rc, "traceback": "".join(err_tail)})
2278
+ emit({"type": "done", "execution_count": m["count"]})
2279
+
2280
+
2281
+ def ctx_for(a):
2282
+ lang = (a.get("language") or "python").lower()
2283
+ lang = ALIASES.get(lang, lang)
2284
+ if lang not in LANGS:
2285
+ fail("unsupported language %r; use python, javascript or bash" % lang)
2286
+ return lang
2287
+
2288
+
2289
+ def main():
2290
+ a = json.loads(base64.b64decode(sys.argv[1]))
2291
+ act = a["action"]
2292
+ os.makedirs(ROOT, exist_ok=True)
2293
+ if act == "create" or (act == "run" and not a.get("context")):
2294
+ lang = ctx_for(a)
2295
+ cid = a.get("context") or ("default-" + lang)
2296
+ m = load(cid) if act == "run" else None
2297
+ if m is None:
2298
+ m = {"id": cid if act == "run" else a["id"], "language": lang, "cwd": a.get("cwd") or "/home/user"}
2299
+ save(m)
2300
+ if lang == "python" and act == "create" and has_kernel():
2301
+ client(m).stop_channels()
2302
+ if act == "create":
2303
+ emit({"type": "context", "id": m["id"], "language": m["language"], "cwd": m["cwd"]})
2304
+ return
2305
+ elif act == "list":
2306
+ out = []
2307
+ for name in sorted(os.listdir(ROOT)):
2308
+ m = load(name)
2309
+ if m and not name.startswith("default-"):
2310
+ out.append({"id": m["id"], "language": m["language"], "cwd": m["cwd"]})
2311
+ emit({"type": "contexts", "data": out})
2312
+ return
2313
+ else:
2314
+ m = load(a["context"])
2315
+ if m is None:
2316
+ fail("context %r not found" % a["context"])
2317
+ if act == "delete":
2318
+ stop_kernel(m)
2319
+ shutil.rmtree(os.path.join(ROOT, m["id"]), ignore_errors=True)
2320
+ emit({"type": "ok"})
2321
+ return
2322
+ if act == "restart":
2323
+ if m["language"] == "python" and has_kernel():
2324
+ start_kernel(m)
2325
+ m["count"] = 0
2326
+ save(m)
2327
+ emit({"type": "ok"})
2328
+ return
2329
+ code = a.get("code")
2330
+ if code is None:
2331
+ with open(a["codeFile"]) as f:
2332
+ code = f.read()
2333
+ os.remove(a["codeFile"])
2334
+ if m["language"] == "python" and has_kernel():
2335
+ run_python(m, code, a.get("envs") or {}, a.get("timeout") or 60)
2336
+ else:
2337
+ run_process(m, code, a.get("envs") or {}, a.get("timeout") or 60)
2338
+
2339
+
2340
+ main()
2341
+ `;
2342
+
1980
2343
  // src/code-interpreter/index.ts
1981
- var CODE_INTERPRETER_PORT = 8080;
2344
+ var DEFAULT_RUN_TIMEOUT_MS = 6e4;
2345
+ var RUNNER_OVERHEAD_MS = 45e3;
2346
+ var MAX_INLINE_ARGS = 1e5;
1982
2347
  var CodeSandbox = class extends Sandbox {
1983
- get serverUrl() {
1984
- return this.getHost(CODE_INTERPRETER_PORT).then((h) => `https://${h}`);
1985
- }
1986
2348
  /**
1987
- * Execute code in a persistent kernel.
2349
+ * Execute code and wait for it to finish.
1988
2350
  *
1989
- * Variables, imports, and function definitions from previous calls are
1990
- * available in subsequent calls within the same context.
2351
+ * Python keeps its state per context (the default context, per language,
2352
+ * when none is given). Code errors appear in `execution.error`; request
2353
+ * failures throw.
1991
2354
  *
1992
2355
  * @param code Source code to run.
1993
2356
  * @param opts Language, context, env vars, timeout, and streaming callbacks.
1994
2357
  *
1995
- * @returns Execution result with stdout, stderr, results, and any error.
1996
- *
1997
- * Code errors appear in `execution.error`; request failures throw.
1998
- *
1999
2358
  * @example
2000
2359
  * ```ts
2001
2360
  * const result = await sandbox.runCode(`
@@ -2009,63 +2368,42 @@ var CodeSandbox = class extends Sandbox {
2009
2368
  if (opts?.context && opts?.language) {
2010
2369
  throw new Error("Provide context or language, not both");
2011
2370
  }
2012
- const body = { code, env_vars: opts?.envs ?? {} };
2013
- if (opts?.context) body.context_id = opts.context.id;
2014
- else if (opts?.language) body.language = opts.language;
2015
- const controller = new AbortController();
2016
- const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : void 0;
2017
- try {
2018
- const res = await fetch(`${await this.serverUrl}/execute`, {
2019
- method: "POST",
2020
- headers: { "Content-Type": "application/json" },
2021
- body: JSON.stringify(body),
2022
- signal: controller.signal
2023
- });
2024
- if (!res.ok) {
2025
- throw new Error(`Code interpreter returned ${res.status}: ${await res.text()}`);
2026
- }
2027
- if (!res.body) throw new Error("Empty response body");
2028
- const execution = new Execution();
2029
- for await (const line of readLines(res.body)) {
2030
- let item;
2031
- try {
2032
- item = JSON.parse(line);
2033
- } catch {
2034
- continue;
2035
- }
2036
- if (item.type === "stdout") {
2037
- execution.stdout += item.data;
2038
- opts?.onStdout?.(item.data);
2039
- } else if (item.type === "stderr") {
2040
- execution.stderr += item.data;
2041
- opts?.onStderr?.(item.data);
2042
- } else if (item.type === "result") {
2043
- const out = item;
2044
- execution.results.push(out);
2045
- opts?.onResult?.(out);
2046
- } else if (item.type === "error") {
2047
- const err = new ExecutionError(
2048
- item.name,
2049
- item.message,
2050
- item.traceback
2051
- );
2052
- execution.error = err;
2053
- opts?.onError?.(err);
2054
- } else if (item.type === "done") {
2055
- execution.executionCount = item.execution_count ?? 0;
2056
- break;
2057
- }
2371
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
2372
+ const args = {
2373
+ action: "run",
2374
+ code,
2375
+ envs: opts?.envs ?? {},
2376
+ timeout: timeoutMs / 1e3
2377
+ };
2378
+ if (opts?.context) args.context = opts.context.id;
2379
+ else args.language = opts?.language ?? "python";
2380
+ const execution = new Execution();
2381
+ await this.runner(args, timeoutMs, (item) => {
2382
+ if (item.type === "stdout") {
2383
+ execution.stdout += item.data;
2384
+ opts?.onStdout?.(item.data);
2385
+ } else if (item.type === "stderr") {
2386
+ execution.stderr += item.data;
2387
+ opts?.onStderr?.(item.data);
2388
+ } else if (item.type === "result") {
2389
+ const out = { type: "result", mime: item.mime, data: item.data };
2390
+ execution.results.push(out);
2391
+ opts?.onResult?.(out);
2392
+ } else if (item.type === "error") {
2393
+ const err = new ExecutionError(item.name, item.message, item.traceback);
2394
+ execution.error = err;
2395
+ opts?.onError?.(err);
2396
+ } else if (item.type === "done") {
2397
+ execution.executionCount = item.execution_count ?? 0;
2058
2398
  }
2059
- return execution;
2060
- } finally {
2061
- clearTimeout(timer);
2062
- }
2399
+ });
2400
+ return execution;
2063
2401
  }
2064
2402
  /**
2065
2403
  * Create a new isolated execution context.
2066
2404
  *
2067
- * Each context maintains its own variable namespace and process state.
2068
- * Useful for running multiple independent sessions in the same sandbox.
2405
+ * A Python context is its own kernel: its own variables, and `cwd` as its
2406
+ * working directory. Other languages get the working directory only.
2069
2407
  *
2070
2408
  * @example
2071
2409
  * ```ts
@@ -2075,40 +2413,80 @@ var CodeSandbox = class extends Sandbox {
2075
2413
  * ```
2076
2414
  */
2077
2415
  async createContext(opts) {
2078
- const res = await fetch(`${await this.serverUrl}/contexts`, {
2079
- method: "POST",
2080
- headers: { "Content-Type": "application/json" },
2081
- body: JSON.stringify({
2082
- language: opts?.language ?? "python",
2083
- cwd: opts?.cwd ?? "/home/user"
2084
- })
2085
- });
2086
- if (!res.ok) throw new Error(`Failed to create context: ${await res.text()}`);
2087
- return res.json();
2416
+ const id = `ctx-${randomId()}`;
2417
+ let ctx;
2418
+ await this.runner(
2419
+ { action: "create", id, language: opts?.language ?? "python", cwd: opts?.cwd ?? "/home/user" },
2420
+ DEFAULT_RUN_TIMEOUT_MS,
2421
+ (ev) => {
2422
+ if (ev.type === "context") ctx = { id: ev.id, language: ev.language, cwd: ev.cwd };
2423
+ }
2424
+ );
2425
+ if (!ctx) throw new LizardError("Failed to create context");
2426
+ return ctx;
2088
2427
  }
2089
- /**
2090
- * List all active execution contexts in this sandbox.
2091
- */
2428
+ /** List the contexts created with {@link createContext} in this sandbox. */
2092
2429
  async listContexts() {
2093
- const res = await fetch(`${await this.serverUrl}/contexts`);
2094
- if (!res.ok) throw new Error(`Failed to list contexts: ${await res.text()}`);
2095
- return res.json();
2430
+ let out = [];
2431
+ await this.runner({ action: "list" }, DEFAULT_RUN_TIMEOUT_MS, (ev) => {
2432
+ if (ev.type === "contexts") out = ev.data;
2433
+ });
2434
+ return out;
2096
2435
  }
2097
- /**
2098
- * Delete an execution context and free its resources.
2099
- */
2436
+ /** Delete an execution context, stopping its kernel. */
2100
2437
  async deleteContext(context) {
2101
2438
  const id = typeof context === "string" ? context : context.id;
2102
- const res = await fetch(`${await this.serverUrl}/contexts/${id}`, { method: "DELETE" });
2103
- if (!res.ok) throw new Error(`Failed to delete context: ${await res.text()}`);
2439
+ await this.runner({ action: "delete", context: id }, DEFAULT_RUN_TIMEOUT_MS, () => {
2440
+ });
2104
2441
  }
2105
- /**
2106
- * Restart a context, clearing all variables and state.
2107
- */
2442
+ /** Restart a context, clearing all variables and state. */
2108
2443
  async restartContext(context) {
2109
2444
  const id = typeof context === "string" ? context : context.id;
2110
- const res = await fetch(`${await this.serverUrl}/contexts/${id}/restart`, { method: "POST" });
2111
- if (!res.ok) throw new Error(`Failed to restart context: ${await res.text()}`);
2445
+ await this.runner({ action: "restart", context: id }, DEFAULT_RUN_TIMEOUT_MS, () => {
2446
+ });
2447
+ }
2448
+ /** Run the in-sandbox runner with `args` and hand each of its events to `onEvent`. */
2449
+ async runner(args, timeoutMs, onEvent) {
2450
+ let encoded = toBase64(JSON.stringify(args));
2451
+ if (encoded.length > MAX_INLINE_ARGS && typeof args.code === "string") {
2452
+ const codeFile = `/tmp/.lizard-code/in-${randomId()}`;
2453
+ await this.fs.write(codeFile, args.code);
2454
+ const { code: _code, ...rest } = args;
2455
+ encoded = toBase64(JSON.stringify({ ...rest, codeFile }));
2456
+ }
2457
+ let part = "";
2458
+ let fatal;
2459
+ let sawEvent = false;
2460
+ const stderr = [];
2461
+ const result = await this.process.exec(["python3", "-c", RUNNER_SOURCE, encoded], {
2462
+ timeoutMs: timeoutMs + RUNNER_OVERHEAD_MS,
2463
+ onStdout: (line) => {
2464
+ let ev;
2465
+ try {
2466
+ ev = JSON.parse(line);
2467
+ if (ev.type === "part") {
2468
+ part += ev.data;
2469
+ if (!ev.end) return;
2470
+ ev = JSON.parse(part);
2471
+ part = "";
2472
+ }
2473
+ } catch {
2474
+ part = "";
2475
+ return;
2476
+ }
2477
+ sawEvent = true;
2478
+ if (ev.type === "fatal") fatal = ev.message;
2479
+ else onEvent(ev);
2480
+ },
2481
+ onStderr: (line) => {
2482
+ stderr.push(line);
2483
+ }
2484
+ });
2485
+ if (fatal) throw new LizardError(fatal);
2486
+ if (result.exitCode !== 0 || !sawEvent) {
2487
+ const detail = stderr.slice(-20).join("\n").trim() || `exit code ${result.exitCode}`;
2488
+ throw new LizardError(`Code runner failed (python3 is required in the sandbox): ${detail}`);
2489
+ }
2112
2490
  }
2113
2491
  static async create(templateOrOpts, opts) {
2114
2492
  return super.create(templateOrOpts, opts);
@@ -2117,26 +2495,17 @@ var CodeSandbox = class extends Sandbox {
2117
2495
  return super.connect(sandboxId, opts);
2118
2496
  }
2119
2497
  };
2120
- CodeSandbox.defaultTemplate = "code-interpreter-v1";
2121
- async function* readLines(body) {
2122
- const reader = body.getReader();
2123
- const decoder = new TextDecoder();
2124
- let buf = "";
2125
- try {
2126
- while (true) {
2127
- const { done, value } = await reader.read();
2128
- if (done) break;
2129
- buf += decoder.decode(value, { stream: true });
2130
- const lines = buf.split("\n");
2131
- buf = lines.pop() ?? "";
2132
- for (const line of lines) {
2133
- if (line.trim()) yield line;
2134
- }
2135
- }
2136
- if (buf.trim()) yield buf;
2137
- } finally {
2138
- reader.releaseLock();
2139
- }
2498
+ CodeSandbox.defaultTemplate = "interpreter";
2499
+ function randomId() {
2500
+ const c = globalThis.crypto;
2501
+ return c?.randomUUID ? c.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(16).slice(2, 18);
2502
+ }
2503
+ function toBase64(text) {
2504
+ if (typeof Buffer !== "undefined") return Buffer.from(text, "utf8").toString("base64");
2505
+ const bytes = new TextEncoder().encode(text);
2506
+ let s = "";
2507
+ for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
2508
+ return btoa(s);
2140
2509
  }
2141
2510
  // Annotate the CommonJS export names for ESM import in node:
2142
2511
  0 && (module.exports = {