@alexkroman1/aai 0.12.3 → 1.0.3
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/.turbo/turbo-build.log +20 -0
- package/CHANGELOG.md +176 -0
- package/dist/constants-VTFoymJ-.js +47 -0
- package/dist/host/_run-code.d.ts +1 -1
- package/dist/host/_runtime-conformance.d.ts +4 -5
- package/dist/host/builtin-tools.d.ts +11 -9
- package/dist/host/runtime-barrel.d.ts +15 -0
- package/dist/{direct-executor-DRRrZUp0.js → host/runtime-barrel.js} +453 -348
- package/dist/host/runtime-config.d.ts +42 -0
- package/dist/host/runtime.d.ts +119 -35
- package/dist/host/s2s.d.ts +14 -38
- package/dist/host/server.d.ts +16 -8
- package/dist/host/session-ctx.d.ts +55 -0
- package/dist/host/session.d.ts +20 -70
- package/dist/host/tool-executor.d.ts +20 -0
- package/dist/host/unstorage-kv.d.ts +1 -1
- package/dist/host/ws-handler.d.ts +4 -2
- package/dist/index.d.ts +9 -20
- package/dist/index.js +63 -2
- package/dist/{isolate → sdk}/_internal-types.d.ts +5 -9
- package/dist/{isolate → sdk}/constants.d.ts +6 -4
- package/dist/sdk/define.d.ts +66 -0
- package/dist/{isolate → sdk}/kv.d.ts +1 -49
- package/dist/sdk/manifest-barrel.d.ts +8 -0
- package/dist/sdk/manifest-barrel.js +52 -0
- package/dist/sdk/manifest.d.ts +50 -0
- package/dist/{isolate → sdk}/protocol.d.ts +59 -36
- package/dist/sdk/protocol.js +163 -0
- package/dist/{isolate → sdk}/system-prompt.d.ts +2 -2
- package/dist/sdk/types.d.ts +201 -0
- package/dist/sdk/ws-upgrade.d.ts +5 -0
- package/dist/{system-prompt-DYAYFW99.js → system-prompt-nik_iavo.js} +10 -10
- package/dist/types-Cfx_4QDK.js +39 -0
- package/dist/ws-upgrade-BeOQ7fXL.js +30 -0
- package/exports-no-dev-deps.test.ts +62 -0
- package/host/_mock-ws.ts +185 -0
- package/host/_run-code.ts +217 -0
- package/host/_runtime-conformance.ts +143 -0
- package/host/_test-utils.ts +276 -0
- package/host/builtin-tools.test.ts +774 -0
- package/host/builtin-tools.ts +255 -0
- package/host/cleanup.test.ts +422 -0
- package/host/fixture-replay.test.ts +463 -0
- package/host/fixtures/README.md +40 -0
- package/host/fixtures/greeting-session-sequence.json +40 -0
- package/host/fixtures/reply-audio-samples.json +42 -0
- package/host/fixtures/reply-lifecycle.json +21 -0
- package/host/fixtures/session-ready.json +48 -0
- package/host/fixtures/session-updated.json +45 -0
- package/host/fixtures/simple-question-sequence.json +73 -0
- package/host/fixtures/tool-call-sequence.json +114 -0
- package/host/fixtures/tool-calls.json +11 -0
- package/host/fixtures/tool-config-session-sequence.json +51 -0
- package/host/fixtures/user-speech-recognition.json +30 -0
- package/host/fixtures/web-search-sequence.json +122 -0
- package/host/integration.test.ts +222 -0
- package/host/runtime-barrel.ts +25 -0
- package/host/runtime-config.test.ts +71 -0
- package/host/runtime-config.ts +99 -0
- package/host/runtime.test.ts +641 -0
- package/host/runtime.ts +308 -0
- package/host/s2s-fixtures.test.ts +237 -0
- package/host/s2s.test.ts +562 -0
- package/host/s2s.ts +310 -0
- package/host/server-shutdown.test.ts +76 -0
- package/host/server.test.ts +116 -0
- package/host/server.ts +223 -0
- package/host/session-ctx.ts +107 -0
- package/host/session-fixture-replay.test.ts +136 -0
- package/host/session-prompt.test.ts +77 -0
- package/host/session.test.ts +590 -0
- package/host/session.ts +370 -0
- package/host/tool-executor.test.ts +124 -0
- package/host/tool-executor.ts +80 -0
- package/host/unstorage-kv.test.ts +99 -0
- package/host/unstorage-kv.ts +69 -0
- package/host/ws-handler.test.ts +739 -0
- package/host/ws-handler.ts +255 -0
- package/index.ts +16 -0
- package/package.json +24 -72
- package/sdk/_internal-types.test.ts +34 -0
- package/sdk/_internal-types.ts +115 -0
- package/sdk/compat-fixtures/README.md +26 -0
- package/sdk/compat-fixtures/v1.json +68 -0
- package/sdk/constants.ts +77 -0
- package/sdk/define.test.ts +57 -0
- package/sdk/define.ts +88 -0
- package/sdk/kv.ts +60 -0
- package/sdk/manifest-barrel.ts +12 -0
- package/sdk/manifest.test.ts +56 -0
- package/sdk/manifest.ts +89 -0
- package/sdk/protocol-compat.test.ts +187 -0
- package/sdk/protocol-snapshot.test.ts +199 -0
- package/sdk/protocol.test.ts +170 -0
- package/sdk/protocol.ts +223 -0
- package/sdk/schema-alignment.test.ts +191 -0
- package/sdk/system-prompt.test.ts +111 -0
- package/sdk/system-prompt.ts +74 -0
- package/sdk/tsconfig.json +12 -0
- package/sdk/types-inference.test.ts +122 -0
- package/sdk/types.test.ts +14 -0
- package/sdk/types.ts +226 -0
- package/sdk/utils.test.ts +52 -0
- package/sdk/utils.ts +20 -0
- package/sdk/ws-upgrade.test.ts +48 -0
- package/sdk/ws-upgrade.ts +13 -0
- package/tsconfig.build.json +14 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +26 -0
- package/vitest.config.ts +17 -0
- package/dist/host/_test-utils.d.ts +0 -73
- package/dist/host/direct-executor.d.ts +0 -130
- package/dist/host/index.d.ts +0 -19
- package/dist/host/index.js +0 -165
- package/dist/host/matchers.d.ts +0 -20
- package/dist/host/matchers.js +0 -41
- package/dist/host/server.js +0 -164
- package/dist/host/testing.d.ts +0 -294
- package/dist/host/testing.js +0 -2
- package/dist/host/vite-plugin.d.ts +0 -15
- package/dist/host/vite-plugin.js +0 -83
- package/dist/isolate/_kv-utils.d.ts +0 -10
- package/dist/isolate/_utils.js +0 -17
- package/dist/isolate/hooks.d.ts +0 -44
- package/dist/isolate/hooks.js +0 -58
- package/dist/isolate/index.d.ts +0 -18
- package/dist/isolate/index.js +0 -6
- package/dist/isolate/kv.js +0 -1
- package/dist/isolate/protocol.js +0 -2
- package/dist/isolate/types.d.ts +0 -418
- package/dist/isolate/types.js +0 -175
- package/dist/protocol-rcOrz7T3.js +0 -183
- package/dist/testing-BreLdpq-.js +0 -513
- package/dist/types.test-d.d.ts +0 -7
- /package/dist/{isolate/_utils.d.ts → sdk/utils.d.ts} +0 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
// Copyright 2025 the AAI authors. MIT license.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { createMockToolContext } from "./_test-utils.ts";
|
|
5
|
+
import { executeInIsolate, resolveAllBuiltins } from "./builtin-tools.ts";
|
|
6
|
+
|
|
7
|
+
describe("resolveAllBuiltins schemas", () => {
|
|
8
|
+
test("returns requested tools", () => {
|
|
9
|
+
const { schemas } = resolveAllBuiltins([
|
|
10
|
+
"web_search",
|
|
11
|
+
"visit_webpage",
|
|
12
|
+
"run_code",
|
|
13
|
+
"fetch_json",
|
|
14
|
+
]);
|
|
15
|
+
expect(schemas).toHaveLength(4);
|
|
16
|
+
const names = schemas.map((s) => s.name);
|
|
17
|
+
expect(names).toContain("web_search");
|
|
18
|
+
expect(names).toContain("visit_webpage");
|
|
19
|
+
expect(names).toContain("run_code");
|
|
20
|
+
expect(names).toContain("fetch_json");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns empty for no tools", () => {
|
|
24
|
+
const { schemas } = resolveAllBuiltins([]);
|
|
25
|
+
expect(schemas).toHaveLength(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("unknown tool name returns empty", () => {
|
|
29
|
+
const { schemas } = resolveAllBuiltins(["nonexistent_tool"]);
|
|
30
|
+
expect(schemas).toHaveLength(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("resolveAllBuiltins defs", () => {
|
|
35
|
+
test("returns tool defs with execute functions", () => {
|
|
36
|
+
const { defs } = resolveAllBuiltins(["web_search", "fetch_json"]);
|
|
37
|
+
expect(Object.keys(defs)).toEqual(["web_search", "fetch_json"]);
|
|
38
|
+
expect(defs.web_search?.execute).toBeTypeOf("function");
|
|
39
|
+
expect(defs.fetch_json?.execute).toBeTypeOf("function");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("unknown tool name is skipped", () => {
|
|
43
|
+
const { defs } = resolveAllBuiltins(["nonexistent_tool"]);
|
|
44
|
+
expect(Object.keys(defs)).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── run_code ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
test("run_code executes and returns stdout", async () => {
|
|
50
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
51
|
+
const ctx = createMockToolContext();
|
|
52
|
+
const result = await defs.run_code?.execute({ code: 'console.log("hello")' }, ctx);
|
|
53
|
+
expect(result).toBe("hello");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("run_code returns error for syntax errors", async () => {
|
|
57
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
58
|
+
const ctx = createMockToolContext();
|
|
59
|
+
const result = await defs.run_code?.execute({ code: "%%%" }, ctx);
|
|
60
|
+
expect(result).toHaveProperty("error");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("run_code returns no-output message for silent code", async () => {
|
|
64
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
65
|
+
const ctx = createMockToolContext();
|
|
66
|
+
const result = await defs.run_code?.execute({ code: "const x = 1 + 1;" }, ctx);
|
|
67
|
+
expect(result).toBe("Code ran successfully (no output)");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("run_code captures console.warn and console.error", async () => {
|
|
71
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
72
|
+
const ctx = createMockToolContext();
|
|
73
|
+
const result = await defs.run_code?.execute(
|
|
74
|
+
{
|
|
75
|
+
code: 'console.warn("w"); console.error("e"); console.debug("d"); console.info("i")',
|
|
76
|
+
},
|
|
77
|
+
ctx,
|
|
78
|
+
);
|
|
79
|
+
expect(result).toBe("w\ne\nd\ni");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── run_code security: vm sandbox prevents host access ──────────────
|
|
83
|
+
|
|
84
|
+
test("run_code sandbox blocks network access", async () => {
|
|
85
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
86
|
+
const ctx = createMockToolContext();
|
|
87
|
+
const result = await defs.run_code?.execute(
|
|
88
|
+
{
|
|
89
|
+
code: `
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch("https://example.com");
|
|
92
|
+
console.log("ESCAPED:" + res.status);
|
|
93
|
+
} catch(e) {
|
|
94
|
+
console.log("BLOCKED:" + e.message);
|
|
95
|
+
}
|
|
96
|
+
`,
|
|
97
|
+
},
|
|
98
|
+
ctx,
|
|
99
|
+
);
|
|
100
|
+
expect(result).toBeTypeOf("string");
|
|
101
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("run_code sandbox blocks filesystem writes", async () => {
|
|
105
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
106
|
+
const ctx = createMockToolContext();
|
|
107
|
+
const result = await defs.run_code?.execute(
|
|
108
|
+
{
|
|
109
|
+
code: `
|
|
110
|
+
try {
|
|
111
|
+
const fs = await import("node:fs");
|
|
112
|
+
fs.writeFileSync("/tmp/pwned.txt", "owned");
|
|
113
|
+
console.log("ESCAPED");
|
|
114
|
+
} catch(e) {
|
|
115
|
+
console.log("BLOCKED:" + e.message);
|
|
116
|
+
}
|
|
117
|
+
`,
|
|
118
|
+
},
|
|
119
|
+
ctx,
|
|
120
|
+
);
|
|
121
|
+
expect(result).toBeTypeOf("string");
|
|
122
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("run_code sandbox blocks child process spawning", async () => {
|
|
126
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
127
|
+
const ctx = createMockToolContext();
|
|
128
|
+
const result = await defs.run_code?.execute(
|
|
129
|
+
{
|
|
130
|
+
code: `
|
|
131
|
+
try {
|
|
132
|
+
const cp = await import("node:child_process");
|
|
133
|
+
const out = cp.execSync("id").toString();
|
|
134
|
+
console.log("ESCAPED:" + out);
|
|
135
|
+
} catch(e) {
|
|
136
|
+
console.log("BLOCKED:" + e.message);
|
|
137
|
+
}
|
|
138
|
+
`,
|
|
139
|
+
},
|
|
140
|
+
ctx,
|
|
141
|
+
);
|
|
142
|
+
expect(result).toBeTypeOf("string");
|
|
143
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("run_code sandbox blocks env var access", async () => {
|
|
147
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
148
|
+
const ctx = createMockToolContext();
|
|
149
|
+
const result = await defs.run_code?.execute(
|
|
150
|
+
{
|
|
151
|
+
code: `
|
|
152
|
+
try {
|
|
153
|
+
const keys = process.env ? Object.keys(process.env) : [];
|
|
154
|
+
const hasPath = keys.includes("PATH");
|
|
155
|
+
const hasHome = keys.includes("HOME");
|
|
156
|
+
console.log(hasPath || hasHome ? "LEAKED_ENV" : "SAFE:" + keys.length);
|
|
157
|
+
} catch(e) {
|
|
158
|
+
console.log("SAFE:" + e.message);
|
|
159
|
+
}
|
|
160
|
+
`,
|
|
161
|
+
},
|
|
162
|
+
ctx,
|
|
163
|
+
);
|
|
164
|
+
expect(result).toBeTypeOf("string");
|
|
165
|
+
expect(result as string).not.toMatch(/LEAKED_ENV/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("run_code sandbox prevents constructor chain escape", async () => {
|
|
169
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
170
|
+
const ctx = createMockToolContext();
|
|
171
|
+
// This was the critical bypass in the old regex approach — the VM context
|
|
172
|
+
// doesn't expose `process` so host secrets can't be exfiltrated.
|
|
173
|
+
const result = await defs.run_code?.execute(
|
|
174
|
+
{
|
|
175
|
+
code: `
|
|
176
|
+
const c = "con" + "stru" + "ctor";
|
|
177
|
+
const F = ""[c][c];
|
|
178
|
+
try {
|
|
179
|
+
const p = F("return process")();
|
|
180
|
+
const keys = p && p.env ? Object.keys(p.env) : [];
|
|
181
|
+
const hasPath = keys.includes("PATH");
|
|
182
|
+
console.log(hasPath ? "LEAKED_ENV" : "SAFE:" + keys.length);
|
|
183
|
+
} catch(e) {
|
|
184
|
+
console.log("SAFE:" + e.message);
|
|
185
|
+
}
|
|
186
|
+
`,
|
|
187
|
+
},
|
|
188
|
+
ctx,
|
|
189
|
+
);
|
|
190
|
+
expect(result).toBeTypeOf("string");
|
|
191
|
+
expect(result as string).not.toMatch(/LEAKED_ENV/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("run_code allows normal .constructor property check", async () => {
|
|
195
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
196
|
+
const ctx = createMockToolContext();
|
|
197
|
+
const result = await defs.run_code?.execute(
|
|
198
|
+
{ code: 'console.log("hello".constructor.name)' },
|
|
199
|
+
ctx,
|
|
200
|
+
);
|
|
201
|
+
expect(result).toBe("String");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("run_code sandbox blocks console.log.constructor code generation", async () => {
|
|
205
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
206
|
+
const ctx = createMockToolContext();
|
|
207
|
+
const result = await defs.run_code?.execute(
|
|
208
|
+
{
|
|
209
|
+
code: `
|
|
210
|
+
try {
|
|
211
|
+
const fn = console.log.constructor('return 1')();
|
|
212
|
+
console.log("ESCAPED:" + fn);
|
|
213
|
+
} catch(e) {
|
|
214
|
+
console.log("BLOCKED:" + e.message);
|
|
215
|
+
}
|
|
216
|
+
`,
|
|
217
|
+
},
|
|
218
|
+
ctx,
|
|
219
|
+
);
|
|
220
|
+
expect(result).toBeTypeOf("string");
|
|
221
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
222
|
+
expect(result as string).not.toMatch(/ESCAPED/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("run_code sandbox blocks URL.constructor.constructor code generation", async () => {
|
|
226
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
227
|
+
const ctx = createMockToolContext();
|
|
228
|
+
const result = await defs.run_code?.execute(
|
|
229
|
+
{
|
|
230
|
+
code: `
|
|
231
|
+
try {
|
|
232
|
+
const fn = URL.constructor.constructor('return 1')();
|
|
233
|
+
console.log("ESCAPED:" + fn);
|
|
234
|
+
} catch(e) {
|
|
235
|
+
console.log("BLOCKED:" + e.message);
|
|
236
|
+
}
|
|
237
|
+
`,
|
|
238
|
+
},
|
|
239
|
+
ctx,
|
|
240
|
+
);
|
|
241
|
+
expect(result).toBeTypeOf("string");
|
|
242
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
243
|
+
expect(result as string).not.toMatch(/ESCAPED/);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("run_code sandbox blocks template literal constructor bypass", async () => {
|
|
247
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
248
|
+
const ctx = createMockToolContext();
|
|
249
|
+
const result = await defs.run_code?.execute(
|
|
250
|
+
{
|
|
251
|
+
code: `
|
|
252
|
+
const c = \`\${"con"}\${"stru"}\${"ctor"}\`;
|
|
253
|
+
const F = ""[c][c];
|
|
254
|
+
try {
|
|
255
|
+
const p = F("return process")();
|
|
256
|
+
const keys = p && p.env ? Object.keys(p.env) : [];
|
|
257
|
+
const hasPath = keys.includes("PATH");
|
|
258
|
+
console.log(hasPath ? "LEAKED_ENV" : "SAFE:" + keys.length + " keys");
|
|
259
|
+
} catch(e) {
|
|
260
|
+
console.log("SAFE:" + e.message);
|
|
261
|
+
}
|
|
262
|
+
`,
|
|
263
|
+
},
|
|
264
|
+
ctx,
|
|
265
|
+
);
|
|
266
|
+
expect(result).toBeTypeOf("string");
|
|
267
|
+
expect(result as string).not.toMatch(/LEAKED_ENV/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("run_code sandbox blocks Array.join constructor bypass", async () => {
|
|
271
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
272
|
+
const ctx = createMockToolContext();
|
|
273
|
+
const result = await defs.run_code?.execute(
|
|
274
|
+
{
|
|
275
|
+
code: `
|
|
276
|
+
const c = ["con","stru","ctor"].join("");
|
|
277
|
+
const F = ""[c][c];
|
|
278
|
+
try {
|
|
279
|
+
const p = F("return process")();
|
|
280
|
+
const keys = p && p.env ? Object.keys(p.env) : [];
|
|
281
|
+
const hasPath = keys.includes("PATH");
|
|
282
|
+
console.log(hasPath ? "LEAKED_ENV" : "SAFE:" + keys.length + " keys");
|
|
283
|
+
} catch(e) {
|
|
284
|
+
console.log("SAFE:" + e.message);
|
|
285
|
+
}
|
|
286
|
+
`,
|
|
287
|
+
},
|
|
288
|
+
ctx,
|
|
289
|
+
);
|
|
290
|
+
expect(result).toBeTypeOf("string");
|
|
291
|
+
expect(result as string).not.toMatch(/LEAKED_ENV/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("run_code sandbox blocks fromCharCode constructor bypass", async () => {
|
|
295
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
296
|
+
const ctx = createMockToolContext();
|
|
297
|
+
const result = await defs.run_code?.execute(
|
|
298
|
+
{
|
|
299
|
+
code: `
|
|
300
|
+
const s = String.fromCharCode(99,111,110,115,116,114,117,99,116,111,114);
|
|
301
|
+
const F = ""[s][s];
|
|
302
|
+
try {
|
|
303
|
+
const p = F("return process")();
|
|
304
|
+
const keys = p && p.env ? Object.keys(p.env) : [];
|
|
305
|
+
const hasPath = keys.includes("PATH");
|
|
306
|
+
console.log(hasPath ? "LEAKED_ENV" : "SAFE:" + keys.length + " keys");
|
|
307
|
+
} catch(e) {
|
|
308
|
+
console.log("SAFE:" + e.message);
|
|
309
|
+
}
|
|
310
|
+
`,
|
|
311
|
+
},
|
|
312
|
+
ctx,
|
|
313
|
+
);
|
|
314
|
+
expect(result).toBeTypeOf("string");
|
|
315
|
+
expect(result as string).not.toMatch(/LEAKED_ENV/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("run_code sandbox blocks dynamic import of node:os", async () => {
|
|
319
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
320
|
+
const ctx = createMockToolContext();
|
|
321
|
+
const result = await defs.run_code?.execute(
|
|
322
|
+
{
|
|
323
|
+
code: `
|
|
324
|
+
try {
|
|
325
|
+
const m = await import("node:os");
|
|
326
|
+
console.log("ESCAPED: " + m.hostname());
|
|
327
|
+
} catch(e) {
|
|
328
|
+
console.log("BLOCKED: " + e.message);
|
|
329
|
+
}
|
|
330
|
+
`,
|
|
331
|
+
},
|
|
332
|
+
ctx,
|
|
333
|
+
);
|
|
334
|
+
expect(result).toBeTypeOf("string");
|
|
335
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
336
|
+
expect(result as string).not.toMatch(/ESCAPED/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("run_code sandbox blocks fetch to cloud metadata endpoint", async () => {
|
|
340
|
+
const { defs } = resolveAllBuiltins(["run_code"]);
|
|
341
|
+
const ctx = createMockToolContext();
|
|
342
|
+
const result = await defs.run_code?.execute(
|
|
343
|
+
{
|
|
344
|
+
code: `
|
|
345
|
+
try {
|
|
346
|
+
const res = await fetch("http://169.254.169.254/latest/meta-data/");
|
|
347
|
+
console.log("ESCAPED:" + res.status);
|
|
348
|
+
} catch(e) {
|
|
349
|
+
console.log("BLOCKED:" + e.message);
|
|
350
|
+
}
|
|
351
|
+
`,
|
|
352
|
+
},
|
|
353
|
+
ctx,
|
|
354
|
+
);
|
|
355
|
+
expect(result).toBeTypeOf("string");
|
|
356
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ─── fetch_json ────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
test("fetch_json fetches and returns JSON", async () => {
|
|
362
|
+
const mockData = { name: "test", value: 42 };
|
|
363
|
+
const mockFetch = () => Promise.resolve(new Response(JSON.stringify(mockData)));
|
|
364
|
+
const { defs } = resolveAllBuiltins(["fetch_json"], {
|
|
365
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
366
|
+
});
|
|
367
|
+
const ctx = createMockToolContext();
|
|
368
|
+
const result = await defs.fetch_json?.execute({ url: "https://api.example.com/data" }, ctx);
|
|
369
|
+
expect(result).toEqual(mockData);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("fetch_json returns error for non-ok response", async () => {
|
|
373
|
+
const mockFetch = () =>
|
|
374
|
+
Promise.resolve(new Response("", { status: 500, statusText: "Internal Server Error" }));
|
|
375
|
+
const { defs } = resolveAllBuiltins(["fetch_json"], {
|
|
376
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
377
|
+
});
|
|
378
|
+
const ctx = createMockToolContext();
|
|
379
|
+
const result = await defs.fetch_json?.execute({ url: "https://api.example.com/fail" }, ctx);
|
|
380
|
+
expect(result).toEqual({
|
|
381
|
+
error: "HTTP 500 Internal Server Error",
|
|
382
|
+
url: "https://api.example.com/fail",
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("fetch_json returns error for invalid JSON response", async () => {
|
|
387
|
+
const mockFetch = () => Promise.resolve(new Response("not-json"));
|
|
388
|
+
const { defs } = resolveAllBuiltins(["fetch_json"], {
|
|
389
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
390
|
+
});
|
|
391
|
+
const ctx = createMockToolContext();
|
|
392
|
+
const result = await defs.fetch_json?.execute({ url: "https://api.example.com/text" }, ctx);
|
|
393
|
+
expect(result).toEqual({
|
|
394
|
+
error: "Response was not valid JSON",
|
|
395
|
+
url: "https://api.example.com/text",
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("fetch_json passes allowed custom headers to fetch", async () => {
|
|
400
|
+
const mockFetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ ok: true }))));
|
|
401
|
+
const { defs } = resolveAllBuiltins(["fetch_json"], {
|
|
402
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
403
|
+
});
|
|
404
|
+
const ctx = createMockToolContext();
|
|
405
|
+
await defs.fetch_json?.execute(
|
|
406
|
+
{
|
|
407
|
+
url: "https://api.example.com",
|
|
408
|
+
headers: { Accept: "application/json", "x-api-key": "tok" },
|
|
409
|
+
},
|
|
410
|
+
ctx,
|
|
411
|
+
);
|
|
412
|
+
const callArgs = mockFetch.mock.calls[0] as unknown as [string, RequestInit];
|
|
413
|
+
expect(callArgs[1]).toMatchObject({
|
|
414
|
+
headers: { Accept: "application/json", "x-api-key": "tok" },
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("fetch_json blocks dangerous headers like Authorization", async () => {
|
|
419
|
+
const mockFetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ ok: true }))));
|
|
420
|
+
const { defs } = resolveAllBuiltins(["fetch_json"], {
|
|
421
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
422
|
+
});
|
|
423
|
+
const ctx = createMockToolContext();
|
|
424
|
+
await defs.fetch_json?.execute(
|
|
425
|
+
{
|
|
426
|
+
url: "https://api.example.com",
|
|
427
|
+
headers: { Authorization: "Bearer tok", Accept: "application/json" },
|
|
428
|
+
},
|
|
429
|
+
ctx,
|
|
430
|
+
);
|
|
431
|
+
const callArgs = mockFetch.mock.calls[0] as unknown as [string, RequestInit];
|
|
432
|
+
// Authorization should be stripped, Accept should remain
|
|
433
|
+
expect(callArgs[1]).toMatchObject({ headers: { Accept: "application/json" } });
|
|
434
|
+
expect((callArgs[1].headers as Record<string, string>).Authorization).toBeUndefined();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("fetch_json delegates fetch without SSRF checks — platform adapter handles it", async () => {
|
|
438
|
+
const mockFetch = vi.fn(async () => new Response(JSON.stringify({ ok: true })));
|
|
439
|
+
const { defs } = resolveAllBuiltins(["fetch_json"], {
|
|
440
|
+
fetch: mockFetch as unknown as typeof globalThis.fetch,
|
|
441
|
+
});
|
|
442
|
+
const ctx = createMockToolContext();
|
|
443
|
+
// SDK tools pass through — SSRF is enforced by the network adapter in
|
|
444
|
+
// the platform sandbox and by the runtime's fetch in self-hosted mode.
|
|
445
|
+
await defs.fetch_json?.execute({ url: "http://169.254.169.254/latest/meta-data/" }, ctx);
|
|
446
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ─── web_search ────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
test("web_search returns error when BRAVE_API_KEY is not set", async () => {
|
|
452
|
+
const { defs } = resolveAllBuiltins(["web_search"]);
|
|
453
|
+
const ctx = createMockToolContext({ env: {} });
|
|
454
|
+
const result = await defs.web_search?.execute({ query: "test" }, ctx);
|
|
455
|
+
expect(result).toEqual({ error: "BRAVE_API_KEY is not set — web search unavailable" });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("web_search returns error on non-ok response", async () => {
|
|
459
|
+
const mockFetch = () =>
|
|
460
|
+
Promise.resolve(new Response("", { status: 500, statusText: "Internal Server Error" }));
|
|
461
|
+
const { defs } = resolveAllBuiltins(["web_search"], {
|
|
462
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
463
|
+
});
|
|
464
|
+
const ctx = createMockToolContext({ env: { BRAVE_API_KEY: "key123" } });
|
|
465
|
+
const result = await defs.web_search?.execute({ query: "test" }, ctx);
|
|
466
|
+
expect(result).toEqual({ error: "Search request failed: 500 Internal Server Error" });
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("web_search returns empty results when response has no web results", async () => {
|
|
470
|
+
const mockFetch = () => Promise.resolve(new Response(JSON.stringify({ invalid: true })));
|
|
471
|
+
const { defs } = resolveAllBuiltins(["web_search"], {
|
|
472
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
473
|
+
});
|
|
474
|
+
const ctx = createMockToolContext({ env: { BRAVE_API_KEY: "key123" } });
|
|
475
|
+
const result = await defs.web_search?.execute({ query: "test" }, ctx);
|
|
476
|
+
expect(result).toEqual([]);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("web_search returns results from Brave API", async () => {
|
|
480
|
+
const braveResponse = {
|
|
481
|
+
web: {
|
|
482
|
+
results: [
|
|
483
|
+
{ title: "Result 1", url: "https://example.com/1", description: "Desc 1" },
|
|
484
|
+
{ title: "Result 2", url: "https://example.com/2", description: "Desc 2" },
|
|
485
|
+
],
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
const mockFetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify(braveResponse))));
|
|
489
|
+
const { defs } = resolveAllBuiltins(["web_search"], {
|
|
490
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
491
|
+
});
|
|
492
|
+
const ctx = createMockToolContext({ env: { BRAVE_API_KEY: "key123" } });
|
|
493
|
+
const result = await defs.web_search?.execute({ query: "test", max_results: 2 }, ctx);
|
|
494
|
+
expect(result).toEqual([
|
|
495
|
+
{ title: "Result 1", url: "https://example.com/1", description: "Desc 1" },
|
|
496
|
+
{ title: "Result 2", url: "https://example.com/2", description: "Desc 2" },
|
|
497
|
+
]);
|
|
498
|
+
// Check correct URL construction
|
|
499
|
+
const fetchUrl = (mockFetch.mock.calls[0] as unknown as [string])[0];
|
|
500
|
+
expect(fetchUrl).toContain("q=test");
|
|
501
|
+
expect(fetchUrl).toContain("count=2");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ─── visit_webpage ─────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
test("visit_webpage returns content for successful fetch", async () => {
|
|
507
|
+
const html = "<html><body><p>Hello World</p></body></html>";
|
|
508
|
+
const mockFetch = () => Promise.resolve(new Response(html));
|
|
509
|
+
const { defs } = resolveAllBuiltins(["visit_webpage"], {
|
|
510
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
511
|
+
});
|
|
512
|
+
const ctx = createMockToolContext();
|
|
513
|
+
const result = (await defs.visit_webpage?.execute(
|
|
514
|
+
{ url: "https://example.com" },
|
|
515
|
+
ctx,
|
|
516
|
+
)) as Record<string, unknown>;
|
|
517
|
+
expect(result.url).toBe("https://example.com");
|
|
518
|
+
expect(result.content).toBeTypeOf("string");
|
|
519
|
+
expect((result.content as string).length).toBeGreaterThan(0);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("visit_webpage returns error for non-ok response", async () => {
|
|
523
|
+
const mockFetch = () =>
|
|
524
|
+
Promise.resolve(new Response("", { status: 404, statusText: "Not Found" }));
|
|
525
|
+
const { defs } = resolveAllBuiltins(["visit_webpage"], {
|
|
526
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
527
|
+
});
|
|
528
|
+
const ctx = createMockToolContext();
|
|
529
|
+
const result = await defs.visit_webpage?.execute({ url: "https://example.com/missing" }, ctx);
|
|
530
|
+
expect(result).toEqual({
|
|
531
|
+
error: "Failed to fetch: 404 Not Found",
|
|
532
|
+
url: "https://example.com/missing",
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("visit_webpage truncates content exceeding MAX_PAGE_CHARS", async () => {
|
|
537
|
+
// MAX_PAGE_CHARS is 10_000. Create content that, when converted from HTML,
|
|
538
|
+
// will exceed that limit.
|
|
539
|
+
const longText = "A".repeat(15_000);
|
|
540
|
+
const html = `<html><body><p>${longText}</p></body></html>`;
|
|
541
|
+
const mockFetch = () => Promise.resolve(new Response(html));
|
|
542
|
+
const { defs } = resolveAllBuiltins(["visit_webpage"], {
|
|
543
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
544
|
+
});
|
|
545
|
+
const ctx = createMockToolContext();
|
|
546
|
+
const result = (await defs.visit_webpage?.execute(
|
|
547
|
+
{ url: "https://example.com" },
|
|
548
|
+
ctx,
|
|
549
|
+
)) as Record<string, unknown>;
|
|
550
|
+
expect((result.content as string).length).toBeLessThanOrEqual(10_000);
|
|
551
|
+
expect(result.truncated).toBe(true);
|
|
552
|
+
expect(typeof result.totalChars).toBe("number");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("visit_webpage follows redirects without re-validating target", async () => {
|
|
556
|
+
const mockFetch = vi.fn(async (url: string) => {
|
|
557
|
+
if (url === "https://evil.com/redirect") {
|
|
558
|
+
return new Response("<html><body>metadata: leaked-iam-creds</body></html>", {
|
|
559
|
+
status: 200,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return new Response("", { status: 404 });
|
|
563
|
+
});
|
|
564
|
+
const { defs } = resolveAllBuiltins(["visit_webpage"], {
|
|
565
|
+
fetch: mockFetch as unknown as typeof globalThis.fetch,
|
|
566
|
+
});
|
|
567
|
+
const ctx = createMockToolContext();
|
|
568
|
+
const result = await defs.visit_webpage?.execute({ url: "https://evil.com/redirect" }, ctx);
|
|
569
|
+
expect(result).toHaveProperty("content");
|
|
570
|
+
expect((result as { content: string }).content).toContain("leaked-iam-creds");
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
describe("executeInIsolate", () => {
|
|
575
|
+
test("arithmetic and output", async () => {
|
|
576
|
+
const result = await executeInIsolate("console.log(2 + 2)");
|
|
577
|
+
expect(result).toBe("4");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("async code works", async () => {
|
|
581
|
+
const result = await executeInIsolate(`
|
|
582
|
+
const delay = (ms) => new Promise(r => setTimeout(r, ms));
|
|
583
|
+
await delay(50);
|
|
584
|
+
console.log("async done");
|
|
585
|
+
`);
|
|
586
|
+
expect(result).toBe("async done");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("runtime errors return error object", async () => {
|
|
590
|
+
const result = await executeInIsolate("throw new Error('boom')");
|
|
591
|
+
expect(result).toHaveProperty("error");
|
|
592
|
+
expect((result as { error: string }).error).toContain("boom");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("http module not available via require", async () => {
|
|
596
|
+
const result = await executeInIsolate(`
|
|
597
|
+
try {
|
|
598
|
+
const http = require("http");
|
|
599
|
+
console.log("ESCAPED");
|
|
600
|
+
} catch(e) {
|
|
601
|
+
console.log("BLOCKED:" + e.message);
|
|
602
|
+
}
|
|
603
|
+
`);
|
|
604
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("filesystem modules not available via require", async () => {
|
|
608
|
+
const result = await executeInIsolate(`
|
|
609
|
+
try {
|
|
610
|
+
const fs = require("fs");
|
|
611
|
+
fs.writeFileSync("/tmp/pwned.txt", "owned");
|
|
612
|
+
console.log("ESCAPED");
|
|
613
|
+
} catch(e) {
|
|
614
|
+
console.log("BLOCKED:" + e.message);
|
|
615
|
+
}
|
|
616
|
+
`);
|
|
617
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("child process modules not available via require", async () => {
|
|
621
|
+
const result = await executeInIsolate(`
|
|
622
|
+
try {
|
|
623
|
+
const cp = require("child_process");
|
|
624
|
+
cp.execSync("id");
|
|
625
|
+
console.log("ESCAPED");
|
|
626
|
+
} catch(e) {
|
|
627
|
+
console.log("BLOCKED:" + e.message);
|
|
628
|
+
}
|
|
629
|
+
`);
|
|
630
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("process.exit is not available", async () => {
|
|
634
|
+
const result = await executeInIsolate(`
|
|
635
|
+
try {
|
|
636
|
+
process.exit(1);
|
|
637
|
+
console.log("STILL_RUNNING");
|
|
638
|
+
} catch(e) {
|
|
639
|
+
console.log("BLOCKED:" + e.message);
|
|
640
|
+
}
|
|
641
|
+
`);
|
|
642
|
+
expect(result as string).toMatch(/BLOCKED/);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("global state does not persist between invocations", async () => {
|
|
646
|
+
await executeInIsolate("globalThis.__secret = 'leaked';");
|
|
647
|
+
const result = await executeInIsolate(`
|
|
648
|
+
console.log(typeof globalThis.__secret === "undefined" ? "ISOLATED" : "LEAKED:" + globalThis.__secret);
|
|
649
|
+
`);
|
|
650
|
+
expect(result).toBe("ISOLATED");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("variables do not leak between invocations", async () => {
|
|
654
|
+
await executeInIsolate("var crossLeak = 42;");
|
|
655
|
+
const result = await executeInIsolate(`
|
|
656
|
+
try {
|
|
657
|
+
console.log(typeof crossLeak === "undefined" ? "ISOLATED" : "LEAKED:" + crossLeak);
|
|
658
|
+
} catch(e) {
|
|
659
|
+
console.log("ISOLATED");
|
|
660
|
+
}
|
|
661
|
+
`);
|
|
662
|
+
expect(result).toBe("ISOLATED");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// ─── timer leak prevention ────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
test("setInterval timers created in sandbox are cleaned up after execution", async () => {
|
|
668
|
+
// The sandbox wraps setInterval and clears all pending timers in the
|
|
669
|
+
// finally block. We verify this by having the interval callback mutate
|
|
670
|
+
// a variable captured in the host closure — if the interval fires after
|
|
671
|
+
// executeInIsolate returns, we'd see the count increment.
|
|
672
|
+
let hostCallbackCount = 0;
|
|
673
|
+
|
|
674
|
+
// Temporarily wrap the real setInterval so the callback can signal the host.
|
|
675
|
+
const origSetInterval = globalThis.setInterval;
|
|
676
|
+
const origClearInterval = globalThis.clearInterval;
|
|
677
|
+
const cancelIds: ReturnType<typeof setInterval>[] = [];
|
|
678
|
+
|
|
679
|
+
// Intercept: wrap the interval the sandbox creates so the fn also bumps our host counter.
|
|
680
|
+
globalThis.setInterval = ((fn: () => void, delay?: number) => {
|
|
681
|
+
const wrapped = () => {
|
|
682
|
+
hostCallbackCount++;
|
|
683
|
+
fn();
|
|
684
|
+
};
|
|
685
|
+
const id = origSetInterval(wrapped, delay);
|
|
686
|
+
cancelIds.push(id);
|
|
687
|
+
return id;
|
|
688
|
+
}) as typeof globalThis.setInterval;
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
await executeInIsolate(`
|
|
692
|
+
setInterval(() => {}, 10);
|
|
693
|
+
console.log("started");
|
|
694
|
+
`);
|
|
695
|
+
} finally {
|
|
696
|
+
globalThis.setInterval = origSetInterval;
|
|
697
|
+
globalThis.clearInterval = origClearInterval;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const countAfterReturn = hostCallbackCount;
|
|
701
|
+
|
|
702
|
+
// Wait longer than the interval delay — if the timer leaked, the counter
|
|
703
|
+
// would increment here.
|
|
704
|
+
await new Promise<void>((r) => setTimeout(r, 80));
|
|
705
|
+
|
|
706
|
+
// Cleanup any ids that slipped through (shouldn't be needed if fix works)
|
|
707
|
+
for (const id of cancelIds) {
|
|
708
|
+
origClearInterval(id);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Counter must not have increased after executeInIsolate returned
|
|
712
|
+
expect(hostCallbackCount).toBe(countAfterReturn);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("setTimeout created in sandbox does not fire after execution completes", async () => {
|
|
716
|
+
// A setTimeout with a short delay should be cancelled by the finally block
|
|
717
|
+
// before it has a chance to run after executeInIsolate resolves.
|
|
718
|
+
let firedAfterReturn = false;
|
|
719
|
+
const origSetTimeout = globalThis.setTimeout;
|
|
720
|
+
const origClearTimeout = globalThis.clearTimeout;
|
|
721
|
+
const cancelIds: ReturnType<typeof setTimeout>[] = [];
|
|
722
|
+
|
|
723
|
+
globalThis.setTimeout = ((fn: () => void, delay?: number) => {
|
|
724
|
+
const wrapped = () => {
|
|
725
|
+
firedAfterReturn = true;
|
|
726
|
+
fn();
|
|
727
|
+
};
|
|
728
|
+
const id = origSetTimeout(wrapped, delay);
|
|
729
|
+
cancelIds.push(id);
|
|
730
|
+
return id;
|
|
731
|
+
}) as typeof globalThis.setTimeout;
|
|
732
|
+
|
|
733
|
+
try {
|
|
734
|
+
// The code schedules a timer with 20 ms delay then returns immediately.
|
|
735
|
+
await executeInIsolate(`
|
|
736
|
+
setTimeout(() => {}, 20);
|
|
737
|
+
console.log("scheduled");
|
|
738
|
+
`);
|
|
739
|
+
} finally {
|
|
740
|
+
globalThis.setTimeout = origSetTimeout;
|
|
741
|
+
globalThis.clearTimeout = origClearTimeout;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Reset after the function returned — any fire from this point is a leak.
|
|
745
|
+
firedAfterReturn = false;
|
|
746
|
+
|
|
747
|
+
// Wait longer than the 20 ms delay to confirm no leak.
|
|
748
|
+
await new Promise<void>((r) => origSetTimeout(r, 80));
|
|
749
|
+
|
|
750
|
+
for (const id of cancelIds) {
|
|
751
|
+
origClearTimeout(id);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
expect(firedAfterReturn).toBe(false);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("setInterval cleanup does not prevent synchronous timer use during execution", async () => {
|
|
758
|
+
// Ensure wrapping timers doesn't break legitimate async usage
|
|
759
|
+
const result = await executeInIsolate(`
|
|
760
|
+
let ticks = 0;
|
|
761
|
+
await new Promise((resolve) => {
|
|
762
|
+
const id = setInterval(() => {
|
|
763
|
+
ticks++;
|
|
764
|
+
if (ticks >= 3) {
|
|
765
|
+
clearInterval(id);
|
|
766
|
+
resolve(undefined);
|
|
767
|
+
}
|
|
768
|
+
}, 10);
|
|
769
|
+
});
|
|
770
|
+
console.log("ticks:" + ticks);
|
|
771
|
+
`);
|
|
772
|
+
expect(result).toBe("ticks:3");
|
|
773
|
+
});
|
|
774
|
+
});
|