@gajae-code/coding-agent 0.14.0 → 0.14.1

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +21 -3
  2. package/dist/types/modes/components/tool-execution.d.ts +1 -1
  3. package/dist/types/sdk/broker/lifecycle.d.ts +3 -0
  4. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  5. package/dist/types/session/blob-store.d.ts +9 -0
  6. package/dist/types/session/session-manager.d.ts +2 -0
  7. package/dist/types/slash-commands/helpers/usage-report.d.ts +10 -0
  8. package/dist/types/tools/ask-contract.d.ts +7 -7
  9. package/dist/types/tools/descriptor-validation.d.ts +2 -2
  10. package/package.json +7 -7
  11. package/src/config/model-profiles.ts +68 -0
  12. package/src/config/model-registry.ts +20 -2
  13. package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -8
  14. package/src/internal-urls/docs-index.generated.ts +3 -3
  15. package/src/modes/components/tool-execution.ts +24 -4
  16. package/src/modes/controllers/command-controller.ts +115 -13
  17. package/src/modes/controllers/input-controller.ts +51 -18
  18. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +10 -1
  19. package/src/sdk/broker/lifecycle.ts +21 -0
  20. package/src/sdk/broker/session-index.ts +13 -0
  21. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  22. package/src/sdk/bus/telegram-daemon.ts +29 -6
  23. package/src/sdk/router/session-router.ts +11 -4
  24. package/src/session/account-inventory.ts +25 -21
  25. package/src/session/agent-session.ts +9 -0
  26. package/src/session/blob-store.ts +73 -7
  27. package/src/session/session-manager.ts +104 -15
  28. package/src/slash-commands/builtin-registry.ts +11 -2
  29. package/src/slash-commands/helpers/usage-report.ts +56 -2
  30. package/src/tools/ask-contract.ts +1 -1
  31. package/src/tools/computer.enforcement.test.ts +9 -1
  32. package/src/tools/computer.redteam.test.ts +893 -0
  33. package/src/utils/herdr-pane.ts +17 -2
