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