@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.mjs CHANGED
@@ -496,7 +496,9 @@ var Process = class {
496
496
  envs: opts?.envs,
497
497
  user: opts?.user,
498
498
  workdir: opts?.workdir,
499
- timeoutMs: opts?.timeoutMs
499
+ timeoutMs: opts?.timeoutMs,
500
+ // The pid event is opt-in: older CLIs print any unknown data line as output.
501
+ pidEvent: opts?.onPid ? true : void 0
500
502
  }),
501
503
  signal: AbortSignal.timeout(opts?.timeoutMs ?? 6e4)
502
504
  });
@@ -504,11 +506,6 @@ var Process = class {
504
506
  if (!streaming) return res.json();
505
507
  return this.consumeStream(res, opts);
506
508
  }
507
- /**
508
- * Read an SSE exec stream, handing each line to the caller as it arrives while
509
- * still accumulating the full result. Events are `{stream, line}` for output and
510
- * `{exitCode}` at the end.
511
- */
512
509
  /**
513
510
  * List the processes this sandbox is currently running.
514
511
  *
@@ -532,8 +529,8 @@ var Process = class {
532
529
  /**
533
530
  * Signal a running process. Defaults to `SIGTERM`.
534
531
  *
535
- * The signal goes to the process group, so a shell's children die with it —
536
- * killing `sh -c 'sleep 100'` otherwise leaves the sleep running.
532
+ * The signal goes to the whole process tree, so a shell's children die with
533
+ * it — killing `sh -c 'sleep 100'` otherwise leaves the sleep running.
537
534
  *
538
535
  * The pid comes from {@link list}, or from the `pid` event at the start of a
539
536
  * streaming `exec`.
@@ -552,6 +549,11 @@ var Process = class {
552
549
  });
553
550
  if (!res.ok) await handleApiError(res);
554
551
  }
552
+ /**
553
+ * Read an SSE exec stream, handing each line to the caller as it arrives while
554
+ * still accumulating the full result. Events are `{pid}` first (when asked for
555
+ * with `pidEvent`), `{stream, line}` for output and `{exitCode}` at the end.
556
+ */
555
557
  async consumeStream(res, opts) {
556
558
  const reader = res.body?.getReader();
557
559
  if (!reader) return { stdout: "", stderr: "", exitCode: 0 };
@@ -602,33 +604,37 @@ var Fs = class {
602
604
  /**
603
605
  * Write a file into the sandbox filesystem.
604
606
  *
605
- * Creates parent directories automatically if they don't exist.
606
- * Byte input must contain valid UTF-8; arbitrary binary uploads are unsupported.
607
+ * Creates parent directories automatically if they don't exist. A string is
608
+ * written as UTF-8; bytes (`Uint8Array`, `Buffer` or `ArrayBuffer`) are written
609
+ * exactly as given, so binary files round-trip with {@link readBytes}.
610
+ *
611
+ * With `opts.user` the file is written as (and owned by) that user.
607
612
  *
608
613
  * @example
609
614
  * ```ts
610
615
  * await sandbox.fs.write('/app/index.js', 'console.log("hello")')
611
616
  * ```
612
617
  *
613
- * @example Write UTF-8 bytes:
618
+ * @example Write binary data:
614
619
  * ```ts
615
- * await sandbox.fs.write('/app/notes.txt', new TextEncoder().encode('hello'))
620
+ * await sandbox.fs.write('/app/logo.png', await readFile('logo.png'))
616
621
  * ```
617
622
  */
618
623
  async write(path, data, opts) {
619
- const content = typeof data === "string" ? data : new TextDecoder("utf-8", { fatal: true }).decode(data);
624
+ 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 };
620
625
  const res = await fetch(`${this.config.apiUrl}/api/sandboxes/${this.sandboxId}/files`, {
621
626
  method: "POST",
622
627
  headers: this.config.headers,
623
- body: JSON.stringify({ path, content, user: opts?.user })
628
+ body: JSON.stringify(body)
624
629
  });
625
630
  if (!res.ok) await handleApiError(res);
626
631
  }
627
632
  /**
628
633
  * Read a file from the sandbox filesystem.
629
634
  *
630
- * @returns The file contents as a UTF-8 string.
635
+ * @returns The file contents as a UTF-8 string. Use {@link readBytes} for binary files.
631
636
  *
637
+ * Throws `NotFoundError` if the path does not exist.
632
638
  * @example
633
639
  * ```ts
634
640
  * const content = await sandbox.fs.read('/app/index.js')
@@ -696,9 +702,7 @@ var Fs = class {
696
702
  * console.log(info.size, info.type)
697
703
  * ```
698
704
  *
699
- * Throws `NotFoundError` if the path does not exist. Sandboxes created before
700
- * this shipped run a guest agent without it and throw `LizardError` (501) —
701
- * recreate the sandbox to use it.
705
+ * Throws `NotFoundError` if the path does not exist.
702
706
  */
703
707
  async stat(path, opts) {
704
708
  const url = new URL(`${this.config.apiUrl}/api/sandboxes/${this.sandboxId}/files/stat`);
@@ -715,9 +719,6 @@ var Fs = class {
715
719
  * ```ts
716
720
  * await sandbox.fs.move('/tmp/build.log', '/app/logs/build.log')
717
721
  * ```
718
- *
719
- * Sandboxes created before this shipped run a guest agent without it and throw
720
- * `LizardError` (501) — recreate the sandbox to use it.
721
722
  */
722
723
  async move(from, to) {
723
724
  const res = await fetch(`${this.config.apiUrl}/api/sandboxes/${this.sandboxId}/files/move`, {
@@ -746,8 +747,7 @@ var Fs = class {
746
747
  * }, 1000)
747
748
  * ```
748
749
  *
749
- * Sandboxes created before this shipped run a guest agent without it and throw
750
- * `LizardError` (501) — recreate the sandbox to use it.
750
+ * Not available on Firecracker sandboxes yet: the call throws `LizardError` (501).
751
751
  */
752
752
  async watch(path, opts) {
753
753
  const res = await fetch(`${this.config.apiUrl}/api/sandboxes/${this.sandboxId}/files/watch`, {
@@ -793,6 +793,12 @@ var Watcher = class {
793
793
  if (!res.ok) await handleApiError(res);
794
794
  }
795
795
  };
796
+ function bytesToBase64(bytes) {
797
+ if (typeof Buffer !== "undefined") return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
798
+ let s = "";
799
+ for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
800
+ return btoa(s);
801
+ }
796
802
 
797
803
  // src/sandbox/desktop.ts
798
804
  var BUTTONS = { left: "1", middle: "2", right: "3" };
@@ -1093,11 +1099,17 @@ var Sandbox = class extends SandboxClient {
1093
1099
  async kill(opts) {
1094
1100
  return SandboxClient.killSandbox(this.sandboxId, this.resolveOpts(opts));
1095
1101
  }
1096
- /** Queue a CRIU checkpoint and stop compute. Use waitForStatus('paused') before resuming. */
1102
+ /**
1103
+ * Pause the sandbox: its memory, running processes and files are kept and
1104
+ * compute stops. Wait with `waitForStatus('paused')` before resuming.
1105
+ */
1097
1106
  async pause(opts) {
1098
1107
  return SandboxClient.pauseSandbox(this.sandboxId, this.resolveOpts(opts));
1099
1108
  }
1100
- /** Queue restoration of saved memory and files. Use waitForStatus('running') before executing. */
1109
+ /**
1110
+ * Resume a paused sandbox exactly where it stopped, processes included. Wait
1111
+ * with `waitForStatus('running')` before running commands.
1112
+ */
1101
1113
  async resume(opts) {
1102
1114
  return SandboxClient.resumeSandbox(this.sandboxId, this.resolveOpts(opts));
1103
1115
  }
@@ -1130,33 +1142,82 @@ var Sandbox = class extends SandboxClient {
1130
1142
  return SandboxClient.setTimeoutSandbox(this.sandboxId, timeoutMs, this.resolveOpts(opts));
1131
1143
  }
1132
1144
  /**
1133
- * Register a public HTTPS route and return its hostname without a scheme.
1145
+ * Publish a port on a public HTTPS hostname and return the hostname without a scheme.
1134
1146
  *
1135
- * Useful for accessing HTTP servers started inside the sandbox from your
1136
- * agent or tests without additional tunneling.
1147
+ * The port is private: requests need {@link accessToken} (set by this call) as
1148
+ * the `X-Lizard-Access-Token` header, or `?lizard_token=<token>` in a browser.
1149
+ * {@link exposePort} returns the token and a ready-made browser URL together.
1137
1150
  *
1138
1151
  * @example
1139
1152
  * ```ts
1140
1153
  * // Start an HTTP server on port 3000 before exposing it.
1141
1154
  * const hostname = await sandbox.getHost(3000)
1142
- * console.log(`https://${hostname}`)
1155
+ * const res = await fetch(`https://${hostname}/`, {
1156
+ * headers: { 'X-Lizard-Access-Token': sandbox.accessToken! },
1157
+ * })
1143
1158
  * ```
1144
1159
  */
1145
1160
  async getHost(port, opts) {
1146
- const { hostname } = await SandboxClient.exposeSandboxPort(this.sandboxId, port, this.resolveOpts(opts));
1147
- return hostname;
1161
+ return (await this.exposePort(port, opts)).hostname;
1162
+ }
1163
+ /**
1164
+ * Publish a port and return everything needed to reach it: the hostname, a
1165
+ * browser `url` that carries the token, and the `accessToken` for API clients.
1166
+ *
1167
+ * @example
1168
+ * ```ts
1169
+ * const { url, hostname, accessToken } = await sandbox.exposePort(3000)
1170
+ * console.log(url) // open in a browser
1171
+ * await fetch(`https://${hostname}/api`, { headers: { 'X-Lizard-Access-Token': accessToken } })
1172
+ * ```
1173
+ */
1174
+ async exposePort(port, opts) {
1175
+ const exposed = await SandboxClient.exposeSandboxPort(this.sandboxId, port, this.resolveOpts(opts));
1176
+ if (exposed.accessToken) this.accessToken = exposed.accessToken;
1177
+ return exposed;
1148
1178
  }
1149
- /** Fork is unsupported on Kubernetes; the backend returns HTTP 501. */
1150
- fork(opts = {}) {
1151
- return new PlatformClient(this.resolveOpts()).post(`/api/sandboxes/${this.sandboxId}/fork`, { count: opts.count ?? 1, timeoutMs: opts.timeoutMs ?? 0 });
1179
+ /**
1180
+ * Clone this running sandbox `count` times (1-10). Each fork is a copy of the
1181
+ * microVM at this instant -- memory, running processes and files -- and is
1182
+ * billed like its source. The source keeps running.
1183
+ *
1184
+ * Returns one entry per requested fork, in order: `{ sandbox, info }`, or
1185
+ * `{ error }` for a copy that could not be made (e.g. the account's sandbox
1186
+ * limit). A sandbox with a volume attached cannot be forked (`ConflictError`, 409).
1187
+ *
1188
+ * @param opts.timeoutMs Lifetime of each fork; defaults to the source's timeout.
1189
+ *
1190
+ * @example
1191
+ * ```ts
1192
+ * const forks = await sandbox.fork({ count: 2 })
1193
+ * for (const f of forks) if (f.sandbox) await f.sandbox.process.exec('echo hi')
1194
+ * ```
1195
+ */
1196
+ async fork(opts = {}) {
1197
+ const result = await new PlatformClient(this.resolveOpts()).post(
1198
+ `/api/sandboxes/${this.sandboxId}/fork`,
1199
+ { count: opts.count ?? 1, timeoutMs: opts.timeoutMs ?? 0 }
1200
+ );
1201
+ const Ctor = this.constructor;
1202
+ return (Array.isArray(result) ? result : []).map((entry) => {
1203
+ const id = entry.sandbox?.sandboxId ?? entry.sandbox?.id;
1204
+ if (!entry.sandbox || !id) return { error: entry.error ?? "fork failed" };
1205
+ return { sandbox: new Ctor({ ...this.resolveOpts(), sandboxId: id }), info: { ...entry.sandbox, sandboxId: id } };
1206
+ });
1152
1207
  }
1153
- /** Capture memory and workspace files. Disconnect active clients before capturing. */
1208
+ /**
1209
+ * Save this sandbox -- memory, running processes and files -- as a private
1210
+ * snapshot. A Firecracker snapshot is `ready` as soon as this returns (about 2 s);
1211
+ * start copies of it with {@link Sandbox.restore} (about 0.4 s each). The source
1212
+ * keeps running. `poolSize` applies to container sandboxes only.
1213
+ */
1154
1214
  snapshot(name = `Snapshot ${this.sandboxId}`, opts = {}) {
1155
1215
  return new PlatformClient(this.resolveOpts()).post(`/api/sandboxes/${this.sandboxId}/snapshot`, { name, poolSize: opts.poolSize ?? 5 });
1156
1216
  }
1157
1217
  unexpose(port) {
1158
1218
  return new PlatformClient(this.resolveOpts()).delete(`/api/sandboxes/${this.sandboxId}/expose/${port}`);
1159
1219
  }
1220
+ /** Stream the sandbox's logs. Not available on Firecracker sandboxes yet (`LizardError`, 501). */
1160
1221
  logs(opts = {}) {
1161
1222
  return new PlatformClient(this.resolveOpts()).events(query(`/api/sandboxes/${this.sandboxId}/logs`, { tail: opts.tail }), { signal: opts.signal });
1162
1223
  }
@@ -1166,7 +1227,7 @@ var Sandbox = class extends SandboxClient {
1166
1227
  static getSnapshot(snapshotId, opts) {
1167
1228
  return new PlatformClient(opts ?? {}).get(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}`);
1168
1229
  }
1169
- /** Start a sandbox from an available warm copy in the snapshot's project and region. */
1230
+ /** Start a new sandbox from a snapshot, in the snapshot's project and region. */
1170
1231
  static async restore(snapshotId, opts) {
1171
1232
  const client = new PlatformClient(opts ?? {});
1172
1233
  const snapshot = await this.getSnapshot(snapshotId, opts);
@@ -1181,7 +1242,7 @@ var Sandbox = class extends SandboxClient {
1181
1242
  static setSnapshotWarmPool(snapshotId, poolSize, opts) {
1182
1243
  return new PlatformClient(opts ?? {}).patch(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}`, { poolSize });
1183
1244
  }
1184
- /** Release idle warm copies; retain saved state and existing claimed sandboxes. */
1245
+ /** Release idle warm copies (container snapshots); retain saved state and existing claimed sandboxes. */
1185
1246
  static pauseSnapshot(snapshotId, opts) {
1186
1247
  return new PlatformClient(opts ?? {}).post(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}/pause`, {});
1187
1248
  }
@@ -1720,7 +1781,7 @@ var Volume = class _Volume {
1720
1781
  * "the scratch disk for this task" no longer has to store an id between runs.
1721
1782
  *
1722
1783
  * An existing volume is returned as-is — `sizeGb` applies only to a fresh create,
1723
- * so an existing volume keeps its size. Call {@link Volume.resize} to change it.
1784
+ * so an existing volume keeps its size.
1724
1785
  */
1725
1786
  static async getOrCreate(projectId, name, opts) {
1726
1787
  const config = new ConnectionConfig(opts);
@@ -1763,11 +1824,15 @@ var Volume = class _Volume {
1763
1824
  /**
1764
1825
  * Resize a volume, by name or by id, to `sizeGb`.
1765
1826
  *
1766
- * The change is in place and online: the size is a quota, so no data is copied,
1767
- * it completes in well under a second, and a sandbox that has the volume mounted
1768
- * keeps running and sees the new size immediately. Both growing and shrinking are
1769
- * allowed, but a shrink must leave at least 10% of the new size free — otherwise
1770
- * it throws `ConflictError` with `code: 'volume_too_full_to_shrink'`.
1827
+ * **Firecracker volumes cannot be resized yet**: volumes created for the default
1828
+ * Firecracker sandboxes throw `LizardError` (400) with `code: 'volume_not_resizable'`.
1829
+ * Create the volume at the size you need, or copy the data to a new, larger one.
1830
+ *
1831
+ * On a container volume the change is in place and online: the size is a quota,
1832
+ * so no data is copied and a mounted sandbox sees the new size immediately. Both
1833
+ * growing and shrinking are allowed, but a shrink must leave at least 10% of the
1834
+ * new size free — otherwise it throws `ConflictError` with
1835
+ * `code: 'volume_too_full_to_shrink'`.
1771
1836
  *
1772
1837
  * Other failures carry a `code` too: `invalid_volume_size`, `volume_not_resizable`,
1773
1838
  * `volume_resize_in_progress`, `volume_not_provisioned`,
@@ -1793,7 +1858,7 @@ var Volume = class _Volume {
1793
1858
  if (!res.ok) await handleApiError(res);
1794
1859
  return res.json();
1795
1860
  }
1796
- /** Resize this volume in place — see the static {@link Volume.resize}. */
1861
+ /** Resize this volume — see the static {@link Volume.resize} (not supported for Firecracker volumes yet). */
1797
1862
  async resize(projectId, sizeGb) {
1798
1863
  return _Volume.resize(projectId, this.volumeId, sizeGb, {
1799
1864
  apiKey: this.config.apiKey,
@@ -1916,7 +1981,7 @@ var Execution = class {
1916
1981
  this.stderr = "";
1917
1982
  /** Rich output items (images, JSON, HTML, plain values). */
1918
1983
  this.results = [];
1919
- /** Monotonically increasing counter for this context. */
1984
+ /** Monotonically increasing counter for this context (the Python kernel's `In[n]`). */
1920
1985
  this.executionCount = 0;
1921
1986
  }
1922
1987
  get success() {
@@ -1927,25 +1992,317 @@ var Execution = class {
1927
1992
  }
1928
1993
  };
1929
1994
 
1995
+ // src/code-interpreter/runner.ts
1996
+ var RUNNER_SOURCE = String.raw`# Lizard code runner: executes code for CodeSandbox.run_code / runCode inside the sandbox.
1997
+ # Python runs in a persistent Jupyter kernel per context when the template has one
1998
+ # (the interpreter template does); everything else runs as a fresh process.
1999
+ # Events are JSON lines on stdout; an event over 24000 chars is sent as "part" lines.
2000
+ import base64, json, os, re, shutil, signal, subprocess, sys, threading, time
2001
+ ROOT = "/tmp/.lizard-code"
2002
+ LANGS = {"python": "python3", "javascript": "node", "bash": "bash"}
2003
+ ALIASES = {"py": "python", "python3": "python", "js": "javascript", "node": "javascript", "sh": "bash"}
2004
+ ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
2005
+ LOCK = threading.Lock()
2006
+
2007
+
2008
+ def emit(ev):
2009
+ s = json.dumps(ev)
2010
+ with LOCK:
2011
+ if len(s) <= 24000:
2012
+ print(s, flush=True)
2013
+ return
2014
+ n = (len(s) + 23999) // 24000
2015
+ for i in range(n):
2016
+ print(json.dumps({"type": "part", "data": s[i * 24000:(i + 1) * 24000], "end": i == n - 1}), flush=True)
2017
+
2018
+
2019
+ def fail(msg):
2020
+ emit({"type": "fatal", "message": msg})
2021
+ sys.exit(3)
2022
+
2023
+
2024
+ def meta_path(ctx):
2025
+ return os.path.join(ROOT, ctx, "meta.json")
2026
+
2027
+
2028
+ def load(ctx):
2029
+ try:
2030
+ with open(meta_path(ctx)) as f:
2031
+ return json.load(f)
2032
+ except OSError:
2033
+ return None
2034
+
2035
+
2036
+ def save(m):
2037
+ os.makedirs(os.path.join(ROOT, m["id"]), exist_ok=True)
2038
+ with open(meta_path(m["id"]), "w") as f:
2039
+ json.dump(m, f)
2040
+
2041
+
2042
+ def alive(pid):
2043
+ try:
2044
+ os.kill(pid, 0)
2045
+ except OSError:
2046
+ return False
2047
+ try:
2048
+ with open("/proc/%d/stat" % pid) as f:
2049
+ return f.read().split(") ")[-1][:1] != "Z"
2050
+ except OSError:
2051
+ return False
2052
+
2053
+
2054
+ def kernel_pid(m):
2055
+ try:
2056
+ with open(os.path.join(ROOT, m["id"], "kernel.pid")) as f:
2057
+ return int(f.read().strip())
2058
+ except (OSError, ValueError):
2059
+ return 0
2060
+
2061
+
2062
+ def stop_kernel(m):
2063
+ pid = kernel_pid(m)
2064
+ if pid and alive(pid):
2065
+ try:
2066
+ os.killpg(pid, signal.SIGKILL)
2067
+ except OSError:
2068
+ pass
2069
+ for name in ("kernel.pid", "kernel.json"):
2070
+ try:
2071
+ os.remove(os.path.join(ROOT, m["id"], name))
2072
+ except OSError:
2073
+ pass
2074
+
2075
+
2076
+ def start_kernel(m):
2077
+ d = os.path.join(ROOT, m["id"])
2078
+ conn = os.path.join(d, "kernel.json")
2079
+ stop_kernel(m)
2080
+ os.makedirs(m["cwd"], exist_ok=True)
2081
+ log = open(os.path.join(d, "kernel.log"), "ab")
2082
+ p = subprocess.Popen([sys.executable, "-m", "ipykernel_launcher", "-f", conn], cwd=m["cwd"],
2083
+ stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True,
2084
+ env=dict(os.environ, PYTHONUNBUFFERED="1"))
2085
+ with open(os.path.join(d, "kernel.pid"), "w") as f:
2086
+ f.write(str(p.pid))
2087
+ end = time.time() + 30
2088
+ while time.time() < end:
2089
+ if os.path.exists(conn) and os.path.getsize(conn) > 0:
2090
+ return
2091
+ if p.poll() is not None:
2092
+ break
2093
+ time.sleep(0.05)
2094
+ with open(os.path.join(d, "kernel.log"), "rb") as f:
2095
+ tail = f.read()[-2000:].decode("utf-8", "replace")
2096
+ fail("python kernel failed to start: " + (tail or "no output"))
2097
+
2098
+
2099
+ def has_kernel():
2100
+ try:
2101
+ import jupyter_client, ipykernel # noqa: F401
2102
+ return True
2103
+ except ImportError:
2104
+ return False
2105
+
2106
+
2107
+ def client(m):
2108
+ from jupyter_client import BlockingKernelClient
2109
+ if not (kernel_pid(m) and alive(kernel_pid(m))):
2110
+ start_kernel(m)
2111
+ kc = BlockingKernelClient()
2112
+ kc.load_connection_file(os.path.join(ROOT, m["id"], "kernel.json"))
2113
+ kc.start_channels()
2114
+ kc.wait_for_ready(timeout=30)
2115
+ return kc
2116
+
2117
+
2118
+ def shell_reply(kc, msg_id, timeout):
2119
+ end = time.time() + timeout
2120
+ while time.time() < end:
2121
+ try:
2122
+ r = kc.get_shell_msg(timeout=max(0.01, end - time.time()))
2123
+ except Exception:
2124
+ return None
2125
+ if r["parent_header"].get("msg_id") == msg_id:
2126
+ return r
2127
+ return None
2128
+
2129
+
2130
+ def silent(kc, code):
2131
+ shell_reply(kc, kc.execute(code, silent=True, store_history=False), 10)
2132
+
2133
+
2134
+ BEST = ("image/png", "image/jpeg", "image/svg+xml", "text/html", "text/markdown", "application/json", "text/latex", "text/plain")
2135
+
2136
+
2137
+ def run_python(m, code, envs, timeout):
2138
+ kc = client(m)
2139
+ if envs:
2140
+ 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))
2141
+ msg_id = kc.execute(code, store_history=True, allow_stdin=False)
2142
+ end = time.time() + timeout
2143
+ timed_out = False
2144
+ while True:
2145
+ left = end - time.time()
2146
+ if left <= 0:
2147
+ timed_out = True
2148
+ break
2149
+ try:
2150
+ msg = kc.get_iopub_msg(timeout=left)
2151
+ except Exception:
2152
+ continue
2153
+ if msg["parent_header"].get("msg_id") != msg_id:
2154
+ continue
2155
+ t, c = msg["msg_type"], msg["content"]
2156
+ if t == "stream":
2157
+ emit({"type": "stderr" if c.get("name") == "stderr" else "stdout", "data": c.get("text", "")})
2158
+ elif t in ("execute_result", "display_data"):
2159
+ data = c.get("data", {})
2160
+ mime = next((k for k in BEST if k in data), next(iter(data), None))
2161
+ if mime:
2162
+ v = data[mime]
2163
+ emit({"type": "result", "mime": mime, "data": v if isinstance(v, str) else json.dumps(v)})
2164
+ elif t == "error":
2165
+ emit({"type": "error", "name": c.get("ename", "Error"), "message": c.get("evalue", ""),
2166
+ "traceback": ANSI.sub("", "\n".join(c.get("traceback", [])))})
2167
+ elif t == "status" and c.get("execution_state") == "idle":
2168
+ break
2169
+ count = 0
2170
+ if timed_out:
2171
+ pid = kernel_pid(m)
2172
+ if pid:
2173
+ try:
2174
+ os.kill(pid, signal.SIGINT)
2175
+ except OSError:
2176
+ pass
2177
+ emit({"type": "error", "name": "TimeoutError", "message": "execution timed out after %gs; the kernel was interrupted" % timeout, "traceback": ""})
2178
+ else:
2179
+ r = shell_reply(kc, msg_id, 10)
2180
+ count = (r or {}).get("content", {}).get("execution_count") or 0
2181
+ if envs and not timed_out:
2182
+ 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)")
2183
+ kc.stop_channels()
2184
+ emit({"type": "done", "execution_count": count})
2185
+
2186
+
2187
+ def run_process(m, code, envs, timeout):
2188
+ interp = LANGS[m["language"]]
2189
+ if shutil.which(interp) is None:
2190
+ emit({"type": "error", "name": "LanguageUnavailable", "message": "%s is not installed in this sandbox" % interp, "traceback": ""})
2191
+ emit({"type": "done", "execution_count": 0})
2192
+ return
2193
+ d = os.path.join(ROOT, m["id"])
2194
+ src = os.path.join(d, "cell" + {"node": ".js", "bash": ".sh"}.get(interp, ".py"))
2195
+ with open(src, "w") as f:
2196
+ f.write(code)
2197
+ os.makedirs(m["cwd"], exist_ok=True)
2198
+ p = subprocess.Popen([interp, src], cwd=m["cwd"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
2199
+ stderr=subprocess.PIPE, env=dict(os.environ, **(envs or {})), start_new_session=True)
2200
+ err_tail = []
2201
+
2202
+ def pump(stream, kind):
2203
+ for line in iter(stream.readline, b""):
2204
+ text = line.decode("utf-8", "replace")
2205
+ if kind == "stderr":
2206
+ err_tail.append(text)
2207
+ del err_tail[:-50]
2208
+ emit({"type": kind, "data": text})
2209
+
2210
+ ts = [threading.Thread(target=pump, args=(p.stdout, "stdout")), threading.Thread(target=pump, args=(p.stderr, "stderr"))]
2211
+ for t in ts:
2212
+ t.start()
2213
+ try:
2214
+ rc = p.wait(timeout=timeout)
2215
+ except subprocess.TimeoutExpired:
2216
+ os.killpg(p.pid, signal.SIGKILL)
2217
+ rc = None
2218
+ for t in ts:
2219
+ t.join(5)
2220
+ m["count"] = m.get("count", 0) + 1
2221
+ save(m)
2222
+ if rc is None:
2223
+ emit({"type": "error", "name": "TimeoutError", "message": "execution timed out after %gs" % timeout, "traceback": ""})
2224
+ elif rc != 0:
2225
+ emit({"type": "error", "name": "ExitError", "message": "exited with code %d" % rc, "traceback": "".join(err_tail)})
2226
+ emit({"type": "done", "execution_count": m["count"]})
2227
+
2228
+
2229
+ def ctx_for(a):
2230
+ lang = (a.get("language") or "python").lower()
2231
+ lang = ALIASES.get(lang, lang)
2232
+ if lang not in LANGS:
2233
+ fail("unsupported language %r; use python, javascript or bash" % lang)
2234
+ return lang
2235
+
2236
+
2237
+ def main():
2238
+ a = json.loads(base64.b64decode(sys.argv[1]))
2239
+ act = a["action"]
2240
+ os.makedirs(ROOT, exist_ok=True)
2241
+ if act == "create" or (act == "run" and not a.get("context")):
2242
+ lang = ctx_for(a)
2243
+ cid = a.get("context") or ("default-" + lang)
2244
+ m = load(cid) if act == "run" else None
2245
+ if m is None:
2246
+ m = {"id": cid if act == "run" else a["id"], "language": lang, "cwd": a.get("cwd") or "/home/user"}
2247
+ save(m)
2248
+ if lang == "python" and act == "create" and has_kernel():
2249
+ client(m).stop_channels()
2250
+ if act == "create":
2251
+ emit({"type": "context", "id": m["id"], "language": m["language"], "cwd": m["cwd"]})
2252
+ return
2253
+ elif act == "list":
2254
+ out = []
2255
+ for name in sorted(os.listdir(ROOT)):
2256
+ m = load(name)
2257
+ if m and not name.startswith("default-"):
2258
+ out.append({"id": m["id"], "language": m["language"], "cwd": m["cwd"]})
2259
+ emit({"type": "contexts", "data": out})
2260
+ return
2261
+ else:
2262
+ m = load(a["context"])
2263
+ if m is None:
2264
+ fail("context %r not found" % a["context"])
2265
+ if act == "delete":
2266
+ stop_kernel(m)
2267
+ shutil.rmtree(os.path.join(ROOT, m["id"]), ignore_errors=True)
2268
+ emit({"type": "ok"})
2269
+ return
2270
+ if act == "restart":
2271
+ if m["language"] == "python" and has_kernel():
2272
+ start_kernel(m)
2273
+ m["count"] = 0
2274
+ save(m)
2275
+ emit({"type": "ok"})
2276
+ return
2277
+ code = a.get("code")
2278
+ if code is None:
2279
+ with open(a["codeFile"]) as f:
2280
+ code = f.read()
2281
+ os.remove(a["codeFile"])
2282
+ if m["language"] == "python" and has_kernel():
2283
+ run_python(m, code, a.get("envs") or {}, a.get("timeout") or 60)
2284
+ else:
2285
+ run_process(m, code, a.get("envs") or {}, a.get("timeout") or 60)
2286
+
2287
+
2288
+ main()
2289
+ `;
2290
+
1930
2291
  // src/code-interpreter/index.ts
1931
- var CODE_INTERPRETER_PORT = 8080;
2292
+ var DEFAULT_RUN_TIMEOUT_MS = 6e4;
2293
+ var RUNNER_OVERHEAD_MS = 45e3;
2294
+ var MAX_INLINE_ARGS = 1e5;
1932
2295
  var CodeSandbox = class extends Sandbox {
1933
- get serverUrl() {
1934
- return this.getHost(CODE_INTERPRETER_PORT).then((h) => `https://${h}`);
1935
- }
1936
2296
  /**
1937
- * Execute code in a persistent kernel.
2297
+ * Execute code and wait for it to finish.
1938
2298
  *
1939
- * Variables, imports, and function definitions from previous calls are
1940
- * available in subsequent calls within the same context.
2299
+ * Python keeps its state per context (the default context, per language,
2300
+ * when none is given). Code errors appear in `execution.error`; request
2301
+ * failures throw.
1941
2302
  *
1942
2303
  * @param code Source code to run.
1943
2304
  * @param opts Language, context, env vars, timeout, and streaming callbacks.
1944
2305
  *
1945
- * @returns Execution result with stdout, stderr, results, and any error.
1946
- *
1947
- * Code errors appear in `execution.error`; request failures throw.
1948
- *
1949
2306
  * @example
1950
2307
  * ```ts
1951
2308
  * const result = await sandbox.runCode(`
@@ -1959,63 +2316,42 @@ var CodeSandbox = class extends Sandbox {
1959
2316
  if (opts?.context && opts?.language) {
1960
2317
  throw new Error("Provide context or language, not both");
1961
2318
  }
1962
- const body = { code, env_vars: opts?.envs ?? {} };
1963
- if (opts?.context) body.context_id = opts.context.id;
1964
- else if (opts?.language) body.language = opts.language;
1965
- const controller = new AbortController();
1966
- const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : void 0;
1967
- try {
1968
- const res = await fetch(`${await this.serverUrl}/execute`, {
1969
- method: "POST",
1970
- headers: { "Content-Type": "application/json" },
1971
- body: JSON.stringify(body),
1972
- signal: controller.signal
1973
- });
1974
- if (!res.ok) {
1975
- throw new Error(`Code interpreter returned ${res.status}: ${await res.text()}`);
1976
- }
1977
- if (!res.body) throw new Error("Empty response body");
1978
- const execution = new Execution();
1979
- for await (const line of readLines(res.body)) {
1980
- let item;
1981
- try {
1982
- item = JSON.parse(line);
1983
- } catch {
1984
- continue;
1985
- }
1986
- if (item.type === "stdout") {
1987
- execution.stdout += item.data;
1988
- opts?.onStdout?.(item.data);
1989
- } else if (item.type === "stderr") {
1990
- execution.stderr += item.data;
1991
- opts?.onStderr?.(item.data);
1992
- } else if (item.type === "result") {
1993
- const out = item;
1994
- execution.results.push(out);
1995
- opts?.onResult?.(out);
1996
- } else if (item.type === "error") {
1997
- const err = new ExecutionError(
1998
- item.name,
1999
- item.message,
2000
- item.traceback
2001
- );
2002
- execution.error = err;
2003
- opts?.onError?.(err);
2004
- } else if (item.type === "done") {
2005
- execution.executionCount = item.execution_count ?? 0;
2006
- break;
2007
- }
2319
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
2320
+ const args = {
2321
+ action: "run",
2322
+ code,
2323
+ envs: opts?.envs ?? {},
2324
+ timeout: timeoutMs / 1e3
2325
+ };
2326
+ if (opts?.context) args.context = opts.context.id;
2327
+ else args.language = opts?.language ?? "python";
2328
+ const execution = new Execution();
2329
+ await this.runner(args, timeoutMs, (item) => {
2330
+ if (item.type === "stdout") {
2331
+ execution.stdout += item.data;
2332
+ opts?.onStdout?.(item.data);
2333
+ } else if (item.type === "stderr") {
2334
+ execution.stderr += item.data;
2335
+ opts?.onStderr?.(item.data);
2336
+ } else if (item.type === "result") {
2337
+ const out = { type: "result", mime: item.mime, data: item.data };
2338
+ execution.results.push(out);
2339
+ opts?.onResult?.(out);
2340
+ } else if (item.type === "error") {
2341
+ const err = new ExecutionError(item.name, item.message, item.traceback);
2342
+ execution.error = err;
2343
+ opts?.onError?.(err);
2344
+ } else if (item.type === "done") {
2345
+ execution.executionCount = item.execution_count ?? 0;
2008
2346
  }
2009
- return execution;
2010
- } finally {
2011
- clearTimeout(timer);
2012
- }
2347
+ });
2348
+ return execution;
2013
2349
  }
2014
2350
  /**
2015
2351
  * Create a new isolated execution context.
2016
2352
  *
2017
- * Each context maintains its own variable namespace and process state.
2018
- * Useful for running multiple independent sessions in the same sandbox.
2353
+ * A Python context is its own kernel: its own variables, and `cwd` as its
2354
+ * working directory. Other languages get the working directory only.
2019
2355
  *
2020
2356
  * @example
2021
2357
  * ```ts
@@ -2025,40 +2361,80 @@ var CodeSandbox = class extends Sandbox {
2025
2361
  * ```
2026
2362
  */
2027
2363
  async createContext(opts) {
2028
- const res = await fetch(`${await this.serverUrl}/contexts`, {
2029
- method: "POST",
2030
- headers: { "Content-Type": "application/json" },
2031
- body: JSON.stringify({
2032
- language: opts?.language ?? "python",
2033
- cwd: opts?.cwd ?? "/home/user"
2034
- })
2035
- });
2036
- if (!res.ok) throw new Error(`Failed to create context: ${await res.text()}`);
2037
- return res.json();
2364
+ const id = `ctx-${randomId()}`;
2365
+ let ctx;
2366
+ await this.runner(
2367
+ { action: "create", id, language: opts?.language ?? "python", cwd: opts?.cwd ?? "/home/user" },
2368
+ DEFAULT_RUN_TIMEOUT_MS,
2369
+ (ev) => {
2370
+ if (ev.type === "context") ctx = { id: ev.id, language: ev.language, cwd: ev.cwd };
2371
+ }
2372
+ );
2373
+ if (!ctx) throw new LizardError("Failed to create context");
2374
+ return ctx;
2038
2375
  }
2039
- /**
2040
- * List all active execution contexts in this sandbox.
2041
- */
2376
+ /** List the contexts created with {@link createContext} in this sandbox. */
2042
2377
  async listContexts() {
2043
- const res = await fetch(`${await this.serverUrl}/contexts`);
2044
- if (!res.ok) throw new Error(`Failed to list contexts: ${await res.text()}`);
2045
- return res.json();
2378
+ let out = [];
2379
+ await this.runner({ action: "list" }, DEFAULT_RUN_TIMEOUT_MS, (ev) => {
2380
+ if (ev.type === "contexts") out = ev.data;
2381
+ });
2382
+ return out;
2046
2383
  }
2047
- /**
2048
- * Delete an execution context and free its resources.
2049
- */
2384
+ /** Delete an execution context, stopping its kernel. */
2050
2385
  async deleteContext(context) {
2051
2386
  const id = typeof context === "string" ? context : context.id;
2052
- const res = await fetch(`${await this.serverUrl}/contexts/${id}`, { method: "DELETE" });
2053
- if (!res.ok) throw new Error(`Failed to delete context: ${await res.text()}`);
2387
+ await this.runner({ action: "delete", context: id }, DEFAULT_RUN_TIMEOUT_MS, () => {
2388
+ });
2054
2389
  }
2055
- /**
2056
- * Restart a context, clearing all variables and state.
2057
- */
2390
+ /** Restart a context, clearing all variables and state. */
2058
2391
  async restartContext(context) {
2059
2392
  const id = typeof context === "string" ? context : context.id;
2060
- const res = await fetch(`${await this.serverUrl}/contexts/${id}/restart`, { method: "POST" });
2061
- if (!res.ok) throw new Error(`Failed to restart context: ${await res.text()}`);
2393
+ await this.runner({ action: "restart", context: id }, DEFAULT_RUN_TIMEOUT_MS, () => {
2394
+ });
2395
+ }
2396
+ /** Run the in-sandbox runner with `args` and hand each of its events to `onEvent`. */
2397
+ async runner(args, timeoutMs, onEvent) {
2398
+ let encoded = toBase64(JSON.stringify(args));
2399
+ if (encoded.length > MAX_INLINE_ARGS && typeof args.code === "string") {
2400
+ const codeFile = `/tmp/.lizard-code/in-${randomId()}`;
2401
+ await this.fs.write(codeFile, args.code);
2402
+ const { code: _code, ...rest } = args;
2403
+ encoded = toBase64(JSON.stringify({ ...rest, codeFile }));
2404
+ }
2405
+ let part = "";
2406
+ let fatal;
2407
+ let sawEvent = false;
2408
+ const stderr = [];
2409
+ const result = await this.process.exec(["python3", "-c", RUNNER_SOURCE, encoded], {
2410
+ timeoutMs: timeoutMs + RUNNER_OVERHEAD_MS,
2411
+ onStdout: (line) => {
2412
+ let ev;
2413
+ try {
2414
+ ev = JSON.parse(line);
2415
+ if (ev.type === "part") {
2416
+ part += ev.data;
2417
+ if (!ev.end) return;
2418
+ ev = JSON.parse(part);
2419
+ part = "";
2420
+ }
2421
+ } catch {
2422
+ part = "";
2423
+ return;
2424
+ }
2425
+ sawEvent = true;
2426
+ if (ev.type === "fatal") fatal = ev.message;
2427
+ else onEvent(ev);
2428
+ },
2429
+ onStderr: (line) => {
2430
+ stderr.push(line);
2431
+ }
2432
+ });
2433
+ if (fatal) throw new LizardError(fatal);
2434
+ if (result.exitCode !== 0 || !sawEvent) {
2435
+ const detail = stderr.slice(-20).join("\n").trim() || `exit code ${result.exitCode}`;
2436
+ throw new LizardError(`Code runner failed (python3 is required in the sandbox): ${detail}`);
2437
+ }
2062
2438
  }
2063
2439
  static async create(templateOrOpts, opts) {
2064
2440
  return super.create(templateOrOpts, opts);
@@ -2067,26 +2443,17 @@ var CodeSandbox = class extends Sandbox {
2067
2443
  return super.connect(sandboxId, opts);
2068
2444
  }
2069
2445
  };
2070
- CodeSandbox.defaultTemplate = "code-interpreter-v1";
2071
- async function* readLines(body) {
2072
- const reader = body.getReader();
2073
- const decoder = new TextDecoder();
2074
- let buf = "";
2075
- try {
2076
- while (true) {
2077
- const { done, value } = await reader.read();
2078
- if (done) break;
2079
- buf += decoder.decode(value, { stream: true });
2080
- const lines = buf.split("\n");
2081
- buf = lines.pop() ?? "";
2082
- for (const line of lines) {
2083
- if (line.trim()) yield line;
2084
- }
2085
- }
2086
- if (buf.trim()) yield buf;
2087
- } finally {
2088
- reader.releaseLock();
2089
- }
2446
+ CodeSandbox.defaultTemplate = "interpreter";
2447
+ function randomId() {
2448
+ const c = globalThis.crypto;
2449
+ return c?.randomUUID ? c.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(16).slice(2, 18);
2450
+ }
2451
+ function toBase64(text) {
2452
+ if (typeof Buffer !== "undefined") return Buffer.from(text, "utf8").toString("base64");
2453
+ const bytes = new TextEncoder().encode(text);
2454
+ let s = "";
2455
+ for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
2456
+ return btoa(s);
2090
2457
  }
2091
2458
  export {
2092
2459
  AddonsAPI,