@dynamicagents/plugins 0.14.0 → 0.16.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.
Files changed (141) hide show
  1. package/README.md +14 -14
  2. package/dist/claude-code/cards.d.ts +2 -0
  3. package/dist/claude-code/cards.js +11 -0
  4. package/dist/claude-code/config.d.ts +10 -83
  5. package/dist/claude-code/config.js +0 -2
  6. package/dist/claude-code/credentials.d.ts +32 -50
  7. package/dist/claude-code/credentials.js +115 -74
  8. package/dist/claude-code/daemon/index.d.ts +24 -0
  9. package/dist/claude-code/daemon/index.js +203 -0
  10. package/dist/claude-code/egress.d.ts +35 -117
  11. package/dist/claude-code/egress.js +80 -325
  12. package/dist/claude-code/events.d.ts +14 -17
  13. package/dist/claude-code/events.js +19 -15
  14. package/dist/claude-code/harness-wire.d.ts +45 -0
  15. package/dist/claude-code/harness-wire.js +47 -0
  16. package/dist/claude-code/index.d.ts +19 -29
  17. package/dist/claude-code/index.js +17 -25
  18. package/dist/claude-code/language-model.d.ts +19 -0
  19. package/dist/claude-code/language-model.js +71 -0
  20. package/dist/claude-code/model.d.ts +77 -70
  21. package/dist/claude-code/model.js +327 -327
  22. package/dist/claude-code/placeholder.d.ts +12 -0
  23. package/dist/claude-code/placeholder.js +12 -0
  24. package/dist/claude-code/spec.d.ts +4 -4
  25. package/dist/claude-code/spec.js +4 -4
  26. package/dist/computer/client.d.ts +88 -0
  27. package/dist/computer/client.js +6 -0
  28. package/dist/computer/context.d.ts +7 -7
  29. package/dist/computer/context.js +14 -18
  30. package/dist/computer/gate.d.ts +4 -23
  31. package/dist/computer/gate.js +1 -37
  32. package/dist/computer/index.d.ts +44 -43
  33. package/dist/computer/index.js +29 -50
  34. package/dist/computer/paths.d.ts +5 -27
  35. package/dist/computer/paths.js +7 -30
  36. package/dist/computer/proxy.d.ts +17 -31
  37. package/dist/computer/proxy.js +9 -44
  38. package/dist/computer/render.d.ts +3 -40
  39. package/dist/computer/render.js +3 -36
  40. package/dist/computer/sandbox.d.ts +55 -0
  41. package/dist/computer/sandbox.js +236 -0
  42. package/dist/computer/shell.d.ts +36 -0
  43. package/dist/computer/shell.js +41 -0
  44. package/dist/computer/tools-exec.js +3 -9
  45. package/dist/computer/tools-file.d.ts +1 -1
  46. package/dist/computer/tools-file.js +1 -1
  47. package/dist/{repo → repo-github}/checkout.d.ts +3 -4
  48. package/dist/{repo → repo-github}/checkout.js +3 -4
  49. package/dist/{repo → repo-github}/context.d.ts +14 -32
  50. package/dist/{repo → repo-github}/context.js +46 -81
  51. package/dist/{repo → repo-github}/index.d.ts +30 -13
  52. package/dist/{repo → repo-github}/index.js +25 -6
  53. package/dist/repo-github/mcp.d.ts +95 -0
  54. package/dist/repo-github/mcp.js +107 -0
  55. package/dist/repo-github/sandbox-egress.d.ts +68 -0
  56. package/dist/repo-github/sandbox-egress.js +272 -0
  57. package/dist/repo-github/sandbox-git.d.ts +30 -0
  58. package/dist/repo-github/sandbox-git.js +113 -0
  59. package/dist/{repo → repo-github}/tools-checks.js +43 -4
  60. package/dist/{repo → repo-github}/tools-clone.js +2 -21
  61. package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
  62. package/dist/{repo → repo-github}/tools-pr.js +85 -20
  63. package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
  64. package/dist/{repo → repo-github}/tools-review.js +62 -176
  65. package/dist/{repo → repo-github}/url.d.ts +11 -0
  66. package/dist/{repo → repo-github}/url.js +12 -1
  67. package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
  68. package/dist/{workspace → sandbox}/advisory.js +2 -29
  69. package/dist/sandbox/agent-exec.d.ts +31 -0
  70. package/dist/sandbox/agent-exec.js +62 -0
  71. package/dist/sandbox/ca-trust.d.ts +9 -0
  72. package/dist/sandbox/ca-trust.js +21 -0
  73. package/dist/sandbox/constants.d.ts +22 -0
  74. package/dist/sandbox/constants.js +22 -0
  75. package/dist/sandbox/daemon/composite.d.ts +9 -0
  76. package/dist/sandbox/daemon/composite.js +35 -0
  77. package/dist/sandbox/daemon/index.d.ts +47 -0
  78. package/dist/sandbox/daemon/index.js +116 -0
  79. package/dist/sandbox/egress.d.ts +81 -0
  80. package/dist/sandbox/egress.js +109 -0
  81. package/dist/sandbox/exec.d.ts +57 -0
  82. package/dist/sandbox/exec.js +52 -0
  83. package/dist/sandbox/format.d.ts +2 -0
  84. package/dist/sandbox/format.js +7 -0
  85. package/dist/sandbox/header.d.ts +50 -0
  86. package/dist/sandbox/header.js +94 -0
  87. package/dist/sandbox/index.d.ts +17 -0
  88. package/dist/sandbox/index.js +17 -0
  89. package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +2 -2
  90. package/dist/{workspace/install.js → sandbox/install-plan.js} +2 -2
  91. package/dist/sandbox/name.d.ts +27 -0
  92. package/dist/sandbox/name.js +32 -0
  93. package/dist/sandbox/object.d.ts +244 -0
  94. package/dist/sandbox/object.js +1139 -0
  95. package/dist/scratch/index.d.ts +1 -9
  96. package/dist/scratch/index.js +2 -18
  97. package/package.json +24 -20
  98. package/dist/claude-code/github.d.ts +0 -39
  99. package/dist/claude-code/github.js +0 -82
  100. package/dist/claude-code/run.d.ts +0 -491
  101. package/dist/claude-code/run.js +0 -913
  102. package/dist/claude-code/session.d.ts +0 -131
  103. package/dist/claude-code/session.js +0 -288
  104. package/dist/workspace/ca-trust.d.ts +0 -95
  105. package/dist/workspace/ca-trust.js +0 -312
  106. package/dist/workspace/container-deps.d.ts +0 -58
  107. package/dist/workspace/container-deps.js +0 -249
  108. package/dist/workspace/container-fault.d.ts +0 -38
  109. package/dist/workspace/container-fault.js +0 -73
  110. package/dist/workspace/deps-snapshot.d.ts +0 -110
  111. package/dist/workspace/deps-snapshot.js +0 -424
  112. package/dist/workspace/exec.d.ts +0 -94
  113. package/dist/workspace/exec.js +0 -153
  114. package/dist/workspace/format.d.ts +0 -36
  115. package/dist/workspace/format.js +0 -57
  116. package/dist/workspace/git-host.d.ts +0 -79
  117. package/dist/workspace/git-host.js +0 -226
  118. package/dist/workspace/git-identity.d.ts +0 -57
  119. package/dist/workspace/git-identity.js +0 -147
  120. package/dist/workspace/index.d.ts +0 -50
  121. package/dist/workspace/index.js +0 -58
  122. package/dist/workspace/install-job.d.ts +0 -170
  123. package/dist/workspace/install-job.js +0 -934
  124. package/dist/workspace/object.d.ts +0 -522
  125. package/dist/workspace/object.js +0 -1541
  126. package/dist/workspace/open.d.ts +0 -95
  127. package/dist/workspace/open.js +0 -63
  128. package/dist/workspace/read.d.ts +0 -16
  129. package/dist/workspace/read.js +0 -22
  130. package/dist/workspace/shell.d.ts +0 -45
  131. package/dist/workspace/shell.js +0 -80
  132. package/dist/workspace/sync.d.ts +0 -111
  133. package/dist/workspace/sync.js +0 -124
  134. package/dist/workspace/wake.d.ts +0 -21
  135. package/dist/workspace/wake.js +0 -10
  136. /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
  137. /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
  138. /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
  139. /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
  140. /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
  141. /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
