runline 0.9.0 → 0.11.0
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/core/engine.d.ts +22 -2
- package/dist/core/engine.js +217 -163
- package/dist/plugins/steel/src/browser.js +175 -0
- package/dist/plugins/steel/src/captchas.js +19 -0
- package/dist/plugins/steel/src/credentials.js +38 -0
- package/dist/plugins/steel/src/extensions.js +46 -0
- package/dist/plugins/steel/src/files.js +96 -0
- package/dist/plugins/steel/src/index.js +21 -374
- package/dist/plugins/steel/src/profiles.js +55 -0
- package/dist/plugins/steel/src/sessions.js +119 -0
- package/dist/plugins/steel/src/shared.js +72 -0
- package/dist/tests/helpers/engine-harness.d.ts +1 -0
- package/dist/tests/helpers/engine-harness.js +236 -0
- package/package.json +9 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
import { LIST_INPUT_SCHEMA, SESSION_OPTIONS_SCHEMA, api, apiKey, compactRecord } from "./shared.js";
|
|
3
|
+
export function registerSessionActions(rl) {
|
|
4
|
+
rl.registerAction("session.create", {
|
|
5
|
+
description: "Create a Steel browser session. Returns session id, websocketUrl/CDP URL, debug/viewer URLs, and profile metadata when present.",
|
|
6
|
+
inputSchema: t.Object(SESSION_OPTIONS_SCHEMA),
|
|
7
|
+
async execute(input, ctx) {
|
|
8
|
+
return api(ctx, "/v1/sessions", { method: "POST", body: compactRecord((input ?? {})) });
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
rl.registerAction("session.list", {
|
|
12
|
+
description: "List Steel sessions.",
|
|
13
|
+
inputSchema: t.Object(LIST_INPUT_SCHEMA),
|
|
14
|
+
async execute(input, ctx) {
|
|
15
|
+
return api(ctx, "/v1/sessions", { query: (input ?? {}) });
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
rl.registerAction("session.get", {
|
|
19
|
+
description: "Get a Steel session by ID.",
|
|
20
|
+
inputSchema: t.Object({ id: t.String({ description: "Session ID" }) }),
|
|
21
|
+
async execute(input, ctx) {
|
|
22
|
+
return api(ctx, `/v1/sessions/${encodeURIComponent(input.id)}`);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
rl.registerAction("session.release", {
|
|
26
|
+
description: "Release a Steel session when work is done.",
|
|
27
|
+
inputSchema: t.Object({ id: t.String({ description: "Session ID" }) }),
|
|
28
|
+
async execute(input, ctx) {
|
|
29
|
+
return api(ctx, `/v1/sessions/${encodeURIComponent(input.id)}/release`, { method: "POST" });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
rl.registerAction("session.releaseAll", {
|
|
33
|
+
description: "Release all live Steel sessions for the organization by listing sessions and releasing each live session individually.",
|
|
34
|
+
inputSchema: t.Object({}),
|
|
35
|
+
async execute(_input, ctx) {
|
|
36
|
+
const listed = await api(ctx, "/v1/sessions");
|
|
37
|
+
const sessions = Array.isArray(listed.sessions) ? listed.sessions : [];
|
|
38
|
+
const live = sessions.filter((session) => session.status === "live" || session.status === "LIVE");
|
|
39
|
+
const released = [];
|
|
40
|
+
const failed = [];
|
|
41
|
+
for (const session of live) {
|
|
42
|
+
const id = String(session.id);
|
|
43
|
+
try {
|
|
44
|
+
released.push({ id, result: await api(ctx, `/v1/sessions/${encodeURIComponent(id)}/release`, { method: "POST" }) });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
failed.push({ id, error: String(error.message ?? error) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { released, failed, count: released.length };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
rl.registerAction("session.context", {
|
|
54
|
+
description: "Capture cookies/localStorage context from a live session. Treat output as sensitive auth material.",
|
|
55
|
+
inputSchema: t.Object({ id: t.String({ description: "Session ID" }) }),
|
|
56
|
+
async execute(input, ctx) {
|
|
57
|
+
return api(ctx, `/v1/sessions/${encodeURIComponent(input.id)}/context`);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
rl.registerAction("session.traces", {
|
|
61
|
+
description: "Fetch the Agent Traces timeline for a Steel session. Supports optional ISO startTime/endTime filters.",
|
|
62
|
+
inputSchema: t.Object({
|
|
63
|
+
id: t.String({ description: "Session ID" }),
|
|
64
|
+
startTime: t.Optional(t.String({ description: "ISO timestamp lower bound" })),
|
|
65
|
+
endTime: t.Optional(t.String({ description: "ISO timestamp upper bound" })),
|
|
66
|
+
}),
|
|
67
|
+
async execute(input, ctx) {
|
|
68
|
+
const { id, ...query } = input;
|
|
69
|
+
return api(ctx, `/v1/sessions/${encodeURIComponent(String(id))}/agent-traces`, { query });
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
rl.registerAction("session.computer", {
|
|
73
|
+
description: "Execute a Steel computer-use action against a live session. Useful for model-native computer-use loops; pass actions such as take_screenshot, click_mouse, type_text, press_key, scroll, or drag_mouse.",
|
|
74
|
+
inputSchema: t.Object({
|
|
75
|
+
id: t.String({ description: "Session ID" }),
|
|
76
|
+
action: t.String({ description: "Steel computer action, e.g. take_screenshot, click_mouse, type_text, press_key, scroll, drag_mouse" }),
|
|
77
|
+
button: t.Optional(t.String({ description: "Mouse button for click actions" })),
|
|
78
|
+
coordinates: t.Optional(t.Array(t.Number(), { description: "[x, y] pixel coordinates" })),
|
|
79
|
+
text: t.Optional(t.String({ description: "Text for type_text or key for press_key" })),
|
|
80
|
+
keys: t.Optional(t.Array(t.String(), { description: "Keys for keyboard actions when supported" })),
|
|
81
|
+
deltaX: t.Optional(t.Number({ description: "Horizontal scroll delta" })),
|
|
82
|
+
deltaY: t.Optional(t.Number({ description: "Vertical scroll delta" })),
|
|
83
|
+
path: t.Optional(t.Array(t.Any(), { description: "Drag path points when supported" })),
|
|
84
|
+
screenshot: t.Optional(t.Boolean({ description: "Return a screenshot after the action" })),
|
|
85
|
+
}),
|
|
86
|
+
async execute(input, ctx) {
|
|
87
|
+
const { id, deltaX, deltaY, ...body } = input;
|
|
88
|
+
const payload = compactRecord({ ...body, delta_x: deltaX, delta_y: deltaY });
|
|
89
|
+
return api(ctx, `/v1/sessions/${encodeURIComponent(String(id))}/computer`, { method: "POST", body: payload });
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
rl.registerAction("session.events", {
|
|
93
|
+
description: "Fetch legacy recorded session events for replay tooling.",
|
|
94
|
+
inputSchema: t.Object({ id: t.String({ description: "Session ID" }) }),
|
|
95
|
+
async execute(input, ctx) {
|
|
96
|
+
return api(ctx, `/v1/sessions/${encodeURIComponent(input.id)}/events`);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
rl.registerAction("session.hls", {
|
|
100
|
+
description: "Fetch the HLS playlist for a recorded headful Steel session.",
|
|
101
|
+
inputSchema: t.Object({ id: t.String({ description: "Session ID" }) }),
|
|
102
|
+
async execute(input, ctx) {
|
|
103
|
+
return api(ctx, `/v1/sessions/${encodeURIComponent(input.id)}/hls`);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
rl.registerAction("session.cdpUrl", {
|
|
107
|
+
description: "Build a Playwright/Puppeteer CDP URL for a Steel session using the configured API key.",
|
|
108
|
+
inputSchema: t.Object({ id: t.String({ description: "Session ID" }), websocketUrl: t.Optional(t.String({ description: "Optional websocketUrl returned by session.create. If omitted, uses wss://connect.steel.dev with sessionId." })) }),
|
|
109
|
+
async execute(input, ctx) {
|
|
110
|
+
const { id, websocketUrl } = input;
|
|
111
|
+
const key = encodeURIComponent(apiKey(ctx));
|
|
112
|
+
if (websocketUrl) {
|
|
113
|
+
const sep = websocketUrl.includes("?") ? "&" : "?";
|
|
114
|
+
return { cdpUrl: `${websocketUrl}${sep}apiKey=${key}` };
|
|
115
|
+
}
|
|
116
|
+
return { cdpUrl: `wss://connect.steel.dev?apiKey=${key}&sessionId=${encodeURIComponent(id)}` };
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
const BASE_URL = "https://api.steel.dev";
|
|
3
|
+
export function apiKey(ctx) {
|
|
4
|
+
return ctx.connection.config.apiKey;
|
|
5
|
+
}
|
|
6
|
+
export async function api(ctx, path, options = {}) {
|
|
7
|
+
const url = new URL(path, BASE_URL);
|
|
8
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
9
|
+
if (value === undefined || value === null || value === "")
|
|
10
|
+
continue;
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
for (const item of value)
|
|
13
|
+
url.searchParams.append(key, String(item));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
url.searchParams.set(key, String(value));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const headers = {
|
|
20
|
+
"steel-api-key": apiKey(ctx),
|
|
21
|
+
...(options.headers ?? {}),
|
|
22
|
+
};
|
|
23
|
+
let body;
|
|
24
|
+
if (options.body !== undefined) {
|
|
25
|
+
if (options.body instanceof FormData) {
|
|
26
|
+
body = options.body;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
headers["Content-Type"] = "application/json";
|
|
30
|
+
body = JSON.stringify(options.body);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const res = await fetch(url.toString(), {
|
|
34
|
+
method: options.method ?? "GET",
|
|
35
|
+
headers,
|
|
36
|
+
body,
|
|
37
|
+
});
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
throw new Error(`Steel API error ${res.status}: ${text || res.statusText}`);
|
|
41
|
+
if (!text)
|
|
42
|
+
return {};
|
|
43
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
44
|
+
if (contentType.includes("application/json") || text.startsWith("{") || text.startsWith("["))
|
|
45
|
+
return JSON.parse(text);
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
export function compactRecord(input) {
|
|
49
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null));
|
|
50
|
+
}
|
|
51
|
+
export const LIST_INPUT_SCHEMA = {
|
|
52
|
+
limit: t.Optional(t.Number({ description: "Maximum number of results when supported" })),
|
|
53
|
+
cursor: t.Optional(t.String({ description: "Pagination cursor when supported" })),
|
|
54
|
+
};
|
|
55
|
+
export const SESSION_OPTIONS_SCHEMA = {
|
|
56
|
+
timeout: t.Optional(t.Number({ description: "Session hard timeout in milliseconds" })),
|
|
57
|
+
inactivityTimeout: t.Optional(t.Number({ description: "Release after this many milliseconds of inactivity" })),
|
|
58
|
+
useProxy: t.Optional(t.Any({ description: "true for Steel managed proxy, or proxy config object" })),
|
|
59
|
+
solveCaptcha: t.Optional(t.Boolean({ description: "Enable CAPTCHA detection/solving" })),
|
|
60
|
+
region: t.Optional(t.String({ description: "Steel region, e.g. lax or iad" })),
|
|
61
|
+
namespace: t.Optional(t.String({ description: "Credential namespace to use for this session" })),
|
|
62
|
+
userAgent: t.Optional(t.String({ description: "Custom browser user agent" })),
|
|
63
|
+
dimensions: t.Optional(t.Any({ description: "Viewport dimensions, e.g. { width: 1280, height: 768 }" })),
|
|
64
|
+
stealthConfig: t.Optional(t.Any({ description: "Steel stealth configuration, e.g. { autoCaptchaSolving: false }" })),
|
|
65
|
+
deviceConfig: t.Optional(t.Any({ description: "Device config, e.g. { device: 'mobile' }" })),
|
|
66
|
+
profileId: t.Optional(t.String({ description: "Profile ID to load" })),
|
|
67
|
+
persistProfile: t.Optional(t.Boolean({ description: "Persist profile changes on release" })),
|
|
68
|
+
credentials: t.Optional(t.Any({ description: "Credentials injection options, or {} to enable defaults" })),
|
|
69
|
+
extensionIds: t.Optional(t.Array(t.String(), { description: "Extension IDs to attach, or ['all_ext']" })),
|
|
70
|
+
sessionContext: t.Optional(t.Any({ description: "Captured session context to restore" })),
|
|
71
|
+
isSelenium: t.Optional(t.Boolean({ description: "Provision a Selenium-compatible session" })),
|
|
72
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Test harness for engine robustness scenarios (large payloads, OOM,
|
|
2
|
+
// timeouts, crashes). Runs one named scenario in this process and prints a
|
|
3
|
+
// JSON report to stdout. Tests spawn this file so that a hard process
|
|
4
|
+
// abort or exit kills the harness, not the test runner.
|
|
5
|
+
//
|
|
6
|
+
// Usage: bun run engine-harness.ts <scenario>
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { DEFAULT_CONFIG } from "../../config/types.js";
|
|
9
|
+
import { ExecutionEngine } from "../../core/engine.js";
|
|
10
|
+
import { createPluginAPI } from "../../plugin/api.js";
|
|
11
|
+
import { PluginRegistry } from "../../plugin/registry.js";
|
|
12
|
+
const BLOB_MB = 8;
|
|
13
|
+
// ~8MB of raw bytes → ~10.7MB base64
|
|
14
|
+
const base64 = Buffer.alloc(BLOB_MB * 1024 * 1024, 0xab).toString("base64");
|
|
15
|
+
const base64Sha = createHash("sha256").update(base64).digest("hex");
|
|
16
|
+
// Captures what the host-side actions actually received, so tests can
|
|
17
|
+
// verify that data crossing the execution boundary arrives byte-identical.
|
|
18
|
+
const received = {};
|
|
19
|
+
function makeFilesPlugin() {
|
|
20
|
+
const { api, resolve } = createPluginAPI("test");
|
|
21
|
+
api.setName("files");
|
|
22
|
+
api.setVersion("0.0.0");
|
|
23
|
+
api.registerAction("getAttachment", {
|
|
24
|
+
description: "Returns a large base64 payload",
|
|
25
|
+
async execute() {
|
|
26
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
27
|
+
return { filename: "msa.pdf", data: base64 };
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
api.registerAction("upload", {
|
|
31
|
+
description: "Accepts a large base64 payload",
|
|
32
|
+
async execute(input) {
|
|
33
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
34
|
+
const { data } = input;
|
|
35
|
+
received.uploadSha =
|
|
36
|
+
typeof data === "string"
|
|
37
|
+
? createHash("sha256").update(data).digest("hex")
|
|
38
|
+
: `not-a-string:${typeof data}`;
|
|
39
|
+
received.uploadBytes = typeof data === "string" ? data.length : -1;
|
|
40
|
+
return { id: "file_123", bytes: received.uploadBytes };
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
api.registerAction("send", {
|
|
44
|
+
description: "Accepts a large attachment",
|
|
45
|
+
async execute(input) {
|
|
46
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
47
|
+
const { attachment } = input;
|
|
48
|
+
received.sendBytes =
|
|
49
|
+
typeof attachment === "string" ? attachment.length : -1;
|
|
50
|
+
return { messageId: "msg_456", bytes: received.sendBytes };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
api.registerAction("append", {
|
|
54
|
+
description: "Small side effect",
|
|
55
|
+
async execute() {
|
|
56
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
57
|
+
return { updatedRows: 1 };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
api.registerAction("slow", {
|
|
61
|
+
description: "Sleeps for the given ms, then returns",
|
|
62
|
+
async execute(input) {
|
|
63
|
+
const { ms } = input;
|
|
64
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
65
|
+
return { waited: ms };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
api.registerAction("circular", {
|
|
69
|
+
description: "Returns a non-JSON-serializable (circular) value",
|
|
70
|
+
async execute() {
|
|
71
|
+
const obj = { name: "loop" };
|
|
72
|
+
obj.self = obj;
|
|
73
|
+
return obj;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return resolve();
|
|
77
|
+
}
|
|
78
|
+
function makeEngine(memoryMb = 64, timeoutMs = 30_000) {
|
|
79
|
+
const registry = new PluginRegistry();
|
|
80
|
+
registry.register(makeFilesPlugin());
|
|
81
|
+
return new ExecutionEngine(registry, {
|
|
82
|
+
...DEFAULT_CONFIG,
|
|
83
|
+
timeoutMs,
|
|
84
|
+
memoryLimitBytes: memoryMb * 1024 * 1024,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const scenarios = {
|
|
88
|
+
// The agent's original failure: multi-step chain with a large payload,
|
|
89
|
+
// default 64MB memory limit. Must complete cleanly.
|
|
90
|
+
async "chain-default"() {
|
|
91
|
+
const engine = makeEngine(64);
|
|
92
|
+
const out = await engine.execute(`
|
|
93
|
+
const att = await files.getAttachment({ messageId: "m1" });
|
|
94
|
+
const up = await files.upload({ name: att.filename, data: att.data });
|
|
95
|
+
const sent = await files.send({ to: "x@y.z", attachment: att.data });
|
|
96
|
+
const row = await files.append({ values: [["Triple-A MSA", "May"]] });
|
|
97
|
+
return { up, sent, row };
|
|
98
|
+
`);
|
|
99
|
+
return { error: out.error ?? null, result: out.result, received };
|
|
100
|
+
},
|
|
101
|
+
// Integrity: the bytes the upload action receives must be byte-identical
|
|
102
|
+
// to what getAttachment produced.
|
|
103
|
+
async integrity() {
|
|
104
|
+
const engine = makeEngine(64);
|
|
105
|
+
const out = await engine.execute(`
|
|
106
|
+
const att = await files.getAttachment({ messageId: "m1" });
|
|
107
|
+
await files.upload({ name: att.filename, data: att.data });
|
|
108
|
+
return "done";
|
|
109
|
+
`);
|
|
110
|
+
return {
|
|
111
|
+
error: out.error ?? null,
|
|
112
|
+
expectedSha: base64Sha,
|
|
113
|
+
expectedBytes: base64.length,
|
|
114
|
+
received,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
// Ergonomics: inside the sandbox a large payload is a plain string the
|
|
118
|
+
// agent can measure, slice, and pass around — no tokens, no proxies.
|
|
119
|
+
async "string-surface"() {
|
|
120
|
+
const engine = makeEngine(64);
|
|
121
|
+
const out = await engine.execute(`
|
|
122
|
+
const att = await files.getAttachment({ messageId: "m1" });
|
|
123
|
+
const d = att.data;
|
|
124
|
+
return { type: typeof d, bytes: d.length, head: d.slice(0, 8) };
|
|
125
|
+
`);
|
|
126
|
+
return {
|
|
127
|
+
error: out.error ?? null,
|
|
128
|
+
result: out.result,
|
|
129
|
+
expectedBytes: base64.length,
|
|
130
|
+
expectedHead: base64.slice(0, 8),
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
// A large value in the final return reaches the host caller intact.
|
|
134
|
+
async "final-result-large"() {
|
|
135
|
+
const engine = makeEngine(64);
|
|
136
|
+
const out = await engine.execute(`
|
|
137
|
+
const att = await files.getAttachment({ messageId: "m1" });
|
|
138
|
+
return { data: att.data };
|
|
139
|
+
`);
|
|
140
|
+
const data = out.result?.data;
|
|
141
|
+
return {
|
|
142
|
+
error: out.error ?? null,
|
|
143
|
+
resultBytes: typeof data === "string" ? data.length : -1,
|
|
144
|
+
resultSha: typeof data === "string"
|
|
145
|
+
? createHash("sha256").update(data).digest("hex")
|
|
146
|
+
: null,
|
|
147
|
+
expectedSha: base64Sha,
|
|
148
|
+
expectedBytes: base64.length,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
// An action still in flight when the run times out must not crash the
|
|
152
|
+
// host when its result arrives after the worker is gone — no unhandled
|
|
153
|
+
// rejections, no uncaught exceptions, clean timeout error.
|
|
154
|
+
async "timeout-inflight-action"() {
|
|
155
|
+
const unhandled = [];
|
|
156
|
+
process.on("unhandledRejection", (e) => unhandled.push(String(e)));
|
|
157
|
+
process.on("uncaughtException", (e) => unhandled.push(String(e)));
|
|
158
|
+
const engine = makeEngine(64, 150);
|
|
159
|
+
const out = await engine.execute(`
|
|
160
|
+
await files.slow({ ms: 1000 });
|
|
161
|
+
return "unreachable";
|
|
162
|
+
`);
|
|
163
|
+
// let the in-flight action resolve against the dead worker
|
|
164
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
165
|
+
return { error: out.error ?? null, unhandled };
|
|
166
|
+
},
|
|
167
|
+
// An action returning a non-JSON-serializable value must surface as a
|
|
168
|
+
// clean per-call error inside the sandbox — catchable by agent code —
|
|
169
|
+
// not a hang or a crash.
|
|
170
|
+
async "circular-result"() {
|
|
171
|
+
const engine = makeEngine(64);
|
|
172
|
+
const out = await engine.execute(`
|
|
173
|
+
try {
|
|
174
|
+
await files.circular({});
|
|
175
|
+
return { caught: false };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return { caught: true, message: e.message };
|
|
178
|
+
}
|
|
179
|
+
`);
|
|
180
|
+
return { error: out.error ?? null, result: out.result };
|
|
181
|
+
},
|
|
182
|
+
// Two executes on the same engine running concurrently must not
|
|
183
|
+
// interfere — separate workers, separate logs, correct results.
|
|
184
|
+
async concurrent() {
|
|
185
|
+
const engine = makeEngine(64);
|
|
186
|
+
const [a, b] = await Promise.all([
|
|
187
|
+
engine.execute(`console.log("run-a"); const r = await files.slow({ ms: 50 }); return { tag: "a", waited: r.waited };`),
|
|
188
|
+
engine.execute(`console.log("run-b"); const r = await files.slow({ ms: 30 }); return { tag: "b", waited: r.waited };`),
|
|
189
|
+
]);
|
|
190
|
+
return {
|
|
191
|
+
aError: a.error ?? null,
|
|
192
|
+
bError: b.error ?? null,
|
|
193
|
+
aResult: a.result,
|
|
194
|
+
bResult: b.result,
|
|
195
|
+
aLogs: a.logs,
|
|
196
|
+
bLogs: b.logs,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
// Agent code killing its own worker (it has the full runtime, so it can)
|
|
200
|
+
// must fail soft with a descriptive error and leave the engine usable.
|
|
201
|
+
async "worker-suicide"() {
|
|
202
|
+
const engine = makeEngine(64);
|
|
203
|
+
const out = await engine.execute(`process.exit(7);`);
|
|
204
|
+
const after = await engine.execute("return 1 + 1");
|
|
205
|
+
return {
|
|
206
|
+
error: out.error ?? null,
|
|
207
|
+
afterError: after.error ?? null,
|
|
208
|
+
afterResult: after.result,
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
// Sandbox code that genuinely exhausts the memory limit must fail soft:
|
|
212
|
+
// a clean error returned from execute(), no process abort, and the engine
|
|
213
|
+
// must remain usable for a subsequent run.
|
|
214
|
+
async "sandbox-oom"() {
|
|
215
|
+
const engine = makeEngine(32);
|
|
216
|
+
const out = await engine.execute(`
|
|
217
|
+
const hog = [];
|
|
218
|
+
while (true) hog.push(new Array(1e6).fill(1));
|
|
219
|
+
`);
|
|
220
|
+
const after = await engine.execute("return 1 + 1");
|
|
221
|
+
return {
|
|
222
|
+
error: out.error ?? null,
|
|
223
|
+
afterError: after.error ?? null,
|
|
224
|
+
afterResult: after.result,
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
const name = process.argv[2];
|
|
229
|
+
const scenario = scenarios[name];
|
|
230
|
+
if (!scenario) {
|
|
231
|
+
console.error(`Unknown scenario: ${name}`);
|
|
232
|
+
process.exit(2);
|
|
233
|
+
}
|
|
234
|
+
const report = await scenario();
|
|
235
|
+
console.log(JSON.stringify(report));
|
|
236
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Code mode for agents — turn any API or command into a callable action",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -62,12 +62,19 @@
|
|
|
62
62
|
"minisearch": "^7.2.0",
|
|
63
63
|
"typescript": "^5.8.0"
|
|
64
64
|
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"playwright": "^1.57.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"playwright": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
65
73
|
"dependencies": {
|
|
66
74
|
"chalk": "^5.6.2",
|
|
67
75
|
"commander": "^14.0.3",
|
|
68
76
|
"jiti": "^2.7.0",
|
|
69
77
|
"proper-lockfile": "^4.1.2",
|
|
70
|
-
"quickjs-emscripten": "^0.32.0",
|
|
71
78
|
"rrule": "^2.8.1",
|
|
72
79
|
"typebox": "^1.1.35"
|
|
73
80
|
}
|