@lchase/kiln 0.5.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 +54 -0
- package/dist/attention.js +18 -2
- package/dist/cli.js +26 -0
- package/dist/config.js +12 -0
- package/dist/driver.js +69 -15
- package/dist/reliability.js +16 -0
- package/dist/report.js +28 -0
- package/dist/sandbox.js +363 -0
- package/dist/schemas.js +19 -0
- package/dist/serve-page.js +7 -0
- package/dist/serve.js +7 -1
- package/package.json +1 -1
- package/workflows/feature.yaml +5 -0
- package/workflows/fix.yaml +4 -0
- package/workflows/investigation.yaml +3 -0
package/README.md
CHANGED
|
@@ -42,6 +42,60 @@ Workflows (`workflows/*.yaml`) and prompts (`prompts/`) are read from the packag
|
|
|
42
42
|
|
|
43
43
|
Each checkout is a git repo with a commit at `HEAD`. `tick` adds a worktree and leaves uncommitted files in the checkout as they are. The job edits the worktree, on branch `yak/<run-id>`. With the default `deliver: "local"`, Kiln does not push. `deliver: "pr"` pushes that branch and opens a draft pull request. See [Delivery and dependencies](#delivery-and-dependencies).
|
|
44
44
|
|
|
45
|
+
## Sandbox
|
|
46
|
+
|
|
47
|
+
Sandbox mode is off. Set it in the kiln config when agent steps should run inside yak's Docker sandbox instead of on the machine. A workflow block overrides the global one field by field. `mode` omitted means off at that layer. `allowDomains`, when set, replaces the list.
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"sandbox": { "mode": "docker" },
|
|
52
|
+
"workflows": {
|
|
53
|
+
"investigation": { "sandbox": { "mode": "off" } }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`image` overrides `yak-agent-sandbox:latest`. Leave it unset to use that tag.
|
|
59
|
+
|
|
60
|
+
With mode `docker`, tick injects `sandbox: docker` and `allowedDomains` under every agent step in the rendered workflow. The default allowlist is exact hostnames, matched by the proxy as `dstdomain` (a subdomain is not implied):
|
|
61
|
+
|
|
62
|
+
- `api.anthropic.com`
|
|
63
|
+
- `registry.npmjs.org`
|
|
64
|
+
- `github.com`
|
|
65
|
+
- `api.github.com`
|
|
66
|
+
- `codeload.github.com`
|
|
67
|
+
- `objects.githubusercontent.com`
|
|
68
|
+
- `raw.githubusercontent.com`
|
|
69
|
+
|
|
70
|
+
That is the Anthropic API, the npm registry, and the GitHub hosts a fetch or install dials. It is not the open internet.
|
|
71
|
+
|
|
72
|
+
| Step | Where it runs |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| feature: spec, build, security, ux, ui, code, approver | Docker sandbox |
|
|
75
|
+
| investigation: explore | Docker sandbox |
|
|
76
|
+
| fix: build | Docker sandbox. A pull-request fix uses the same allowlist. |
|
|
77
|
+
| feature: verify | Host. It runs `KILN_TEST` through Kiln's truncate wrapper. Yak's command sandbox does not forward that environment and uses `--network none`. |
|
|
78
|
+
| approve gates | Host. A person answers them. |
|
|
79
|
+
| feature: land, fix: land | Host. `git commit` uses the host git identity. Publishing the fix branch is the kiln process. |
|
|
80
|
+
| Pull request open, `git push`, `gh pr view`, feedback reads | Host kiln process. They are not workflow steps. |
|
|
81
|
+
|
|
82
|
+
`kiln sandbox` prints the resolved mode, the allowlist, and anything still missing. `kiln sandbox build` builds the images yak ships but does not publish, from the tag that matches the installed `@lchase/yak`:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
docker build -t yak-agent-sandbox:latest https://github.com/lchase/yak.git#yak-v0.7.1:docker/agent-sandbox
|
|
86
|
+
docker build -t yak-agent-sandbox-proxy:latest https://github.com/lchase/yak.git#yak-v0.7.1:docker/agent-proxy
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The agent image is `node:22-slim` plus the Claude CLI at `/app/claude-cli`. It has no git. `kiln sandbox build` adds `git` and `ca-certificates` and tags that result `yak-agent-sandbox:latest` as well. The proxy image is `yak-agent-sandbox-proxy:latest` (squid on port 3128). Yak mounts the worktree at `/workspace` and puts the container on an `--internal` network whose only egress is that proxy.
|
|
90
|
+
|
|
91
|
+
A linked worktree's `.git` file points at the checkout's common git dir, which is outside that mount. `git` inside the container cannot see the repository until yak mounts that dir too. Land stays on the host, where the worktree link works.
|
|
92
|
+
|
|
93
|
+
On macOS, Claude subscription auth lives in the keychain. Yak forwards only `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` into the container, and never a `~/.claude` directory. Before the first sandboxed launch, run `claude setup-token` and export `CLAUDE_CODE_OAUTH_TOKEN` in the environment that `kiln tick` or `kiln serve` inherits. Kiln checks that the variable is set. It does not print or write the value. An API key in `ANTHROPIC_API_KEY` works the same way.
|
|
94
|
+
|
|
95
|
+
`kiln tick` and `kiln serve` name what is missing when sandbox mode is on: the docker binary, the docker daemon, each image, the credential, and a yak build that actually reads `allowedDomains` from the workflow. Installed `@lchase/yak` 0.7.1 drops `sandbox`, `image`, and `allowedDomains` while loading YAML, and opens the proxy with only `api.anthropic.com`. Kiln refuses to launch in that case, because the job would run on the host with the open internet. The job stays failed until `kiln retry`, after that piece is fixed. The yak change that copies those fields and passes `allowedDomains` into `createAgentSandboxNetwork` has to be in the installed package first.
|
|
96
|
+
|
|
97
|
+
A step that fails with yak reason `sandbox-error` goes to Needs human. The card shows the failure detail. Kiln does not schedule an automatic retry for it. The card of a sandboxed job shows `sandbox · docker` in the same monospace metadata as the rest of the board.
|
|
98
|
+
|
|
45
99
|
## Register repos
|
|
46
100
|
|
|
47
101
|
```bash
|
package/dist/attention.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
3
|
+
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
3
4
|
/**
|
|
4
5
|
* Attention order for `kiln serve`. Built from the same derived jobs as
|
|
5
6
|
* `kiln status`. The console renders this and does not classify jobs again.
|
|
@@ -103,9 +104,11 @@ export function attentionReason(job, tail = errorTail(job)) {
|
|
|
103
104
|
return question ? `Gate: ${oneLine(question)}` : "Waiting on a person";
|
|
104
105
|
}
|
|
105
106
|
case "dead":
|
|
106
|
-
return "Run died before it finished";
|
|
107
|
+
return job.sandboxError ? `Sandbox: ${oneLine(job.sandboxError)}` : "Run died before it finished";
|
|
107
108
|
case "failed": {
|
|
108
|
-
|
|
109
|
+
if (job.sandboxError)
|
|
110
|
+
return `Sandbox: ${oneLine(job.sandboxError)}`;
|
|
111
|
+
const why = failureLine(job.launchError) || firstLine(job.deliveryError) || firstLine(job.failureDetail) || firstLine(tail) || "Run failed";
|
|
109
112
|
return `Failed: ${oneLine(why)}`;
|
|
110
113
|
}
|
|
111
114
|
case "in-review":
|
|
@@ -228,6 +231,7 @@ function attentionRevision(needsYou, inFlight, doneRecently, graph) {
|
|
|
228
231
|
item.job.lastFailureReason ?? "",
|
|
229
232
|
item.job.nextRetryAt ?? "",
|
|
230
233
|
feedbackStamp(item.job),
|
|
234
|
+
item.job.sandboxed ? "sandbox" : "",
|
|
231
235
|
]),
|
|
232
236
|
inFlight: inFlight.map((item) => [
|
|
233
237
|
item.job.jobId,
|
|
@@ -238,6 +242,7 @@ function attentionRevision(needsYou, inFlight, doneRecently, graph) {
|
|
|
238
242
|
item.job.lastFailureReason ?? "",
|
|
239
243
|
item.job.nextRetryAt ?? "",
|
|
240
244
|
feedbackStamp(item.job),
|
|
245
|
+
item.job.sandboxed ? "sandbox" : "",
|
|
241
246
|
]),
|
|
242
247
|
doneRecently: doneRecently.map((item) => [item.job.jobId, item.job.state]),
|
|
243
248
|
edges: graph.edges.map((edge) => [edge.from, edge.to, edge.stack]),
|
|
@@ -262,3 +267,14 @@ function firstLine(text) {
|
|
|
262
267
|
const line = text.split("\n").find((candidate) => candidate.trim().length > 0);
|
|
263
268
|
return line?.trim();
|
|
264
269
|
}
|
|
270
|
+
/** The preflight header names the policy. The card should name the missing piece. */
|
|
271
|
+
function failureLine(text) {
|
|
272
|
+
const line = firstLine(text);
|
|
273
|
+
if (!line?.startsWith(SANDBOX_PREFLIGHT_PREFIX))
|
|
274
|
+
return line;
|
|
275
|
+
const bullet = text
|
|
276
|
+
?.split("\n")
|
|
277
|
+
.map((candidate) => candidate.trim())
|
|
278
|
+
.find((candidate) => candidate.startsWith("- "));
|
|
279
|
+
return bullet ? bullet.slice(2) : line;
|
|
280
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
|
|
3
|
+
import { renderSandboxStatus, runSandboxBuild } from "./sandbox.js";
|
|
3
4
|
import { answerJob, retryJob, runStatus, runTick } from "./driver.js";
|
|
4
5
|
import { startServe } from "./serve.js";
|
|
5
6
|
import { addFromFile } from "./intake/file.js";
|
|
@@ -18,6 +19,7 @@ function usage() {
|
|
|
18
19
|
" kiln approve <jobId> [--config <file>]",
|
|
19
20
|
" kiln reject <jobId> [--config <file>]",
|
|
20
21
|
" kiln retry <jobId> [--config <file>]",
|
|
22
|
+
" kiln sandbox [build] [--config <file>]",
|
|
21
23
|
" kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
|
|
22
24
|
"",
|
|
23
25
|
"repo add registers a checkout. A relative path is stored absolute, resolved",
|
|
@@ -69,6 +71,11 @@ function usage() {
|
|
|
69
71
|
"Empty stages collapse. Approve, reject, and retry call the same",
|
|
70
72
|
"functions as the CLI. POST from another origin is rejected. A tick",
|
|
71
73
|
"runs every 30 seconds unless --no-tick is set, using the tick lock.",
|
|
74
|
+
"",
|
|
75
|
+
"sandbox prints the resolved mode, allowlist, and anything missing before a",
|
|
76
|
+
"sandboxed run can start. sandbox build builds the yak agent and proxy images",
|
|
77
|
+
"and installs git into the agent image. Sandbox mode stays off until the kiln",
|
|
78
|
+
"config sets it. A run that fails with yak reason sandbox-error is not retried.",
|
|
72
79
|
].join("\n"));
|
|
73
80
|
process.exit(2);
|
|
74
81
|
}
|
|
@@ -315,6 +322,21 @@ function importCommand(argv) {
|
|
|
315
322
|
if (result.warnings.length > 0)
|
|
316
323
|
process.stderr.write(`${result.warnings.join("\n")}\n`);
|
|
317
324
|
}
|
|
325
|
+
function sandboxCommand(argv) {
|
|
326
|
+
const build = argv[0] === "build";
|
|
327
|
+
const flags = parseFlags(build ? argv.slice(1) : argv);
|
|
328
|
+
rejectServeFlags(flags);
|
|
329
|
+
if (flags.positionals.length !== 0 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
330
|
+
usage();
|
|
331
|
+
}
|
|
332
|
+
if (build) {
|
|
333
|
+
runSandboxBuild();
|
|
334
|
+
process.stdout.write("built yak-agent-sandbox:latest and yak-agent-sandbox-proxy:latest\n");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
338
|
+
process.stdout.write(renderSandboxStatus(config));
|
|
339
|
+
}
|
|
318
340
|
function tickCommand(argv) {
|
|
319
341
|
const flags = parseFlags(argv);
|
|
320
342
|
rejectServeFlags(flags);
|
|
@@ -422,6 +444,10 @@ async function main() {
|
|
|
422
444
|
retryCommand(argv.slice(1));
|
|
423
445
|
return;
|
|
424
446
|
}
|
|
447
|
+
if (command === "sandbox") {
|
|
448
|
+
sandboxCommand(argv.slice(1));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
425
451
|
if (command === "serve") {
|
|
426
452
|
await serveCommand(argv.slice(1));
|
|
427
453
|
return;
|
package/dist/config.js
CHANGED
|
@@ -49,6 +49,8 @@ export function loadKilnConfig(configPath, options = {}) {
|
|
|
49
49
|
maxAttempts: parsed.data.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
50
50
|
maxFixRounds: parsed.data.maxFixRounds ?? DEFAULT_MAX_FIX_ROUNDS,
|
|
51
51
|
adapter: parsed.data.adapter ?? "claude-code",
|
|
52
|
+
sandbox: parsed.data.sandbox ?? {},
|
|
53
|
+
workflowSandbox: workflowSandboxFrom(parsed.data.workflows),
|
|
52
54
|
...(jira ? { jira } : {}),
|
|
53
55
|
};
|
|
54
56
|
}
|
|
@@ -235,6 +237,16 @@ function pathToStore(userPath, cwd, home) {
|
|
|
235
237
|
return userPath;
|
|
236
238
|
return resolve(cwd, userPath);
|
|
237
239
|
}
|
|
240
|
+
function workflowSandboxFrom(workflows) {
|
|
241
|
+
if (!workflows)
|
|
242
|
+
return {};
|
|
243
|
+
const out = {};
|
|
244
|
+
for (const [name, entry] of Object.entries(workflows)) {
|
|
245
|
+
if (entry.sandbox)
|
|
246
|
+
out[name] = entry.sandbox;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
238
250
|
function jiraFromFile(jira) {
|
|
239
251
|
if (!jira)
|
|
240
252
|
return undefined;
|
package/dist/driver.js
CHANGED
|
@@ -11,7 +11,8 @@ import { readJobSpec, resolveJobSpec } from "./jobs.js";
|
|
|
11
11
|
import { jobBasename, packageRoot } from "./paths.js";
|
|
12
12
|
import { decide, isLanded, jobState, } from "./reconcile.js";
|
|
13
13
|
import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
|
|
14
|
-
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
|
|
14
|
+
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
|
|
15
|
+
import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
|
|
15
16
|
import { JobSchema } from "./schemas.js";
|
|
16
17
|
import { leafStepIds, materializeWorkflow, workflowInputs, workflowNeedsTestCommand, workflowStepPlan } from "./workflow.js";
|
|
17
18
|
const TAG_PREFIX = "kiln:";
|
|
@@ -39,13 +40,18 @@ export function runTick(config, host = defaultHost(), options = {}) {
|
|
|
39
40
|
const now = options.now ?? new Date();
|
|
40
41
|
return withTickLock(config.kilnDir, () => {
|
|
41
42
|
const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
|
|
43
|
+
const facts = sandboxIsEnabled(config)
|
|
44
|
+
? (options.sandboxFacts ?? probeSandboxEnvironment(process.env, requiredSandboxImages(config)))
|
|
45
|
+
: undefined;
|
|
42
46
|
const midway = observe(config, host);
|
|
43
47
|
bindOpenFixRuns(config, midway);
|
|
44
48
|
const published = publishReadyFixes(config, host, midway);
|
|
45
49
|
const world = observe(config, host);
|
|
46
50
|
const feedbackNotes = watchPullRequests(config, world, host);
|
|
47
51
|
const actions = decide(world.observation, now);
|
|
48
|
-
|
|
52
|
+
const body = apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes], facts);
|
|
53
|
+
const problem = facts ? sandboxPreflight(config, facts) : undefined;
|
|
54
|
+
return problem ? `${problem}\n${body}` : body;
|
|
49
55
|
});
|
|
50
56
|
}
|
|
51
57
|
/** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
|
|
@@ -247,6 +253,7 @@ function observe(config, host) {
|
|
|
247
253
|
...(exhausted ? { retriesExhausted: true } : {}),
|
|
248
254
|
...(picked.retired ? { runRetired: true } : {}),
|
|
249
255
|
...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
|
|
256
|
+
...(entry.job ? { sandboxed: jobIsSandboxed(config, entry.job.id, entry.job.workflow) } : {}),
|
|
250
257
|
...(feedback?.rounds.length ? { feedbackRounds: feedback.rounds } : {}),
|
|
251
258
|
...(feedback?.exhausted ? { feedbackExhausted: true } : {}),
|
|
252
259
|
...(feedback?.ambiguous ? { feedbackAmbiguous: true } : {}),
|
|
@@ -268,7 +275,7 @@ function observe(config, host) {
|
|
|
268
275
|
jobs: records,
|
|
269
276
|
};
|
|
270
277
|
}
|
|
271
|
-
function apply(config, world, actions, host, notes = [], now = new Date(), feedbackNotes = []) {
|
|
278
|
+
function apply(config, world, actions, host, notes = [], now = new Date(), feedbackNotes = [], sandboxFacts) {
|
|
272
279
|
const launches = [];
|
|
273
280
|
const delivers = [];
|
|
274
281
|
const findings = [];
|
|
@@ -297,7 +304,12 @@ function apply(config, world, actions, host, notes = [], now = new Date(), feedb
|
|
|
297
304
|
if (!job) {
|
|
298
305
|
throw new Error(`Tick planned a fix for ${action.jobId}, which is not in the observed jobs.`);
|
|
299
306
|
}
|
|
300
|
-
const
|
|
307
|
+
const blocked = sandboxBlock(config, "fix", sandboxFacts);
|
|
308
|
+
if (blocked) {
|
|
309
|
+
feedbackNotes.push(`fix ${action.jobId} not started\n${blocked}`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const report = startFix(config, world, job, host, now, sandboxFacts);
|
|
301
313
|
launches.push({ ...report, kind: "fix" });
|
|
302
314
|
continue;
|
|
303
315
|
}
|
|
@@ -305,7 +317,7 @@ function apply(config, world, actions, host, notes = [], now = new Date(), feedb
|
|
|
305
317
|
if (!job) {
|
|
306
318
|
throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
|
|
307
319
|
}
|
|
308
|
-
const report = launchJob(config, world, job, host);
|
|
320
|
+
const report = launchJob(config, world, job, host, sandboxFacts);
|
|
309
321
|
launches.push(action.type === "retry" ? { ...report, retry: true } : report);
|
|
310
322
|
}
|
|
311
323
|
return renderTick(world.observation, actions, launches, delivers, findings, notes, feedbackNotes);
|
|
@@ -620,7 +632,7 @@ function markFeedback(kilnDir, jobId, reason) {
|
|
|
620
632
|
* The round is written only after that commit resolves, so a missing sha
|
|
621
633
|
* retries on the next tick without consuming a round.
|
|
622
634
|
*/
|
|
623
|
-
function startFix(config, world, job, host, now) {
|
|
635
|
+
function startFix(config, world, job, host, now, sandboxFacts) {
|
|
624
636
|
const observed = world.observation.jobs.find((candidate) => candidate.jobId === job.id);
|
|
625
637
|
const plan = observed?.feedbackPlan;
|
|
626
638
|
const repo = config.repos[job.repo];
|
|
@@ -661,11 +673,11 @@ function startFix(config, world, job, host, now) {
|
|
|
661
673
|
writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
|
|
662
674
|
return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
|
|
663
675
|
}
|
|
664
|
-
const report = launchFixRun(config, job, host, repo.path, headSha, specText);
|
|
676
|
+
const report = launchFixRun(config, job, host, repo.path, headSha, specText, sandboxFacts);
|
|
665
677
|
return { ...report, output: `round ${round}\n${report.output}` };
|
|
666
678
|
}
|
|
667
679
|
/** Relaunch the open fix round. Does not append a round. Failures become a dead launch so retry can see them. */
|
|
668
|
-
function launchOpenFix(config, world, job, host, checkout, open) {
|
|
680
|
+
function launchOpenFix(config, world, job, host, checkout, open, sandboxFacts) {
|
|
669
681
|
let specText;
|
|
670
682
|
let headSha;
|
|
671
683
|
try {
|
|
@@ -682,7 +694,7 @@ function launchOpenFix(config, world, job, host, checkout, open) {
|
|
|
682
694
|
writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
|
|
683
695
|
return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
|
|
684
696
|
}
|
|
685
|
-
return launchFixRun(config, job, host, checkout, headSha, specText);
|
|
697
|
+
return launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts);
|
|
686
698
|
}
|
|
687
699
|
function resolveCommit(host, checkout, sha) {
|
|
688
700
|
try {
|
|
@@ -694,12 +706,15 @@ function resolveCommit(host, checkout, sha) {
|
|
|
694
706
|
}
|
|
695
707
|
}
|
|
696
708
|
/** `workflows/fix.yaml` on `headSha`. Commits on `yak/<run-id>`. Does not push. */
|
|
697
|
-
function launchFixRun(config, job, host, checkout, headSha, specText) {
|
|
709
|
+
function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts) {
|
|
698
710
|
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", "fix.yaml"), "utf8");
|
|
699
711
|
const workflow = materializeWorkflow(config.kilnDir, job.id, "fix");
|
|
700
712
|
const repo = config.repos[job.repo];
|
|
701
713
|
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
702
714
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
715
|
+
const blocked = commitAgentSandbox(config, job.id, "fix", workflow, logPath, sandboxFacts);
|
|
716
|
+
if (blocked)
|
|
717
|
+
return { jobId: job.id, repo: job.repo, kind: "fix", output: blocked };
|
|
703
718
|
ensureCheckoutExcludes(checkout);
|
|
704
719
|
const input = workflowInputs(sourceWorkflow, {
|
|
705
720
|
title: job.title,
|
|
@@ -724,6 +739,34 @@ function launchFixRun(config, job, host, checkout, headSha, specText) {
|
|
|
724
739
|
logPath,
|
|
725
740
|
});
|
|
726
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* Names why a sandboxed workflow cannot launch. Does not write a round or a
|
|
744
|
+
* breadcrumb. `undefined` when this workflow stays on the host or the checks pass.
|
|
745
|
+
*/
|
|
746
|
+
function sandboxBlock(config, workflowName, sandboxFacts) {
|
|
747
|
+
const sandbox = resolveSandbox(config.sandbox, config.workflowSandbox[workflowName]);
|
|
748
|
+
if (sandbox.mode !== "docker")
|
|
749
|
+
return undefined;
|
|
750
|
+
const checked = sandboxFacts ??
|
|
751
|
+
probeSandboxEnvironment(process.env, [PROXY_SANDBOX_IMAGE, sandbox.image ?? AGENT_SANDBOX_IMAGE]);
|
|
752
|
+
return sandboxPreflightFor(sandbox, config.adapter, checked);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Inject `sandbox: docker` into a materialized workflow and refuse the launch
|
|
756
|
+
* when preflight fails. The refusal is a launch error with no yak process.
|
|
757
|
+
*/
|
|
758
|
+
function commitAgentSandbox(config, jobId, workflowName, workflowPath, logPath, sandboxFacts) {
|
|
759
|
+
const sandbox = resolveSandbox(config.sandbox, config.workflowSandbox[workflowName]);
|
|
760
|
+
if (sandbox.mode !== "docker")
|
|
761
|
+
return undefined;
|
|
762
|
+
writeFileSync(workflowPath, injectAgentSandbox(readFileSync(workflowPath, "utf8"), sandbox));
|
|
763
|
+
const problem = sandboxBlock(config, workflowName, sandboxFacts);
|
|
764
|
+
if (!problem)
|
|
765
|
+
return undefined;
|
|
766
|
+
writeLaunchError(config.kilnDir, jobId, problem);
|
|
767
|
+
writeFileSync(logPath, `${problem}\n`);
|
|
768
|
+
return problem;
|
|
769
|
+
}
|
|
727
770
|
function agentArgs(config, job, workflow, headSha, input) {
|
|
728
771
|
const args = [
|
|
729
772
|
yakCliPath(),
|
|
@@ -783,7 +826,7 @@ function spawnAgent(host, spec) {
|
|
|
783
826
|
child.unref();
|
|
784
827
|
return { pid: child.pid ?? 0 };
|
|
785
828
|
}
|
|
786
|
-
function launchJob(config, world, job, host) {
|
|
829
|
+
function launchJob(config, world, job, host, sandboxFacts) {
|
|
787
830
|
const repo = config.repos[job.repo];
|
|
788
831
|
if (!repo) {
|
|
789
832
|
throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
|
|
@@ -791,7 +834,7 @@ function launchJob(config, world, job, host) {
|
|
|
791
834
|
const feedback = readFeedbackLedger(config.kilnDir, job.id);
|
|
792
835
|
const open = feedback && feedback !== "corrupt" ? openFeedbackRound(feedback) : undefined;
|
|
793
836
|
if (open)
|
|
794
|
-
return launchOpenFix(config, world, job, host, repo.path, open);
|
|
837
|
+
return launchOpenFix(config, world, job, host, repo.path, open, sandboxFacts);
|
|
795
838
|
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
|
|
796
839
|
const testCommand = repo.test;
|
|
797
840
|
if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
|
|
@@ -805,6 +848,9 @@ function launchJob(config, world, job, host) {
|
|
|
805
848
|
let specText;
|
|
806
849
|
try {
|
|
807
850
|
clearLaunchError(config.kilnDir, job.id);
|
|
851
|
+
const blocked = commitAgentSandbox(config, job.id, job.workflow, workflow, logPath, sandboxFacts);
|
|
852
|
+
if (blocked)
|
|
853
|
+
return { jobId: job.id, repo: job.repo, output: blocked };
|
|
808
854
|
assertStackTarget(world, job);
|
|
809
855
|
baseSha = resolveLaunchBase({
|
|
810
856
|
checkout: repo.path,
|
|
@@ -887,6 +933,7 @@ function scanRuns(config) {
|
|
|
887
933
|
const finished = [...events].reverse().find((event) => event.t === "run.finished");
|
|
888
934
|
const stepPlan = readRunStepPlan(runDir);
|
|
889
935
|
const stepEvents = stepEventsFrom(events);
|
|
936
|
+
const sandboxError = sandboxFailureDetail(stepEvents);
|
|
890
937
|
const pid = livePid(events);
|
|
891
938
|
const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
|
|
892
939
|
const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
|
|
@@ -904,6 +951,7 @@ function scanRuns(config) {
|
|
|
904
951
|
...(pid !== undefined ? { pid } : {}),
|
|
905
952
|
...(activityMs !== undefined ? { activityMs } : {}),
|
|
906
953
|
...(finishedReason ? { finishedReason } : {}),
|
|
954
|
+
...(sandboxError ? { sandboxError } : {}),
|
|
907
955
|
};
|
|
908
956
|
const list = byTag.get(started.tag) ?? [];
|
|
909
957
|
list.push(run);
|
|
@@ -919,11 +967,12 @@ function stepEventsFrom(events) {
|
|
|
919
967
|
continue;
|
|
920
968
|
if (!event.stepId)
|
|
921
969
|
continue;
|
|
922
|
-
const
|
|
970
|
+
const failure = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
|
|
923
971
|
found.push({
|
|
924
972
|
t: event.t,
|
|
925
973
|
stepId: event.stepId,
|
|
926
|
-
...(detail ? { detail } : {}),
|
|
974
|
+
...(failure?.detail ? { detail: failure.detail } : {}),
|
|
975
|
+
...(failure?.reason ? { failureReason: failure.reason } : {}),
|
|
927
976
|
});
|
|
928
977
|
}
|
|
929
978
|
return found;
|
|
@@ -934,7 +983,12 @@ function formatFailure(failure) {
|
|
|
934
983
|
const detail = typeof failure.detail === "string" ? failure.detail.trim() : "";
|
|
935
984
|
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
|
|
936
985
|
const text = detail && reason ? `${detail} (${reason})` : detail || reason;
|
|
937
|
-
|
|
986
|
+
if (!text && !reason)
|
|
987
|
+
return undefined;
|
|
988
|
+
return {
|
|
989
|
+
...(text ? { detail: text } : {}),
|
|
990
|
+
...(reason ? { reason } : {}),
|
|
991
|
+
};
|
|
938
992
|
}
|
|
939
993
|
function readRunStepPlan(runDir) {
|
|
940
994
|
const file = join(runDir, "workflow.json");
|
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
|
@@ -77,6 +77,27 @@ export function latestStepFailure(events) {
|
|
|
77
77
|
}
|
|
78
78
|
return undefined;
|
|
79
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
|
+
}
|
|
80
101
|
/**
|
|
81
102
|
* Derived state for every job, or for one id.
|
|
82
103
|
* Throws `No job <id>` when that id is not on disk, same as `kiln status`.
|
|
@@ -124,6 +145,11 @@ export function statusDetails(job) {
|
|
|
124
145
|
lines.push(`log ${job.logPath}`);
|
|
125
146
|
if (job.state === "failed" && job.launchError)
|
|
126
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");
|
|
127
153
|
if (job.ledgerError)
|
|
128
154
|
lines.push(job.ledgerError);
|
|
129
155
|
if (job.feedbackLedgerError)
|
|
@@ -257,6 +283,8 @@ function toStatusJob(job, observation) {
|
|
|
257
283
|
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
258
284
|
...(job.diffSummary ? { diffSummary: job.diffSummary } : {}),
|
|
259
285
|
...(failureDetail ? { failureDetail } : {}),
|
|
286
|
+
...(job.run?.sandboxError ? { sandboxError: job.run.sandboxError } : {}),
|
|
287
|
+
...(job.sandboxed ? { sandboxed: true } : {}),
|
|
260
288
|
...boardFields(job),
|
|
261
289
|
};
|
|
262
290
|
}
|
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,6 +26,22 @@ 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. */
|
|
@@ -48,6 +64,9 @@ export const KilnConfigFileSchema = z.object({
|
|
|
48
64
|
*/
|
|
49
65
|
maxFixRounds: z.number().int().min(1).optional(),
|
|
50
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(),
|
|
51
70
|
repos: z.record(z.string(), RepoEntrySchema),
|
|
52
71
|
jira: JiraConfigSchema.optional(),
|
|
53
72
|
});
|
package/dist/serve-page.js
CHANGED
|
@@ -127,6 +127,7 @@ function card(item, stage) {
|
|
|
127
127
|
<div class="branch repo">${escapeHtml(job.repo)}</div>
|
|
128
128
|
<p class="title">${escapeHtml(job.title || job.jobId)}</p>
|
|
129
129
|
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
130
|
+
${sandboxHtml(job)}
|
|
130
131
|
${prLink(job)}
|
|
131
132
|
${roundsHtml(job, stage)}
|
|
132
133
|
${attemptHtml(job, stage)}
|
|
@@ -146,6 +147,7 @@ function sheet(item) {
|
|
|
146
147
|
<div class="num">${escapeHtml(num)}</div>
|
|
147
148
|
<h3>${escapeHtml(job.title || job.jobId)}</h3>
|
|
148
149
|
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
150
|
+
${sandboxHtml(job)}
|
|
149
151
|
${prLink(job)}
|
|
150
152
|
${actionsHtml(item)}
|
|
151
153
|
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
@@ -198,6 +200,11 @@ function actionsHtml(item) {
|
|
|
198
200
|
return "";
|
|
199
201
|
return `<div class="d-actions">${forms.join("")}</div>`;
|
|
200
202
|
}
|
|
203
|
+
function sandboxHtml(job) {
|
|
204
|
+
if (!job.sandboxed)
|
|
205
|
+
return "";
|
|
206
|
+
return `<div class="meta">sandbox · docker</div>`;
|
|
207
|
+
}
|
|
201
208
|
function roundsHtml(job, stage) {
|
|
202
209
|
const rounds = job.rounds ?? [];
|
|
203
210
|
const last = rounds[rounds.length - 1]?.n;
|
package/dist/serve.js
CHANGED
|
@@ -5,6 +5,7 @@ import { answerJob, readStatus, retryJob, runTick, startupRecovery } from "./dri
|
|
|
5
5
|
import { defaultHost } from "./host.js";
|
|
6
6
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
7
7
|
import { packageRoot } from "./paths.js";
|
|
8
|
+
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
8
9
|
import { renderConsole, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
9
10
|
const BODY_LIMIT = 65_536;
|
|
10
11
|
const TEXT_LIMIT = 100_000;
|
|
@@ -71,7 +72,12 @@ export function loadServeCss() {
|
|
|
71
72
|
}
|
|
72
73
|
function safeTick(config, secrets, startup) {
|
|
73
74
|
try {
|
|
74
|
-
runTick(config, defaultHost(), { startup });
|
|
75
|
+
const report = runTick(config, defaultHost(), { startup });
|
|
76
|
+
if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX)) {
|
|
77
|
+
const tickAt = report.indexOf("\ntick ");
|
|
78
|
+
const head = tickAt === -1 ? report : report.slice(0, tickAt);
|
|
79
|
+
process.stderr.write(`${redact(head.trimEnd(), secrets)}\n`);
|
|
80
|
+
}
|
|
75
81
|
}
|
|
76
82
|
catch (error) {
|
|
77
83
|
const message = error instanceof Error ? error.message : String(error);
|
package/package.json
CHANGED
package/workflows/feature.yaml
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# One Kiln job, run by yak (@lchase/yak).
|
|
2
2
|
#
|
|
3
|
+
# Sandbox (kiln config `sandbox.mode: docker`, off by default): tick injects
|
|
4
|
+
# `sandbox: docker` under each agent step (spec, build, security, ux, ui,
|
|
5
|
+
# code, approver). verify, the approve gate, and land stay on the host.
|
|
6
|
+
# Pull request open and `gh pr view` run in the kiln process.
|
|
7
|
+
#
|
|
3
8
|
# Faithful to Lawrence Chase's factory plugin v0.7.0 (MIT):
|
|
4
9
|
# https://github.com/lchase/coding-agent-plugins/tree/4c84a2ce7fbde5ac298c0de9f0b90141eac4ed98/plugins/factory
|
|
5
10
|
# skills/factory/SKILL.md (the loop: spec, build, 4 reviews, approver)
|
package/workflows/fix.yaml
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# One pull-request fix, run by yak (@lchase/yak).
|
|
2
2
|
#
|
|
3
|
+
# Sandbox (kiln config `sandbox.mode: docker`, off by default): tick injects
|
|
4
|
+
# `sandbox: docker` under the build agent. land stays on the host, and Kiln
|
|
5
|
+
# fast-forwards the pull request branch from the host.
|
|
6
|
+
#
|
|
3
7
|
# The same builder as workflows/feature.yaml (factory builder, opus). Kiln
|
|
4
8
|
# launches it with `yak run --isolation worktree --from <pull request head>`
|
|
5
9
|
# on a job whose pull request is already open. Yak cuts a worktree branch
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# One Kiln investigation, run by yak (@lchase/yak).
|
|
2
2
|
#
|
|
3
|
+
# Sandbox (kiln config `sandbox.mode: docker`, off by default): tick injects
|
|
4
|
+
# `sandbox: docker` under the explore agent. The approve gate stays on the host.
|
|
5
|
+
#
|
|
3
6
|
# A spike, probe, or research ticket. The agent explores the worktree and
|
|
4
7
|
# returns a findings document. A person approves or rejects it. There is no
|
|
5
8
|
# build, review, verify, or land step, and Kiln does not open a pull request
|