@lizard-build/sdk 0.1.49 → 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,6 +845,12 @@ 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" };
@@ -1145,11 +1151,17 @@ var Sandbox = class extends SandboxClient {
1145
1151
  async kill(opts) {
1146
1152
  return SandboxClient.killSandbox(this.sandboxId, this.resolveOpts(opts));
1147
1153
  }
1148
- /** 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
+ */
1149
1158
  async pause(opts) {
1150
1159
  return SandboxClient.pauseSandbox(this.sandboxId, this.resolveOpts(opts));
1151
1160
  }
1152
- /** 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
+ */
1153
1165
  async resume(opts) {
1154
1166
  return SandboxClient.resumeSandbox(this.sandboxId, this.resolveOpts(opts));
1155
1167
  }
@@ -1182,33 +1194,82 @@ var Sandbox = class extends SandboxClient {
1182
1194
  return SandboxClient.setTimeoutSandbox(this.sandboxId, timeoutMs, this.resolveOpts(opts));
1183
1195
  }
1184
1196
  /**
1185
- * 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.
1186
1198
  *
1187
- * Useful for accessing HTTP servers started inside the sandbox from your
1188
- * 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.
1189
1202
  *
1190
1203
  * @example
1191
1204
  * ```ts
1192
1205
  * // Start an HTTP server on port 3000 before exposing it.
1193
1206
  * const hostname = await sandbox.getHost(3000)
1194
- * console.log(`https://${hostname}`)
1207
+ * const res = await fetch(`https://${hostname}/`, {
1208
+ * headers: { 'X-Lizard-Access-Token': sandbox.accessToken! },
1209
+ * })
1195
1210
  * ```
1196
1211
  */
1197
1212
  async getHost(port, opts) {
1198
- const { hostname } = await SandboxClient.exposeSandboxPort(this.sandboxId, port, this.resolveOpts(opts));
1199
- 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;
1200
1230
  }
1201
- /** Fork is unsupported on Kubernetes; the backend returns HTTP 501. */
1202
- fork(opts = {}) {
1203
- 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
+ });
1204
1259
  }
1205
- /** 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
+ */
1206
1266
  snapshot(name = `Snapshot ${this.sandboxId}`, opts = {}) {
1207
1267
  return new PlatformClient(this.resolveOpts()).post(`/api/sandboxes/${this.sandboxId}/snapshot`, { name, poolSize: opts.poolSize ?? 5 });
1208
1268
  }
1209
1269
  unexpose(port) {
1210
1270
  return new PlatformClient(this.resolveOpts()).delete(`/api/sandboxes/${this.sandboxId}/expose/${port}`);
1211
1271
  }
1272
+ /** Stream the sandbox's logs. Not available on Firecracker sandboxes yet (`LizardError`, 501). */
1212
1273
  logs(opts = {}) {
1213
1274
  return new PlatformClient(this.resolveOpts()).events(query(`/api/sandboxes/${this.sandboxId}/logs`, { tail: opts.tail }), { signal: opts.signal });
1214
1275
  }
@@ -1218,7 +1279,7 @@ var Sandbox = class extends SandboxClient {
1218
1279
  static getSnapshot(snapshotId, opts) {
1219
1280
  return new PlatformClient(opts ?? {}).get(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}`);
1220
1281
  }
1221
- /** 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. */
1222
1283
  static async restore(snapshotId, opts) {
1223
1284
  const client = new PlatformClient(opts ?? {});
1224
1285
  const snapshot = await this.getSnapshot(snapshotId, opts);
@@ -1233,7 +1294,7 @@ var Sandbox = class extends SandboxClient {
1233
1294
  static setSnapshotWarmPool(snapshotId, poolSize, opts) {
1234
1295
  return new PlatformClient(opts ?? {}).patch(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}`, { poolSize });
1235
1296
  }
