@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.
@@ -1,6 +1,7 @@
1
1
  import { tool } from "ai";
2
2
  import { z } from "zod";
3
- import { guardPath, WALK_SKIPS } from "./paths.js";
3
+ import { SANDBOX_ROOT } from "../sandbox/header.js";
4
+ import { guardPath, walkSkips } from "./paths.js";
4
5
  import { renderGrepMatches } from "./render.js";
5
6
  /**
6
7
  * Searching file contents, under Think's name so it replaces Think's `grep` —
@@ -31,7 +32,7 @@ export function grepTools(ctx) {
31
32
  /**
32
33
  * Search, without the container.
33
34
  *
34
- * The alternative is `bash("grep -rn …")`, and the case for a native one is
35
+ * The alternative is `exec("grep -rn …")`, and the case for a native one is
35
36
  * not that shelling out fails — it is where the search runs. `fs.grep` reads
36
37
  * the Durable Object's SQLite, so it answers while the container is being
37
38
  * replaced or an install is still running, which is exactly the window the
@@ -51,7 +52,7 @@ export function grepTools(ctx) {
51
52
  "The query is matched literally — set `regex` to interpret it as a regular expression. " +
52
53
  "Pass `include` to limit which files are searched, e.g. '**/*.ts' — without it every file under `path` is read, which is slower and rarely what you meant. " +
53
54
  "A cut result reports the `offset` that continues it. Use `context` to see the lines around a match. " +
