@dynamicagents/plugins 0.16.1 → 0.18.0
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/README.md +3 -3
- package/dist/browser/index.d.ts +13 -16
- package/dist/browser/index.js +88 -26
- package/dist/claude-code/cards.d.ts +6 -1
- package/dist/claude-code/cards.js +21 -2
- package/dist/claude-code/model.d.ts +4 -0
- package/dist/claude-code/model.js +74 -7
- package/dist/claude-code/verified.d.ts +1 -1
- package/dist/claude-code/verified.js +1 -1
- package/dist/computer/client.d.ts +9 -1
- package/dist/computer/context.js +1 -1
- package/dist/computer/index.d.ts +10 -6
- package/dist/computer/index.js +34 -17
- package/dist/computer/paths.d.ts +26 -2
- package/dist/computer/paths.js +51 -5
- package/dist/computer/proxy.d.ts +10 -3
- package/dist/computer/proxy.js +77 -87
- package/dist/computer/render.d.ts +6 -35
- package/dist/computer/render.js +5 -43
- package/dist/computer/sandbox.d.ts +4 -3
- package/dist/computer/sandbox.js +9 -2
- package/dist/computer/shell.d.ts +8 -7
- package/dist/computer/shell.js +8 -7
- package/dist/computer/tools-exec.d.ts +39 -0
- package/dist/computer/tools-exec.js +357 -228
- package/dist/computer/tools-grep.js +10 -4
- package/dist/repo-github/sandbox-egress.d.ts +5 -1
- package/dist/repo-github/sandbox-egress.js +9 -3
- package/dist/sandbox/header.d.ts +13 -0
- package/dist/sandbox/header.js +19 -4
- package/dist/sandbox/install-plan.d.ts +1 -1
- package/dist/sandbox/install-plan.js +2 -4
- package/dist/sandbox/object.d.ts +1 -0
- package/dist/sandbox/object.js +21 -13
- package/package.json +12 -8
package/dist/computer/paths.d.ts
CHANGED
|
@@ -21,11 +21,35 @@
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function isGitInternal(path: string): boolean;
|
|
23
23
|
/**
|
|
24
|
-
* What every walk steps over, as directory names. Both the workspace's `
|
|
24
|
+
* What every walk steps over, as directory names. Both the workspace's `find`
|
|
25
25
|
* and the `grep` tool hand these on as `**\/<name>` exclusions, which prunes
|
|
26
26
|
* them: no directory named here is descended into, so none costs a read.
|
|
27
27
|
*/
|
|
28
28
|
export declare const WALK_SKIPS: readonly [".git", "node_modules"];
|
|
29
|
+
/**
|
|
30
|
+
* The directories Think writes on its own account, directly under the
|
|
31
|
+
* workspace root: evicted media under `attachments/evicted/`, a fetch tool's
|
|
32
|
+
* spilled responses under `fetched/`, projected skills under `.agents/skills`.
|
|
33
|
+
* Think builds those paths from a root of its own and offers no way to move
|
|
34
|
+
* them, so a walk of the root reaches them.
|
|
35
|
+
*
|
|
36
|
+
* Pruned from a walk of the root and from nowhere else, which is what
|
|
37
|
+
* {@link walkSkips} spells: these are *names at the root*, not names anywhere,
|
|
38
|
+
* so a checkout with its own `attachments/` is still searched. A direct read is
|
|
39
|
+
* not refused either, because an eviction marker hands the model one of these
|
|
40
|
+
* paths and expects it to be readable.
|
|
41
|
+
*/
|
|
42
|
+
export declare const AGENT_OWNED: readonly ["attachments", "fetched", ".agents"];
|
|
43
|
+
/**
|
|
44
|
+
* The exclusions one walk hands on, for a walk rooted at `directory` inside a
|
|
45
|
+
* workspace whose root is `root`.
|
|
46
|
+
*
|
|
47
|
+
* Every pattern is matched against the walked directory's *relative* path, so a
|
|
48
|
+
* root-anchored absolute pattern would match nothing. {@link AGENT_OWNED} is
|
|
49
|
+
* therefore relative to `directory` and only added when the walk starts at the
|
|
50
|
+
* root; {@link WALK_SKIPS} is a name at any depth.
|
|
51
|
+
*/
|
|
52
|
+
export declare function walkSkips(directory: string, root: string): string[];
|
|
29
53
|
/**
|
|
30
54
|
* The path check every file tool and every workspace call makes, before it
|
|
31
55
|
* opens anything.
|
|
@@ -40,7 +64,7 @@ export declare const WALK_SKIPS: readonly [".git", "node_modules"];
|
|
|
40
64
|
* actually happens is a model reaching into `.git/HEAD` to fix a merge.
|
|
41
65
|
*
|
|
42
66
|
* It is not containment, and this is the only place worth saying so. An agent
|
|
43
|
-
* that can write files here almost always holds `
|
|
67
|
+
* that can write files here almost always holds `exec` too, which reads and
|
|
44
68
|
* writes `.git` directly.
|
|
45
69
|
*
|
|
46
70
|
* Symlinks are not resolved, for the same reason: a tracked
|
package/dist/computer/paths.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Everything here is a string comparison: no filesystem, no `await`, which is
|
|
11
11
|
* what lets {@link guardPath} run before a tool opens the workspace at all.
|
|
12
12
|
*/
|
|
13
|
+
import { normalizeAbsolute } from "../sandbox/header.js";
|
|
13
14
|
/** Whether any segment of `path` is exactly `segment`. */
|
|
14
15
|
function hasSegment(path, segment) {
|
|
15
16
|
return path.split("/").includes(segment);
|
|
@@ -27,15 +28,60 @@ export function isGitInternal(path) {
|
|
|
27
28
|
return hasSegment(path, ".git");
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
30
|
-
* What every walk steps over, as directory names. Both the workspace's `
|
|
31
|
+
* What every walk steps over, as directory names. Both the workspace's `find`
|
|
31
32
|
* and the `grep` tool hand these on as `**\/<name>` exclusions, which prunes
|
|
32
33
|
* them: no directory named here is descended into, so none costs a read.
|
|
33
34
|
*/
|
|
34
35
|
export const WALK_SKIPS = [".git", "node_modules"];
|
|
36
|
+
/**
|
|
37
|
+
* The directories Think writes on its own account, directly under the
|
|
38
|
+
* workspace root: evicted media under `attachments/evicted/`, a fetch tool's
|
|
39
|
+
* spilled responses under `fetched/`, projected skills under `.agents/skills`.
|
|
40
|
+
* Think builds those paths from a root of its own and offers no way to move
|
|
41
|
+
* them, so a walk of the root reaches them.
|
|
42
|
+
*
|
|
43
|
+
* Pruned from a walk of the root and from nowhere else, which is what
|
|
44
|
+
* {@link walkSkips} spells: these are *names at the root*, not names anywhere,
|
|
45
|
+
* so a checkout with its own `attachments/` is still searched. A direct read is
|
|
46
|
+
* not refused either, because an eviction marker hands the model one of these
|
|
47
|
+
* paths and expects it to be readable.
|
|
48
|
+
*/
|
|
49
|
+
export const AGENT_OWNED = ["attachments", "fetched", ".agents"];
|
|
50
|
+
/**
|
|
51
|
+
* The exclusions one walk hands on, for a walk rooted at `directory` inside a
|
|
52
|
+
* workspace whose root is `root`.
|
|
53
|
+
*
|
|
54
|
+
* Every pattern is matched against the walked directory's *relative* path, so a
|
|
55
|
+
* root-anchored absolute pattern would match nothing. {@link AGENT_OWNED} is
|
|
56
|
+
* therefore relative to `directory` and only added when the walk starts at the
|
|
57
|
+
* root; {@link WALK_SKIPS} is a name at any depth.
|
|
58
|
+
*/
|
|
59
|
+
export function walkSkips(directory, root) {
|
|
60
|
+
const anywhere = WALK_SKIPS.map((segment) => `**/${segment}`);
|
|
61
|
+
return atRoot(directory, root)
|
|
62
|
+
? [...anywhere, ...AGENT_OWNED.map((name) => `${name}/**`)]
|
|
63
|
+
: anywhere;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether a walk starts at the workspace root, by what the two paths mean
|
|
67
|
+
* rather than by how they are spelled.
|
|
68
|
+
*
|
|
69
|
+
* The directory is the model's to spell, and `/workspace/`, `/workspace/.` and
|
|
70
|
+
* `/workspace/repo/..` are all spellings it produces. Every one of them is this
|
|
71
|
+
* root to the sandbox, which normalises before it confines, so a comparison
|
|
72
|
+
* here that read them literally would answer no to a walk that then went ahead
|
|
73
|
+
* from the root and came back with the agent's own directories in it. The
|
|
74
|
+
* normaliser is {@link file://../sandbox/header.ts normalizeAbsolute}, shared
|
|
75
|
+
* with that confinement so the two cannot disagree.
|
|
76
|
+
*/
|
|
77
|
+
function atRoot(directory, root) {
|
|
78
|
+
const resolved = normalizeAbsolute(directory);
|
|
79
|
+
return resolved !== undefined && resolved === normalizeAbsolute(root);
|
|
80
|
+
}
|
|
35
81
|
/** The sentence a `node_modules` path gets. */
|
|
36
82
|
function dependencyTreeNote(path, verb) {
|
|
37
83
|
return (`${path} is inside node_modules, which ${verb} leaves out: it is installed ` +
|
|
38
|
-
`output, not source. Use
|
|
84
|
+
`output, not source. Use exec there — \`cat\`, \`ls\`, \`rg\`.`);
|
|
39
85
|
}
|
|
40
86
|
/**
|
|
41
87
|
* The sentence a `.git` path gets.
|
|
@@ -43,9 +89,9 @@ function dependencyTreeNote(path, verb) {
|
|
|
43
89
|
* Names a route deliberately: a refusal with no destination is worse than no
|
|
44
90
|
* refusal, because the model retries and then works around it.
|
|
45
91
|
*
|
|
46
|
-
* What it must never name is `
|
|
92
|
+
* What it must never name is `exec`. That hands back the exact capability
|
|
47
93
|
* being withheld, in the one place the model is already looking for a way around
|
|
48
|
-
* it, with the tool's own authority behind it. `
|
|
94
|
+
* it, with the tool's own authority behind it. `exec` is unguarded because a
|
|
49
95
|
* shell takes an opaque command string and pattern-matching git out of one is
|
|
50
96
|
* neither reliable nor this guard's job — a fact about the implementation, not a
|
|
51
97
|
* route to advertise.
|
|
@@ -72,7 +118,7 @@ function gitInternalNote(path, verb) {
|
|
|
72
118
|
* actually happens is a model reaching into `.git/HEAD` to fix a merge.
|
|
73
119
|
*
|
|
74
120
|
* It is not containment, and this is the only place worth saying so. An agent
|
|
75
|
-
* that can write files here almost always holds `
|
|
121
|
+
* that can write files here almost always holds `exec` too, which reads and
|
|
76
122
|
* writes `.git` directly.
|
|
77
123
|
*
|
|
78
124
|
* Symlinks are not resolved, for the same reason: a tracked
|
package/dist/computer/proxy.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ThinkWorkspace } from "@cloudflare/think";
|
|
2
2
|
import type { SandboxAdvisory } from "../sandbox/advisory.js";
|
|
3
3
|
import type { ComputerClient } from "./client.js";
|
|
4
4
|
/**
|
|
@@ -6,7 +6,7 @@ import type { ComputerClient } from "./client.js";
|
|
|
6
6
|
*
|
|
7
7
|
* Think's `read`, `write`, `edit` and `delete` run against whatever an agent's
|
|
8
8
|
* workspace is, and the computer tools replace only the ones they name. So an
|
|
9
|
-
* agent with a container and Think's default workspace would run `
|
|
9
|
+
* agent with a container and Think's default workspace would run `exec` in the
|
|
10
10
|
* container and `write` in its own SQLite, and nothing would say so. An agent
|
|
11
11
|
* that installs the tools sets its workspace to `computerWorkspace`
|
|
12
12
|
* (`./sandbox.ts`), and the plugin refuses to start without it — and reaches
|
|
@@ -28,4 +28,11 @@ export interface WorkspaceRoute {
|
|
|
28
28
|
/** The route of a workspace {@link routedWorkspace} built, or `undefined`. */
|
|
29
29
|
export declare function workspaceRoute(workspace: unknown): WorkspaceRoute | undefined;
|
|
30
30
|
/** Think's file tools over `route`, carrying it for the computer tools to find. */
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* `root` is the sandbox's served root and never a working directory. The
|
|
33
|
+
* directories Think writes its own files into are fixed under that root, so
|
|
34
|
+
* anchoring their exclusions anywhere else both hides a checkout's own
|
|
35
|
+
* `attachments/` and leaves Think's in a search of the root: see
|
|
36
|
+
* {@link file://./paths.ts walkSkips}.
|
|
37
|
+
*/
|
|
38
|
+
export declare function routedWorkspace(route: WorkspaceRoute, root: string): ThinkWorkspace;
|
package/dist/computer/proxy.js
CHANGED
|
@@ -52,78 +52,31 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
52
52
|
});
|
|
53
53
|
import { withFileLock } from "./file-lock.js";
|
|
54
54
|
import { writeGate } from "./gate.js";
|
|
55
|
-
import { guardPath,
|
|
55
|
+
import { guardPath, walkSkips } from "./paths.js";
|
|
56
56
|
/**
|
|
57
57
|
* The key the route is carried under. `Symbol.for`, so a second copy of this
|
|
58
58
|
* module in one bundle still recognises the first's workspaces.
|
|
59
59
|
*/
|
|
60
60
|
const ROUTE = Symbol.for("@dynamicagents/plugins/computer:workspace");
|
|
61
|
-
/**
|
|
62
|
-
* The most one `glob` returns. Its caller is Think's `find`, which shows 200
|
|
63
|
-
* and says when there were more — so one past that is all it can use, and
|
|
64
|
-
* reading the rest of a large checkout into the object would buy nothing.
|
|
65
|
-
*/
|
|
66
|
-
const GLOB_LIMIT = 201;
|
|
67
61
|
/** The route of a workspace {@link routedWorkspace} built, or `undefined`. */
|
|
68
62
|
export function workspaceRoute(workspace) {
|
|
69
63
|
if (typeof workspace !== "object" || workspace === null)
|
|
70
64
|
return undefined;
|
|
71
65
|
return workspace[ROUTE];
|
|
72
66
|
}
|
|
73
|
-
/**
|
|
74
|
-
* Missing, as the filesystem says it across the boundary. The code does not
|
|
75
|
-
* always survive RPC, so the message is read too.
|
|
76
|
-
*/
|
|
77
|
-
function isMissing(err) {
|
|
78
|
-
if (!err || typeof err !== "object")
|
|
79
|
-
return false;
|
|
80
|
-
const { code, message } = err;
|
|
81
|
-
if (code === "ENOENT")
|
|
82
|
-
return true;
|
|
83
|
-
return typeof message === "string" && /ENOENT|no such/i.test(message);
|
|
84
|
-
}
|
|
85
|
-
/** `null` for a path that is not there, which is what Think's tools expect. */
|
|
86
|
-
async function orNull(read) {
|
|
87
|
-
try {
|
|
88
|
-
return await read;
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
if (isMissing(err))
|
|
92
|
-
return null;
|
|
93
|
-
throw err;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
67
|
+
/** The bytes of a stream, for a host RPC that takes a value rather than one. */
|
|
96
68
|
async function drain(stream) {
|
|
97
69
|
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
98
70
|
}
|
|
99
|
-
function fileInfo(entry) {
|
|
100
|
-
const type = entry.isDirectory ? "directory" : "file";
|
|
101
|
-
return {
|
|
102
|
-
path: entry.path,
|
|
103
|
-
name: entry.path.slice(entry.path.lastIndexOf("/") + 1),
|
|
104
|
-
type,
|
|
105
|
-
// Generic on purpose: Think's `read` sniffs a generic type from the bytes,
|
|
106
|
-
// and an extension is not evidence of what a file holds.
|
|
107
|
-
mimeType: type === "directory" ? "inode/directory" : "application/octet-stream",
|
|
108
|
-
size: entry.size ?? 0,
|
|
109
|
-
createdAt: entry.mtime ?? 0,
|
|
110
|
-
updatedAt: entry.mtime ?? 0
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
/** The directory a glob walks from, and the pattern relative to it. */
|
|
114
|
-
function splitGlob(pattern, cwd) {
|
|
115
|
-
const absolute = pattern.startsWith("/") ? pattern : `${cwd}/${pattern}`;
|
|
116
|
-
const wildcard = absolute.search(/[*?[{]/);
|
|
117
|
-
const cut = wildcard === -1
|
|
118
|
-
? absolute.lastIndexOf("/")
|
|
119
|
-
: absolute.lastIndexOf("/", wildcard);
|
|
120
|
-
return {
|
|
121
|
-
dir: cut <= 0 ? "/" : absolute.slice(0, cut),
|
|
122
|
-
rest: absolute.slice(cut + 1)
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
71
|
/** Think's file tools over `route`, carrying it for the computer tools to find. */
|
|
126
|
-
|
|
72
|
+
/**
|
|
73
|
+
* `root` is the sandbox's served root and never a working directory. The
|
|
74
|
+
* directories Think writes its own files into are fixed under that root, so
|
|
75
|
+
* anchoring their exclusions anywhere else both hides a checkout's own
|
|
76
|
+
* `attachments/` and leaves Think's in a search of the root: see
|
|
77
|
+
* {@link file://./paths.ts walkSkips}.
|
|
78
|
+
*/
|
|
79
|
+
export function routedWorkspace(route, root) {
|
|
127
80
|
/** One call, against the workspace this turn names. */
|
|
128
81
|
const withFs = async (path, verb, body) => {
|
|
129
82
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
@@ -158,42 +111,79 @@ export function routedWorkspace(route, cwd) {
|
|
|
158
111
|
throw new Error(lost);
|
|
159
112
|
return body(fs, name);
|
|
160
113
|
});
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Read whole or ranged, and as text or as bytes, in the one overloaded
|
|
116
|
+
* method Computer's filesystem declares. The options go through untouched:
|
|
117
|
+
* an `edit` reads text and writes back what it read, and a `read` of an
|
|
118
|
+
* evicted image asks for the bytes.
|
|
119
|
+
*
|
|
120
|
+
* **A host's `readFile` has to materialise what it answers**, because the
|
|
121
|
+
* client is released when this call returns and a stream that outlived it
|
|
122
|
+
* would be read against a disposed one. `/sandbox`'s does: it awaits the
|
|
123
|
+
* bytes and hands back a stream over them.
|
|
124
|
+
*/
|
|
125
|
+
const readFile = (path, options) => withFs(path, "read", (fs) => options === undefined
|
|
126
|
+
? fs.readFile(path)
|
|
127
|
+
: fs.readFile(path, options));
|
|
128
|
+
const fs = {
|
|
129
|
+
readFile: readFile,
|
|
130
|
+
stat: (path) => withFs(path, "read", async (fs) => ({
|
|
131
|
+
...(await fs.stat(path)),
|
|
132
|
+
// The container has one, and the sandbox does not report it. Nothing
|
|
133
|
+
// downstream reads it: Computer's tools take `size`, `mtime`, `mode`
|
|
134
|
+
// and the three kind flags, and a number is all the type needs.
|
|
135
|
+
inode: 0
|
|
136
|
+
})),
|
|
137
|
+
readdir: (path, options) => withFs(path, "list", async (fs) => (await fs.readdir(path, options)).map((entry) => ({
|
|
174
138
|
...entry,
|
|
175
|
-
|
|
139
|
+
// The directory this listing was read from, which is what a caller
|
|
140
|
+
// joins a name onto. The sandbox lists names and kinds, so the size
|
|
141
|
+
// and the time are the entry's own where it has them and zero where
|
|
142
|
+
// it does not: `ls` shows both to the model, and a second round trip
|
|
143
|
+
// per entry to fill them in would cost a directory's worth of RPCs.
|
|
144
|
+
parentPath: path,
|
|
145
|
+
size: entry.size ?? 0,
|
|
146
|
+
mtime: entry.mtime ?? 0,
|
|
147
|
+
isSymbolicLink: entry.isSymbolicLink ?? false
|
|
176
148
|
}))),
|
|
177
149
|
// Pruned rather than filtered: at a repository root either skipped
|
|
178
|
-
// directory outnumbers everything else.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
150
|
+
// directory outnumbers everything else. No limit of this layer's own —
|
|
151
|
+
// `./sandbox.ts` caps a walk that arrives without one.
|
|
152
|
+
find: (directory, pattern, options) => withFs(directory, "find", (fs) => fs.find(directory, pattern, {
|
|
153
|
+
...options,
|
|
154
|
+
exclude: [...walkSkips(directory, root), ...(options?.exclude ?? [])]
|
|
155
|
+
})),
|
|
156
|
+
grep: (pattern, path, options) => withFs(path, "search", (fs) => fs.grep(pattern, path, {
|
|
157
|
+
...options,
|
|
158
|
+
exclude: [
|
|
159
|
+
...walkSkips(path, root),
|
|
160
|
+
...(options?.exclude ?? [])
|
|
161
|
+
]
|
|
162
|
+
})),
|
|
163
|
+
/**
|
|
164
|
+
* Locked per file, so a write never lands inside the `edit` tool's read and
|
|
165
|
+
* write of the same file — see `./file-lock.ts`.
|
|
166
|
+
*
|
|
167
|
+
* A stream is drained here because the sandbox RPC takes a value. The
|
|
168
|
+
* `mode` a caller may send is dropped: the sandbox's write has nowhere to
|
|
169
|
+
* put one, so a mode this cannot set is not one it can claim to preserve.
|
|
170
|
+
*/
|
|
171
|
+
writeFile: (path, content) => withWritableFs(path, "write", async (fs, name) => {
|
|
172
|
+
const body = content instanceof ReadableStream ? await drain(content) : content;
|
|
173
|
+
return withFileLock(name, path, () => fs.writeFile(path, body));
|
|
174
|
+
}),
|
|
175
|
+
mkdir: (path, options) => withWritableFs(path, "write", (fs) => fs.mkdir(path, options)),
|
|
190
176
|
// Locked too: a delete landing between an edit's read and its write would
|
|
191
177
|
// be undone by the write, and both would report success.
|
|
192
|
-
rm: (path,
|
|
193
|
-
// No `writeFileBytes`. Think writes through it on its own account —
|
|
194
|
-
// evicted media, projected skills — and none of that belongs in a
|
|
195
|
-
// repository's checkout. Without it Think skips both, with a warning.
|
|
178
|
+
rm: (path, options) => withWritableFs(path, "delete", (fs, name) => withFileLock(name, path, () => fs.rm(path, options)))
|
|
196
179
|
};
|
|
180
|
+
/**
|
|
181
|
+
* `fs` alone. Computer's exec tool is offered only for a workspace carrying a
|
|
182
|
+
* `runtime`, so withholding one is how this keeps Think from building a
|
|
183
|
+
* second shell over the same container — `./tools-exec.ts` is the one the
|
|
184
|
+
* model gets, and it is this package's.
|
|
185
|
+
*/
|
|
186
|
+
const workspace = { fs };
|
|
197
187
|
Object.defineProperty(workspace, ROUTE, { value: route });
|
|
198
188
|
return workspace;
|
|
199
189
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { GrepMatch } from "./client.js";
|
|
2
2
|
/**
|
|
3
3
|
* Emit whole items until the byte budget is spent, and report exactly how many.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Core's `truncateOutput` is wrong for every list this module produces, and is
|
|
6
6
|
* deliberately not used by them. It keeps both ends and drops the middle, which is
|
|
7
|
-
* right for a build log
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* right for a build log and destructive for a match list or a directory listing,
|
|
8
|
+
* where the middle is a whole file's worth of hits or a whole subtree that silently
|
|
9
|
+
* vanishes. So the budget is spent item by item, in order, and the caller learns
|
|
10
|
+
* where it stopped.
|
|
11
11
|
*
|
|
12
12
|
* `shown` is the load-bearing return value: it is what makes the next page's offset
|
|
13
13
|
* exact rather than a guess. A block is committed whole or not at all, because half
|
|
@@ -44,32 +44,3 @@ export declare function renderGrepMatches(matches: GrepMatch[], maxChars: number
|
|
|
44
44
|
shown: number;
|
|
45
45
|
capped: boolean;
|
|
46
46
|
};
|
|
47
|
-
/**
|
|
48
|
-
* Render a completed command the way a terminal would, minus the noise.
|
|
49
|
-
*
|
|
50
|
-
* ## Every command ends with its verdict, including the successful ones
|
|
51
|
-
*
|
|
52
|
-
* Printing the exit code only when it is non-zero looks like saving noise and is
|
|
53
|
-
* not: a model with no verdict appends `; echo "EXIT_CODE=$?"` itself, and
|
|
54
|
-
* reaches for `${PIPESTATUS[0]}` to read a piped one — which dash does not
|
|
55
|
-
* implement, so the whole line fails after the command it wrapped has run.
|
|
56
|
-
*
|
|
57
|
-
* ## `status` is not the exit code
|
|
58
|
-
*
|
|
59
|
-
* A `status` is `completed | failed | cancelled`. Dropping it
|
|
60
|
-
* renders a command killed at the `timeoutMs` ceiling exactly like one that ran
|
|
61
|
-
* to completion and failed — "your test suite was killed at ten minutes" and
|
|
62
|
-
* "your test suite has a failing test", which want opposite responses. Anything
|
|
63
|
-
* other than `completed` is stated.
|
|
64
|
-
*
|
|
65
|
-
* ## One budget, applied once
|
|
66
|
-
*
|
|
67
|
-
* Truncating per stream makes `maxOutputChars` mean "up to twice this". The bound
|
|
68
|
-
* is on the rendered transcript, which is what reaches the context window.
|
|
69
|
-
*/
|
|
70
|
-
export declare function renderResult(result: {
|
|
71
|
-
exitCode: number;
|
|
72
|
-
stdout: string;
|
|
73
|
-
stderr: string;
|
|
74
|
-
status?: CommandResult["status"];
|
|
75
|
-
}, maxChars: number): string;
|
package/dist/computer/render.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { truncateOutput } from "@dynamicagents/core";
|
|
2
1
|
/**
|
|
3
2
|
* Turning a result into the text a model reads.
|
|
4
3
|
*
|
|
@@ -31,12 +30,12 @@ function capLine(text) {
|
|
|
31
30
|
/**
|
|
32
31
|
* Emit whole items until the byte budget is spent, and report exactly how many.
|
|
33
32
|
*
|
|
34
|
-
*
|
|
33
|
+
* Core's `truncateOutput` is wrong for every list this module produces, and is
|
|
35
34
|
* deliberately not used by them. It keeps both ends and drops the middle, which is
|
|
36
|
-
* right for a build log
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* right for a build log and destructive for a match list or a directory listing,
|
|
36
|
+
* where the middle is a whole file's worth of hits or a whole subtree that silently
|
|
37
|
+
* vanishes. So the budget is spent item by item, in order, and the caller learns
|
|
38
|
+
* where it stopped.
|
|
40
39
|
*
|
|
41
40
|
* `shown` is the load-bearing return value: it is what makes the next page's offset
|
|
42
41
|
* exact rather than a guess. A block is committed whole or not at all, because half
|
|
@@ -102,40 +101,3 @@ export function renderGrepMatches(matches, maxChars) {
|
|
|
102
101
|
// Only a line that actually made it out is worth explaining.
|
|
103
102
|
return { body, shown, capped: hasLongLine.slice(0, shown).includes(true) };
|
|
104
103
|
}
|
|
105
|
-
/**
|
|
106
|
-
* Render a completed command the way a terminal would, minus the noise.
|
|
107
|
-
*
|
|
108
|
-
* ## Every command ends with its verdict, including the successful ones
|
|
109
|
-
*
|
|
110
|
-
* Printing the exit code only when it is non-zero looks like saving noise and is
|
|
111
|
-
* not: a model with no verdict appends `; echo "EXIT_CODE=$?"` itself, and
|
|
112
|
-
* reaches for `${PIPESTATUS[0]}` to read a piped one — which dash does not
|
|
113
|
-
* implement, so the whole line fails after the command it wrapped has run.
|
|
114
|
-
*
|
|
115
|
-
* ## `status` is not the exit code
|
|
116
|
-
*
|
|
117
|
-
* A `status` is `completed | failed | cancelled`. Dropping it
|
|
118
|
-
* renders a command killed at the `timeoutMs` ceiling exactly like one that ran
|
|
119
|
-
* to completion and failed — "your test suite was killed at ten minutes" and
|
|
120
|
-
* "your test suite has a failing test", which want opposite responses. Anything
|
|
121
|
-
* other than `completed` is stated.
|
|
122
|
-
*
|
|
123
|
-
* ## One budget, applied once
|
|
124
|
-
*
|
|
125
|
-
* Truncating per stream makes `maxOutputChars` mean "up to twice this". The bound
|
|
126
|
-
* is on the rendered transcript, which is what reaches the context window.
|
|
127
|
-
*/
|
|
128
|
-
export function renderResult(result, maxChars) {
|
|
129
|
-
const parts = [];
|
|
130
|
-
if (result.stdout)
|
|
131
|
-
parts.push(result.stdout);
|
|
132
|
-
// Empty whenever the host configured a `shell`, which merges the streams at the
|
|
133
|
-
// source so the transcript keeps its causal order. Kept for hosts that did not:
|
|
134
|
-
// two labelled blocks beat silently dropping half the output.
|
|
135
|
-
if (result.stderr)
|
|
136
|
-
parts.push(`--- stderr ---\n${result.stderr}`);
|
|
137
|
-
const body = truncateOutput(parts.join("\n"), maxChars);
|
|
138
|
-
const state = result.status && result.status !== "completed" ? ` (${result.status})` : "";
|
|
139
|
-
const verdict = `--- exit ${result.exitCode}${state} ---`;
|
|
140
|
-
return body ? `${body}\n${verdict}` : `(no output)\n${verdict}`;
|
|
141
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ThinkWorkspace } from "@cloudflare/think";
|
|
2
2
|
import type { SandboxAdvisory } from "../sandbox/advisory.js";
|
|
3
3
|
import type { ComputerClient } from "./client.js";
|
|
4
4
|
/**
|
|
@@ -27,11 +27,12 @@ export interface SandboxComputerHost extends Rpc.DurableObjectBranded {
|
|
|
27
27
|
stderr: string;
|
|
28
28
|
}>;
|
|
29
29
|
readFile(path: string): Promise<Uint8Array | null>;
|
|
30
|
-
writeFile(path: string, content: string): Promise<void>;
|
|
30
|
+
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
31
31
|
stat(path: string): Promise<{
|
|
32
32
|
type: string;
|
|
33
33
|
size: number;
|
|
34
34
|
modifiedAt: number;
|
|
35
|
+
mode: number;
|
|
35
36
|
} | null>;
|
|
36
37
|
readDir(path: string): Promise<{
|
|
37
38
|
name: string;
|
|
@@ -50,6 +51,6 @@ export interface SandboxComputerConfig {
|
|
|
50
51
|
sandboxName: () => string;
|
|
51
52
|
cwd?: string;
|
|
52
53
|
}
|
|
53
|
-
export declare function computerWorkspace(config: SandboxComputerConfig, runtime?: () => unknown):
|
|
54
|
+
export declare function computerWorkspace(config: SandboxComputerConfig, runtime?: () => unknown): ThinkWorkspace;
|
|
54
55
|
/** The computer tools' client over one sandbox. */
|
|
55
56
|
export declare function sandboxClient(host: SandboxComputerHost): ComputerClient;
|
package/dist/computer/sandbox.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RUN_DIR } from "../sandbox/constants.js";
|
|
2
|
+
import { SANDBOX_ROOT } from "../sandbox/header.js";
|
|
2
3
|
import { sandboxNameFromRuntime } from "../sandbox/name.js";
|
|
3
4
|
import { routedWorkspace } from "./proxy.js";
|
|
4
5
|
/** After `timeout`'s TERM, how long before its KILL. */
|
|
@@ -39,7 +40,10 @@ export function computerWorkspace(config, runtime) {
|
|
|
39
40
|
name: () => sandboxNameFromRuntime(runtime?.()) ?? config.sandboxName(),
|
|
40
41
|
open: async (name) => sandboxClient(host(name)),
|
|
41
42
|
advisories: (name) => host(name).advisories()
|
|
42
|
-
},
|
|
43
|
+
},
|
|
44
|
+
// The served root, not `config.cwd`: a host may point the tools at a
|
|
45
|
+
// checkout, and Think's own directories stay where Think puts them.
|
|
46
|
+
SANDBOX_ROOT);
|
|
43
47
|
}
|
|
44
48
|
function missing(path) {
|
|
45
49
|
return Object.assign(new Error(`ENOENT: no such file or directory, ${path}`), { code: "ENOENT" });
|
|
@@ -61,8 +65,10 @@ export function sandboxClient(host) {
|
|
|
61
65
|
if (!stat)
|
|
62
66
|
throw missing(path);
|
|
63
67
|
return {
|
|
68
|
+
name: path.slice(path.lastIndexOf("/") + 1),
|
|
64
69
|
size: stat.size,
|
|
65
70
|
mtime: stat.modifiedAt,
|
|
71
|
+
mode: stat.mode,
|
|
66
72
|
isFile: stat.type === "file",
|
|
67
73
|
isDirectory: stat.type === "directory",
|
|
68
74
|
isSymbolicLink: stat.type === "symlink"
|
|
@@ -75,7 +81,8 @@ export function sandboxClient(host) {
|
|
|
75
81
|
.map((entry) => ({
|
|
76
82
|
name: entry.name,
|
|
77
83
|
isFile: entry.type === "file",
|
|
78
|
-
isDirectory: entry.type === "directory"
|
|
84
|
+
isDirectory: entry.type === "directory",
|
|
85
|
+
isSymbolicLink: entry.type === "symlink"
|
|
79
86
|
}));
|
|
80
87
|
},
|
|
81
88
|
find: (directory, pattern, options = {}) => find(host, directory, pattern, options),
|
package/dist/computer/shell.d.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* two output streams merged into one transcript, in the order they were
|
|
4
4
|
* written, or hand it back untouched when no shell is configured.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* The shell tool's consumer is **a model reading output**. A project's check is a
|
|
7
|
+
* chain
|
|
7
8
|
* — `wrangler types && prettier && eslint && tsc` — and *which tool spoke last*
|
|
8
9
|
* is how you know which one failed. A stdout block and a separate stderr block
|
|
9
10
|
* destroy that ordering, and a model that wants it back re-runs the whole gate
|
|
@@ -25,12 +26,12 @@
|
|
|
25
26
|
*
|
|
26
27
|
* The trade is worth stating. `pipefail` surfaces SIGPIPE too, so
|
|
27
28
|
* `ls big-dir | head -1` reports 141 rather than 0 — noisy, but *visible*. The
|
|
28
|
-
* tool description
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* tool's description is Computer's and says nothing about either, so the
|
|
30
|
+
* plugin's own context block in {@link file://./index.ts} is where the model is
|
|
31
|
+
* told not to pipe into `head`/`tail`. It needs a shell that implements it —
|
|
32
|
+
* `bash`, `zsh`, `ksh`, not `sh`/dash.
|
|
31
33
|
*
|
|
32
|
-
* With no shell configured the two streams arrive separate, and
|
|
33
|
-
*
|
|
34
|
-
* `--- stderr ---` block.
|
|
34
|
+
* With no shell configured the two streams arrive separate, and the tool reports
|
|
35
|
+
* them as the `stdout` and `stderr` of its result.
|
|
35
36
|
*/
|
|
36
37
|
export declare function withShellTranscript(command: string, shell: string | undefined): string;
|
package/dist/computer/shell.js
CHANGED
|
@@ -4,7 +4,8 @@ import { shellQuote } from "../sandbox/agent-exec.js";
|
|
|
4
4
|
* two output streams merged into one transcript, in the order they were
|
|
5
5
|
* written, or hand it back untouched when no shell is configured.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* The shell tool's consumer is **a model reading output**. A project's check is a
|
|
8
|
+
* chain
|
|
8
9
|
* — `wrangler types && prettier && eslint && tsc` — and *which tool spoke last*
|
|
9
10
|
* is how you know which one failed. A stdout block and a separate stderr block
|
|
10
11
|
* destroy that ordering, and a model that wants it back re-runs the whole gate
|
|
@@ -26,13 +27,13 @@ import { shellQuote } from "../sandbox/agent-exec.js";
|
|
|
26
27
|
*
|
|
27
28
|
* The trade is worth stating. `pipefail` surfaces SIGPIPE too, so
|
|
28
29
|
* `ls big-dir | head -1` reports 141 rather than 0 — noisy, but *visible*. The
|
|
29
|
-
* tool description
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* tool's description is Computer's and says nothing about either, so the
|
|
31
|
+
* plugin's own context block in {@link file://./index.ts} is where the model is
|
|
32
|
+
* told not to pipe into `head`/`tail`. It needs a shell that implements it —
|
|
33
|
+
* `bash`, `zsh`, `ksh`, not `sh`/dash.
|
|
32
34
|
*
|
|
33
|
-
* With no shell configured the two streams arrive separate, and
|
|
34
|
-
*
|
|
35
|
-
* `--- stderr ---` block.
|
|
35
|
+
* With no shell configured the two streams arrive separate, and the tool reports
|
|
36
|
+
* them as the `stdout` and `stderr` of its result.
|
|
36
37
|
*/
|
|
37
38
|
export function withShellTranscript(command, shell) {
|
|
38
39
|
return shell
|