@frockbot/plugin-computer 0.3.21 → 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 +12 -12
- package/src/agent.test.ts +76 -0
- package/src/agent.ts +86 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-computer",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
33
|
-
"@frockbot/client-ui": "0.3.
|
|
34
|
-
"@frockbot/computer-core": "0.3.
|
|
35
|
-
"@frockbot/computer-host-runtime": "0.3.
|
|
36
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
37
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
38
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
39
|
-
"@frockbot/plugin-shell": "0.3.
|
|
40
|
-
"@frockbot/plugin-tools": "0.3.
|
|
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.
|
|
46
|
-
"@frockbot/plugin-testkit": "0.3.
|
|
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
|
@@ -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
|
-
|
|
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
|
|
303
|
+
const background = value.background;
|
|
285
304
|
if (background !== undefined && typeof background !== "boolean") {
|
|
286
305
|
return undefined;
|
|
287
306
|
}
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
},
|