54
- "Results from `.git` and `node_modules` are left out — search node_modules with bash.",
55
+ "Results from `.git` and `node_modules` are left out — search node_modules with exec.",
55
56
  inputSchema: z.object({
56
57
  query: z.string().describe("Text to find, e.g. 'buildComputerTools'"),
57
58
  path: z
@@ -102,7 +103,12 @@ export function grepTools(ctx) {
102
103
  context: around,
103
104
  limit: DEFAULT_MAX_MATCHES + 1,
104
105
  offset: from,
105
- exclude: WALK_SKIPS.map((segment) => `**/${segment}`)
106
+ // The same skips the workspace's own walks hand on, anchored to
107
+ // the served root rather than to the target: this tool replaces
108
+ // Think's `grep`, so a search of the root that kept the agent's
109
+ // own directories would fill a bounded page with them before the
110
+ // checkout's matches. See `src/computer/paths.ts`.
111
+ exclude: walkSkips(target, SANDBOX_ROOT)
106
112
  });
107
113
  if (matches.length === 0)
108
114
  return `no matches for ${JSON.stringify(query)} in ${target}${include ? ` (${include})` : ""}${from > 0 ? ` past offset ${from}` : ""}`;
@@ -39,7 +39,11 @@ export interface GitHubSandboxEgressOptions {
39
39
  tokenBinding: string;
40
40
  /** Absent: any repository and any work branch the token reaches. */
41
41
  push?: GitPushPolicy;
42
- /** Intercept `api.github.com` as well, so `gh` works as the deployment's account. */
42
+ /**
43
+ * Intercept `api.github.com` as well, so `gh` works as the deployment's
44
+ * account. Nothing here reads those requests, so GitHub alone bounds them:
45
+ * `./README.md`, under "Protect the repositories", says how to set that up.
46
+ */
43
47
  api?: boolean;
44
48
  }
45
49
  /** The egress rule of a sandbox whose processes use GitHub. */
@@ -74,9 +74,15 @@ async function githubEgress(call, options) {
74
74
  return githubError("no GitHub token is configured for this deployment, so nothing can be done as its account", 401);
75
75
  }
76
76
  const policy = options.push;
77
- const git = new URL(call.request.url).hostname === GIT_HOST
78
- ? gitTarget(call.target.pathname)
79
- : undefined;
77
+ const onGitHost = new URL(call.request.url).hostname === GIT_HOST;
78
+ const git = onGitHost ? gitTarget(call.target.pathname) : undefined;
79
+ // GitHub serves spellings of a git path that `gitTarget` does not read, and a
80
+ // push it cannot read is one it cannot hold to the rules below, so what it
81
+ // does not recognise never gets the token. Git LFS is on such a path: a
82
+ // sandbox that needs it has to have it recognised first.
83
+ if (onGitHost && !git) {
84
+ return githubError(`this sandbox may use ${GIT_HOST} as the deployment's account for git alone, and ${call.target.pathname} is not a path git uses`, 403);
85
+ }
80
86
  let body;
81
87
  if (git) {
82
88
  if (policy &&
@@ -48,3 +48,16 @@ export declare function readRunHeader(text: string): {
48
48
  };
49
49
  /** Whether `path` is `root` or inside it, with no `..` escaping it. */
50
50
  export declare function isUnder(path: string, root: string): boolean;
51
+ /**
52
+ * `path` with its dot segments and repeated separators resolved, or `undefined`
53
+ * for one that is not absolute or that climbs above `/`.
54
+ *
55
+ * Shared with the tools rather than kept here, because anything comparing a
56
+ * path against this root has to compare what the path *means*: `/workspace/.`
57
+ * and `/workspace/repo/..` are both this root, and a comparison that read them
58
+ * literally would answer no to a path the sandbox itself answers yes to.
59
+ *
60
+ * A `..` with nothing left to pop is refused rather than clamped at `/`: a
61
+ * clamp would turn an attempt to climb out into a valid root path.
62
+ */
63
+ export declare function normalizeAbsolute(path: string): string | undefined;
@@ -75,20 +75,35 @@ function assertRunHeader(value) {
75
75
  }
76
76
  /** Whether `path` is `root` or inside it, with no `..` escaping it. */
77
77
  export function isUnder(path, root) {
78
+ const normal = normalizeAbsolute(path);
79
+ return (normal !== undefined && (normal === root || normal.startsWith(`${root}/`)));
80
+ }
81
+ /**
82
+ * `path` with its dot segments and repeated separators resolved, or `undefined`
83
+ * for one that is not absolute or that climbs above `/`.
84
+ *
85
+ * Shared with the tools rather than kept here, because anything comparing a
86
+ * path against this root has to compare what the path *means*: `/workspace/.`
87
+ * and `/workspace/repo/..` are both this root, and a comparison that read them
88
+ * literally would answer no to a path the sandbox itself answers yes to.
89
+ *
90
+ * A `..` with nothing left to pop is refused rather than clamped at `/`: a
91
+ * clamp would turn an attempt to climb out into a valid root path.
92
+ */
93
+ export function normalizeAbsolute(path) {
78
94
  if (!path.startsWith("/"))
79
- return false;
95
+ return undefined;
80
96
  const parts = [];
81
97
  for (const part of path.split("/")) {
82
98
  if (part === "" || part === ".")
83
99
  continue;
84
100
  if (part === "..") {
85
101
  if (parts.length === 0)
86
- return false;
102
+ return undefined;
87
103
  parts.pop();
88
104
  continue;
89
105
  }
90
106
  parts.push(part);
91
107
  }
92
- const normal = `/${parts.join("/")}`;
93
- return normal === root || normal.startsWith(`${root}/`);
108
+ return `/${parts.join("/")}`;
94
109
  }
@@ -36,7 +36,7 @@ export interface InstallProbe {
36
36
  * Where the install has got to, as the host reports it and the tools read it.
37
37
  *
38
38
  * Declared here rather than in the host because both sides depend on it and
39
- * neither owns the other: the Durable Object runs the job, and `bash` refuses
39
+ * neither owns the other: the Durable Object runs the job, and the shell tool refuses
40
40
  * to run against a tree that is still being built. A shape they agreed on
41
41
  * informally would drift, and the drift would show up as a shell command running
42
42
  * halfway through an `npm ci`.
@@ -26,6 +26,7 @@
26
26
  * absent. So: an explicit override wins, then the repository's own
27
27
  * `packageManager` pin, then lockfiles in the order the plan lists them.
28
28
  */
29
+ import { sha256Hex } from "@dynamicagents/core";
29
30
  /**
30
31
  * A sensible starting table, meant to be copied into a host and edited.
31
32
  *
@@ -304,8 +305,5 @@ async function digestFiles(fs, dir, names, prefix) {
304
305
  }
305
306
  if (!found)
306
307
  return null;
307
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
308
- return [...new Uint8Array(digest)]
309
- .map((b) => b.toString(16).padStart(2, "0"))
310
- .join("");
308
+ return await sha256Hex(input);
311
309
  }
@@ -96,6 +96,7 @@ export declare abstract class SandboxObjectBase<Env extends object = Cloudflare.
96
96
  type: string;
97
97
  size: number;
98
98
  modifiedAt: number;
99
+ mode: number;
99
100
  } | null>;
100
101
  readDir(path: string): Promise<{
101
102
  name: string;
@@ -1,5 +1,6 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import { ContainerHarness, containerAgent } from "agents/harness/container";
3
+ import { sha256Hex } from "@dynamicagents/core";
3
4
  import { installScheduler, namedDeadline } from "@dynamicagents/core/alarm";
4
5
  import { DirectoryBackup, Files } from "@cloudflare/sandbox";
5
6
  import { asAgent, AGENT_GID, AGENT_UID, shellQuote } from "./agent-exec.js";
@@ -380,7 +381,11 @@ export class SandboxObjectBase extends DurableObject {
380
381
  return {
381
382
  type: stat.type,
382
383
  size: Number(stat.size),
383
- modifiedAt: stat.modifiedAt.getTime()
384
+ modifiedAt: stat.modifiedAt.getTime(),
385
+ // Reported because the file tools' own contract asks for one, and
386
+ // the container knows it. Nothing can set it back: the sandbox's
387
+ // write takes no mode.
388
+ mode: stat.mode
384
389
  };
385
390
  }
386
391
  catch (err) {
@@ -1105,14 +1110,16 @@ function markOf(dir) {
1105
1110
  function installMarkOf(dir) {
1106
1111
  return `${INSTALL_MARKS}/${encodeURIComponent(dir)}`;
1107
1112
  }
1108
- /** `env`, in key order, as a digest a fingerprint can carry. */
1113
+ /**
1114
+ * `env`, in key order, as a digest a fingerprint can carry.
1115
+ *
1116
+ * The width is load-bearing: this goes into an install mark stored on the
1117
+ * container's disk, so changing it makes every tree already marked look
1118
+ * uninstalled and costs each one an install it does not need.
1119
+ */
1109
1120
  async function digestEnv(env) {
1110
1121
  const sorted = Object.entries(env ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1111
- const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(sorted)));
1112
- return [...new Uint8Array(bytes)]
1113
- .map((b) => b.toString(16).padStart(2, "0"))
1114
- .join("")
1115
- .slice(0, 16);
1122
+ return (await sha256Hex(JSON.stringify(sorted))).slice(0, 16);
1116
1123
  }
1117
1124
  function isNotFound(err) {
1118
1125
  return (typeof err === "object" &&
@@ -1129,11 +1136,12 @@ function isAbort(err) {
1129
1136
  function sleep(ms) {
1130
1137
  return new Promise((resolve) => setTimeout(resolve, ms));
1131
1138
  }
1139
+ /**
1140
+ * `value`, as the digest a poll returns beside a message.
1141
+ *
1142
+ * Nothing stores it: a poll composes it again for every message it answers
1143
+ * with, so the width has only to tell two messages apart.
1144
+ */
1132
1145
  async function digest(value) {
1133
- const bytes = new TextEncoder().encode(JSON.stringify(value));
1134
- const hash = await crypto.subtle.digest("SHA-256", bytes);
1135
- return [...new Uint8Array(hash)]
1136
- .slice(0, 12)
1137
- .map((byte) => byte.toString(16).padStart(2, "0"))
1138
- .join("");
1146
+ return (await sha256Hex(JSON.stringify(value))).slice(0, 24);
1139
1147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamicagents/plugins",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Optional, composable capabilities for a Dynamic Agent: browser rendering, a Linux container, git and pull requests on GitHub, a scratchpad, and Claude Code sessions. One subpath per plugin, so a bundle grows only with what it imports.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -75,7 +75,7 @@
75
75
  "test": "vitest run",
76
76
  "test:watch": "vitest",
77
77
  "verify:exports": "node scripts/verify-exports.mjs",
78
- "verify:references": "node scripts/verify-references.mjs",
78
+ "verify:references": "da-verify-references",
79
79
  "verify:peer-ranges": "node scripts/verify-peer-ranges.mjs",
80
80
  "probe:claude-code": "npm run build && node scripts/probe-claude-code.mjs",
81
81
  "probe:github-mcp": "node scripts/probe-github-mcp.mjs",
@@ -84,11 +84,13 @@
84
84
  "prepare": "husky || true && npm run build"
85
85
  },
86
86
  "peerDependencies": {
87
+ "@cloudflare/computer": "^0.8.0",
87
88
  "@cloudflare/sandbox": "^1.0.1",
88
- "@cloudflare/think": ">=0.20.2 <0.21.0",
89
- "@dynamicagents/core": ">=0.16.0 <0.17.0",
90
- "agents": ">=0.28.0 <0.29.0",
89
+ "@cloudflare/think": ">=0.23.0 <0.24.0",
90
+ "@dynamicagents/core": ">=0.18.0 <0.19.0",
91
+ "agents": ">=0.33.0 <0.34.0",
91
92
  "ai": "^7.0.88",
93
+ "just-bash": "^3.0.1",
92
94
  "zod": "^4.4.3"
93
95
  },
94
96
  "peerDependenciesMeta": {
@@ -97,15 +99,17 @@
97
99
  }
98
100
  },
99
101
  "devDependencies": {
102
+ "@cloudflare/computer": "^0.8.0",
100
103
  "@cloudflare/sandbox": "^1.0.1",
101
- "@cloudflare/think": "^0.20.2",
104
+ "@cloudflare/think": "^0.23.0",
102
105
  "@cloudflare/vitest-plugin": "^1.4.4",
103
- "@dynamicagents/core": "^0.16.0",
106
+ "@dynamicagents/core": "^0.18.0",
104
107
  "@types/node": "^26.5.1",
105
- "agents": "^0.28.0",
108
+ "agents": "^0.33.0",
106
109
  "ai": "^7.0.101",
107
110
  "eslint": "^10.10.0",
108
111
  "husky": "^9.1.7",
112
+ "just-bash": "^3.0.1",
109
113
  "prettier": "^3.9.6",
110
114
  "semver": "^7.8.5",
111
115
  "typescript": "^6.0.3",