@lchase/kiln 0.4.0 → 0.6.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 +61 -5
- package/dist/attention.js +28 -2
- package/dist/cli.js +30 -0
- package/dist/config.js +14 -1
- package/dist/driver.js +484 -54
- package/dist/feedback.js +610 -0
- package/dist/host.js +26 -1
- package/dist/reconcile.js +31 -3
- package/dist/reliability.js +16 -0
- package/dist/report.js +76 -9
- package/dist/sandbox.js +363 -0
- package/dist/schemas.js +26 -0
- package/dist/serve-page.js +14 -1
- package/dist/serve.js +7 -1
- package/dist/workflow.js +2 -0
- package/package.json +1 -1
- package/prompts/kiln/fix.md +17 -0
- package/workflows/feature.yaml +5 -0
- package/workflows/fix.yaml +48 -0
- package/workflows/investigation.yaml +3 -0
package/dist/reconcile.js
CHANGED
|
@@ -5,7 +5,7 @@ export function inFlightCount(observation) {
|
|
|
5
5
|
return observation.jobs.filter((job) => inFlight(job, observation)).length;
|
|
6
6
|
}
|
|
7
7
|
function inFlight(job, observation) {
|
|
8
|
-
if (job.error)
|
|
8
|
+
if (job.error || job.feedbackExhausted || job.feedbackAmbiguous)
|
|
9
9
|
return false;
|
|
10
10
|
const state = jobState(job, observation);
|
|
11
11
|
return state === "running" || state === "needs-human" || state === "approved";
|
|
@@ -20,8 +20,17 @@ function inFlight(job, observation) {
|
|
|
20
20
|
* An investigation is `done` on approve and never `in-review`.
|
|
21
21
|
*/
|
|
22
22
|
export function jobState(job, observation) {
|
|
23
|
-
if (job.
|
|
23
|
+
if (job.fixAwaitingPublish)
|
|
24
|
+
return "in-review";
|
|
25
|
+
if (job.launching && (!job.run || job.runRetired || job.fixInProgress))
|
|
24
26
|
return "running";
|
|
27
|
+
if ((job.feedbackExhausted || job.feedbackAmbiguous) && isLanded(job) && !job.fixInProgress) {
|
|
28
|
+
if (job.delivery?.merged || job.delivery?.prState === "MERGED")
|
|
29
|
+
return "done";
|
|
30
|
+
if (job.delivery?.prState === "CLOSED")
|
|
31
|
+
return "failed";
|
|
32
|
+
return "needs-human";
|
|
33
|
+
}
|
|
25
34
|
if (job.run && !job.runRetired)
|
|
26
35
|
return activeRunState(job);
|
|
27
36
|
if (job.run && isLanded(job))
|
|
@@ -126,10 +135,16 @@ function needsDelivery(job) {
|
|
|
126
135
|
return true;
|
|
127
136
|
}
|
|
128
137
|
function canLaunch(job, observation) {
|
|
129
|
-
if (job.error || job.run || job.launching || job.launchFailed)
|
|
138
|
+
if (job.error || job.run || job.launching || job.launchFailed || job.fixInProgress)
|
|
130
139
|
return false;
|
|
131
140
|
return dependenciesMet(job, observation);
|
|
132
141
|
}
|
|
142
|
+
/** An in-review job with new pull request feedback and room under `maxFixRounds`. */
|
|
143
|
+
function canStartFix(job) {
|
|
144
|
+
if (job.error || job.launching || job.fixInProgress || job.fixAwaitingPublish)
|
|
145
|
+
return false;
|
|
146
|
+
return job.feedbackPlan?.action === "fix";
|
|
147
|
+
}
|
|
133
148
|
/** A recorded transient failure whose backoff has elapsed. One retry per tick, ahead of a new job. */
|
|
134
149
|
function canRetry(job, observation, now) {
|
|
135
150
|
if (job.error || job.launching || job.retriesExhausted || job.ledgerError)
|
|
@@ -164,6 +179,14 @@ export function decide(observation, now = new Date()) {
|
|
|
164
179
|
continue;
|
|
165
180
|
actions.push({ type: "deliver", jobId: job.jobId, repo: job.repo });
|
|
166
181
|
}
|
|
182
|
+
for (const job of observation.jobs) {
|
|
183
|
+
if (job.feedbackPlan?.action === "exhaust") {
|
|
184
|
+
actions.push({ type: "mark-feedback", jobId: job.jobId, reason: "feedback-exhausted" });
|
|
185
|
+
}
|
|
186
|
+
else if (job.feedbackPlan?.action === "ambiguous") {
|
|
187
|
+
actions.push({ type: "mark-feedback", jobId: job.jobId, reason: "feedback-ambiguous" });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
167
190
|
if (inFlightCount(observation) >= observation.maxConcurrent)
|
|
168
191
|
return actions;
|
|
169
192
|
const retry = observation.jobs.find((job) => canRetry(job, observation, now));
|
|
@@ -171,6 +194,11 @@ export function decide(observation, now = new Date()) {
|
|
|
171
194
|
actions.push({ type: "retry", jobId: retry.jobId, repo: retry.repo });
|
|
172
195
|
return actions;
|
|
173
196
|
}
|
|
197
|
+
const fix = observation.jobs.find((job) => canStartFix(job));
|
|
198
|
+
if (fix) {
|
|
199
|
+
actions.push({ type: "start-fix", jobId: fix.jobId, repo: fix.repo });
|
|
200
|
+
return actions;
|
|
201
|
+
}
|
|
174
202
|
const ready = observation.jobs.filter((job) => canLaunch(job, observation));
|
|
175
203
|
const healthy = ready.filter((job) => !job.launchError);
|
|
176
204
|
const picked = healthy[0] ?? ready[0];
|
package/dist/reliability.js
CHANGED
|
@@ -90,6 +90,10 @@ export function recoverRuns(config, jobs, options) {
|
|
|
90
90
|
const existing = readAttemptLedger(config.kilnDir, job.jobId);
|
|
91
91
|
if (existing === "corrupt")
|
|
92
92
|
continue;
|
|
93
|
+
if (job.run?.sandboxError && job.run.status !== "alive") {
|
|
94
|
+
suppressAutomaticRetry(config.kilnDir, job.jobId);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
93
97
|
const pending = pendingFailure(job, config, options);
|
|
94
98
|
if (!pending)
|
|
95
99
|
continue;
|
|
@@ -113,6 +117,18 @@ export function recoverRuns(config, jobs, options) {
|
|
|
113
117
|
}
|
|
114
118
|
return notes;
|
|
115
119
|
}
|
|
120
|
+
/** Drop a scheduled retry so a sandbox failure stays in Needs human. */
|
|
121
|
+
function suppressAutomaticRetry(kilnDir, jobId) {
|
|
122
|
+
const existing = readAttemptLedger(kilnDir, jobId);
|
|
123
|
+
if (!existing || existing === "corrupt" || !existing.nextRetryAt)
|
|
124
|
+
return;
|
|
125
|
+
const written = {
|
|
126
|
+
jobId: existing.jobId,
|
|
127
|
+
attempts: existing.attempts,
|
|
128
|
+
...(existing.ignoredRunIds && existing.ignoredRunIds.length > 0 ? { ignoredRunIds: existing.ignoredRunIds } : {}),
|
|
129
|
+
};
|
|
130
|
+
writeLedger(kilnDir, written);
|
|
131
|
+
}
|
|
116
132
|
function pendingFailure(job, config, options) {
|
|
117
133
|
if (job.run?.gateDecision === "reject")
|
|
118
134
|
return undefined;
|
package/dist/report.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { readBoardFiles, readReviewRounds } from "./board.js";
|
|
5
|
+
import { feedbackRoundText } from "./feedback.js";
|
|
5
6
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
6
7
|
import { inFlightCount, isDone, isLanded, jobState, } from "./reconcile.js";
|
|
7
8
|
export function shellQuote(value) {
|
|
@@ -76,6 +77,27 @@ export function latestStepFailure(events) {
|
|
|
76
77
|
}
|
|
77
78
|
return undefined;
|
|
78
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Detail of the latest unresolved step whose yak reason is `sandbox-error`.
|
|
82
|
+
* The `(sandbox-error)` suffix formatFailure adds is stripped.
|
|
83
|
+
*/
|
|
84
|
+
export function sandboxFailureDetail(events) {
|
|
85
|
+
const settled = new Set();
|
|
86
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
87
|
+
const event = events[index];
|
|
88
|
+
if (!event)
|
|
89
|
+
continue;
|
|
90
|
+
if (event.t === "step.started" || event.t === "step.completed") {
|
|
91
|
+
settled.add(event.stepId);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (event.t !== "step.failed" || settled.has(event.stepId) || event.failureReason !== "sandbox-error")
|
|
95
|
+
continue;
|
|
96
|
+
const text = (event.detail ?? "").replace(/\s*\(sandbox-error\)\s*$/, "").trim();
|
|
97
|
+
return text || "The sandbox could not run this step.";
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
79
101
|
/**
|
|
80
102
|
* Derived state for every job, or for one id.
|
|
81
103
|
* Throws `No job <id>` when that id is not on disk, same as `kiln status`.
|
|
@@ -123,8 +145,19 @@ export function statusDetails(job) {
|
|
|
123
145
|
lines.push(`log ${job.logPath}`);
|
|
124
146
|
if (job.state === "failed" && job.launchError)
|
|
125
147
|
lines.push(job.launchError);
|
|
148
|
+
if (job.sandboxError && (job.state === "failed" || job.state === "dead" || job.state === "needs-human")) {
|
|
149
|
+
lines.push(`sandbox ${job.sandboxError}`);
|
|
150
|
+
}
|
|
151
|
+
if (job.sandboxed)
|
|
152
|
+
lines.push("sandbox docker");
|
|
126
153
|
if (job.ledgerError)
|
|
127
154
|
lines.push(job.ledgerError);
|
|
155
|
+
if (job.feedbackLedgerError)
|
|
156
|
+
lines.push(job.feedbackLedgerError);
|
|
157
|
+
if ((job.feedbackRounds?.length ?? 0) > 0)
|
|
158
|
+
lines.push(`feedback ${job.feedbackRounds?.length}`);
|
|
159
|
+
if (job.state === "needs-human" && job.feedbackReason)
|
|
160
|
+
lines.push(`reason ${job.feedbackReason}`);
|
|
128
161
|
if (job.attemptCount > 0)
|
|
129
162
|
lines.push(`attempts ${job.attemptCount}`);
|
|
130
163
|
if (job.lastFailureReason)
|
|
@@ -197,10 +230,15 @@ function toStatusJob(job, observation) {
|
|
|
197
230
|
};
|
|
198
231
|
}
|
|
199
232
|
const state = jobState(job, observation);
|
|
200
|
-
const
|
|
233
|
+
const feedbackReason = job.feedbackExhausted
|
|
234
|
+
? "feedback-exhausted"
|
|
235
|
+
: job.feedbackAmbiguous
|
|
236
|
+
? "feedback-ambiguous"
|
|
237
|
+
: undefined;
|
|
238
|
+
const gates = state === "needs-human" && job.run && !feedbackReason ? job.run.gates.map(toGateView) : [];
|
|
201
239
|
const prNumber = job.delivery?.prNumber;
|
|
202
240
|
const runId = job.run?.runId;
|
|
203
|
-
const showBranch = (state === "done" || state === "in-review"
|
|
241
|
+
const showBranch = runId !== undefined && (state === "done" || state === "in-review" || feedbackReason !== undefined);
|
|
204
242
|
const failureDetail = job.run?.stepEvents ? latestStepFailure(job.run.stepEvents) : undefined;
|
|
205
243
|
return {
|
|
206
244
|
jobId: job.jobId,
|
|
@@ -224,6 +262,8 @@ function toStatusJob(job, observation) {
|
|
|
224
262
|
...(job.lastFailureReason ? { lastFailureReason: job.lastFailureReason } : {}),
|
|
225
263
|
...(job.nextRetryAt && !job.retriesExhausted ? { nextRetryAt: job.nextRetryAt } : {}),
|
|
226
264
|
...(job.ledgerError ? { ledgerError: job.ledgerError } : {}),
|
|
265
|
+
...(job.feedbackLedgerError ? { feedbackLedgerError: job.feedbackLedgerError } : {}),
|
|
266
|
+
...(feedbackReason ? { feedbackReason } : {}),
|
|
227
267
|
...(job.launchError ? { launchError: job.launchError } : {}),
|
|
228
268
|
...(job.delivery?.error ? { deliveryError: job.delivery.error } : {}),
|
|
229
269
|
...(job.delivery?.viewError ? { viewError: job.delivery.viewError } : {}),
|
|
@@ -243,6 +283,8 @@ function toStatusJob(job, observation) {
|
|
|
243
283
|
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
244
284
|
...(job.diffSummary ? { diffSummary: job.diffSummary } : {}),
|
|
245
285
|
...(failureDetail ? { failureDetail } : {}),
|
|
286
|
+
...(job.run?.sandboxError ? { sandboxError: job.run.sandboxError } : {}),
|
|
287
|
+
...(job.sandboxed ? { sandboxed: true } : {}),
|
|
246
288
|
...boardFields(job),
|
|
247
289
|
};
|
|
248
290
|
}
|
|
@@ -258,12 +300,29 @@ function boardFields(job) {
|
|
|
258
300
|
...(job.workflow ? { workflow: job.workflow } : {}),
|
|
259
301
|
gates: job.run?.gates ?? [],
|
|
260
302
|
});
|
|
303
|
+
for (const round of job.feedbackRounds ?? []) {
|
|
304
|
+
const label = round.trigger === "ci" ? "CI" : "review";
|
|
305
|
+
files.push({
|
|
306
|
+
id: `feedback-r${round.round}`,
|
|
307
|
+
group: "Feedback",
|
|
308
|
+
label: `R${round.round} ${label}.md`,
|
|
309
|
+
text: feedbackRoundText(round),
|
|
310
|
+
tone: round.outcome ? "good" : "warn",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
261
313
|
return {
|
|
262
314
|
...(rounds.length > 0 ? { rounds } : {}),
|
|
315
|
+
...(job.feedbackRounds?.length ? { feedbackRounds: job.feedbackRounds } : {}),
|
|
263
316
|
...(files.length > 0 ? { files } : {}),
|
|
264
317
|
};
|
|
265
318
|
}
|
|
266
319
|
function progressFor(job) {
|
|
320
|
+
if (job.fixInProgress && !job.run) {
|
|
321
|
+
return [
|
|
322
|
+
{ id: "build", phase: "current" },
|
|
323
|
+
{ id: "land", phase: "pending" },
|
|
324
|
+
];
|
|
325
|
+
}
|
|
267
326
|
const plan = job.run?.stepPlan?.length ? job.run.stepPlan : (job.stepPlan ?? []);
|
|
268
327
|
return stepProgress(plan, job.run?.stepEvents ?? []);
|
|
269
328
|
}
|
|
@@ -317,7 +376,7 @@ function blockReasons(job, observation) {
|
|
|
317
376
|
}
|
|
318
377
|
return reasons;
|
|
319
378
|
}
|
|
320
|
-
export function renderTick(observation, actions, launches, delivers = [], findings = [], notes = []) {
|
|
379
|
+
export function renderTick(observation, actions, launches, delivers = [], findings = [], notes = [], feedbackNotes = []) {
|
|
321
380
|
const lines = [`tick inFlight=${inFlightCount(observation)} maxConcurrent=${observation.maxConcurrent}`];
|
|
322
381
|
for (const job of observation.jobs) {
|
|
323
382
|
if (job.error)
|
|
@@ -341,18 +400,26 @@ export function renderTick(observation, actions, launches, delivers = [], findin
|
|
|
341
400
|
const next = note.nextRetryAt ? ` next ${note.nextRetryAt}` : " dead";
|
|
342
401
|
lines.push(`${note.kind} ${note.jobId} ${note.reason} attempt ${note.attempt}${next}`);
|
|
343
402
|
}
|
|
403
|
+
for (const note of feedbackNotes) {
|
|
404
|
+
if (note.trim())
|
|
405
|
+
lines.push(note);
|
|
406
|
+
}
|
|
344
407
|
for (const launch of launches) {
|
|
345
|
-
|
|
408
|
+
const verb = launch.kind === "fix" ? "fix" : launch.retry ? "retry" : "launch";
|
|
409
|
+
lines.push(`${verb} ${launch.jobId} ${launch.repo}`);
|
|
346
410
|
for (const line of launch.output.split("\n")) {
|
|
347
411
|
if (line.trim())
|
|
348
412
|
lines.push(` ${line}`);
|
|
349
413
|
}
|
|
350
414
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
415
|
+
const acted = actions.some((action) => action.type === "launch" ||
|
|
416
|
+
action.type === "retry" ||
|
|
417
|
+
action.type === "start-fix" ||
|
|
418
|
+
action.type === "mark-feedback") ||
|
|
419
|
+
launches.length > 0 ||
|
|
420
|
+
delivers.length > 0 ||
|
|
421
|
+
findings.length > 0;
|
|
422
|
+
if (!acted)
|
|
355
423
|
lines.push("nothing to launch");
|
|
356
|
-
}
|
|
357
424
|
return `${lines.join("\n")}\n`;
|
|
358
425
|
}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { jobBasename } from "./paths.js";
|
|
7
|
+
/**
|
|
8
|
+
* Opt-in Docker sandbox for Kiln agent steps.
|
|
9
|
+
*
|
|
10
|
+
* Off unless kiln config sets `sandbox.mode` to `docker` (a per-workflow
|
|
11
|
+
* block can override that). Kiln injects `sandbox: docker` under each
|
|
12
|
+
* agent step. verify, the approve gate, and land stay on the host: yak's
|
|
13
|
+
* command sandbox does not forward `KILN_TEST` or the truncate wrapper,
|
|
14
|
+
* and land commits with the host git identity. Pull requests and
|
|
15
|
+
* `gh pr view` run in the kiln process.
|
|
16
|
+
*
|
|
17
|
+
* @lchase/yak 0.7.1 drops `sandbox`, `image`, and `allowedDomains` while
|
|
18
|
+
* loading workflow YAML, and calls `createAgentSandboxNetwork` with only
|
|
19
|
+
* `api.anthropic.com`. Kiln refuses to launch while that is still true,
|
|
20
|
+
* because the job would run on the host.
|
|
21
|
+
*/
|
|
22
|
+
export const AGENT_SANDBOX_IMAGE = "yak-agent-sandbox:latest";
|
|
23
|
+
export const PROXY_SANDBOX_IMAGE = "yak-agent-sandbox-proxy:latest";
|
|
24
|
+
export const CONTAINER_CLI_PATH = "/app/claude-cli";
|
|
25
|
+
export const CONTAINER_WORKDIR = "/workspace";
|
|
26
|
+
/**
|
|
27
|
+
* Hosts a coding agent needs on a typical Node repo, and nothing wider.
|
|
28
|
+
* `api.anthropic.com` is the model API. The rest are npm and the GitHub
|
|
29
|
+
* hosts a fetch or an install actually dials. Exact hostnames only: the
|
|
30
|
+
* proxy matches `dstdomain` without a leading dot, so a subdomain is not
|
|
31
|
+
* implied.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_SANDBOX_DOMAINS = [
|
|
34
|
+
"api.anthropic.com",
|
|
35
|
+
"registry.npmjs.org",
|
|
36
|
+
"github.com",
|
|
37
|
+
"api.github.com",
|
|
38
|
+
"codeload.github.com",
|
|
39
|
+
"objects.githubusercontent.com",
|
|
40
|
+
"raw.githubusercontent.com",
|
|
41
|
+
];
|
|
42
|
+
/** Printed at the start of a tick when sandbox mode is on and a check failed. */
|
|
43
|
+
export const SANDBOX_PREFLIGHT_PREFIX = "Sandbox is on, and a sandboxed run cannot start:";
|
|
44
|
+
/** Present in a yak build that copies `allowedDomains` off the agent step. */
|
|
45
|
+
export const YAK_ALLOWLIST_MARKER = "raw.agent.allowedDomains";
|
|
46
|
+
/**
|
|
47
|
+
* Workflow override wins per field. An omitted field inherits the global
|
|
48
|
+
* setting, then the built-in default. `allowDomains`, when set, replaces
|
|
49
|
+
* the list rather than extending it, so a tighter workflow cannot keep a
|
|
50
|
+
* wider global host by accident.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveSandbox(global, workflow) {
|
|
53
|
+
const mode = workflow?.mode ?? global?.mode ?? "off";
|
|
54
|
+
const image = workflow?.image ?? global?.image;
|
|
55
|
+
const allowDomains = workflow?.allowDomains ?? global?.allowDomains ?? DEFAULT_SANDBOX_DOMAINS;
|
|
56
|
+
return {
|
|
57
|
+
mode,
|
|
58
|
+
allowDomains: [...allowDomains],
|
|
59
|
+
...(image ? { image } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Resolved sandboxes that would actually containerize a packaged or configured workflow. */
|
|
63
|
+
export function sandboxTargets(config) {
|
|
64
|
+
const names = new Set(["feature", "investigation", "fix", ...Object.keys(config.workflowSandbox)]);
|
|
65
|
+
const targets = [];
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
const resolved = resolveSandbox(config.sandbox, config.workflowSandbox[name]);
|
|
68
|
+
if (resolved.mode === "docker")
|
|
69
|
+
targets.push(resolved);
|
|
70
|
+
}
|
|
71
|
+
return targets;
|
|
72
|
+
}
|
|
73
|
+
export function sandboxIsEnabled(config) {
|
|
74
|
+
return sandboxTargets(config).length > 0;
|
|
75
|
+
}
|
|
76
|
+
export function requiredSandboxImages(config) {
|
|
77
|
+
const targets = sandboxTargets(config);
|
|
78
|
+
if (targets.length === 0)
|
|
79
|
+
return [];
|
|
80
|
+
const images = new Set([PROXY_SANDBOX_IMAGE]);
|
|
81
|
+
for (const target of targets)
|
|
82
|
+
images.add(target.image ?? AGENT_SANDBOX_IMAGE);
|
|
83
|
+
return [...images];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Insert `sandbox: docker` as the first keys of every `agent:` block.
|
|
87
|
+
* Command steps, gates, and comments are left alone. A block that already
|
|
88
|
+
* has `sandbox:` is not edited again.
|
|
89
|
+
*/
|
|
90
|
+
export function injectAgentSandbox(yaml, sandbox) {
|
|
91
|
+
if (sandbox.mode !== "docker")
|
|
92
|
+
return yaml;
|
|
93
|
+
const lines = yaml.split("\n");
|
|
94
|
+
const out = [];
|
|
95
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
96
|
+
const line = lines[index] ?? "";
|
|
97
|
+
out.push(line);
|
|
98
|
+
const match = /^(\s*)agent:\s*(?:#.*)?$/.exec(line);
|
|
99
|
+
if (!match)
|
|
100
|
+
continue;
|
|
101
|
+
const base = match[1]?.length ?? 0;
|
|
102
|
+
if (blockHasKey(lines, index + 1, base, "sandbox"))
|
|
103
|
+
continue;
|
|
104
|
+
const child = childIndent(lines, index + 1, base);
|
|
105
|
+
const pad = " ".repeat(child);
|
|
106
|
+
out.push(`${pad}sandbox: docker`);
|
|
107
|
+
if (sandbox.image)
|
|
108
|
+
out.push(`${pad}image: ${yamlDoubleQuote(sandbox.image)}`);
|
|
109
|
+
out.push(`${pad}allowedDomains:`);
|
|
110
|
+
for (const domain of sandbox.allowDomains)
|
|
111
|
+
out.push(`${pad} - ${domain}`);
|
|
112
|
+
}
|
|
113
|
+
return out.join("\n");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* True when this job's rendered workflow sandboxes agents.
|
|
117
|
+
* A job that has not been rendered yet follows the current config.
|
|
118
|
+
*/
|
|
119
|
+
export function jobIsSandboxed(config, jobId, workflow) {
|
|
120
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(workflow)) {
|
|
121
|
+
return resolveSandbox(config.sandbox, config.workflowSandbox[workflow]).mode === "docker";
|
|
122
|
+
}
|
|
123
|
+
const dir = join(config.kilnDir, "rendered", jobBasename(jobId));
|
|
124
|
+
const names = workflow === "fix" ? ["fix.yaml"] : [`${workflow}.yaml`, "fix.yaml"];
|
|
125
|
+
let sawRender = false;
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
try {
|
|
128
|
+
const text = readFileSync(join(dir, name), "utf8");
|
|
129
|
+
sawRender = true;
|
|
130
|
+
if (/^\s*sandbox:\s*docker\s*$/m.test(text))
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// That render is absent.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (sawRender)
|
|
138
|
+
return false;
|
|
139
|
+
return resolveSandbox(config.sandbox, config.workflowSandbox[workflow]).mode === "docker";
|
|
140
|
+
}
|
|
141
|
+
export function yakDistHonorsAllowlist(distSource) {
|
|
142
|
+
return distSource.includes(YAK_ALLOWLIST_MARKER);
|
|
143
|
+
}
|
|
144
|
+
export function credentialPresent(env) {
|
|
145
|
+
return Boolean(env.ANTHROPIC_API_KEY?.trim() || env.CLAUDE_CODE_OAUTH_TOKEN?.trim());
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Names every missing piece. Returns undefined when this sandbox can launch.
|
|
149
|
+
* The text never includes an environment value.
|
|
150
|
+
*/
|
|
151
|
+
export function sandboxPreflightFor(sandbox, adapter, facts) {
|
|
152
|
+
if (sandbox.mode !== "docker")
|
|
153
|
+
return undefined;
|
|
154
|
+
return formatPreflight(missingPieces([sandbox.image ?? AGENT_SANDBOX_IMAGE], adapter, facts));
|
|
155
|
+
}
|
|
156
|
+
/** Union of every workflow whose resolved mode is docker. */
|
|
157
|
+
export function sandboxPreflight(config, facts) {
|
|
158
|
+
const images = requiredSandboxImages(config);
|
|
159
|
+
if (images.length === 0)
|
|
160
|
+
return undefined;
|
|
161
|
+
const agentImages = images.filter((image) => image !== PROXY_SANDBOX_IMAGE);
|
|
162
|
+
return formatPreflight(missingPieces(agentImages, config.adapter, facts));
|
|
163
|
+
}
|
|
164
|
+
export function sandboxPreflightMessage(config, facts) {
|
|
165
|
+
if (!sandboxIsEnabled(config))
|
|
166
|
+
return undefined;
|
|
167
|
+
return sandboxPreflight(config, facts ?? probeSandboxEnvironment(process.env, requiredSandboxImages(config)));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* `docker` argv yak 0.7.1 builds for one sandboxed agent step
|
|
171
|
+
* (`dockerAgentRunArgs` in `@lchase/yak`). Kiln does not spawn docker
|
|
172
|
+
* itself. Credential values are copied through so a test can show the
|
|
173
|
+
* shape; do not log the result when `env` holds a real token.
|
|
174
|
+
*/
|
|
175
|
+
export function agentSandboxDockerArgs(input) {
|
|
176
|
+
const env = input.env ?? {};
|
|
177
|
+
const credentialEnv = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]
|
|
178
|
+
.filter((key) => env[key] !== undefined)
|
|
179
|
+
.flatMap((key) => ["-e", `${key}=${env[key]}`]);
|
|
180
|
+
return [
|
|
181
|
+
"run",
|
|
182
|
+
"--rm",
|
|
183
|
+
"-i",
|
|
184
|
+
"--network",
|
|
185
|
+
input.networkName,
|
|
186
|
+
"-v",
|
|
187
|
+
`${input.cwd}:${CONTAINER_WORKDIR}`,
|
|
188
|
+
"-w",
|
|
189
|
+
CONTAINER_WORKDIR,
|
|
190
|
+
"-e",
|
|
191
|
+
`HTTPS_PROXY=${input.proxyUrl}`,
|
|
192
|
+
"-e",
|
|
193
|
+
`HTTP_PROXY=${input.proxyUrl}`,
|
|
194
|
+
...credentialEnv,
|
|
195
|
+
input.image ?? AGENT_SANDBOX_IMAGE,
|
|
196
|
+
CONTAINER_CLI_PATH,
|
|
197
|
+
...input.cliArgs,
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
/** Proxy sidecar argv from yak's `startProxyArgs`. */
|
|
201
|
+
export function proxySandboxDockerArgs(networkName, allowedDomains) {
|
|
202
|
+
return [
|
|
203
|
+
"run",
|
|
204
|
+
"-d",
|
|
205
|
+
"--rm",
|
|
206
|
+
"--name",
|
|
207
|
+
`${networkName}-proxy`,
|
|
208
|
+
"--network",
|
|
209
|
+
networkName,
|
|
210
|
+
"-e",
|
|
211
|
+
`ALLOWED_DOMAINS=${allowedDomains.join(",")}`,
|
|
212
|
+
PROXY_SANDBOX_IMAGE,
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
export function sandboxImageBuildArgs(yakVersion) {
|
|
216
|
+
const ref = `yak-v${yakVersion}`;
|
|
217
|
+
const remote = "https://github.com/lchase/yak.git";
|
|
218
|
+
return [
|
|
219
|
+
["build", "-t", AGENT_SANDBOX_IMAGE, `${remote}#${ref}:docker/agent-sandbox`],
|
|
220
|
+
["build", "-t", PROXY_SANDBOX_IMAGE, `${remote}#${ref}:docker/agent-proxy`],
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
/** git is not in yak's node:22-slim agent image. Agents shell out to git. */
|
|
224
|
+
export function sandboxGitOverlayDockerfile() {
|
|
225
|
+
return [
|
|
226
|
+
"FROM yak-agent-sandbox:latest",
|
|
227
|
+
"USER root",
|
|
228
|
+
"RUN apt-get update \\",
|
|
229
|
+
" && apt-get install -y --no-install-recommends git ca-certificates \\",
|
|
230
|
+
" && rm -rf /var/lib/apt/lists/*",
|
|
231
|
+
"",
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
234
|
+
export function renderSandboxStatus(config, facts) {
|
|
235
|
+
const names = [...new Set(["feature", "investigation", "fix", ...Object.keys(config.workflowSandbox)])].sort();
|
|
236
|
+
const lines = names.map((name) => {
|
|
237
|
+
const resolved = resolveSandbox(config.sandbox, config.workflowSandbox[name]);
|
|
238
|
+
const image = resolved.image ? ` image ${resolved.image}` : "";
|
|
239
|
+
const domains = resolved.mode === "docker" ? ` allow ${resolved.allowDomains.join(" ")}` : "";
|
|
240
|
+
return `${name} ${resolved.mode}${image}${domains}`;
|
|
241
|
+
});
|
|
242
|
+
const problem = sandboxPreflightMessage(config, facts);
|
|
243
|
+
lines.push(problem ?? (sandboxIsEnabled(config) ? "sandbox ready" : "sandbox off"));
|
|
244
|
+
return `${lines.join("\n")}\n`;
|
|
245
|
+
}
|
|
246
|
+
export function runSandboxBuild() {
|
|
247
|
+
const version = installedYakVersion();
|
|
248
|
+
for (const args of sandboxImageBuildArgs(version))
|
|
249
|
+
runDocker(args);
|
|
250
|
+
const dir = mkdtempSync(join(tmpdir(), "kiln-sandbox-"));
|
|
251
|
+
const file = join(dir, "Dockerfile");
|
|
252
|
+
writeFileSync(file, sandboxGitOverlayDockerfile());
|
|
253
|
+
runDocker(["build", "-t", AGENT_SANDBOX_IMAGE, "-f", file, dir]);
|
|
254
|
+
}
|
|
255
|
+
export function probeSandboxEnvironment(env = process.env, images = [AGENT_SANDBOX_IMAGE, PROXY_SANDBOX_IMAGE]) {
|
|
256
|
+
const binary = dockerResult(["--version"]);
|
|
257
|
+
const dockerBinary = binary.status === 0;
|
|
258
|
+
const daemon = dockerBinary ? dockerResult(["info"]) : undefined;
|
|
259
|
+
const present = dockerBinary && daemon?.status === 0
|
|
260
|
+
? images.filter((name) => dockerResult(["image", "inspect", name]).status === 0)
|
|
261
|
+
: [];
|
|
262
|
+
const yak = readInstalledYak();
|
|
263
|
+
return {
|
|
264
|
+
dockerBinary,
|
|
265
|
+
dockerDaemon: daemon?.status === 0,
|
|
266
|
+
images: present,
|
|
267
|
+
yakVersion: yak.version,
|
|
268
|
+
yakDist: yak.dist,
|
|
269
|
+
env,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function missingPieces(agentImages, adapter, facts) {
|
|
273
|
+
const missing = [];
|
|
274
|
+
if (!facts.dockerBinary) {
|
|
275
|
+
missing.push("docker binary is not on PATH");
|
|
276
|
+
}
|
|
277
|
+
else if (!facts.dockerDaemon) {
|
|
278
|
+
missing.push("docker daemon is not reachable");
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
if (!facts.images.includes(PROXY_SANDBOX_IMAGE)) {
|
|
282
|
+
missing.push(`image ${PROXY_SANDBOX_IMAGE} is missing (kiln sandbox build)`);
|
|
283
|
+
}
|
|
284
|
+
for (const image of agentImages) {
|
|
285
|
+
if (!facts.images.includes(image))
|
|
286
|
+
missing.push(`image ${image} is missing (kiln sandbox build)`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (adapter === "claude-code" && !credentialPresent(facts.env)) {
|
|
290
|
+
missing.push("no Claude credential for the sandbox. The container only receives ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN. macOS keychain login is not forwarded. Run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN for this kiln process.");
|
|
291
|
+
}
|
|
292
|
+
if (!yakDistHonorsAllowlist(facts.yakDist)) {
|
|
293
|
+
missing.push(`installed @lchase/yak ${facts.yakVersion} does not pass allowedDomains from a workflow step into the sandbox proxy. @lchase/yak 0.7.1 drops sandbox, image, and allowedDomains when it loads workflow YAML, and the proxy allowlist stays api.anthropic.com. Kiln will not launch an unsandboxed run while sandbox mode is docker.`);
|
|
294
|
+
}
|
|
295
|
+
return missing;
|
|
296
|
+
}
|
|
297
|
+
function formatPreflight(missing) {
|
|
298
|
+
if (missing.length === 0)
|
|
299
|
+
return undefined;
|
|
300
|
+
return `${SANDBOX_PREFLIGHT_PREFIX}\n${missing.map((line) => `- ${line}`).join("\n")}`;
|
|
301
|
+
}
|
|
302
|
+
function childIndent(lines, start, base) {
|
|
303
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
304
|
+
const line = lines[index] ?? "";
|
|
305
|
+
if (line.trim().length === 0)
|
|
306
|
+
continue;
|
|
307
|
+
const indent = /^(\s*)/.exec(line)?.[1]?.length ?? 0;
|
|
308
|
+
if (indent <= base)
|
|
309
|
+
break;
|
|
310
|
+
return indent;
|
|
311
|
+
}
|
|
312
|
+
return base + 2;
|
|
313
|
+
}
|
|
314
|
+
function blockHasKey(lines, start, base, key) {
|
|
315
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
316
|
+
const line = lines[index] ?? "";
|
|
317
|
+
if (line.trim().length === 0)
|
|
318
|
+
continue;
|
|
319
|
+
const indent = /^(\s*)/.exec(line)?.[1]?.length ?? 0;
|
|
320
|
+
if (indent <= base)
|
|
321
|
+
return false;
|
|
322
|
+
if (new RegExp(`^\\s*${key}:\\s*`).test(line))
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
function yamlDoubleQuote(value) {
|
|
328
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
329
|
+
}
|
|
330
|
+
function installedYakVersion() {
|
|
331
|
+
return readInstalledYak().version;
|
|
332
|
+
}
|
|
333
|
+
function readInstalledYak() {
|
|
334
|
+
const require = createRequire(import.meta.url);
|
|
335
|
+
const pkgPath = require.resolve("@lchase/yak/package.json");
|
|
336
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
337
|
+
const version = typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "unknown";
|
|
338
|
+
let dist = "";
|
|
339
|
+
try {
|
|
340
|
+
dist = readFileSync(join(dirname(pkgPath), "dist", "index.js"), "utf8");
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
dist = "";
|
|
344
|
+
}
|
|
345
|
+
return { version, dist };
|
|
346
|
+
}
|
|
347
|
+
function dockerResult(args) {
|
|
348
|
+
const result = spawnSync("docker", args, { encoding: "utf8", stdio: "ignore" });
|
|
349
|
+
return { status: result.error ? null : result.status };
|
|
350
|
+
}
|
|
351
|
+
function runDocker(args) {
|
|
352
|
+
const result = spawnSync("docker", args, { stdio: "inherit" });
|
|
353
|
+
if (result.error) {
|
|
354
|
+
const code = result.error.code;
|
|
355
|
+
if (code === "ENOENT") {
|
|
356
|
+
throw new Error("docker binary is not on PATH. Install Docker and run kiln sandbox build again.");
|
|
357
|
+
}
|
|
358
|
+
throw new Error(`docker ${args[0] ?? "build"} failed: ${result.error.message}`);
|
|
359
|
+
}
|
|
360
|
+
if (result.status !== 0) {
|
|
361
|
+
throw new Error(`docker ${args.join(" ")} exited ${result.status ?? "with a signal"}.`);
|
|
362
|
+
}
|
|
363
|
+
}
|
package/dist/schemas.js
CHANGED
|
@@ -26,10 +26,28 @@ export const JiraConfigSchema = z.object({
|
|
|
26
26
|
email: z.string().optional(),
|
|
27
27
|
apiToken: z.string().optional(),
|
|
28
28
|
});
|
|
29
|
+
/**
|
|
30
|
+
* Opt-in Docker sandbox. Omitted mode means off at that layer.
|
|
31
|
+
* `image` overrides yak's `yak-agent-sandbox:latest`.
|
|
32
|
+
* `allowDomains` replaces the built-in host list when set.
|
|
33
|
+
*/
|
|
34
|
+
const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
35
|
+
export const SandboxSettingsSchema = z.object({
|
|
36
|
+
mode: z.enum(["off", "docker"]).optional(),
|
|
37
|
+
image: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.regex(/^\S+$/, "image must not contain whitespace")
|
|
41
|
+
.optional(),
|
|
42
|
+
allowDomains: z.array(z.string().regex(HOSTNAME, "must be a hostname")).min(1).optional(),
|
|
43
|
+
});
|
|
44
|
+
const WORKFLOW_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
29
45
|
/** No yak event and no log output for this long stalls a running job. `<= 0` disables it. */
|
|
30
46
|
export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
31
47
|
/** Transient failures retry until this many attempts, then the job is dead. */
|
|
32
48
|
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
49
|
+
/** Pull request fix rounds before the job is needs-human (`feedback-exhausted`). */
|
|
50
|
+
export const DEFAULT_MAX_FIX_ROUNDS = 2;
|
|
33
51
|
export const KilnConfigFileSchema = z.object({
|
|
34
52
|
note: z.string().min(1).optional(),
|
|
35
53
|
maxConcurrent: z.number().int().min(1).optional(),
|
|
@@ -40,7 +58,15 @@ export const KilnConfigFileSchema = z.object({
|
|
|
40
58
|
stallTimeoutMs: z.number().int().optional(),
|
|
41
59
|
/** Transient failures before the job is dead. Default 3. */
|
|
42
60
|
maxAttempts: z.number().int().min(1).optional(),
|
|
61
|
+
/**
|
|
62
|
+
* Fix rounds for an open pull request before the job is needs-human with
|
|
63
|
+
* reason `feedback-exhausted`. Default 2. Kiln does not merge.
|
|
64
|
+
*/
|
|
65
|
+
maxFixRounds: z.number().int().min(1).optional(),
|
|
43
66
|
adapter: z.enum(["mock", "claude-code"]).optional(),
|
|
67
|
+
sandbox: SandboxSettingsSchema.optional(),
|
|
68
|
+
/** Per-workflow sandbox override. A set field replaces the global one; an omitted field inherits. */
|
|
69
|
+
workflows: z.record(z.string().regex(WORKFLOW_KEY), z.object({ sandbox: SandboxSettingsSchema.optional() })).optional(),
|
|
44
70
|
repos: z.record(z.string(), RepoEntrySchema),
|
|
45
71
|
jira: JiraConfigSchema.optional(),
|
|
46
72
|
});
|