1236
- /** 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. */
1237
1298
  static pauseSnapshot(snapshotId, opts) {
1238
1299
  return new PlatformClient(opts ?? {}).post(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}/pause`, {});
1239
1300
  }
@@ -1772,7 +1833,7 @@ var Volume = class _Volume {
1772
1833
  * "the scratch disk for this task" no longer has to store an id between runs.
1773
1834
  *
1774
1835
  * An existing volume is returned as-is — `sizeGb` applies only to a fresh create,
1775
- * so an existing volume keeps its size. Call {@link Volume.resize} to change it.
1836
+ * so an existing volume keeps its size.
1776
1837
  */
1777
1838
  static async getOrCreate(projectId, name, opts) {
1778
1839
  const config = new ConnectionConfig(opts);
@@ -1815,11 +1876,15 @@ var Volume = class _Volume {
1815
1876
  /**
1816
1877
  * Resize a volume, by name or by id, to `sizeGb`.
1817
1878
  *
1818
- * The change is in place and online: the size is a quota, so no data is copied,
1819
- * it completes in well under a second, and a sandbox that has the volume mounted
1820
- * keeps running and sees the new size immediately. Both growing and shrinking are
1821
- * allowed, but a shrink must leave at least 10% of the new size free — otherwise
1822
- * 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'`.
1823
1888
  *
1824
1889
  * Other failures carry a `code` too: `invalid_volume_size`, `volume_not_resizable`,
1825
1890
  * `volume_resize_in_progress`, `volume_not_provisioned`,
@@ -1845,7 +1910,7 @@ var Volume = class _Volume {
1845
1910
  if (!res.ok) await handleApiError(res);
1846
1911
  return res.json();
1847
1912
  }
1848
- /** 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). */
1849
1914
  async resize(projectId, sizeGb) {
1850
1915
  return _Volume.resize(projectId, this.volumeId, sizeGb, {
1851
1916
  apiKey: this.config.apiKey,
@@ -1968,7 +2033,7 @@ var Execution = class {
1968
2033
  this.stderr = "";
1969
2034
  /** Rich output items (images, JSON, HTML, plain values). */
1970
2035
  this.results = [];
1971
- /** Monotonically increasing counter for this context. */
2036
+ /** Monotonically increasing counter for this context (the Python kernel's `In[n]`). */
1972
2037
  this.executionCount = 0;
1973
2038
  }
1974
2039
  get success() {
@@ -1979,25 +2044,317 @@ var Execution = class {
1979
2044
  }
1980
2045
  };
1981
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
+
1982
2343
  // src/code-interpreter/index.ts
1983
- var CODE_INTERPRETER_PORT = 8080;
2344
+ var DEFAULT_RUN_TIMEOUT_MS = 6e4;
2345
+ var RUNNER_OVERHEAD_MS = 45e3;
2346
+ var MAX_INLINE_ARGS = 1e5;
1984
2347
  var CodeSandbox = class extends Sandbox {
1985
- get serverUrl() {
1986
- return this.getHost(CODE_INTERPRETER_PORT).then((h) => `https://${h}`);
1987
- }
1988
2348
  /**
1989
- * Execute code in a persistent kernel.
2349
+ * Execute code and wait for it to finish.
1990
2350
  *
1991
- * Variables, imports, and function definitions from previous calls are
1992
- * 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.
1993
2354
  *
1994
2355
  * @param code Source code to run.
1995
2356
  * @param opts Language, context, env vars, timeout, and streaming callbacks.
1996
2357
  *
1997
- * @returns Execution result with stdout, stderr, results, and any error.
1998
- *
1999
- * Code errors appear in `execution.error`; request failures throw.
2000
- *
2001
2358
  * @example
2002
2359
  * ```ts
2003
2360
  * const result = await sandbox.runCode(`
@@ -2011,63 +2368,42 @@ var CodeSandbox = class extends Sandbox {
2011
2368
  if (opts?.context && opts?.language) {
2012
2369
  throw new Error("Provide context or language, not both");
2013
2370
  }
2014
- const body = { code, env_vars: opts?.envs ?? {} };
2015
- if (opts?.context) body.context_id = opts.context.id;
2016
- else if (opts?.language) body.language = opts.language;
2017
- const controller = new AbortController();
2018
- const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : void 0;
2019
- try {
2020
- const res = await fetch(`${await this.serverUrl}/execute`, {
2021
- method: "POST",
2022
- headers: { "Content-Type": "application/json" },
2023
- body: JSON.stringify(body),
2024
- signal: controller.signal
2025
- });
2026
- if (!res.ok) {
2027
- throw new Error(`Code interpreter returned ${res.status}: ${await res.text()}`);
2028
- }
2029
- if (!res.body) throw new Error("Empty response body");
2030
- const execution = new Execution();
2031
- for await (const line of readLines(res.body)) {
2032
- let item;
2033
- try {
2034
- item = JSON.parse(line);
2035
- } catch {
2036
- continue;
2037
- }
2038
- if (item.type === "stdout") {
2039
- execution.stdout += item.data;
2040
- opts?.onStdout?.(item.data);
2041
- } else if (item.type === "stderr") {
2042
- execution.stderr += item.data;
2043
- opts?.onStderr?.(item.data);
2044
- } else if (item.type === "result") {
2045
- const out = item;
2046
- execution.results.push(out);
2047
- opts?.onResult?.(out);
2048
- } else if (item.type === "error") {
2049
- const err = new ExecutionError(
2050
- item.name,
2051
- item.message,
2052
- item.traceback
2053
- );
2054
- execution.error = err;
2055
- opts?.onError?.(err);
2056
- } else if (item.type === "done") {
2057
- execution.executionCount = item.execution_count ?? 0;
2058
- break;
2059
- }
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;
2060
2398
  }
2061
- return execution;
2062
- } finally {
2063
- clearTimeout(timer);
2064
- }
2399
+ });
2400
+ return execution;
2065
2401
  }
2066
2402
  /**
2067
2403
  * Create a new isolated execution context.
2068
2404
  *
2069
- * Each context maintains its own variable namespace and process state.
2070
- * 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.
2071
2407
  *
2072
2408
  * @example
2073
2409
  * ```ts
@@ -2077,40 +2413,80 @@ var CodeSandbox = class extends Sandbox {
2077
2413
  * ```
2078
2414
  */
2079
2415
  async createContext(opts) {
2080
- const res = await fetch(`${await this.serverUrl}/contexts`, {
2081
- method: "POST",
2082
- headers: { "Content-Type": "application/json" },
2083
- body: JSON.stringify({
2084
- language: opts?.language ?? "python",
2085
- cwd: opts?.cwd ?? "/home/user"
2086
- })
2087
- });
2088
- if (!res.ok) throw new Error(`Failed to create context: ${await res.text()}`);
2089
- 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;
2090
2427
  }
2091
- /**
2092
- * List all active execution contexts in this sandbox.
2093
- */
2428
+ /** List the contexts created with {@link createContext} in this sandbox. */
2094
2429
  async listContexts() {
2095
- const res = await fetch(`${await this.serverUrl}/contexts`);
2096
- if (!res.ok) throw new Error(`Failed to list contexts: ${await res.text()}`);
2097
- 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;
2098
2435
  }
2099
- /**
2100
- * Delete an execution context and free its resources.
2101
- */
2436
+ /** Delete an execution context, stopping its kernel. */
2102
2437
  async deleteContext(context) {
2103
2438
  const id = typeof context === "string" ? context : context.id;
2104
- const res = await fetch(`${await this.serverUrl}/contexts/${id}`, { method: "DELETE" });
2105
- 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
+ });
2106
2441
  }
2107
- /**
2108
- * Restart a context, clearing all variables and state.
2109
- */
2442
+ /** Restart a context, clearing all variables and state. */
2110
2443
  async restartContext(context) {
2111
2444
  const id = typeof context === "string" ? context : context.id;
2112
- const res = await fetch(`${await this.serverUrl}/contexts/${id}/restart`, { method: "POST" });
2113
- 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
+ }
2114
2490
  }
2115
2491
  static async create(templateOrOpts, opts) {
2116
2492
  return super.create(templateOrOpts, opts);
@@ -2119,26 +2495,17 @@ var CodeSandbox = class extends Sandbox {
2119
2495
  return super.connect(sandboxId, opts);
2120
2496
  }
2121
2497
  };
2122
- CodeSandbox.defaultTemplate = "code-interpreter-v1";
2123
- async function* readLines(body) {
2124
- const reader = body.getReader();
2125
- const decoder = new TextDecoder();
2126
- let buf = "";
2127
- try {
2128
- while (true) {
2129
- const { done, value } = await reader.read();
2130
- if (done) break;
2131
- buf += decoder.decode(value, { stream: true });
2132
- const lines = buf.split("\n");
2133
- buf = lines.pop() ?? "";
2134
- for (const line of lines) {
2135
- if (line.trim()) yield line;
2136
- }
2137
- }
2138
- if (buf.trim()) yield buf;
2139
- } finally {
2140
- reader.releaseLock();
2141
- }
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);
2142
2509
  }
2143
2510
  // Annotate the CommonJS export names for ESM import in node:
2144
2511
  0 && (module.exports = {