@frockbot/plugin-computer 0.3.20 → 0.3.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-computer",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,21 +29,21 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@cordisjs/client": "0.8.2",
32
- "@frockbot/client-core": "0.3.20",
33
- "@frockbot/client-ui": "0.3.20",
34
- "@frockbot/computer-core": "0.3.20",
35
- "@frockbot/computer-host-runtime": "0.3.20",
36
- "@frockbot/kernel-agent-loop": "0.3.20",
37
- "@frockbot/kernel-contracts": "0.3.20",
38
- "@frockbot/plugin-prompt": "0.3.20",
39
- "@frockbot/plugin-shell": "0.3.20",
40
- "@frockbot/plugin-tools": "0.3.20",
32
+ "@frockbot/client-core": "0.3.22",
33
+ "@frockbot/client-ui": "0.3.22",
34
+ "@frockbot/computer-core": "0.3.22",
35
+ "@frockbot/computer-host-runtime": "0.3.22",
36
+ "@frockbot/kernel-agent-loop": "0.3.22",
37
+ "@frockbot/kernel-contracts": "0.3.22",
38
+ "@frockbot/plugin-prompt": "0.3.22",
39
+ "@frockbot/plugin-shell": "0.3.22",
40
+ "@frockbot/plugin-tools": "0.3.22",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "vue": "3.5.41"
43
43
  },
