@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/README.md +25 -6
- package/dist/index.d.mts +209 -87
- package/dist/index.d.ts +209 -87
- package/dist/index.js +535 -166
- package/dist/index.mjs +535 -166
- package/package.json +4 -3
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
|
|
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
|
-
*
|
|
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
|
|
618
|
+
* @example Write binary data:
|
|
614
619
|
* ```ts
|
|
615
|
-
* await sandbox.fs.write('/app/
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
*
|
|
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,16 +793,24 @@ 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" };
|
|
799
805
|
var SCROLL_BUTTONS = { up: "4", down: "5", left: "6", right: "7" };
|
|
806
|
+
var SCREENSHOT_PATH = /^\/tmp\/lizard-screenshot-[A-Za-z0-9_-]+\.png$/;
|
|
800
807
|
var TYPE_DELAY_MS = 12;
|
|
801
808
|
var Desktop = class {
|
|
802
|
-
constructor(sandboxId, config, process2) {
|
|
809
|
+
constructor(sandboxId, config, process2, fs) {
|
|
803
810
|
this.sandboxId = sandboxId;
|
|
804
811
|
this.config = config;
|
|
805
812
|
this.process = process2;
|
|
813
|
+
this.fs = fs;
|
|
806
814
|
}
|
|
807
815
|
get client() {
|
|
808
816
|
return new PlatformClient(this.config);
|
|
@@ -839,11 +847,17 @@ var Desktop = class {
|
|
|
839
847
|
* ```
|
|
840
848
|
*/
|
|
841
849
|
async screenshot() {
|
|
842
|
-
const r = await this.run(["lizard-desktop", "screenshot"]);
|
|
843
|
-
|
|
844
|
-
|
|
850
|
+
const r = await this.run(["lizard-desktop", "screenshot", "--path"]);
|
|
851
|
+
const path = r.stdout.split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? "";
|
|
852
|
+
if (!SCREENSHOT_PATH.test(path)) {
|
|
853
|
+
throw new LizardError(`Screenshot returned an unexpected path: ${path.slice(0, 200)}`);
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
return await this.fs.readBytes(path);
|
|
857
|
+
} finally {
|
|
858
|
+
await this.fs.remove(path).catch(() => {
|
|
859
|
+
});
|
|
845
860
|
}
|
|
846
|
-
return decodeBase64(r.stdout.trim());
|
|
847
861
|
}
|
|
848
862
|
/** Click at (x, y). */
|
|
849
863
|
async click(x, y, opts = {}) {
|
|
@@ -938,12 +952,6 @@ function coord(n) {
|
|
|
938
952
|
if (!Number.isFinite(n) || n < 0) throw new LizardError(`Invalid screen coordinate: ${n}`);
|
|
939
953
|
return String(Math.round(n));
|
|
940
954
|
}
|
|
941
|
-
function decodeBase64(b64) {
|
|
942
|
-
if (!b64 || !/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) {
|
|
943
|
-
throw new LizardError("Screenshot returned no image");
|
|
944
|
-
}
|
|
945
|
-
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
946
|
-
}
|
|
947
955
|
|
|
948
956
|
// src/sandbox/client.ts
|
|
949
957
|
var SandboxClient = class {
|
|
@@ -1046,7 +1054,7 @@ var Sandbox = class extends SandboxClient {
|
|
|
1046
1054
|
this.connectionConfig = new ConnectionConfig(opts);
|
|
1047
1055
|
this.fs = new Fs(this.sandboxId, this.connectionConfig);
|
|
1048
1056
|
this.process = new Process(this.sandboxId, this.connectionConfig);
|
|
1049
|
-
this.desktop = new Desktop(this.sandboxId, this.connectionConfig, this.process);
|
|
1057
|
+
this.desktop = new Desktop(this.sandboxId, this.connectionConfig, this.process, this.fs);
|
|
1050
1058
|
}
|
|
1051
1059
|
static async create(templateOrOpts, opts) {
|
|
1052
1060
|
const template = typeof templateOrOpts === "string" ? templateOrOpts : templateOrOpts?.template ?? this.defaultTemplate;
|
|
@@ -1091,11 +1099,17 @@ var Sandbox = class extends SandboxClient {
|
|
|
1091
1099
|
async kill(opts) {
|
|
1092
1100
|
return SandboxClient.killSandbox(this.sandboxId, this.resolveOpts(opts));
|
|
1093
1101
|
}
|
|
1094
|
-
/**
|
|
1102
|
+
/**
|
|
1103
|
+
* Pause the sandbox: its memory, running processes and files are kept and
|
|
1104
|
+
* compute stops. Wait with `waitForStatus('paused')` before resuming.
|
|
1105
|
+
*/
|
|
1095
1106
|
async pause(opts) {
|
|
1096
1107
|
return SandboxClient.pauseSandbox(this.sandboxId, this.resolveOpts(opts));
|
|
1097
1108
|
}
|
|
1098
|
-
/**
|
|
1109
|
+
/**
|
|
1110
|
+
* Resume a paused sandbox exactly where it stopped, processes included. Wait
|
|
1111
|
+
* with `waitForStatus('running')` before running commands.
|
|
1112
|
+
*/
|
|
1099
1113
|
async resume(opts) {
|
|
1100
1114
|
return SandboxClient.resumeSandbox(this.sandboxId, this.resolveOpts(opts));
|
|
1101
1115
|
}
|
|
@@ -1128,33 +1142,82 @@ var Sandbox = class extends SandboxClient {
|
|
|
1128
1142
|
return SandboxClient.setTimeoutSandbox(this.sandboxId, timeoutMs, this.resolveOpts(opts));
|
|
1129
1143
|
}
|
|
1130
1144
|
/**
|
|
1131
|
-
*
|
|
1145
|
+
* Publish a port on a public HTTPS hostname and return the hostname without a scheme.
|
|
1132
1146
|
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
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.
|
|
1135
1150
|
*
|
|
1136
1151
|
* @example
|
|
1137
1152
|
* ```ts
|
|
1138
1153
|
* // Start an HTTP server on port 3000 before exposing it.
|
|
1139
1154
|
* const hostname = await sandbox.getHost(3000)
|
|
1140
|
-
*
|
|
1155
|
+
* const res = await fetch(`https://${hostname}/`, {
|
|
1156
|
+
* headers: { 'X-Lizard-Access-Token': sandbox.accessToken! },
|
|
1157
|
+
* })
|
|
1141
1158
|
* ```
|
|
1142
1159
|
*/
|
|
1143
1160
|
async getHost(port, opts) {
|
|
1144
|
-
|
|
1145
|
-
|
|
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;
|
|
1146
1178
|
}
|
|
1147
|
-
/**
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
+
});
|
|
1150
1207
|
}
|
|
1151
|
-
/**
|
|
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
|
+
*/
|
|
1152
1214
|
snapshot(name = `Snapshot ${this.sandboxId}`, opts = {}) {
|
|
1153
1215
|
return new PlatformClient(this.resolveOpts()).post(`/api/sandboxes/${this.sandboxId}/snapshot`, { name, poolSize: opts.poolSize ?? 5 });
|
|
1154
1216
|
}
|
|
1155
1217
|
unexpose(port) {
|
|
1156
1218
|
return new PlatformClient(this.resolveOpts()).delete(`/api/sandboxes/${this.sandboxId}/expose/${port}`);
|
|
1157
1219
|
}
|
|
1220
|
+
/** Stream the sandbox's logs. Not available on Firecracker sandboxes yet (`LizardError`, 501). */
|
|
1158
1221
|
logs(opts = {}) {
|
|
1159
1222
|
return new PlatformClient(this.resolveOpts()).events(query(`/api/sandboxes/${this.sandboxId}/logs`, { tail: opts.tail }), { signal: opts.signal });
|
|
1160
1223
|
}
|
|
@@ -1164,7 +1227,7 @@ var Sandbox = class extends SandboxClient {
|
|
|
1164
1227
|
static getSnapshot(snapshotId, opts) {
|
|
1165
1228
|
return new PlatformClient(opts ?? {}).get(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}`);
|
|
1166
1229
|
}
|
|
1167
|
-
/** Start a sandbox from
|
|
1230
|
+
/** Start a new sandbox from a snapshot, in the snapshot's project and region. */
|
|
1168
1231
|
static async restore(snapshotId, opts) {
|
|
1169
1232
|
const client = new PlatformClient(opts ?? {});
|
|
1170
1233
|
const snapshot = await this.getSnapshot(snapshotId, opts);
|
|
@@ -1179,7 +1242,7 @@ var Sandbox = class extends SandboxClient {
|
|
|
1179
1242
|
static setSnapshotWarmPool(snapshotId, poolSize, opts) {
|
|
1180
1243
|
return new PlatformClient(opts ?? {}).patch(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}`, { poolSize });
|
|
1181
1244
|
}
|
|
1182
|
-
/** 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. */
|
|
1183
1246
|
static pauseSnapshot(snapshotId, opts) {
|
|
1184
1247
|
return new PlatformClient(opts ?? {}).post(`/api/sandbox-snapshots/${encodeURIComponent(snapshotId)}/pause`, {});
|
|
1185
1248
|
}
|
|
@@ -1718,7 +1781,7 @@ var Volume = class _Volume {
|
|
|
1718
1781
|
* "the scratch disk for this task" no longer has to store an id between runs.
|
|
1719
1782
|
*
|
|
1720
1783
|
* An existing volume is returned as-is — `sizeGb` applies only to a fresh create,
|
|
1721
|
-
* so an existing volume keeps its size.
|
|
1784
|
+
* so an existing volume keeps its size.
|
|
1722
1785
|
*/
|
|
1723
1786
|
static async getOrCreate(projectId, name, opts) {
|
|
1724
1787
|
const config = new ConnectionConfig(opts);
|
|
@@ -1761,11 +1824,15 @@ var Volume = class _Volume {
|
|
|
1761
1824
|
/**
|
|
1762
1825
|
* Resize a volume, by name or by id, to `sizeGb`.
|
|
1763
1826
|
*
|
|
1764
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
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'`.
|
|
1769
1836
|
*
|
|
1770
1837
|
* Other failures carry a `code` too: `invalid_volume_size`, `volume_not_resizable`,
|
|
1771
1838
|
* `volume_resize_in_progress`, `volume_not_provisioned`,
|
|
@@ -1791,7 +1858,7 @@ var Volume = class _Volume {
|
|
|
1791
1858
|
if (!res.ok) await handleApiError(res);
|
|
1792
1859
|
return res.json();
|
|
1793
1860
|
}
|
|
1794
|
-
/** Resize this volume
|
|
1861
|
+
/** Resize this volume — see the static {@link Volume.resize} (not supported for Firecracker volumes yet). */
|
|
1795
1862
|
async resize(projectId, sizeGb) {
|
|
1796
1863
|
return _Volume.resize(projectId, this.volumeId, sizeGb, {
|
|
1797
1864
|
apiKey: this.config.apiKey,
|
|
@@ -1914,7 +1981,7 @@ var Execution = class {
|
|
|
1914
1981
|
this.stderr = "";
|
|
1915
1982
|
/** Rich output items (images, JSON, HTML, plain values). */
|
|
1916
1983
|
this.results = [];
|
|
1917
|
-
/** Monotonically increasing counter for this context. */
|
|
1984
|
+
/** Monotonically increasing counter for this context (the Python kernel's `In[n]`). */
|
|
1918
1985
|
this.executionCount = 0;
|
|
1919
1986
|
}
|
|
1920
1987
|
get success() {
|
|
@@ -1925,25 +1992,317 @@ var Execution = class {
|
|
|
1925
1992
|
}
|
|
1926
1993
|
};
|
|
1927
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
|
+
|
|
1928
2291
|
// src/code-interpreter/index.ts
|
|
1929
|
-
var
|
|
2292
|
+
var DEFAULT_RUN_TIMEOUT_MS = 6e4;
|
|
2293
|
+
var RUNNER_OVERHEAD_MS = 45e3;
|
|
2294
|
+
var MAX_INLINE_ARGS = 1e5;
|
|
1930
2295
|
var CodeSandbox = class extends Sandbox {
|
|
1931
|
-
get serverUrl() {
|
|
1932
|
-
return this.getHost(CODE_INTERPRETER_PORT).then((h) => `https://${h}`);
|
|
1933
|
-
}
|
|
1934
2296
|
/**
|
|
1935
|
-
* Execute code
|
|
2297
|
+
* Execute code and wait for it to finish.
|
|
1936
2298
|
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
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.
|
|
1939
2302
|
*
|
|
1940
2303
|
* @param code Source code to run.
|
|
1941
2304
|
* @param opts Language, context, env vars, timeout, and streaming callbacks.
|
|
1942
2305
|
*
|
|
1943
|
-
* @returns Execution result with stdout, stderr, results, and any error.
|
|
1944
|
-
*
|
|
1945
|
-
* Code errors appear in `execution.error`; request failures throw.
|
|
1946
|
-
*
|
|
1947
2306
|
* @example
|
|
1948
2307
|
* ```ts
|
|
1949
2308
|
* const result = await sandbox.runCode(`
|
|
@@ -1957,63 +2316,42 @@ var CodeSandbox = class extends Sandbox {
|
|
|
1957
2316
|
if (opts?.context && opts?.language) {
|
|
1958
2317
|
throw new Error("Provide context or language, not both");
|
|
1959
2318
|
}
|
|
1960
|
-
const
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
} else if (item.type === "stderr") {
|
|
1988
|
-
execution.stderr += item.data;
|
|
1989
|
-
opts?.onStderr?.(item.data);
|
|
1990
|
-
} else if (item.type === "result") {
|
|
1991
|
-
const out = item;
|
|
1992
|
-
execution.results.push(out);
|
|
1993
|
-
opts?.onResult?.(out);
|
|
1994
|
-
} else if (item.type === "error") {
|
|
1995
|
-
const err = new ExecutionError(
|
|
1996
|
-
item.name,
|
|
1997
|
-
item.message,
|
|
1998
|
-
item.traceback
|
|
1999
|
-
);
|
|
2000
|
-
execution.error = err;
|
|
2001
|
-
opts?.onError?.(err);
|
|
2002
|
-
} else if (item.type === "done") {
|
|
2003
|
-
execution.executionCount = item.execution_count ?? 0;
|
|
2004
|
-
break;
|
|
2005
|
-
}
|
|
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;
|
|
2006
2346
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
clearTimeout(timer);
|
|
2010
|
-
}
|
|
2347
|
+
});
|
|
2348
|
+
return execution;
|
|
2011
2349
|
}
|
|
2012
2350
|
/**
|
|
2013
2351
|
* Create a new isolated execution context.
|
|
2014
2352
|
*
|
|
2015
|
-
*
|
|
2016
|
-
*
|
|
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.
|
|
2017
2355
|
*
|
|
2018
2356
|
* @example
|
|
2019
2357
|
* ```ts
|
|
@@ -2023,40 +2361,80 @@ var CodeSandbox = class extends Sandbox {
|
|
|
2023
2361
|
* ```
|
|
2024
2362
|
*/
|
|
2025
2363
|
async createContext(opts) {
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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;
|
|
2036
2375
|
}
|
|
2037
|
-
/**
|
|
2038
|
-
* List all active execution contexts in this sandbox.
|
|
2039
|
-
*/
|
|
2376
|
+
/** List the contexts created with {@link createContext} in this sandbox. */
|
|
2040
2377
|
async listContexts() {
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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;
|
|
2044
2383
|
}
|
|
2045
|
-
/**
|
|
2046
|
-
* Delete an execution context and free its resources.
|
|
2047
|
-
*/
|
|
2384
|
+
/** Delete an execution context, stopping its kernel. */
|
|
2048
2385
|
async deleteContext(context) {
|
|
2049
2386
|
const id = typeof context === "string" ? context : context.id;
|
|
2050
|
-
|
|
2051
|
-
|
|
2387
|
+
await this.runner({ action: "delete", context: id }, DEFAULT_RUN_TIMEOUT_MS, () => {
|
|
2388
|
+
});
|
|
2052
2389
|
}
|
|
2053
|
-
/**
|
|
2054
|
-
* Restart a context, clearing all variables and state.
|
|
2055
|
-
*/
|
|
2390
|
+
/** Restart a context, clearing all variables and state. */
|
|
2056
2391
|
async restartContext(context) {
|
|
2057
2392
|
const id = typeof context === "string" ? context : context.id;
|
|
2058
|
-
|
|
2059
|
-
|
|
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
|
+
}
|
|
2060
2438
|
}
|
|
2061
2439
|
static async create(templateOrOpts, opts) {
|
|
2062
2440
|
return super.create(templateOrOpts, opts);
|
|
@@ -2065,26 +2443,17 @@ var CodeSandbox = class extends Sandbox {
|
|
|
2065
2443
|
return super.connect(sandboxId, opts);
|
|
2066
2444
|
}
|
|
2067
2445
|
};
|
|
2068
|
-
CodeSandbox.defaultTemplate = "
|
|
2069
|
-
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
buf = lines.pop() ?? "";
|
|
2080
|
-
for (const line of lines) {
|
|
2081
|
-
if (line.trim()) yield line;
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
if (buf.trim()) yield buf;
|
|
2085
|
-
} finally {
|
|
2086
|
-
reader.releaseLock();
|
|
2087
|
-
}
|
|
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);
|
|
2088
2457
|
}
|
|
2089
2458
|
export {
|
|
2090
2459
|
AddonsAPI,
|