@@ -0,0 +1,272 @@
1
+ import { CREDENTIAL_HEADERS, forward } from "../sandbox/egress.js";
2
+ import { PROTECTED_BRANCHES } from "./url.js";
3
+ /**
4
+ * GitHub from inside a sandbox, with the token on the Worker side: the
5
+ * sandbox's egress rule for it.
6
+ *
7
+ * The harness intercepts `github.com` over HTTPS, and git's credential helper
8
+ * answers with {@link GITHUB_TOKEN_PLACEHOLDER}. **Never an `insteadOf` onto a
9
+ * placeholder host:** git reports the rewritten URL to whatever reads a remote
10
+ * (`git remote get-url`, `git remote -v`), so `/repo` would take a checkout's
11
+ * origin for a host it does not allow, and `gh` would find no GitHub remote. A
12
+ * sandbox allowed the API intercepts `api.github.com` too, and gives `gh` the
13
+ * placeholder as `GH_TOKEN`.
14
+ *
15
+ * The egress swaps the placeholder for the real token, so a process can *use*
16
+ * GitHub as the deployment's account and never *read* the token. A request
17
+ * that presents no placeholder goes out anonymous, as it would anywhere else.
18
+ *
19
+ * Any process in the container can present the placeholder, a model's own
20
+ * shell included, so what `repo_push` refuses is refused here too, for every
21
+ * credentialed push: a ref that is not a branch, a protected name, and the
22
+ * repository's own default branch, which work reaches through a pull request.
23
+ */
24
+ /** What `GH_TOKEN` and git's credential helper hold. Shaped like a classic token, so nothing refuses it on shape. */
25
+ export const GITHUB_TOKEN_PLACEHOLDER = "ghp_" + "0".repeat(36);
26
+ /** Where git's own traffic goes. */
27
+ const GIT_HOST = "github.com";
28
+ /** The username GitHub expects beside a token in git's Basic auth. */
29
+ const GIT_USER = "x-access-token";
30
+ const API_HOST = "api.github.com";
31
+ /** The egress rule of a sandbox whose processes use GitHub. */
32
+ export function githubSandboxEgress(options) {
33
+ const api = options.api ?? false;
34
+ return {
35
+ name: "github",
36
+ https: {
37
+ [GIT_HOST]: `https://${GIT_HOST}`,
38
+ ...(api ? { [API_HOST]: `https://${API_HOST}` } : {})
39
+ },
40
+ env: githubEnv(api),
41
+ handle: (call) => githubEgress(call, options)
42
+ };
43
+ }
44
+ /**
45
+ * The environment a sandbox gives its processes.
46
+ *
47
+ * In the environment rather than a gitconfig file because a session's home is
48
+ * copied from the agent's when the session opens, so a `git config --global`
49
+ * written afterwards never reaches it; an environment reaches every process.
50
+ *
51
+ * `GH_TOKEN` only with the API intercepted: otherwise `gh` would send the
52
+ * placeholder to the real API, which refuses even a read anyone may make.
53
+ */
54
+ function githubEnv(api) {
55
+ return {
56
+ ...(api ? { GH_TOKEN: GITHUB_TOKEN_PLACEHOLDER } : {}),
57
+ GIT_CONFIG_COUNT: "1",
58
+ GIT_CONFIG_KEY_0: `credential.https://${GIT_HOST}.helper`,
59
+ // Answers `get` only; `store` and `erase` are no-ops that still exit 0.
60
+ GIT_CONFIG_VALUE_0: `!f() { if test "$1" = get; then printf 'username=${GIT_USER}\\npassword=${GITHUB_TOKEN_PLACEHOLDER}\\n'; fi; }; f`
61
+ };
62
+ }
63
+ /** Anonymous unless the placeholder is presented; then the real token, and for a push, what it may update. */
64
+ async function githubEgress(call, options) {
65
+ const presented = presentsPlaceholder(call.request.headers.get("authorization"));
66
+ const strip = (headers) => {
67
+ for (const name of CREDENTIAL_HEADERS)
68
+ headers.delete(name);
69
+ };
70
+ if (!presented)
71
+ return forward(call, strip);
72
+ const token = call.env[options.tokenBinding];
73
+ if (typeof token !== "string" || token === "") {
74
+ return githubError("no GitHub token is configured for this deployment, so nothing can be done as its account", 401);
75
+ }
76
+ const policy = options.push;
77
+ const git = new URL(call.request.url).hostname === GIT_HOST
78
+ ? gitTarget(call.target.pathname)
79
+ : undefined;
80
+ let body;
81
+ if (git) {
82
+ if (policy &&
83
+ !policy.repos.some((repo) => repo.toLowerCase() === git.repo)) {
84
+ return githubError(`this sandbox may not use ${git.repo} as the deployment's account`, 403);
85
+ }
86
+ if (git.service === "receive-pack" && call.request.method === "POST") {
87
+ const [inspected, forwarded] = (call.request.body ?? new Blob().stream()).tee();
88
+ const refuse = (message, status) => {
89
+ void forwarded.cancel().catch(() => undefined);
90
+ return githubError(message, status);
91
+ };
92
+ const refs = await receivePackRefs(inspected, call.request.headers.get("content-encoding")).catch(() => undefined);
93
+ if (!refs || refs.length === 0) {
94
+ return refuse("this push names no ref this egress can read", 400);
95
+ }
96
+ const defaultBranch = await defaultBranchOf(git.repo, token);
97
+ if (defaultBranch === undefined) {
98
+ return refuse(`could not read ${git.repo}'s default branch, so nothing was pushed: retry`, 503);
99
+ }
100
+ const refused = refs.flatMap((ref) => {
101
+ const why = refusal(ref, defaultBranch, policy);
102
+ return why ? [`${ref} (${why})`] : [];
103
+ });
104
+ if (refused.length > 0) {
105
+ return refuse(`this sandbox may not push ${refused.join(", ")}`, 403);
106
+ }
107
+ body = forwarded;
108
+ }
109
+ }
110
+ return forward(call, (headers) => {
111
+ strip(headers);
112
+ headers.set("authorization", githubAuthorization(presented, token));
113
+ }, body);
114
+ }
115
+ /** Why a push may not update `ref`, if it may not. */
116
+ function refusal(ref, defaultBranch, policy) {
117
+ if (!ref.startsWith("refs/heads/"))
118
+ return "not a branch";
119
+ const branch = ref.slice("refs/heads/".length);
120
+ if (branch === defaultBranch)
121
+ return "the repository's default branch: work reaches it through a pull request";
122
+ if (PROTECTED_BRANCHES.has(branch))
123
+ return "a protected branch";
124
+ if (policy && !branchAllowed(policy, branch))
125
+ return `this sandbox may push ${policy.branches.join(", ")}`;
126
+ return undefined;
127
+ }
128
+ /** `repo`'s default branch, as the token sees it; `undefined` when it cannot be read. */
129
+ async function defaultBranchOf(repo, token) {
130
+ try {
131
+ const response = await fetch(`https://${API_HOST}/repos/${repo}`, {
132
+ headers: {
133
+ authorization: `Bearer ${token}`,
134
+ accept: "application/vnd.github+json",
135
+ // GitHub refuses a request with none.
136
+ "user-agent": "dynamicagents-sandbox-egress"
137
+ }
138
+ });
139
+ if (!response.ok)
140
+ return undefined;
141
+ const found = (await response.json());
142
+ return typeof found.default_branch === "string" && found.default_branch
143
+ ? found.default_branch
144
+ : undefined;
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ }
150
+ /** Whether `value` presents the placeholder: Bearer/`token` as `gh` sends it, or Basic `user:placeholder` as git does after a 401. */
151
+ function presentsPlaceholder(value) {
152
+ if (!value)
153
+ return undefined;
154
+ const [scheme = "", credential = ""] = value.trim().split(/\s+/, 2);
155
+ switch (scheme.toLowerCase()) {
156
+ case "bearer":
157
+ case "token":
158
+ return credential === GITHUB_TOKEN_PLACEHOLDER ? "bearer" : undefined;
159
+ case "basic": {
160
+ let decoded;
161
+ try {
162
+ decoded = atob(credential);
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ const password = decoded.slice(decoded.indexOf(":") + 1);
168
+ return decoded.includes(":") && password === GITHUB_TOKEN_PLACEHOLDER
169
+ ? "basic"
170
+ : undefined;
171
+ }
172
+ default:
173
+ return undefined;
174
+ }
175
+ }
176
+ /** The real credential, in the scheme the placeholder came in. */
177
+ function githubAuthorization(presented, token) {
178
+ return presented === "basic"
179
+ ? `Basic ${btoa(`${GIT_USER}:${token}`)}`
180
+ : `Bearer ${token}`;
181
+ }
182
+ /** A refusal in GitHub's error shape, which `gh` and git both print. */
183
+ function githubError(message, status) {
184
+ return new Response(JSON.stringify({ message }), {
185
+ status,
186
+ headers: { "content-type": "application/json" }
187
+ });
188
+ }
189
+ /** `owner/repo` of a smart-HTTP git path, and which service it asks for. */
190
+ export function gitTarget(pathname) {
191
+ const match = /^\/([^/]+)\/([^/]+?)(?:\.git)?\/(info\/refs|git-upload-pack|git-receive-pack)$/.exec(pathname);
192
+ if (!match)
193
+ return undefined;
194
+ const [, owner = "", name = "", tail = ""] = match;
195
+ const service = tail.endsWith("receive-pack")
196
+ ? "receive-pack"
197
+ : tail.endsWith("upload-pack")
198
+ ? "upload-pack"
199
+ : undefined;
200
+ return { repo: `${owner}/${name}`.toLowerCase(), service };
201
+ }
202
+ /** Whether `branch` is one the policy allows. */
203
+ export function branchAllowed(policy, branch) {
204
+ return policy.branches.some((pattern) => pattern.endsWith("*")
205
+ ? branch.startsWith(pattern.slice(0, -1))
206
+ : branch === pattern);
207
+ }
208
+ /** A command list git would never send: thousands of refs fit well inside it. */
209
+ const COMMANDS_MAX_BYTES = 1 << 20;
210
+ /** pkt-line's own ceiling on one packet, its four length digits included. */
211
+ const PKT_MAX = 65520;
212
+ /**
213
+ * The refs a `git-receive-pack` request updates, from its pkt-line commands.
214
+ *
215
+ * Each command is `<old> <new> <ref>`, the first carrying `\0<capabilities>`,
216
+ * and the list ends at a flush packet; the pack follows. Only the list is read,
217
+ * so a caller hands this one branch of a `tee()` and forwards the other: the
218
+ * pack is never held in the Worker. git compresses the request body when it is
219
+ * large, so a gzip body is inflated as it is read.
220
+ *
221
+ * Read as strictly as git reads it, since the refs this returns are the ones a
222
+ * policy allows: a length is four hex digits, `0000` ends the list, and any
223
+ * other packet shorter than its own length or past the ceiling is refused
224
+ * rather than skipped. A ref is UTF-8, as git writes it.
225
+ */
226
+ export async function receivePackRefs(body, encoding) {
227
+ const reader = (encoding?.toLowerCase() === "gzip"
228
+ ? body.pipeThrough(new DecompressionStream("gzip"))
229
+ : body).getReader();
230
+ const ascii = new TextDecoder("latin1");
231
+ const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
232
+ const refs = [];
233
+ let buffer = new Uint8Array(0);
234
+ let read = 0;
235
+ try {
236
+ for (;;) {
237
+ while (buffer.length >= 4) {
238
+ const digits = ascii.decode(buffer.subarray(0, 4));
239
+ if (!/^[0-9a-f]{4}$/i.test(digits))
240
+ throw new Error(`not a pkt-line length: ${JSON.stringify(digits)}`);
241
+ const length = Number.parseInt(digits, 16);
242
+ if (length === 0)
243
+ return refs;
244
+ if (length < 4 || length > PKT_MAX)
245
+ throw new Error(`a pkt-line of length ${length} is not a command`);
246
+ if (buffer.length < length)
247
+ break;
248
+ const line = utf8.decode(buffer.subarray(4, length));
249
+ buffer = buffer.subarray(length);
250
+ const ref = line.split("\0", 1)[0]?.trim().split(" ")[2];
251
+ if (ref)
252
+ refs.push(ref);
253
+ }
254
+ const chunk = await reader.read();
255
+ if (chunk.done)
256
+ return refs;
257
+ read += chunk.value.byteLength;
258
+ if (read > COMMANDS_MAX_BYTES)
259
+ throw new Error("the push's command list never ended");
260
+ const next = new Uint8Array(buffer.length + chunk.value.byteLength);
261
+ next.set(buffer);
262
+ next.set(chunk.value, buffer.length);
263
+ buffer = next;
264
+ }
265
+ }
266
+ finally {
267
+ // Not awaited: a tee branch's cancel settles only once the other branch is
268
+ // cancelled or read to its end, and the caller does neither until this
269
+ // returns.
270
+ void reader.cancel().catch(() => undefined);
271
+ }
272
+ }
@@ -0,0 +1,30 @@
1
+ import type { RepoGit } from "./index.js";
2
+ /**
3
+ * The three credentialed operations, as `git` in a sandbox.
4
+ *
5
+ * The workspace ran them in its object, beside the token. Here they run where
6
+ * the checkout is, as the agent, with only the placeholder. The sandbox carries
7
+ * `githubSandboxEgress` (`./sandbox-egress.ts`), whose environment sends
8
+ * github.com to the intercepted host and whose handler swaps the token in and
9
+ * holds a push to its policy. So the container still never holds the token, and
10
+ * a push the policy refuses is refused before GitHub sees it.
11
+ *
12
+ * A host passes this as `RepoConfig.git`, and records the checkouts that are
13
+ * its own on the sandbox — `afterCheckout` is the place for the one `repo_clone`
14
+ * makes. This notes none: a clone may be a superproject's submodule, which is
15
+ * that superproject's checkout and not one of its own.
16
+ */
17
+ /** The sandbox RPCs this uses. */
18
+ export interface SandboxGitHost {
19
+ exec(argv: string[], options?: {
20
+ cwd?: string;
21
+ env?: Record<string, string>;
22
+ timeoutMs?: number;
23
+ }): Promise<{
24
+ exitCode: number;
25
+ stdout: string;
26
+ stderr: string;
27
+ }>;
28
+ }
29
+ /** @param sandbox Called per operation, so each reaches the object through a new stub. */
30
+ export declare function sandboxRepoGit(sandbox: () => SandboxGitHost): RepoGit;
@@ -0,0 +1,113 @@
1
+ import { SANDBOX_ROOT } from "../sandbox/header.js";
2
+ const LIMIT_MS = { clone: 10 * 60_000, fetch: 5 * 60_000, push: 5 * 60_000 };
3
+ /** After `timeout`'s TERM, how long before its KILL. */
4
+ const KILL_AFTER_S = 5;
5
+ /**
6
+ * `timeout`'s exits for a git it ended: TERM taken, or KILL after a hook
7
+ * ignored it. Read as the limit only once the limit has passed, since any
8
+ * SIGKILL is a 137.
9
+ */
10
+ const TIMED_OUT = new Set([124, 137]);
11
+ /** @param sandbox Called per operation, so each reaches the object through a new stub. */
12
+ export function sandboxRepoGit(sandbox) {
13
+ const git = async (cwd, args, limitMs) => {
14
+ const startedAt = Date.now();
15
+ const run = await sandbox().exec([
16
+ "timeout",
17
+ `--kill-after=${KILL_AFTER_S}`,
18
+ String(Math.ceil(limitMs / 1000)),
19
+ "git",
20
+ ...args
21
+ ], {
22
+ cwd,
23
+ // A credential prompt would wait out the limit: there is nobody to answer it.
24
+ env: { GIT_TERMINAL_PROMPT: "0" },
25
+ timeoutMs: limitMs + (KILL_AFTER_S + 5) * 1000
26
+ });
27
+ if (run.exitCode === 0)
28
+ return { ok: true, stdout: run.stdout, stderr: run.stderr };
29
+ const timedOut = TIMED_OUT.has(run.exitCode) && Date.now() - startedAt >= limitMs;
30
+ return {
31
+ ok: false,
32
+ code: timedOut ? "ETIMEDOUT" : `exit-${run.exitCode}`,
33
+ message: timedOut
34
+ ? `git ${args[0]} ran past its limit of ${limitMs / 60_000} minutes`
35
+ : run.stderr.trim() ||
36
+ run.stdout.trim() ||
37
+ `git ${args[0]} exited ${run.exitCode}`
38
+ };
39
+ };
40
+ return {
41
+ async clone(req) {
42
+ const refused = refuse(req.url, req.allowedHosts);
43
+ if (refused)
44
+ return refused;
45
+ const cloned = await git(SANDBOX_ROOT, [
46
+ "clone",
47
+ ...(req.depth ? ["--depth", String(req.depth)] : []),
48
+ ...(req.branch ? ["--branch", req.branch] : []),
49
+ "--",
50
+ req.url,
51
+ req.dir
52
+ ], LIMIT_MS.clone);
53
+ if (!cloned.ok)
54
+ return cloned;
55
+ // The branch's name, unborn too — an empty repository's — and `HEAD`
56
+ // for a checkout that landed detached, as one of a tag does.
57
+ const branch = await git(req.dir, ["symbolic-ref", "--short", "-q", "HEAD"], 30_000);
58
+ if (branch.ok)
59
+ return { ok: true, detail: branch.stdout.trim() };
60
+ const head = await git(req.dir, ["rev-parse", "--abbrev-ref", "HEAD"], 30_000);
61
+ return head.ok ? { ok: true, detail: head.stdout.trim() } : head;
62
+ },
63
+ async fetch(req) {
64
+ const refused = refuse(req.url, req.allowedHosts);
65
+ if (refused)
66
+ return refused;
67
+ const fetched = await git(req.dir, [
68
+ "fetch",
69
+ "--prune",
70
+ ...(req.depth ? ["--depth", String(req.depth)] : []),
71
+ "--",
72
+ req.url,
73
+ "+refs/heads/*:refs/remotes/origin/*"
74
+ ], LIMIT_MS.fetch);
75
+ return fetched.ok ? { ok: true, detail: `fetched ${req.url}` } : fetched;
76
+ },
77
+ async push(req) {
78
+ const refused = refuse(req.url, req.allowedHosts);
79
+ if (refused)
80
+ return refused;
81
+ // Never forced, and only the one branch, to its own name: no tag rides
82
+ // along, whatever `push.followTags` says.
83
+ const pushed = await git(req.dir, [
84
+ "push",
85
+ "--no-follow-tags",
86
+ "--",
87
+ req.url,
88
+ `refs/heads/${req.branch}:refs/heads/${req.branch}`
89
+ ], LIMIT_MS.push);
90
+ return pushed.ok
91
+ ? { ok: true, detail: pushed.stderr.trim() || `pushed ${req.branch}` }
92
+ : pushed;
93
+ }
94
+ };
95
+ }
96
+ /** A URL this may not use, as the answer; `undefined` for one it may. */
97
+ function refuse(url, allowedHosts) {
98
+ let parsed;
99
+ try {
100
+ parsed = new URL(url);
101
+ }
102
+ catch {
103
+ return { ok: false, code: "EINVAL", message: `not a URL: ${url}` };
104
+ }
105
+ if (parsed.protocol !== "https:" || !allowedHosts.includes(parsed.hostname)) {
106
+ return {
107
+ ok: false,
108
+ code: "EACCES",
109
+ message: `git may use https only, and only these hosts: ${allowedHosts.join(", ")}`
110
+ };
111
+ }
112
+ return undefined;
113
+ }
@@ -48,7 +48,7 @@ function listed(checks) {
48
48
  .join("\n");
49
49
  }
50
50
  export function checkTools(ctx) {
51
- const { bounded, github, githubRepo } = ctx;
51
+ const { bounded, mcp, githubRepo } = ctx;
52
52
  return {
53
53
  repo_pr_checks: tool({
54
54
  description: "Say how a pull request's checks stand on its latest commit — CI that is still running, failed or passed — in the repository you have checked out. Use it to know whether CI has finished and what failed, without waiting inside a session for it.",
@@ -61,23 +61,62 @@ export function checkTools(ctx) {
61
61
  if ("refusal" in target)
62
62
  return target.refusal;
63
63
  const { owner, repo } = target;
64
- const pr = await github("repo_pr_checks", `/repos/${owner}/${repo}/pulls/${number}`);
64
+ const where = {
65
+ owner: decodeURIComponent(owner),
66
+ repo: decodeURIComponent(repo),
67
+ pullNumber: number,
68
+ perPage: GITHUB_PAGE_SIZE
69
+ };
70
+ /**
71
+ * The head commit, read first and checked again at the end.
72
+ *
73
+ * Neither CI method takes a commit: each resolves the pull request's
74
+ * head for itself, so a push landing between the two reads combines one
75
+ * commit's check runs with another's statuses — and the combination
76
+ * renders as "every check has finished and passed" for a commit whose
77
+ * runs were never read, which is the one answer a waiting agent acts on
78
+ * by stopping. Reading the head first costs a call and makes the
79
+ * mismatch detectable: the combined status reports the sha it answered
80
+ * for, so an answer that disagrees with this one is thrown away rather
81
+ * than rendered.
82
+ */
83
+ const pr = await mcp("repo_pr_checks", "pull_request_read", {
84
+ ...where,
85
+ method: "get"
86
+ });
65
87
  if (!pr.ok)
66
88
  return bounded(pr.message);
67
89
  const sha = pr.data.head?.sha;
68
90
  if (!sha)
69
91
  return `#${number} is not a pull request in ${owner}/${repo}`;
70
92
  const short = sha.slice(0, 7);
71
- const runs = await github("repo_pr_checks", `/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=${GITHUB_PAGE_SIZE}`);
93
+ const runs = await mcp("repo_pr_checks", "pull_request_read", {
94
+ ...where,
95
+ method: "get_check_runs"
96
+ });
72
97
  if (!runs.ok)
73
98
  return bounded(runs.message);
74
99
  // The combined status holds the latest per context, so one page is
75
100
  // every context there is.
76
- const statuses = await github("repo_pr_checks", `/repos/${owner}/${repo}/commits/${sha}/status?per_page=${GITHUB_PAGE_SIZE}`);
101
+ const statuses = await mcp("repo_pr_checks", "pull_request_read", {
102
+ ...where,
103
+ method: "get_status"
104
+ });
77
105
  if (!statuses.ok)
78
106
  return bounded(statuses.message);
79
107
  const runData = runs.data;
80
108
  const statusData = statuses.data;
109
+ // The head was this commit before the check runs were read and still is
110
+ // after the statuses were, so both answers are that commit's. Unequal —
111
+ // or absent, which is the server declining to say — leaves no partial
112
+ // answer worth rendering, because the mixture is indistinguishable from
113
+ // a clean pass.
114
+ if (statusData.sha !== sha) {
115
+ return (`#${number}'s checks could not be read as one commit's: this started ` +
116
+ `at ${short} and the combined status answered for ` +
117
+ `${statusData.sha ? statusData.sha.slice(0, 7) : "no commit it named"}. ` +
118
+ "A push landing during the read does this — ask again.");
119
+ }
81
120
  const checks = readChecks(runData.check_runs ?? [], statusData.statuses ?? []);
82
121
  if (checks.length === 0) {
83
122
  return (`No checks have reported on #${number}'s latest commit (${short}). ` +
@@ -60,9 +60,8 @@ export function cloneTools(ctx) {
60
60
  //
61
61
  // Checked here rather than in `refreshCheckout` because this is where
62
62
  // model input arrives and where the message can say what to send instead.
63
- // It also covers the fresh-clone path, where the name reaches
64
- // isomorphic-git as a ref: no argv there, so no option to parse, but an
65
- // unresolvable ref is a worse error than this sentence.
63
+ // It also covers the fresh-clone path, where an unresolvable ref is a
64
+ // worse error than this sentence.
66
65
  if (branch !== undefined && UNSAFE_BRANCH.test(branch))
67
66
  return `"${branch}" is not a plain branch name — use something like "main"`;
68
67
  const dir = `${workdir}/${parsed.repo}`;
@@ -151,24 +150,6 @@ export function cloneTools(ctx) {
151
150
  // plugin never cloned, is on
152
151
  // {@link file://./checkout.ts writeGitIdentity}.
153
152
  await writeGitIdentity(plain, dir, author);
154
- // Two gits now share one `.git`, and this is where they could disagree.
155
- //
156
- // The container's git and the host's isomorphic-git read and write the
157
- // same index. isomorphic-git understands version 2 and the extensions
158
- // git writes for its own caches are not part of that contract, so a
159
- // repository that picked up an untracked cache or a split index — from a
160
- // `feature.manyFiles` default, or a user config in some future image —
161
- // would be written by one and misread by the other. Pinned at clone
162
- // time, repo-locally, where it costs three commands and cannot surprise
163
- // anyone later.
164
- //
165
- // Unchecked deliberately, like the upstream-tracking pair in
166
- // `repo_push`: git's own defaults are already these values, so a failure
167
- // here means the config could not be written at all, which the very next
168
- // command will say far more clearly than this one could.
169
- await plain(`config index.version 2`, dir);
170
- await plain(`config core.untrackedCache false`, dir);
171
- await plain(`config core.splitIndex false`, dir);
172
153
  // The clone reports the branch it landed on, so nothing has to ask git
173
154
  // afterwards — and a failed question would fire `afterCheckout` with no
174
155
  // branch at all, starting an install against a checkout nobody can name.
@@ -1,7 +1,6 @@
1
1
  import { type Action } from "@cloudflare/think";
2
2
  import type { ToolSet } from "ai";
3
3
  import type { RepoContext } from "./context.js";
4
- /** GitHub's REST surface: pull requests, issues and their comments. */
5
4
  export declare function prTools(ctx: RepoContext): ToolSet;
6
5
  /**
7
6
  * The GitHub writes a recovered turn must not repeat, as Think actions.