@frockbot/plugin-computer 0.3.21 → 0.3.23

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.21",
3
+ "version": "0.3.23",
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.21",
33
- "@frockbot/client-ui": "0.3.21",
34
- "@frockbot/computer-core": "0.3.21",
35
- "@frockbot/computer-host-runtime": "0.3.21",
36
- "@frockbot/kernel-agent-loop": "0.3.21",
37
- "@frockbot/kernel-contracts": "0.3.21",
38
- "@frockbot/plugin-prompt": "0.3.21",
39
- "@frockbot/plugin-shell": "0.3.21",
40
- "@frockbot/plugin-tools": "0.3.21",
32
+ "@frockbot/client-core": "0.3.23",
33
+ "@frockbot/client-ui": "0.3.23",
34
+ "@frockbot/computer-core": "0.3.23",
35
+ "@frockbot/computer-host-runtime": "0.3.23",
36
+ "@frockbot/kernel-agent-loop": "0.3.23",
37
+ "@frockbot/kernel-contracts": "0.3.23",
38
+ "@frockbot/plugin-prompt": "0.3.23",
39
+ "@frockbot/plugin-shell": "0.3.23",
40
+ "@frockbot/plugin-tools": "0.3.23",
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.21",
46
- "@frockbot/plugin-testkit": "0.3.21",
45
+ "@frockbot/plugin-models": "0.3.23",
46
+ "@frockbot/plugin-testkit": "0.3.23",
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
@@ -181,6 +181,82 @@ describe("computer agent contribution", () => {
181
181
  await harness.dispose();
182
182
  });
183
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
+
184
260
  test("computer_exec during an update returns an actionable tool failure", async () => {
185
261
  const provider: ComputerProvider = {
186
262
  id: "fixture",
package/src/agent.ts CHANGED
@@ -266,6 +266,7 @@ function base64Of(bytes: Uint8Array): string {
266
266
  interface ExecInput {
267
267
  command: string;
268
268
  background: boolean;
269
+ cwd?: string;
269
270
  }
270
271
 
271
272
  function record(input: unknown): Record<string, unknown> | undefined {
@@ -275,17 +276,75 @@ function record(input: unknown): Record<string, unknown> | undefined {
275
276
  }
276
277
 
277
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;
278
289
 
279
290
  function decodeExec(input: unknown): ExecInput | undefined {
280
291
  const value = record(input);
281
- 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;
282
301
  if (typeof command !== "string" || !command.trim()) return undefined;
283
302
  if (command.length > MAX_EXEC_COMMAND_LENGTH) return undefined;
284
- const background = value?.background;
303
+ const background = value.background;
285
304
  if (background !== undefined && typeof background !== "boolean") {
286
305
  return undefined;
287
306
  }
288
- 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.`;
289
348
  }
290
349
 
291
350
  /** The durable root a finished process's log tail is mirrored into. */
@@ -741,6 +800,7 @@ export function createComputerAgentPlugin(
741
800
  idempotent: config.idempotentEffects === true,
742
801
  description: [
743
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.",
744
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.",
745
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.",
746
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.`,
@@ -750,6 +810,12 @@ export function createComputerAgentPlugin(
750
810
  type: "object",
751
811
  properties: {
752
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
+ },
753
819
  background: {
754
820
  type: "boolean",
755
821
  description:
@@ -759,14 +825,15 @@ export function createComputerAgentPlugin(
759
825
  required: ["command"],
760
826
  additionalProperties: false,
761
827
  },
762
- 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),
763
832
  execute: async (input, context) => {
764
833
  const decoded = decodeExec(input);
765
- if (!decoded)
766
- return {
767
- content: `A command of at most ${MAX_EXEC_COMMAND_LENGTH} characters is required`,
768
- isError: true,
769
- };
834
+ if (!decoded) {
835
+ return { content: execInputRefusalV1(input), isError: true };
836
+ }
770
837
  // "The GUI is never driven from the shell" (parity row 33), refused at
771
838
  // the seam where the model can be told why. This is policy and not a
772
839
  // boundary — a regex over a shell string is defeatable, and the
@@ -779,7 +846,15 @@ export function createComputerAgentPlugin(
779
846
  }
780
847
  if (decoded.background) {
781
848
  return processes
782
- ? 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
+ )
783
858
  : {
784
859
  content:
785
860
  "A background process is recorded before it is launched; this runtime has nowhere durable to record it",
@@ -800,6 +875,7 @@ export function createComputerAgentPlugin(
800
875
  {
801
876
  executable: "/bin/bash",
802
877
  args: ["-lc", decoded.command],
878
+ ...(decoded.cwd ? { cwd: decoded.cwd } : {}),
803
879
  timeoutMs: 120_000,
804
880
  maxOutputBytes: 30_000,
805
881
  },