@@ -0,0 +1,893 @@
1
+ /**
2
+ * Genuine computer-use red-team suite for the GJC `computer` tool safety envelope.
3
+ *
4
+ * Required because any edit to `src/config/settings-schema.ts` — the file that
5
+ * defines computer.enabled / computer.alwaysOn / computer.killSwitchHotkey /
6
+ * computer.autoScreenshot / computer.screenshotMaxBytes / computer.auditLog.enabled /
7
+ * computer.screenshotGc.* — must be proven not to weaken computer-control safety.
8
+ *
9
+ * Invariant ids (fixed by the gate contract) map to test titles by prefix:
10
+ * 1. kill-switch-bypass → "kill-switch-bypass: ..."
11
+ * 2. suspended-enforcement → "suspended-enforcement: ..."
12
+ * 3. permission-revoked → "permission-revoked: ..."
13
+ * 4. display-stale → "display-stale: ..."
14
+ * 5. out-of-bounds-drift → "out-of-bounds-drift: ..."
15
+ * 6. runaway-loop-halt → "runaway-loop-halt: ..."
16
+ * 7. blast-radius → "blast-radius: ..."
17
+ * plus "settings-envelope: ..." — the direct falsification test for edits to
18
+ * settings-schema.ts.
19
+ *
20
+ * Every test asserts observable behavior of the real policy / validation / GC /
21
+ * audit layers. Real desktop input is never invoked: the native controller is
22
+ * always the test seam `setComputerControllerFactoryForTests`, and platform/arch
23
+ * overrides pin the host to darwin/arm64.
24
+ */
25
+ import { afterEach, describe, expect, test, vi } from "bun:test";
26
+ import * as fs from "node:fs/promises";
27
+ import * as os from "node:os";
28
+ import * as path from "node:path";
29
+ import type { AgentToolResult } from "@gajae-code/agent-core";
30
+ import { zlibSync } from "fflate";
31
+ import { getDefault, getType, Settings } from "../config/settings";
32
+ import {
33
+ type ComputerParams,
34
+ ComputerTool,
35
+ type ComputerToolDetails,
36
+ isComputerCallable,
37
+ isComputerEnabled,
38
+ isComputerLoadablePlatform,
39
+ isComputerSupportedPlatform,
40
+ setComputerArchForTests,
41
+ setComputerControllerFactoryForTests,
42
+ setComputerPlatformForTests,
43
+ } from "./computer";
44
+ import {
45
+ cleanupStaleScreenshotFallbackDirs,
46
+ resetScreenshotFallbackGcForTest,
47
+ SCREENSHOT_FALLBACK_DIR_PREFIX,
48
+ } from "./computer-gc";
49
+ import type { ToolSession } from "./index";
50
+ import {
51
+ __resetResourceGcForTest,
52
+ __setResourceGcDepsForTest,
53
+ type ResourceGcDeps,
54
+ registerResourceGcSession,
55
+ resolveComputerGcPolicy,
56
+ sweepOnce,
57
+ } from "./resource-gc";
58
+ import { ToolAbortError } from "./tool-errors";
59
+
60
+ type ToolResult = AgentToolResult<ComputerToolDetails>;
61
+
62
+ function makeSession(settings: Settings, sessionFile: string | null = null): ToolSession {
63
+ return {
64
+ cwd: process.cwd(),
65
+ hasUI: false,
66
+ settings,
67
+ getSessionSpawns: () => "*",
68
+ getSessionFile: () => sessionFile,
69
+ };
70
+ }
71
+
72
+ function nativeError(code: string, reason: string): Error & { code: string } {
73
+ const value = new Error(`${code}: ${reason}`) as Error & { code: string };
74
+ value.code = code;
75
+ return value;
76
+ }
77
+
78
+ function resultCode(result: ToolResult): string | undefined {
79
+ return result.details?.code;
80
+ }
81
+
82
+ function resultMessage(result: ToolResult): string {
83
+ return result.details?.message ?? result.content.find(block => block.type === "text")?.text ?? "";
84
+ }
85
+
86
+ function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
87
+ const startedAt = performance.now();
88
+ return new Promise((resolve, reject) => {
89
+ const poll = (): void => {
90
+ if (predicate()) {
91
+ resolve();
92
+ return;
93
+ }
94
+ if (performance.now() - startedAt > timeoutMs) {
95
+ reject(new Error(`waitFor condition not met within ${timeoutMs}ms`));
96
+ return;
97
+ }
98
+ setTimeout(poll, 1);
99
+ };
100
+ poll();
101
+ });
102
+ }
103
+
104
+ async function pathExists(filePath: string): Promise<boolean> {
105
+ return fs
106
+ .stat(filePath)
107
+ .then(() => true)
108
+ .catch(() => false);
109
+ }
110
+
111
+ function crc32(bytes: Uint8Array): number {
112
+ let crc = 0xffffffff;
113
+ for (const byte of bytes) {
114
+ crc ^= byte;
115
+ for (let bit = 0; bit < 8; bit += 1) {
116
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
117
+ }
118
+ }
119
+ return (crc ^ 0xffffffff) >>> 0;
120
+ }
121
+
122
+ function pngChunk(type: string, data: Uint8Array): Buffer {
123
+ const typeBytes = Buffer.from(type, "ascii");
124
+ const chunk = Buffer.alloc(12 + data.length);
125
+ chunk.writeUInt32BE(data.length, 0);
126
+ typeBytes.copy(chunk, 4);
127
+ Buffer.from(data).copy(chunk, 8);
128
+ const crcInput = Buffer.concat([typeBytes, Buffer.from(data)]);
129
+ chunk.writeUInt32BE(crc32(crcInput), 8 + data.length);
130
+ return chunk;
131
+ }
132
+
133
+ function makeNoisePng(width: number, height: number): Buffer {
134
+ const ihdr = Buffer.alloc(13);
135
+ ihdr.writeUInt32BE(width, 0);
136
+ ihdr.writeUInt32BE(height, 4);
137
+ ihdr[8] = 8;
138
+ ihdr[9] = 6;
139
+ const stride = 1 + width * 4;
140
+ const raw = Buffer.alloc(stride * height);
141
+ for (let y = 0; y < height; y += 1) {
142
+ const row = y * stride;
143
+ for (let x = 0; x < width; x += 1) {
144
+ const offset = row + 1 + x * 4;
145
+ const seed = (x * 1103515245 + y * 12345) >>> 0;
146
+ raw[offset] = seed & 0xff;
147
+ raw[offset + 1] = (seed >>> 8) & 0xff;
148
+ raw[offset + 2] = (seed >>> 16) & 0xff;
149
+ raw[offset + 3] = 0xff;
150
+ }
151
+ }
152
+ return Buffer.concat([
153
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
154
+ pngChunk("IHDR", ihdr),
155
+ pngChunk("IDAT", zlibSync(raw, { level: 0 })),
156
+ pngChunk("IEND", Buffer.alloc(0)),
157
+ ]);
158
+ }
159
+
160
+ interface AuditRecordShape {
161
+ action?: string;
162
+ status?: string;
163
+ code?: string;
164
+ x?: number;
165
+ y?: number;
166
+ timestamp?: string;
167
+ message?: string;
168
+ }
169
+
170
+ function parseAuditRecord(line: string): AuditRecordShape {
171
+ return JSON.parse(line) as AuditRecordShape;
172
+ }
173
+
174
+ afterEach(() => {
175
+ setComputerControllerFactoryForTests(undefined);
176
+ setComputerPlatformForTests(undefined);
177
+ setComputerArchForTests(undefined);
178
+ resetScreenshotFallbackGcForTest();
179
+ __resetResourceGcForTest();
180
+ });
181
+
182
+ describe("computer red-team 1: kill-switch-bypass", () => {
183
+ test("kill-switch-bypass: a disabled surface refuses every action and never constructs the native controller", async () => {
184
+ setComputerPlatformForTests("darwin");
185
+ setComputerArchForTests("arm64");
186
+ const settings = Settings.isolated({
187
+ "computer.enabled": false,
188
+ "computer.alwaysOn": false,
189
+ "computer.killSwitchHotkey": "Control+Option+Command+Escape",
190
+ });
191
+ const session = makeSession(settings);
192
+ expect(isComputerCallable(session)).toBe(false);
193
+
194
+ let factoryCalls = 0;
195
+ setComputerControllerFactoryForTests(() => {
196
+ factoryCalls += 1;
197
+ throw new Error("native controller constructed despite kill switch");
198
+ });
199
+ const tool = new ComputerTool(session);
200
+
201
+ const result = await tool.execute("probe", { action: "click", x: 10, y: 10 });
202
+
203
+ expect(result.isError).toBe(true);
204
+ expect(resultCode(result)).toBe("COMPUTER_DISABLED");
205
+ expect(resultMessage(result)).toContain("disabled or unsupported");
206
+ expect(factoryCalls).toBe(0); // no code path reached native dispatch
207
+ });
208
+
209
+ test("kill-switch-bypass: a supervisor refusal surfaces the configured hotkey and stops the batch", async () => {
210
+ setComputerPlatformForTests("darwin");
211
+ setComputerArchForTests("arm64");
212
+ const settings = Settings.isolated({
213
+ "computer.enabled": true,
214
+ "computer.killSwitchHotkey": "Control+Option+Command+Escape",
215
+ });
216
+ let nativeCalls = 0;
217
+ setComputerControllerFactoryForTests(() => ({
218
+ click: () => {
219
+ nativeCalls += 1;
220
+ throw nativeError("COMPUTER_SUPERVISOR_NOT_LIVE", "kill switch engaged");
221
+ },
222
+ keypress: () => {
223
+ nativeCalls += 1;
224
+ },
225
+ }));
226
+ const tool = new ComputerTool(makeSession(settings));
227
+
228
+ const result = await tool.execute("probe", {
229
+ action: "batch",
230
+ actions: [
231
+ { action: "click", x: 1, y: 1 },
232
+ { action: "keypress", keys: ["A"] },
233
+ ],
234
+ });
235
+
236
+ expect(result.isError).toBe(true);
237
+ expect(resultCode(result)).toBe("COMPUTER_SUPERVISOR_NOT_LIVE");
238
+ expect(resultMessage(result)).toContain("Control+Option+Command+Escape");
239
+ expect(nativeCalls).toBe(1); // follow-up keypress never dispatched
240
+ expect(result.details?.steps?.length).toBe(1);
241
+ });
242
+ });
243
+
244
+ describe("computer red-team 2: suspended-enforcement", () => {
245
+ test("suspended-enforcement: a disabled session refuses repeated single and batch attempts consistently", async () => {
246
+ setComputerPlatformForTests("darwin");
247
+ setComputerArchForTests("arm64");
248
+ const settings = Settings.isolated({ "computer.enabled": false, "computer.alwaysOn": false });
249
+ let factoryCalls = 0;
250
+ setComputerControllerFactoryForTests(() => {
251
+ factoryCalls += 1;
252
+ return { click: () => undefined };
253
+ });
254
+ const tool = new ComputerTool(makeSession(settings));
255
+
256
+ const first = await tool.execute("attempt-1", { action: "click", x: 1, y: 1 });
257
+ const second = await tool.execute("attempt-2", { action: "keypress", keys: ["A"] });
258
+ const third = await tool.execute("attempt-3", {
259
+ action: "batch",
260
+ actions: [
261
+ { action: "click", x: 1, y: 1 },
262
+ { action: "wait", ms: 1 },
263
+ ],
264
+ });
265
+
266
+ for (const result of [first, second, third]) {
267
+ expect(result.isError).toBe(true);
268
+ expect(resultCode(result)).toBe("COMPUTER_DISABLED");
269
+ }
270
+ expect(factoryCalls).toBe(0); // enforcement is not one-shot and not bypassed by a batch
271
+ });
272
+
273
+ test("suspended-enforcement: a native COMPUTER_SUSPENDED refusal halts the batch with no follow-up dispatch", async () => {
274
+ setComputerPlatformForTests("darwin");
275
+ setComputerArchForTests("arm64");
276
+ const settings = Settings.isolated({
277
+ "computer.enabled": true,
278
+ "computer.killSwitchHotkey": "Control+Option+Command+Escape",
279
+ });
280
+ let nativeCalls = 0;
281
+ setComputerControllerFactoryForTests(() => ({
282
+ keypress: () => {
283
+ nativeCalls += 1;
284
+ throw nativeError("COMPUTER_SUSPENDED", "session suspended");
285
+ },
286
+ click: () => {
287
+ nativeCalls += 1;
288
+ },
289
+ }));
290
+ const tool = new ComputerTool(makeSession(settings));
291
+
292
+ const result = await tool.execute("probe", {
293
+ action: "batch",
294
+ actions: [
295
+ { action: "keypress", keys: ["A"] },
296
+ { action: "click", x: 1, y: 1 },
297
+ ],
298
+ });
299
+
300
+ expect(result.isError).toBe(true);
301
+ expect(resultCode(result)).toBe("COMPUTER_SUSPENDED");
302
+ expect(resultMessage(result)).toContain("Control+Option+Command+Escape");
303
+ expect(nativeCalls).toBe(1);
304
+ expect(result.details?.steps?.length).toBe(1);
305
+ });
306
+ });
307
+
308
+ describe("computer red-team 3: permission-revoked", () => {
309
+ test("permission-revoked: revoking computer.enabled mid-session takes effect on the next action (no stale permission cache)", async () => {
310
+ setComputerPlatformForTests("darwin");
311
+ setComputerArchForTests("arm64");
312
+ const settings = Settings.isolated({ "computer.enabled": true });
313
+ let clickCalls = 0;
314
+ setComputerControllerFactoryForTests(() => ({
315
+ click: () => {
316
+ clickCalls += 1;
317
+ },
318
+ }));
319
+ const tool = new ComputerTool(makeSession(settings));
320
+
321
+ const allowed = await tool.execute("before-revocation", { action: "click", x: 1, y: 1 });
322
+ expect(allowed.isError).toBeFalsy();
323
+ expect(clickCalls).toBe(1);
324
+
325
+ settings.set("computer.enabled", false);
326
+
327
+ const revoked = await tool.execute("after-revocation", { action: "click", x: 2, y: 2 });
328
+ expect(revoked.isError).toBe(true);
329
+ expect(resultCode(revoked)).toBe("COMPUTER_DISABLED");
330
+ expect(clickCalls).toBe(1); // no dispatch after revocation
331
+ });
332
+
333
+ test("permission-revoked: unsetting computer.enabled mid-session disables through the has() fallback", async () => {
334
+ setComputerPlatformForTests("darwin");
335
+ setComputerArchForTests("arm64");
336
+ const settings = Settings.isolated({ "computer.enabled": true, "computer.alwaysOn": false });
337
+ let clickCalls = 0;
338
+ setComputerControllerFactoryForTests(() => ({
339
+ click: () => {
340
+ clickCalls += 1;
341
+ },
342
+ }));
343
+ const tool = new ComputerTool(makeSession(settings));
344
+
345
+ const allowed = await tool.execute("before-unset", { action: "click", x: 1, y: 1 });
346
+ expect(allowed.isError).toBeFalsy();
347
+ expect(clickCalls).toBe(1);
348
+
349
+ settings.unset("computer.enabled");
350
+
351
+ const revoked = await tool.execute("after-unset", { action: "click", x: 3, y: 3 });
352
+ expect(revoked.isError).toBe(true);
353
+ expect(resultCode(revoked)).toBe("COMPUTER_DISABLED");
354
+ expect(clickCalls).toBe(1);
355
+ });
356
+ });
357
+
358
+ describe("computer red-team 4: display-stale", () => {
359
+ const NOW = 10_000_000_000;
360
+ const STALE_MS = 1000;
361
+
362
+ test("display-stale: stale screenshot fallback dirs are cleaned; recent and non-matching dirs are preserved", async () => {
363
+ const base = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-computer-redteam-gc-"));
364
+ try {
365
+ const makeDir = async (name: string, ageMs: number): Promise<string> => {
366
+ const dir = path.join(base, name);
367
+ await fs.mkdir(dir, { recursive: true });
368
+ const mtimeSeconds = (NOW - ageMs) / 1000;
369
+ await fs.utimes(dir, mtimeSeconds, mtimeSeconds);
370
+ return dir;
371
+ };
372
+ const staleDir = await makeDir(`${SCREENSHOT_FALLBACK_DIR_PREFIX}stale`, 5000);
373
+ const recentDir = await makeDir(`${SCREENSHOT_FALLBACK_DIR_PREFIX}recent`, 100);
374
+ const foreignDir = await makeDir("unrelated-tool-dir-old", 5000);
375
+
376
+ const result = await cleanupStaleScreenshotFallbackDirs({
377
+ now: () => NOW,
378
+ staleMs: STALE_MS,
379
+ tmpDir: base,
380
+ });
381
+
382
+ expect(result).toEqual({ scanned: 2, removed: 1 });
383
+ expect(await pathExists(staleDir)).toBe(false); // stale artifact cleaned, not served as fresh
384
+ expect(await pathExists(recentDir)).toBe(true);
385
+ expect(await pathExists(foreignDir)).toBe(true); // outside GC scope
386
+
387
+ // A follow-up sweep sees nothing stale to reuse.
388
+ const second = await cleanupStaleScreenshotFallbackDirs({
389
+ now: () => NOW,
390
+ staleMs: STALE_MS,
391
+ tmpDir: base,
392
+ });
393
+ expect(second).toEqual({ scanned: 1, removed: 0 });
394
+ } finally {
395
+ await fs.rm(base, { recursive: true, force: true });
396
+ }
397
+ });
398
+
399
+ test("display-stale: the resource-GC sweep wires screenshotGc.enabled/staleMs/scanIntervalMs and rate-limits scans", async () => {
400
+ const cleanupScreenshots = vi.fn(async (_opts: { now: () => number; staleMs: number }) => ({
401
+ scanned: 1,
402
+ removed: 1,
403
+ }));
404
+ const deps: Partial<ResourceGcDeps> = {
405
+ now: () => NOW,
406
+ rssBytes: () => 1,
407
+ memorySnapshot: async () => ({
408
+ hardCapBytes: 1024 * 1024 * 1024,
409
+ totalUsageBytes: 1,
410
+ parentBytes: 1,
411
+ source: "host",
412
+ }),
413
+ runGc: () => undefined,
414
+ logWarn: () => undefined,
415
+ listTabs: () => [],
416
+ releaseTab: async () => true,
417
+ cleanupScreenshots,
418
+ screenshotArmed: () => true,
419
+ };
420
+ __setResourceGcDepsForTest(deps);
421
+
422
+ const settings = Settings.isolated({
423
+ "resourceGc.sweepIntervalMs": 30_000,
424
+ "memoryGuard.enabled": false,
425
+ "computer.screenshotGc.enabled": true,
426
+ "computer.screenshotGc.staleMs": 7_000,
427
+ "computer.screenshotGc.scanIntervalMs": 60_000,
428
+ });
429
+ expect(resolveComputerGcPolicy(settings)).toEqual({
430
+ enabled: true,
431
+ staleMs: 7_000,
432
+ scanIntervalMs: 60_000,
433
+ });
434
+
435
+ const unregister = registerResourceGcSession({
436
+ sessionId: "computer-redteam-display-stale",
437
+ settings,
438
+ cwd: process.cwd(),
439
+ });
440
+ try {
441
+ await sweepOnce();
442
+ expect(cleanupScreenshots.mock.calls.length).toBe(1);
443
+ expect(cleanupScreenshots.mock.calls[0]?.[0]?.staleMs).toBe(7_000);
444
+
445
+ // Rate limit: a second sweep inside the scanIntervalMs window must not rescan.
446
+ await sweepOnce();
447
+ expect(cleanupScreenshots.mock.calls.length).toBe(1);
448
+
449
+ // Past the scanIntervalMs window the sweep runs again.
450
+ __setResourceGcDepsForTest({ ...deps, now: () => NOW + 60_001 });
451
+ await sweepOnce();
452
+ expect(cleanupScreenshots.mock.calls.length).toBe(2);
453
+ } finally {
454
+ unregister();
455
+ }
456
+
457
+ // With screenshotGc.enabled=false the sweep contributes no cleanup at all.
458
+ const disabledSettings = Settings.isolated({
459
+ "resourceGc.sweepIntervalMs": 30_000,
460
+ "memoryGuard.enabled": false,
461
+ "computer.screenshotGc.enabled": false,
462
+ "computer.screenshotGc.staleMs": 7_000,
463
+ "computer.screenshotGc.scanIntervalMs": 60_000,
464
+ });
465
+ const disabledUnregister = registerResourceGcSession({
466
+ sessionId: "computer-redteam-display-stale-disabled",
467
+ settings: disabledSettings,
468
+ cwd: process.cwd(),
469
+ });
470
+ try {
471
+ __setResourceGcDepsForTest({ ...deps, now: () => NOW + 120_000 });
472
+ await sweepOnce();
473
+ expect(cleanupScreenshots.mock.calls.length).toBe(2); // unchanged
474
+ } finally {
475
+ disabledUnregister();
476
+ }
477
+ });
478
+
479
+ test("display-stale: the expected display epoch is forwarded to native and stale frames are refused with guidance", async () => {
480
+ setComputerPlatformForTests("darwin");
481
+ setComputerArchForTests("arm64");
482
+ const settings = Settings.isolated({ "computer.enabled": true });
483
+ let receivedEpoch: number | undefined;
484
+ let nativeCalls = 0;
485
+ setComputerControllerFactoryForTests(() => ({
486
+ screenshot: () => ({ widthPx: 100, heightPx: 80, displayEpoch: 7 }),
487
+ click: (expectedEpoch: number | undefined) => {
488
+ nativeCalls += 1;
489
+ receivedEpoch = expectedEpoch;
490
+ throw nativeError("COMPUTER_DISPLAY_STALE", "display epoch changed");
491
+ },
492
+ }));
493
+ const tool = new ComputerTool(makeSession(settings));
494
+
495
+ await tool.execute("fresh-shot", { action: "screenshot" });
496
+ const stale = await tool.execute("stale-click", { action: "click", x: 10, y: 10 });
497
+
498
+ expect(receivedEpoch).toBe(7); // the epoch of the last screenshot is what native must validate against
499
+ expect(nativeCalls).toBe(1);
500
+ expect(stale.isError).toBe(true);
501
+ expect(resultCode(stale)).toBe("COMPUTER_DISPLAY_STALE");
502
+ expect(resultMessage(stale)).toContain("Capture a fresh screenshot");
503
+ });
504
+ });
505
+
506
+ describe("computer red-team 5: out-of-bounds-drift", () => {
507
+ test("out-of-bounds-drift: every pointer action is refused pre-dispatch outside CoordinateBounds; boundary values are exact", async () => {
508
+ setComputerPlatformForTests("darwin");
509
+ setComputerArchForTests("arm64");
510
+ const settings = Settings.isolated({ "computer.enabled": true });
511
+ const nativeCalls: string[] = [];
512
+ setComputerControllerFactoryForTests(() => ({
513
+ screenshot: () => ({ widthPx: 100, heightPx: 80, originX: 10, originY: 20, displayEpoch: 1 }),
514
+ click: () => {
515
+ nativeCalls.push("click");
516
+ },
517
+ doubleClick: () => {
518
+ nativeCalls.push("double_click");
519
+ },
520
+ move: () => {
521
+ nativeCalls.push("move");
522
+ },
523
+ drag: () => {
524
+ nativeCalls.push("drag");
525
+ },
526
+ scroll: () => {
527
+ nativeCalls.push("scroll");
528
+ },
529
+ }));
530
+ const tool = new ComputerTool(makeSession(settings));
531
+ await tool.execute("shot", { action: "screenshot" });
532
+
533
+ // Frame is x∈[10,110), y∈[20,100): origin offset, exclusive max, negatives.
534
+ const refusalCases: Array<{ name: string; params: ComputerParams }> = [
535
+ { name: "click x=max", params: { action: "click", x: 110, y: 50 } },
536
+ { name: "click y=max", params: { action: "click", x: 50, y: 100 } },
537
+ { name: "click negative y", params: { action: "click", x: 50, y: -1 } },
538
+ { name: "click x=origin-1", params: { action: "click", x: 9, y: 50 } },
539
+ { name: "double_click x=max+1", params: { action: "double_click", x: 111, y: 50 } },
540
+ { name: "move y=max+1", params: { action: "move", x: 50, y: 101 } },
541
+ { name: "drag start out of bounds", params: { action: "drag", x: 9, y: 50, to_x: 50, to_y: 50 } },
542
+ { name: "drag end out of bounds", params: { action: "drag", x: 50, y: 50, to_x: 50, to_y: 100 } },
543
+ { name: "scroll negative x", params: { action: "scroll", x: -5, y: 50, scroll_x: 0, scroll_y: -10 } },
544
+ { name: "scroll y=max+1", params: { action: "scroll", x: 50, y: 101, scroll_x: 0, scroll_y: -10 } },
545
+ ];
546
+ for (const { name, params } of refusalCases) {
547
+ const before = nativeCalls.length;
548
+ const result = await tool.execute(name, params);
549
+ expect(result.isError).toBe(true);
550
+ expect(resultCode(result)).toBe("COMPUTER_COORD_INVALID");
551
+ expect(nativeCalls.length).toBe(before); // native layer never invoked on rejection
552
+ }
553
+ expect(nativeCalls).toEqual([]);
554
+
555
+ // Exactly-on-the-edge vs one-past: max-1 and origin dispatch; max itself does not.
556
+ const boundaryCases: Array<{ name: string; params: ComputerParams; expectedNative: string }> = [
557
+ { name: "click x=max-1", params: { action: "click", x: 109, y: 50 }, expectedNative: "click" },
558
+ { name: "click y=max-1", params: { action: "click", x: 50, y: 99 }, expectedNative: "click" },
559
+ { name: "click x=origin", params: { action: "click", x: 10, y: 50 }, expectedNative: "click" },
560
+ ];
561
+ for (const { name, params, expectedNative } of boundaryCases) {
562
+ const before = nativeCalls.length;
563
+ const result = await tool.execute(name, params);
564
+ expect(result.isError).toBeFalsy();
565
+ expect(nativeCalls.length).toBe(before + 1);
566
+ expect(nativeCalls[nativeCalls.length - 1]).toBe(expectedNative);
567
+ }
568
+ });
569
+
570
+ test("out-of-bounds-drift: a batch containing one out-of-bounds action is refused with zero native dispatch", async () => {
571
+ setComputerPlatformForTests("darwin");
572
+ setComputerArchForTests("arm64");
573
+ const settings = Settings.isolated({ "computer.enabled": true });
574
+ const nativeCalls: string[] = [];
575
+ setComputerControllerFactoryForTests(() => ({
576
+ screenshot: () => ({ widthPx: 100, heightPx: 80, originX: 0, originY: 0, displayEpoch: 2 }),
577
+ click: () => {
578
+ nativeCalls.push("click");
579
+ },
580
+ move: () => {
581
+ nativeCalls.push("move");
582
+ },
583
+ }));
584
+ const tool = new ComputerTool(makeSession(settings));
585
+ await tool.execute("shot", { action: "screenshot" });
586
+
587
+ const result = await tool.execute("bad-batch", {
588
+ action: "batch",
589
+ actions: [
590
+ { action: "click", x: 50, y: 50 },
591
+ { action: "move", x: 120, y: 50 }, // out of bounds — must veto the whole batch pre-dispatch
592
+ { action: "click", x: 51, y: 51 },
593
+ ],
594
+ });
595
+
596
+ expect(result.isError).toBe(true);
597
+ expect(resultCode(result)).toBe("COMPUTER_COORD_INVALID");
598
+ expect(nativeCalls).toEqual([]); // nothing, not even the first in-bounds click, dispatched
599
+ });
600
+ });
601
+
602
+ describe("computer red-team 6: runaway-loop-halt", () => {
603
+ test("runaway-loop-halt: a never-resolving action is cancelled at the clamped deadline and the batch halts", async () => {
604
+ setComputerPlatformForTests("darwin");
605
+ setComputerArchForTests("arm64");
606
+ const settings = Settings.isolated({ "computer.enabled": true });
607
+ let dispatched = 0;
608
+ let releaseGate!: () => void;
609
+ const gate = new Promise<void>(resolve => {
610
+ releaseGate = resolve;
611
+ });
612
+ setComputerControllerFactoryForTests(() => ({
613
+ click: () => {
614
+ dispatched += 1;
615
+ return gate;
616
+ },
617
+ keypress: () => {
618
+ dispatched += 1;
619
+ },
620
+ }));
621
+ const tool = new ComputerTool(makeSession(settings));
622
+
623
+ const result = await tool.execute("stuck-batch", {
624
+ action: "batch",
625
+ actions: [
626
+ { action: "click", x: 10, y: 20 },
627
+ { action: "keypress", keys: ["A"] },
628
+ ],
629
+ timeout: 1, // clamped to the 1s floor
630
+ });
631
+ releaseGate();
632
+
633
+ expect(result.isError).toBe(true);
634
+ expect(resultCode(result)).toBe("COMPUTER_CANCELLED");
635
+ expect(dispatched).toBe(1); // the queued follow-up never ran after the deadline
636
+ expect(result.details?.steps?.length).toBe(1);
637
+ });
638
+
639
+ test("runaway-loop-halt: aborting stops dispatch mid-sequence", async () => {
640
+ setComputerPlatformForTests("darwin");
641
+ setComputerArchForTests("arm64");
642
+ const settings = Settings.isolated({ "computer.enabled": true });
643
+ let dispatched = 0;
644
+ let releaseGate!: () => void;
645
+ const gate = new Promise<void>(resolve => {
646
+ releaseGate = resolve;
647
+ });
648
+ setComputerControllerFactoryForTests(() => ({
649
+ click: () => {
650
+ dispatched += 1;
651
+ return gate;
652
+ },
653
+ keypress: () => {
654
+ dispatched += 1;
655
+ },
656
+ }));
657
+ const tool = new ComputerTool(makeSession(settings));
658
+ const controller = new AbortController();
659
+
660
+ const pending = tool.execute(
661
+ "abort-batch",
662
+ {
663
+ action: "batch",
664
+ actions: [
665
+ { action: "click", x: 10, y: 20 },
666
+ { action: "keypress", keys: ["A"] },
667
+ ],
668
+ timeout: 5,
669
+ },
670
+ controller.signal,
671
+ );
672
+ await waitFor(() => dispatched === 1);
673
+ controller.abort();
674
+ releaseGate();
675
+
676
+ const result = await pending;
677
+ expect(result.isError).toBe(true);
678
+ expect(dispatched).toBe(1); // abort stopped the sequence; the keypress never ran
679
+ expect(resultMessage(result)).toContain("Operation aborted");
680
+ });
681
+
682
+ test("runaway-loop-halt: a pre-aborted signal prevents any dispatch", async () => {
683
+ setComputerPlatformForTests("darwin");
684
+ setComputerArchForTests("arm64");
685
+ const settings = Settings.isolated({ "computer.enabled": true });
686
+ let nativeCalls = 0;
687
+ setComputerControllerFactoryForTests(() => ({
688
+ click: () => {
689
+ nativeCalls += 1;
690
+ },
691
+ }));
692
+ const tool = new ComputerTool(makeSession(settings));
693
+ const controller = new AbortController();
694
+ controller.abort();
695
+
696
+ const outcome = await tool
697
+ .execute("aborted-before-dispatch", { action: "click", x: 1, y: 1 }, controller.signal)
698
+ .then(
699
+ () => "resolved" as const,
700
+ (error: unknown) => (error instanceof ToolAbortError ? "aborted" : "other"),
701
+ );
702
+
703
+ expect(outcome).toBe("aborted");
704
+ expect(nativeCalls).toBe(0);
705
+ });
706
+ });
707
+
708
+ describe("computer red-team 7: blast-radius", () => {
709
+ test("blast-radius: inline screenshot payloads respect computer.screenshotMaxBytes", async () => {
710
+ setComputerPlatformForTests("darwin");
711
+ setComputerArchForTests("arm64");
712
+ const maxBytes = 300 * 1024;
713
+ const png = makeNoisePng(1024, 1024);
714
+ expect(png.length).toBeGreaterThan(maxBytes); // native payload itself exceeds the budget
715
+ setComputerControllerFactoryForTests(() => ({
716
+ screenshot: () => ({ widthPx: 1024, heightPx: 1024, png }),
717
+ }));
718
+ const tool = new ComputerTool(
719
+ makeSession(Settings.isolated({ "computer.enabled": true, "computer.screenshotMaxBytes": maxBytes })),
720
+ );
721
+
722
+ const result = await tool.execute("bounded-shot", { action: "screenshot" });
723
+
724
+ expect(result.isError).toBeFalsy();
725
+ expect(result.details?.screenshot?.pngBytes).toBe(png.length);
726
+ const image = result.content.find(block => block.type === "image");
727
+ expect(image?.type).toBe("image");
728
+ if (image?.type !== "image") throw new Error("expected a bounded inline image");
729
+ expect(Buffer.byteLength(image.data, "base64")).toBeLessThanOrEqual(maxBytes);
730
+
731
+ // The full-resolution artifact stays inside the GC-scoped fallback dir.
732
+ const artifactPath = result.details?.screenshot?.path;
733
+ expect(artifactPath).toBeTruthy();
734
+ if (artifactPath) {
735
+ expect(path.dirname(artifactPath)).toContain(SCREENSHOT_FALLBACK_DIR_PREFIX);
736
+ await fs.rm(path.dirname(artifactPath), { recursive: true, force: true });
737
+ }
738
+ });
739
+
740
+ test("blast-radius: audit records are written with shape when computer.auditLog.enabled is true and skipped when false", async () => {
741
+ setComputerPlatformForTests("darwin");
742
+ setComputerArchForTests("arm64");
743
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-computer-audit-redteam-"));
744
+ const sessionFile = path.join(tmpDir, "session.jsonl");
745
+ const auditPath = path.join(tmpDir, ".computer-audit.jsonl");
746
+ try {
747
+ setComputerControllerFactoryForTests(() => ({
748
+ screenshot: () => ({ widthPx: 100, heightPx: 80, displayEpoch: 3 }),
749
+ click: () => undefined,
750
+ keypress: () => undefined,
751
+ }));
752
+
753
+ const enabledTool = new ComputerTool(
754
+ makeSession(
755
+ Settings.isolated({ "computer.enabled": true, "computer.auditLog.enabled": true }),
756
+ sessionFile,
757
+ ),
758
+ );
759
+ const single = await enabledTool.execute("audited-click", { action: "click", x: 5, y: 6 });
760
+ expect(single.isError).toBeFalsy();
761
+
762
+ const lines = (await fs.readFile(auditPath, "utf8")).trim().split("\n");
763
+ expect(lines).toHaveLength(1);
764
+ const record = parseAuditRecord(lines[0]!);
765
+ expect(record.action).toBe("click");
766
+ expect(record.status).toBe("success");
767
+ expect(record.x).toBe(5);
768
+ expect(record.y).toBe(6);
769
+ expect(record.timestamp).toBeTruthy();
770
+ expect(record).not.toHaveProperty("screenshotPng");
771
+
772
+ // A batch writes one record per step plus the batch record.
773
+ const batch = await enabledTool.execute("audited-batch", {
774
+ action: "batch",
775
+ actions: [
776
+ { action: "click", x: 1, y: 1 },
777
+ { action: "keypress", keys: ["A"] },
778
+ ],
779
+ });
780
+ expect(batch.isError).toBeFalsy();
781
+ const afterBatch = (await fs.readFile(auditPath, "utf8")).trim().split("\n");
782
+ expect(afterBatch).toHaveLength(4);
783
+ expect(afterBatch.some(line => parseAuditRecord(line).action === "batch")).toBe(true);
784
+ expect(afterBatch.filter(line => parseAuditRecord(line).action === "click")).toHaveLength(2);
785
+ expect(afterBatch.filter(line => parseAuditRecord(line).action === "keypress")).toHaveLength(1);
786
+
787
+ // Refusals are audited with status and code when audit logging is on.
788
+ await enabledTool.execute("shot", { action: "screenshot" }); // establishes coordinate bounds
789
+ const refused = await enabledTool.execute("audited-refusal", { action: "click", x: -1, y: 6 });
790
+ expect(refused.isError).toBe(true);
791
+ expect(resultCode(refused)).toBe("COMPUTER_COORD_INVALID");
792
+ const auditContent = (await fs.readFile(auditPath, "utf8")).trim().split("\n");
793
+ expect(auditContent).toHaveLength(6);
794
+ const lastRecord = parseAuditRecord(auditContent.at(-1)!);
795
+ expect(lastRecord.status).toBe("error");
796
+ expect(lastRecord.code).toBe("COMPUTER_COORD_INVALID");
797
+ } finally {
798
+ await fs.rm(tmpDir, { recursive: true, force: true });
799
+ }
800
+ });
801
+
802
+ test("blast-radius: audit logging disabled writes nothing", async () => {
803
+ setComputerPlatformForTests("darwin");
804
+ setComputerArchForTests("arm64");
805
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-computer-audit-off-"));
806
+ const sessionFile = path.join(tmpDir, "session.jsonl");
807
+ const auditPath = path.join(tmpDir, ".computer-audit.jsonl");
808
+ try {
809
+ setComputerControllerFactoryForTests(() => ({
810
+ click: () => undefined,
811
+ }));
812
+ const tool = new ComputerTool(
813
+ makeSession(
814
+ Settings.isolated({ "computer.enabled": true, "computer.auditLog.enabled": false }),
815
+ sessionFile,
816
+ ),
817
+ );
818
+ const result = await tool.execute("un-audited-click", { action: "click", x: 5, y: 6 });
819
+ expect(result.isError).toBeFalsy();
820
+ expect(await pathExists(auditPath)).toBe(false);
821
+ } finally {
822
+ await fs.rm(tmpDir, { recursive: true, force: true });
823
+ }
824
+ });
825
+ });
826
+
827
+ describe("computer safety envelope settings registry", () => {
828
+ test("settings-envelope: every computer safety key exists in the settings schema with the expected type and default", () => {
829
+ const settings = Settings.isolated();
830
+ expect(getType("computer.enabled")).toBe("boolean");
831
+ expect(getDefault("computer.enabled")).toBe(false);
832
+ expect(settings.get("computer.enabled")).toBe(false);
833
+
834
+ expect(getType("computer.alwaysOn")).toBe("boolean");
835
+ expect(getDefault("computer.alwaysOn")).toBe(false);
836
+ expect(settings.get("computer.alwaysOn")).toBe(false);
837
+
838
+ expect(getType("computer.killSwitchHotkey")).toBe("string");
839
+ expect(getDefault("computer.killSwitchHotkey")).toBe("Control+Option+Command+Escape");
840
+ expect(settings.get("computer.killSwitchHotkey")).toBe("Control+Option+Command+Escape");
841
+
842
+ expect(getType("computer.autoScreenshot")).toBe("boolean");
843
+ expect(getDefault("computer.autoScreenshot")).toBe(false);
844
+ expect(settings.get("computer.autoScreenshot")).toBe(false);
845
+
846
+ expect(getType("computer.screenshotMaxBytes")).toBe("number");
847
+ expect(getDefault("computer.screenshotMaxBytes")).toBe(5_000_000);
848
+ expect(settings.get("computer.screenshotMaxBytes")).toBe(5_000_000);
849
+
850
+ expect(getType("computer.auditLog.enabled")).toBe("boolean");
851
+ expect(getDefault("computer.auditLog.enabled")).toBe(true);
852
+ expect(settings.get("computer.auditLog.enabled")).toBe(true);
853
+
854
+ expect(getType("computer.screenshotGc.enabled")).toBe("boolean");
855
+ expect(getDefault("computer.screenshotGc.enabled")).toBe(true);
856
+ expect(settings.get("computer.screenshotGc.enabled")).toBe(true);
857
+ });
858
+
859
+ test("settings-envelope: the policy layer consumes the registry for the enablement decision", () => {
860
+ setComputerPlatformForTests("darwin");
861
+ setComputerArchForTests("arm64");
862
+ expect(isComputerSupportedPlatform("darwin", "arm64")).toBe(true);
863
+ expect(isComputerSupportedPlatform("linux", "arm64")).toBe(false);
864
+ expect(isComputerLoadablePlatform("win32")).toBe(false);
865
+ expect(isComputerLoadablePlatform("darwin")).toBe(true);
866
+
867
+ expect(isComputerEnabled(makeSession(Settings.isolated({ "computer.enabled": true })))).toBe(true);
868
+ expect(isComputerEnabled(makeSession(Settings.isolated({ "computer.enabled": false })))).toBe(false);
869
+ expect(isComputerEnabled(makeSession(Settings.isolated({ "computer.alwaysOn": true })))).toBe(true);
870
+ expect(
871
+ isComputerEnabled(makeSession(Settings.isolated({ "computer.enabled": false, "computer.alwaysOn": false }))),
872
+ ).toBe(false);
873
+ expect(
874
+ isComputerEnabled(makeSession(Settings.isolated({ "computer.enabled": true, "computer.alwaysOn": false }))),
875
+ ).toBe(true);
876
+
877
+ // Absent keys fall back to enabled-on-supported-host (documented posture); explicit off wins.
878
+ expect(isComputerCallable(makeSession(Settings.isolated()), "darwin", "arm64")).toBe(true);
879
+ expect(
880
+ isComputerCallable(
881
+ makeSession(Settings.isolated({ "computer.enabled": false, "computer.alwaysOn": false })),
882
+ "darwin",
883
+ "arm64",
884
+ ),
885
+ ).toBe(false);
886
+ expect(isComputerCallable(makeSession(Settings.isolated({ "computer.enabled": true })), "linux", "arm64")).toBe(
887
+ false,
888
+ );
889
+ expect(isComputerCallable(makeSession(Settings.isolated({ "computer.enabled": true })), "darwin", "x64")).toBe(
890
+ false,
891
+ );
892
+ });
893
+ });