44
44
  "devDependencies": {
45
- "@frockbot/plugin-models": "0.3.20",
46
- "@frockbot/plugin-testkit": "0.3.20",
45
+ "@frockbot/plugin-models": "0.3.22",
46
+ "@frockbot/plugin-testkit": "0.3.22",
47
47
  "@types/bun": "1.4.0",
48
48
  "@types/node": "26.2.0",
49
49
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.test.ts CHANGED
@@ -14,6 +14,7 @@ import { SessionStore } from "@frockbot/kernel-contracts";
14
14
  import manifest from "../frockbot.json" with { type: "json" };
15
15
  import packageJson from "../package.json" with { type: "json" };
16
16
  import {
17
+ COMPUTER_OVERLOADED_TOOL_MESSAGE_V1,
17
18
  createComputerAgentPlugin,
18
19
  HUMAN_CONTROL_PROMPT_LINE,
19
20
  } from "./agent.js";
@@ -180,6 +181,82 @@ describe("computer agent contribution", () => {
180
181
  await harness.dispose();
181
182
  });
182
183
 
184
+ // Production, 2026-09-04: the model sent a `cwd` the tool did not have, the
185
+ // key was dropped without a word, and `cat server.ts` ran in the home
186
+ // directory. Four steps went into working that out. The directory is carried
187
+ // now, and an argument the tool does not know is refused by name.
188
+ test("computer_exec runs in the cwd it is given and names an argument it does not know", async () => {
189
+ const requests: Array<{ cwd?: string; command?: string }> = [];
190
+ const provider: ComputerProvider = {
191
+ id: "fixture",
192
+ open: async (identity, tenant, assignment) => ({
193
+ assignment,
194
+ identity,
195
+ tenant,
196
+ exec: {
197
+ execute: async (request) => {
198
+ requests.push({
199
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
200
+ ...(request.args?.[1] === undefined
201
+ ? {}
202
+ : { command: request.args[1] }),
203
+ });
204
+ return {
205
+ exitCode: 0,
206
+ stdout: new TextEncoder().encode("server.ts ui.tsx"),
207
+ stderr: new Uint8Array(),
208
+ outputTruncated: false,
209
+ };
210
+ },
211
+ },
212
+ close: () => Promise.resolve(),
213
+ }),
214
+ };
215
+ const harness = await createPluginHarness([
216
+ ComputerRegistry,
217
+ ToolRegistry,
218
+ SystemPromptRegistry,
219
+ SessionStore,
220
+ ]);
221
+ harness.root.computers.register(provider);
222
+ await harness.mount(
223
+ createComputerAgentPlugin({
224
+ userId: "user-1",
225
+ defaultProviderId: "fixture",
226
+ }),
227
+ );
228
+
229
+ const listed = await execute(harness, "computer_exec", {
230
+ command: "ls",
231
+ cwd: "/home/box/agent-data/source/todo",
232
+ });
233
+ expect(listed).toMatchObject({
234
+ content: "server.ts ui.tsx",
235
+ isError: false,
236
+ });
237
+ expect(requests).toEqual([
238
+ { cwd: "/home/box/agent-data/source/todo", command: "ls" },
239
+ ]);
240
+
241
+ const relative = await execute(harness, "computer_exec", {
242
+ command: "ls",
243
+ cwd: "todo",
244
+ });
245
+ expect(relative.isError).toBe(true);
246
+ expect(relative.content).toContain('"cwd" must be an absolute path');
247
+
248
+ const misspelled = await execute(harness, "computer_exec", {
249
+ command: "ls",
250
+ directory: "/home/box",
251
+ });
252
+ expect(misspelled.isError).toBe(true);
253
+ expect(misspelled.content).toContain('"directory"');
254
+ expect(misspelled.content).toContain('It takes "command"');
255
+ // Refused, never run with the argument quietly dropped.
256
+ expect(requests).toHaveLength(1);
257
+ await harness.dispose();
258
+ });
259
+
183
260
  test("computer_exec during an update returns an actionable tool failure", async () => {
184
261
  const provider: ComputerProvider = {
185
262
  id: "fixture",
@@ -223,6 +300,50 @@ describe("computer agent contribution", () => {
223
300
  await harness.dispose();
224
301
  });
225
302
 
303
+ test("computer_exec maps overloaded transport failures to one bounded plain reason", async () => {
304
+ for (const message of [
305
+ "Computer command failed: WebSocket keepalive timeout after 45000ms",
306
+ "The Computer effect was cancelled",
307
+ ]) {
308
+ const provider: ComputerProvider = {
309
+ id: "fixture",
310
+ open: async (identity, tenant, assignment) => ({
311
+ assignment,
312
+ identity,
313
+ tenant,
314
+ exec: {
315
+ execute: () => Promise.reject(new Error(message)),
316
+ },
317
+ close: () => Promise.resolve(),
318
+ }),
319
+ };
320
+ const harness = await createPluginHarness([
321
+ ComputerRegistry,
322
+ ToolRegistry,
323
+ SystemPromptRegistry,
324
+ SessionStore,
325
+ ]);
326
+ harness.root.computers.register(provider);
327
+ await harness.mount(
328
+ createComputerAgentPlugin({
329
+ userId: "user-1",
330
+ defaultProviderId: "fixture",
331
+ }),
332
+ );
333
+
334
+ await expect(
335
+ execute(harness, "computer_exec", { command: "pwd" }),
336
+ ).resolves.toEqual({
337
+ content: COMPUTER_OVERLOADED_TOOL_MESSAGE_V1,
338
+ isError: true,
339
+ });
340
+ expect(COMPUTER_OVERLOADED_TOOL_MESSAGE_V1.length).toBeLessThanOrEqual(
341
+ 160,
342
+ );
343
+ await harness.dispose();
344
+ }
345
+ });
346
+
226
347
  test("injects and records the human-control line only while the durable lease is fresh", async () => {
227
348
  const records = new Map<string, unknown>([
228
349
  [
package/src/agent.ts CHANGED
@@ -153,6 +153,48 @@ export interface ComputerAgentPluginConfig {
153
153
  export const HUMAN_CONTROL_PROMPT_LINE =
154
154
  "Your User is currently controlling the Computer; do not use it this Turn.";
155
155
 
156
+ /** Bounded copy for the two transport failures an overloaded Sprite emits. */
157
+ export const COMPUTER_OVERLOADED_TOOL_MESSAGE_V1 =
158
+ "The Computer is overloaded; a browser tab using too much memory was closed. Try again.";
159
+
160
+ function errorMessage(error: unknown): string {
161
+ return error instanceof Error ? error.message : String(error);
162
+ }
163
+
164
+ function isOverloadedTransportFailure(error: unknown): boolean {
165
+ const message = errorMessage(error).toLowerCase();
166
+ return (
167
+ message.includes("websocket keepalive timeout") ||
168
+ message.includes("computer effect was cancelled")
169
+ );
170
+ }
171
+
172
+ /** A local HTTP origin an Applet preview can own; public sites never qualify. */
173
+ export function localPreviewOriginV1(value: string): string | undefined {
174
+ try {
175
+ const url = new URL(value);
176
+ const local = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
177
+ return local && (url.protocol === "http:" || url.protocol === "https:")
178
+ ? url.origin
179
+ : undefined;
180
+ } catch {
181
+ return undefined;
182
+ }
183
+ }
184
+
185
+ function localPreviewOriginsV1(value: string): string[] {
186
+ const found = new Set<string>();
187
+ for (const match of value.matchAll(/https?:\/\/[^\s"'<>]+/g)) {
188
+ const origin = localPreviewOriginV1(match[0].replace(/[),.;!?]+$/g, ""));
189
+ if (origin) found.add(origin);
190
+ }
191
+ return [...found];
192
+ }
193
+
194
+ function runsAppletPreviewV1(command: string): boolean {
195
+ return /(?:^|[\s;&|])(?:[^\s;&|]*\/)?applet\s+dev(?:\s|$)/.test(command);
196
+ }
197
+
156
198
  /** The wake-free, Turn-scoped projection shared by prompt render and its log. */
157
199
  class ComputerControlPromptProjection {
158
200
  #line = "";
@@ -224,6 +266,7 @@ function base64Of(bytes: Uint8Array): string {
224
266
  interface ExecInput {
225
267
  command: string;
226
268
  background: boolean;
269
+ cwd?: string;
227
270
  }
228
271
 
229
272
  function record(input: unknown): Record<string, unknown> | undefined {
@@ -233,17 +276,75 @@ function record(input: unknown): Record<string, unknown> | undefined {
233
276
  }
234
277
 
235
278
  const MAX_EXEC_COMMAND_LENGTH = 20_000;
279
+ /** An absolute path on the Computer, at the Computer host's own path bound. */
280
+ const MAX_EXEC_CWD_LENGTH = 4_096;
281
+
282
+ /** One shell word, whatever the path holds. */
283
+ function shellQuoteV1(value: string): string {
284
+ return `'${value.replaceAll("'", `'\\''`)}'`;
285
+ }
286
+
287
+ /** Every key `computer_exec` accepts; anything else is refused by name. */
288
+ const EXEC_INPUT_KEYS = ["command", "background", "cwd"] as const;
236
289
 
237
290
  function decodeExec(input: unknown): ExecInput | undefined {
238
291
  const value = record(input);
239
- const command = value?.command;
292
+ if (!value) return undefined;
293
+ if (
294
+ Object.keys(value).some(
295
+ (key) => !(EXEC_INPUT_KEYS as readonly string[]).includes(key),
296
+ )
297
+ ) {
298
+ return undefined;
299
+ }
300
+ const command = value.command;
240
301
  if (typeof command !== "string" || !command.trim()) return undefined;
241
302
  if (command.length > MAX_EXEC_COMMAND_LENGTH) return undefined;
242
- const background = value?.background;
303
+ const background = value.background;
243
304
  if (background !== undefined && typeof background !== "boolean") {
244
305
  return undefined;
245
306
  }
246
- return { command, background: background === true };
307
+ const cwd = value.cwd;
308
+ if (cwd !== undefined) {
309
+ if (typeof cwd !== "string") return undefined;
310
+ if (!cwd.startsWith("/") || cwd.length > MAX_EXEC_CWD_LENGTH) {
311
+ return undefined;
312
+ }
313
+ if (/[\0\n\r]/.test(cwd)) return undefined;
314
+ }
315
+ return {
316
+ command,
317
+ background: background === true,
318
+ ...(typeof cwd === "string" ? { cwd } : {}),
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Why a `computer_exec` input could not be used, in the words that fix it.
324
+ *
325
+ * An argument the tool does not know used to be dropped on the way in, so a
326
+ * `cwd` the model sent was silently ignored and the command ran somewhere else
327
+ * — four wasted steps on production (2026-09-04) working out why `cat` could
328
+ * not see a file that was plainly there. An unknown key is refused now, and the
329
+ * refusal names it, which is the only reason refusing is better than dropping.
330
+ */
331
+ export function execInputRefusalV1(input: unknown): string {
332
+ const value = record(input);
333
+ const unknown = Object.keys(value ?? {}).filter(
334
+ (key) => !(EXEC_INPUT_KEYS as readonly string[]).includes(key),
335
+ );
336
+ if (unknown.length > 0) {
337
+ return `computer_exec input is invalid: ${unknown
338
+ .map((key) => `"${key}"`)
339
+ .join(", ")} ${unknown.length === 1 ? "is not a" : "are not"} field${
340
+ unknown.length === 1 ? "" : "s"
341
+ } of this tool. It takes "command", optional "cwd", and optional "background".`;
342
+ }
343
+ const cwd = value?.cwd;
344
+ if (cwd !== undefined && (typeof cwd !== "string" || !cwd.startsWith("/"))) {
345
+ return `computer_exec input is invalid: "cwd" must be an absolute path of at most ${MAX_EXEC_CWD_LENGTH} characters, such as "/home/box/agent-data".`;
346
+ }
347
+ return `computer_exec input is invalid: "command" must be a shell command of at most ${MAX_EXEC_COMMAND_LENGTH} characters.`;
247
348
  }
248
349
 
249
350
  /** The durable root a finished process's log tail is mirrored into. */
@@ -345,6 +446,9 @@ function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
345
446
  }
346
447
 
347
448
  function failure(error: unknown): { content: string; isError: true } {
449
+ if (isOverloadedTransportFailure(error)) {
450
+ return { content: COMPUTER_OVERLOADED_TOOL_MESSAGE_V1, isError: true };
451
+ }
348
452
  if (error instanceof ComputerError) {
349
453
  if (error.code === "human-control-active") {
350
454
  // The holder is named, so a second Bot of the same User — and the User
@@ -367,7 +471,7 @@ function failure(error: unknown): { content: string; isError: true } {
367
471
  return { content: error.message, isError: true };
368
472
  }
369
473
  return {
370
- content: error instanceof Error ? error.message : String(error),
474
+ content: errorMessage(error),
371
475
  isError: true,
372
476
  };
373
477
  }
@@ -621,6 +725,8 @@ export function createComputerAgentPlugin(
621
725
  // Agent loop knows it; a tool context does not, so it is caught where the
622
726
  // loop already announces it.
623
727
  let currentTurn = 1;
728
+ /** Local preview origins this Bot navigated to during the current Turn. */
729
+ const previewOrigins = new Set<string>();
624
730
  // One cadence per mounted plugin, which is one per Turn: a Turn's first
625
731
  // Computer action always gets its capture, and the rest are debounced.
626
732
  const progressCadence = createComputerCaptureCadenceV1(
@@ -660,6 +766,24 @@ export function createComputerAgentPlugin(
660
766
  await selfCheck(computer, botId, signal);
661
767
  return computer;
662
768
  };
769
+ const closePreviewTabs = async (
770
+ computer: ComputerHandle,
771
+ origins: readonly string[],
772
+ effectId: string,
773
+ signal?: AbortSignal,
774
+ ): Promise<void> => {
775
+ if (!computer.browser) return;
776
+ const unique = [...new Set(origins)];
777
+ for (let offset = 0; offset < unique.length; offset += 16) {
778
+ await computer.browser.perform(
779
+ { type: "close-origins", origins: unique.slice(offset, offset + 16) },
780
+ {
781
+ ...(signal ? { signal } : {}),
782
+ effectId: `${effectId}:${Math.floor(offset / 16)}`,
783
+ },
784
+ );
785
+ }
786
+ };
663
787
 
664
788
  const execTool: ToolDefinition = {
665
789
  name: "computer_exec",
@@ -676,6 +800,7 @@ export function createComputerAgentPlugin(
676
800
  idempotent: config.idempotentEffects === true,
677
801
  description: [
678
802
  "Run a shell command in the Bot's selected persistent Computer. New calls are blocked while the user has taken control.",
803
+ "Pass cwd as an absolute path to run the command in that directory instead of the home directory.",
679
804
  "With background:true the command keeps running after this call returns and after this Turn ends, and you get a processId to check later.",
680
805
  "A background process runs only while the Computer is awake. Nothing keeps it awake for you: if the Computer hibernates first, the outcome is reported as unknown, with whatever log was durable at the time.",
681
806
  `${SCRATCH_ROOT} (also $FROCKBOT_SCRATCH) is scratch shared with your User's other Bots: it survives hibernation but is not durable and never reaches storage, so keep nothing there you cannot lose.`,
@@ -685,6 +810,12 @@ export function createComputerAgentPlugin(
685
810
  type: "object",
686
811
  properties: {
687
812
  command: { type: "string", maxLength: MAX_EXEC_COMMAND_LENGTH },
813
+ cwd: {
814
+ type: "string",
815
+ maxLength: MAX_EXEC_CWD_LENGTH,
816
+ description:
817
+ "Absolute path to run the command in. Defaults to the Bot's home directory.",
818
+ },
688
819
  background: {
689
820
  type: "boolean",
690
821
  description:
@@ -694,14 +825,15 @@ export function createComputerAgentPlugin(
694
825
  required: ["command"],
695
826
  additionalProperties: false,
696
827
  },
697
- validate: (input) => decodeExec(input) !== undefined,
828
+ // Deliberately permissive, for the same reason `computer_browser` is: a
829
+ // wrong shape reaches `execute`, which names the field. A `false` here is
830
+ // the loop's generic "Invalid input for tool", which names nothing.
831
+ validate: (input) => !!record(input),
698
832
  execute: async (input, context) => {
699
833
  const decoded = decodeExec(input);
700
- if (!decoded)
701
- return {
702
- content: `A command of at most ${MAX_EXEC_COMMAND_LENGTH} characters is required`,
703
- isError: true,
704
- };
834
+ if (!decoded) {
835
+ return { content: execInputRefusalV1(input), isError: true };
836
+ }
705
837
  // "The GUI is never driven from the shell" (parity row 33), refused at
706
838
  // the seam where the model can be told why. This is policy and not a
707
839
  // boundary — a regex over a shell string is defeatable, and the
@@ -714,7 +846,15 @@ export function createComputerAgentPlugin(
714
846
  }
715
847
  if (decoded.background) {
716
848
  return processes
717
- ? launchBackground(decoded.command, context)
849
+ ? // A launch carries a command and not a directory, so the
850
+ // directory becomes part of the command. `cd` failing stops the
851
+ // process before it starts, which is what a wrong path deserves.
852
+ launchBackground(
853
+ decoded.cwd
854
+ ? `cd ${shellQuoteV1(decoded.cwd)} && ${decoded.command}`
855
+ : decoded.command,
856
+ context,
857
+ )
718
858
  : {
719
859
  content:
720
860
  "A background process is recorded before it is launched; this runtime has nowhere durable to record it",
@@ -735,6 +875,7 @@ export function createComputerAgentPlugin(
735
875
  {
736
876
  executable: "/bin/bash",
737
877
  args: ["-lc", decoded.command],
878
+ ...(decoded.cwd ? { cwd: decoded.cwd } : {}),
738
879
  timeoutMs: 120_000,
739
880
  maxOutputBytes: 30_000,
740
881
  },
@@ -1039,6 +1180,22 @@ export function createComputerAgentPlugin(
1039
1180
  // A mirror that could not be written never withholds an outcome
1040
1181
  // that was read.
1041
1182
  }
1183
+ if (
1184
+ action === "stop" &&
1185
+ runsAppletPreviewV1(held.command) &&
1186
+ computer.browser
1187
+ ) {
1188
+ const origins = localPreviewOriginsV1(observed.logTail);
1189
+ if (origins.length > 0) {
1190
+ await closePreviewTabs(
1191
+ computer,
1192
+ origins,
1193
+ `${context.effectId}:close-preview-tabs`,
1194
+ context.signal,
1195
+ );
1196
+ for (const origin of origins) previewOrigins.delete(origin);
1197
+ }
1198
+ }
1042
1199
  if (action === "logs") {
1043
1200
  return {
1044
1201
  content: observed.logTail || "(no output yet)",
@@ -1355,7 +1512,7 @@ export function createComputerAgentPlugin(
1355
1512
  subagentRoles: ["executor", "computerUse"],
1356
1513
  },
1357
1514
  description:
1358
- "Run the Computer's self-check and read the report: disk, the shared scratch, the desktop gateway, your display, the browser profile and what the browser announces itself as, the durable-root sync and its conflicts, the reference docs, the browser launcher, the clock, and DNS. Read-only; it changes nothing and repairs nothing.",
1515
+ "Run the Computer's self-check and read the report: disk, the shared scratch, the desktop gateway, your display, the browser profile, renderer-watchdog actions, top memory consumers, the durable-root sync and its conflicts, the reference docs, the browser launcher, the clock, and DNS. Read-only; it changes nothing and repairs nothing.",
1359
1516
  inputSchema: {
1360
1517
  type: "object",
1361
1518
  properties: {},
@@ -1575,6 +1732,10 @@ export function createComputerAgentPlugin(
1575
1732
  signal: context.signal,
1576
1733
  effectId: context.effectId,
1577
1734
  });
1735
+ if (action.type === "navigate") {
1736
+ const origin = localPreviewOriginV1(action.url);
1737
+ if (origin) previewOrigins.add(origin);
1738
+ }
1578
1739
  await fileProgressCapture(computer, context.botId, context);
1579
1740
  return {
1580
1741
  content: result.accessibilitySnapshot,
@@ -1605,6 +1766,7 @@ export function createComputerAgentPlugin(
1605
1766
  ctx.on("agent/pre-step", async (agent, _inputs, turn, _step, next) => {
1606
1767
  if (turn !== currentTurn) {
1607
1768
  projectionWrites.clear();
1769
+ previewOrigins.clear();
1608
1770
  // Every Turn's first Computer action is worth a capture, however
1609
1771
  // soon after the previous Turn's last one it happens.
1610
1772
  progressCadence.reset();
@@ -1630,6 +1792,15 @@ export function createComputerAgentPlugin(
1630
1792
  return;
1631
1793
  }
1632
1794
  try {
1795
+ if (computer.browser && previewOrigins.size > 0) {
1796
+ const origins = [...previewOrigins];
1797
+ await closePreviewTabs(
1798
+ computer,
1799
+ origins,
1800
+ `computer:${writer?.runId ?? agent.session.id}:${turn}:close-preview-tabs`,
1801
+ );
1802
+ previewOrigins.clear();
1803
+ }
1633
1804
  if (writer && computer.workspace) {
1634
1805
  const root: WorkspaceRootV1 = {
1635
1806
  kind: "package-declared",
@@ -1681,7 +1852,7 @@ export function createComputerAgentPlugin(
1681
1852
  "Use computer_exec to inspect the filesystem before claiming that a path or file exists.",
1682
1853
  "Use computer_screenshot to see your own desktop; each capture is filed in your durable screenshots root.",
1683
1854
  "For a job that outlasts this Turn, use computer_exec with background:true and check it later with computer_process_check. Do not poll it in a loop.",
1684
- "Use computer_doctor when the Computer misbehaves; it reports disk, desktop, sync, and network in one read-only call.",
1855
+ "Use computer_doctor when the Computer misbehaves; it reports disk, desktop, renderer-watchdog actions, top memory consumers, sync, and network in one read-only call.",
1685
1856
  ...(controlPrompt?.current() ? [controlPrompt.current()] : []),
1686
1857
  "Never invent a directory listing.",
1687
1858
  ].join("\n"),
@@ -92,6 +92,20 @@ function fakeComputer(options: { launchFails?: boolean } = {}): Computer {
92
92
  },
93
93
  generation: () => Promise.resolve(computer.generation),
94
94
  },
95
+ browser: {
96
+ perform: (action) => {
97
+ const cleanup = action as {
98
+ type: string;
99
+ origins?: readonly string[];
100
+ };
101
+ calls.push(
102
+ cleanup.type === "close-origins"
103
+ ? `browser:close-origins:${cleanup.origins?.join(",") ?? ""}`
104
+ : `browser:${cleanup.type}`,
105
+ );
106
+ return Promise.resolve({ accessibilitySnapshot: "" });
107
+ },
108
+ },
95
109
  close: () => Promise.resolve(),
96
110
  }),
97
111
  },
@@ -418,4 +432,69 @@ describe("stopping a background process", () => {
418
432
  ]);
419
433
  await harness.dispose();
420
434
  });
435
+
436
+ test("closes the Bot's tabs on an applet dev origin when its process stops", async () => {
437
+ const computer = fakeComputer();
438
+ const held = storage();
439
+ const harness = await mount(computer, held);
440
+ const launched = JSON.parse(
441
+ (
442
+ await call(harness, "computer_exec", {
443
+ command: "applet dev --port 8787",
444
+ background: true,
445
+ })
446
+ ).content,
447
+ ) as { processId: string };
448
+ await call(harness, "computer_browser", {
449
+ action: "navigate",
450
+ url: "http://127.0.0.1:8787/",
451
+ });
452
+ computer.state = {
453
+ alive: false,
454
+ exitCode: 143,
455
+ logTail: "http://127.0.0.1:8787/\nterminated",
456
+ };
457
+
458
+ const stopped = await call(harness, "computer_process_stop", {
459
+ processId: launched.processId,
460
+ });
461
+
462
+ expect(stopped.isError).toBe(false);
463
+ expect(computer.calls).toEqual([
464
+ `launch:${launched.processId}:applet dev --port 8787`,
465
+ "browser:navigate",
466
+ `stop:${launched.processId}`,
467
+ "browser:close-origins:http://127.0.0.1:8787",
468
+ ]);
469
+ await harness.dispose();
470
+ });
471
+ });
472
+
473
+ describe("releasing a Bot's Turn", () => {
474
+ test("closes local preview origins the Bot navigated during the Turn", async () => {
475
+ const computer = fakeComputer();
476
+ const harness = await mount(computer, storage());
477
+ const session = harness.root.sessions.create("session-1");
478
+ const agent = { botId: "bot-1", session };
479
+ await harness.root.waterfall(
480
+ "agent/pre-step",
481
+ agent as never,
482
+ [],
483
+ 1,
484
+ 1,
485
+ () => Promise.resolve({ kind: "enter" as const, inputs: [] }),
486
+ );
487
+ await call(harness, "computer_browser", {
488
+ action: "navigate",
489
+ url: "http://localhost:8787/preview",
490
+ });
491
+
492
+ await harness.root.serial("agent/turn-stopping", agent as never, 1);
493
+
494
+ expect(computer.calls).toEqual([
495
+ "browser:navigate",
496
+ "browser:close-origins:http://localhost:8787",
497
+ ]);
498
+ await harness.dispose();
499
+ });
421
500
  });