@lchase/kiln 0.6.0 → 0.7.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/LICENSE +21 -0
- package/README.md +58 -21
- package/assets/serve.css +93 -0
- package/assets/serve.js +844 -0
- package/dist/attention.js +5 -0
- package/dist/auth.js +48 -0
- package/dist/board.js +10 -5
- package/dist/cli.js +31 -7
- package/dist/config.js +33 -10
- package/dist/delivery.js +53 -15
- package/dist/driver.js +29 -5
- package/dist/errors.js +9 -0
- package/dist/fs-browse.js +113 -0
- package/dist/host.js +7 -1
- package/dist/intake/file.js +1 -0
- package/dist/intake/jira.js +19 -4
- package/dist/intake/prompt.js +1 -0
- package/dist/intake/tickets.js +41 -1
- package/dist/intake-http.js +201 -0
- package/dist/jobs.js +5 -1
- package/dist/pr-names.js +116 -0
- package/dist/reconcile.js +5 -2
- package/dist/reliability.js +83 -22
- package/dist/report.js +7 -2
- package/dist/sandbox.js +18 -10
- package/dist/schemas.js +76 -16
- package/dist/serve-page.js +157 -13
- package/dist/serve.js +254 -16
- package/dist/settings.js +204 -0
- package/dist/workflow.js +17 -1
- package/package.json +3 -2
- package/workflows/feature.yaml +7 -4
package/dist/serve-page.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readBoardFiles, stageOf, stagesFor } from "./board.js";
|
|
|
5
5
|
import { feedbackRoundLabel } from "./feedback.js";
|
|
6
6
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
7
7
|
import { packageRoot } from "./paths.js";
|
|
8
|
+
import { listWorkflowNames } from "./workflow.js";
|
|
8
9
|
const REVIEWERS = [
|
|
9
10
|
["security", "sec"],
|
|
10
11
|
["ux", "ux"],
|
|
@@ -22,7 +23,8 @@ function kilnVersion() {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
const VERSION = kilnVersion();
|
|
25
|
-
export function renderConsole(css, attention) {
|
|
26
|
+
export function renderConsole(css, attention, page) {
|
|
27
|
+
const intake = page ?? { csrf: "", repos: [], workflows: listWorkflowNames() };
|
|
26
28
|
const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
|
|
27
29
|
const stages = stagesFor(items.map((item) => item.job));
|
|
28
30
|
const grouped = new Map();
|
|
@@ -46,27 +48,34 @@ export function renderConsole(css, attention) {
|
|
|
46
48
|
<div class="brand"><span class="logo" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V6l5-3 5 3v6l-5 3-5-3z"/><path d="M3 6l5 3 5-3M8 9v6"/></svg></span><h1>Kiln</h1>${version}</div>
|
|
47
49
|
<div class="status" id="status">${items.length === 1 ? "1 job" : `${items.length} jobs`}</div>
|
|
48
50
|
</header>
|
|
51
|
+
<p class="meta config-error" id="config-error" role="status"${intake.configError ? "" : " hidden"}>${escapeHtml(intake.configError ?? "")}</p>
|
|
49
52
|
<nav class="repos" aria-label="Filter by repo">${chips}</nav>
|
|
50
53
|
<main class="board" id="board" style="--cols: ${tracks}">
|
|
51
54
|
${columns}
|
|
52
55
|
</main>
|
|
53
56
|
<nav class="toolbar" aria-label="Kiln commands">
|
|
57
|
+
<button class="tool" type="button" id="add-job" data-tip="Add job" aria-label="Add job"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
54
58
|
<button class="tool" type="button" data-act="approve" data-tip="Approve" aria-label="Approve"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button>
|
|
55
59
|
<button class="tool" type="button" data-act="retry" data-tip="Retry" aria-label="Retry"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/></svg></button>
|
|
56
60
|
<button class="tool" type="button" data-act="pr" data-tip="Open pull request" aria-label="Open pull request"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M6 8.5v7M18 15.5V10a3 3 0 0 0-3-3h-3"/></svg></button>
|
|
57
61
|
<button class="tool" type="button" id="notify" data-tip="Notify me" aria-label="Notify me"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 7H3s3 0 3-7"/><path d="M10 19a2 2 0 0 0 4 0"/></svg></button>
|
|
58
62
|
<span class="sep" aria-hidden="true"></span>
|
|
59
63
|
<button class="tool" type="button" id="compact" aria-pressed="true" data-tip="expand empty columns" aria-label="expand empty columns"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h7m0 0-3-3m3 3-3 3M21 12h-7m0 0 3-3m-3 3 3 3M12 4v16"/></svg></button>
|
|
64
|
+
<span class="sep" aria-hidden="true"></span>
|
|
65
|
+
<button class="tool" type="button" id="open-settings" data-tip="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6"/></svg></button>
|
|
60
66
|
</nav>
|
|
61
67
|
<div class="scrim" data-close></div>
|
|
62
68
|
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Job">
|
|
63
|
-
${
|
|
69
|
+
${intakeSheet(intake, items)}
|
|
70
|
+
${settingsSheet()}
|
|
71
|
+
${items.map((item) => sheet(item, intake.csrf)).join("\n")}
|
|
64
72
|
</aside>`;
|
|
65
73
|
return document({
|
|
66
74
|
css,
|
|
67
75
|
title: `(${attention.needsYouCount}) Kiln`,
|
|
68
76
|
needsYou: attention.needsYouCount,
|
|
69
77
|
revision: attention.revision,
|
|
78
|
+
csrf: intake.csrf,
|
|
70
79
|
live: true,
|
|
71
80
|
body,
|
|
72
81
|
});
|
|
@@ -114,7 +123,7 @@ function card(item, stage) {
|
|
|
114
123
|
classes.push("retrying");
|
|
115
124
|
else if (stage === "needs-human")
|
|
116
125
|
classes.push("human");
|
|
117
|
-
if (stage === "merged")
|
|
126
|
+
if (stage === "done" || stage === "merged")
|
|
118
127
|
classes.push("merged");
|
|
119
128
|
if (stage === "queued")
|
|
120
129
|
classes.push("queued");
|
|
@@ -124,7 +133,7 @@ function card(item, stage) {
|
|
|
124
133
|
const branch = branchOf(job);
|
|
125
134
|
return `<div role="button" tabindex="0" class="${classes.join(" ")}" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-deps="${escapeHtml(deps)}" data-repo="${escapeHtml(job.repo)}" data-stage="${escapeHtml(stage)}">
|
|
126
135
|
<div class="card-top">${pulse}<span class="num">${escapeHtml(job.jobId)}</span>${flag}</div>
|
|
127
|
-
<div class="branch repo">${escapeHtml(job
|
|
136
|
+
<div class="branch repo">${escapeHtml(repoLabel(job))}</div>
|
|
128
137
|
<p class="title">${escapeHtml(job.title || job.jobId)}</p>
|
|
129
138
|
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
130
139
|
${sandboxHtml(job)}
|
|
@@ -135,7 +144,130 @@ ${waiting ? "" : noteHtml(item)}
|
|
|
135
144
|
${dependsHtml(job)}
|
|
136
145
|
</div>`;
|
|
137
146
|
}
|
|
138
|
-
function
|
|
147
|
+
function intakeSheet(intake, items) {
|
|
148
|
+
return `<section class="sheet intake" id="intake" aria-label="Add job" hidden>
|
|
149
|
+
<div class="d-head">
|
|
150
|
+
<div class="num">kiln add</div>
|
|
151
|
+
<h3>Add job</h3>
|
|
152
|
+
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
153
|
+
</div>
|
|
154
|
+
<form id="add-form" class="intake-form">
|
|
155
|
+
<div class="field"><span class="k">Repo</span><div class="picks" id="repo-pick">${repoButtons(intake.repos)}</div></div>
|
|
156
|
+
<label class="field"><span class="k">Workflow</span><select id="workflow" name="workflow">${workflowOptions(intake.workflows)}</select></label>
|
|
157
|
+
<div class="seg" role="tablist" aria-label="Source">
|
|
158
|
+
<button type="button" data-source="prompt" aria-pressed="true">Prompt</button>
|
|
159
|
+
<button type="button" data-source="spec" aria-pressed="false">Spec file</button>
|
|
160
|
+
<button type="button" data-source="tickets" aria-pressed="false">Tickets folder</button>
|
|
161
|
+
<button type="button" data-source="jira" aria-pressed="false">Jira</button>
|
|
162
|
+
</div>
|
|
163
|
+
<div data-pane="prompt"><label class="field"><span class="k">Prompt</span><textarea id="prompt-text" rows="4" placeholder="What should this job do?"></textarea></label></div>
|
|
164
|
+
<div id="browser-field" hidden>
|
|
165
|
+
<div class="k" id="browser-label">Spec file</div>
|
|
166
|
+
<div class="fs" id="browser"></div>
|
|
167
|
+
<p class="meta" id="picked"></p>
|
|
168
|
+
</div>
|
|
169
|
+
<div data-pane="jira" hidden><label class="field"><span class="k">Jira key</span><input id="jira-key" type="text" placeholder="PROJ-123" autocomplete="off" spellcheck="false"></label></div>
|
|
170
|
+
<label class="field" id="issue-field"><span class="k">Issue</span><input id="job-issue" type="text" placeholder="ABC-123" autocomplete="off" spellcheck="false"></label>
|
|
171
|
+
<label class="field" id="title-field"><span class="k">Title</span><input id="job-title" type="text" autocomplete="off"></label>
|
|
172
|
+
<fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items)}</div></fieldset>
|
|
173
|
+
<pre class="text preview" id="intake-preview" hidden></pre>
|
|
174
|
+
<p class="form-error" id="intake-error" role="alert" hidden></p>
|
|
175
|
+
<div class="d-actions"><button type="button" id="preview-job" hidden>Preview</button><button type="submit">Add</button></div>
|
|
176
|
+
</form>
|
|
177
|
+
</section>`;
|
|
178
|
+
}
|
|
179
|
+
function settingsSheet() {
|
|
180
|
+
return `<section class="sheet intake" id="settings" aria-label="Settings" hidden>
|
|
181
|
+
<div class="d-head">
|
|
182
|
+
<div class="num">kiln.config.json</div>
|
|
183
|
+
<h3>Settings</h3>
|
|
184
|
+
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
185
|
+
</div>
|
|
186
|
+
<form id="settings-form" class="intake-form">
|
|
187
|
+
<p class="meta" id="settings-path"></p>
|
|
188
|
+
<p class="saved" id="settings-saved" role="status" hidden>Saved</p>
|
|
189
|
+
<p class="form-error" id="settings-error" role="alert" hidden></p>
|
|
190
|
+
<div class="seg" role="tablist" aria-label="Settings sections">
|
|
191
|
+
<button type="button" data-settings-tab="repos" aria-pressed="true">Repos</button>
|
|
192
|
+
<button type="button" data-settings-tab="global" aria-pressed="false">Global</button>
|
|
193
|
+
</div>
|
|
194
|
+
<div id="settings-repos">
|
|
195
|
+
<div id="repo-editor" hidden>
|
|
196
|
+
<label class="field"><span class="k">Name</span><input id="repo-name" type="text" autocomplete="off" spellcheck="false"></label>
|
|
197
|
+
<p class="field-error" data-error="name" hidden></p>
|
|
198
|
+
<div class="field"><span class="k">Path</span><div class="path-row"><input id="repo-path" type="text" autocomplete="off" spellcheck="false" placeholder="~/code/web"><button type="button" class="pick" id="repo-browse">Browse</button></div></div>
|
|
199
|
+
<div class="fs" id="repo-browser" hidden></div>
|
|
200
|
+
<p class="field-error" data-error="path" hidden></p>
|
|
201
|
+
<label class="field"><span class="k">Test command</span><input id="repo-test" type="text" autocomplete="off" spellcheck="false" placeholder="npm test"></label>
|
|
202
|
+
<p class="field-error" data-error="test" hidden></p>
|
|
203
|
+
<div class="field"><span class="k">Deliver</span><div class="seg" id="repo-deliver" role="group" aria-label="Deliver mode">
|
|
204
|
+
<button type="button" data-deliver="local" aria-pressed="true">local</button>
|
|
205
|
+
<button type="button" data-deliver="pr" aria-pressed="false">pr</button>
|
|
206
|
+
</div></div>
|
|
207
|
+
<p class="field-error" data-error="deliver" hidden></p>
|
|
208
|
+
<label class="field"><span class="k">Base branch</span><input id="repo-base" type="text" autocomplete="off" spellcheck="false" placeholder="main"></label>
|
|
209
|
+
<p class="field-error" data-error="base" hidden></p>
|
|
210
|
+
<label class="field"><span class="k">PR title template</span><input id="repo-pr-title" type="text" autocomplete="off" spellcheck="false"></label>
|
|
211
|
+
<p class="hint" id="hint-pr-title">Default [{issue}] {title}, which renders as [ABC-123] Title.</p>
|
|
212
|
+
<p class="field-error" data-error="prTitle" hidden></p>
|
|
213
|
+
<label class="field"><span class="k">Branch template</span><input id="repo-branch" type="text" autocomplete="off" spellcheck="false"></label>
|
|
214
|
+
<p class="hint" id="hint-branch">Default {issue}-{slug}, which renders as ABC-123-slug.</p>
|
|
215
|
+
<p class="field-error" data-error="branch" hidden></p>
|
|
216
|
+
<div class="d-actions"><button type="button" id="repo-done">Done</button></div>
|
|
217
|
+
</div>
|
|
218
|
+
<div id="repo-list"></div>
|
|
219
|
+
<div class="d-actions"><button type="button" id="repo-add">Add repo</button></div>
|
|
220
|
+
</div>
|
|
221
|
+
<div id="settings-global" hidden>
|
|
222
|
+
<div class="field"><span class="k">Sandbox</span><div class="seg" id="sandbox-mode" role="group" aria-label="Sandbox mode">
|
|
223
|
+
<button type="button" data-sandbox="off" aria-pressed="true">Off</button>
|
|
224
|
+
<button type="button" data-sandbox="docker" aria-pressed="false">Docker</button>
|
|
225
|
+
</div>
|
|
226
|
+
<p class="hint">Off leaves the agent unsandboxed. Docker runs agent steps in the sandbox.</p>
|
|
227
|
+
<p class="field-error" data-error="sandbox.mode" hidden></p></div>
|
|
228
|
+
<label class="field"><span class="k">Allow domains</span><textarea id="sandbox-domains" rows="3" placeholder="example.com" spellcheck="false"></textarea></label>
|
|
229
|
+
<p class="hint">One hostname per line. When set, this replaces the built-in host list.</p>
|
|
230
|
+
<p class="field-error" data-error="sandbox.allowDomains" hidden></p>
|
|
231
|
+
<label class="field"><span class="k">Max attempts</span><input id="max-attempts" type="number" min="1" step="1"></label>
|
|
232
|
+
<p class="hint" id="hint-attempts"></p>
|
|
233
|
+
<p class="field-error" data-error="maxAttempts" hidden></p>
|
|
234
|
+
<label class="field"><span class="k">Stall timeout (ms)</span><input id="stall-timeout" type="number" step="1"></label>
|
|
235
|
+
<p class="hint" id="hint-stall"></p>
|
|
236
|
+
<p class="field-error" data-error="stallTimeoutMs" hidden></p>
|
|
237
|
+
<label class="field"><span class="k">Max fix rounds</span><input id="max-fix-rounds" type="number" min="1" step="1"></label>
|
|
238
|
+
<p class="hint" id="hint-fix"></p>
|
|
239
|
+
<p class="field-error" data-error="maxFixRounds" hidden></p>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="d-actions"><button type="submit">Save</button></div>
|
|
242
|
+
</form>
|
|
243
|
+
</section>`;
|
|
244
|
+
}
|
|
245
|
+
function repoButtons(repos) {
|
|
246
|
+
if (repos.length === 0)
|
|
247
|
+
return `<p class="empty">No repos are registered.</p>`;
|
|
248
|
+
return repos
|
|
249
|
+
.map((repo, index) => `<button type="button" class="pick" data-repo="${escapeHtml(repo.name)}" data-path="${escapeHtml(repo.path)}" aria-pressed="${index === 0 ? "true" : "false"}">${escapeHtml(repo.name)}</button>`)
|
|
250
|
+
.join("");
|
|
251
|
+
}
|
|
252
|
+
function workflowOptions(names) {
|
|
253
|
+
if (names.length === 0)
|
|
254
|
+
return `<option value="">none</option>`;
|
|
255
|
+
return names
|
|
256
|
+
.map((name) => `<option value="${escapeHtml(name)}"${name === "feature" ? " selected" : ""}>${escapeHtml(name)}</option>`)
|
|
257
|
+
.join("");
|
|
258
|
+
}
|
|
259
|
+
function afterOptions(items) {
|
|
260
|
+
const open = items.filter((item) => item.job.state !== "done");
|
|
261
|
+
if (open.length === 0)
|
|
262
|
+
return `<p class="empty">No open jobs.</p>`;
|
|
263
|
+
return open
|
|
264
|
+
.map((item) => {
|
|
265
|
+
const job = item.job;
|
|
266
|
+
return `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span class="num">${escapeHtml(job.jobId)}</span><span>${escapeHtml(job.title || job.jobId)}</span></label>`;
|
|
267
|
+
})
|
|
268
|
+
.join("");
|
|
269
|
+
}
|
|
270
|
+
function sheet(item, csrf) {
|
|
139
271
|
const job = item.job;
|
|
140
272
|
const files = displayFiles(job);
|
|
141
273
|
const round = lastRound(job);
|
|
@@ -149,7 +281,7 @@ function sheet(item) {
|
|
|
149
281
|
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
150
282
|
${sandboxHtml(job)}
|
|
151
283
|
${prLink(job)}
|
|
152
|
-
${actionsHtml(item)}
|
|
284
|
+
${actionsHtml(item, csrf)}
|
|
153
285
|
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
154
286
|
</div>
|
|
155
287
|
<div class="d-body">${filesNav(files, job)}</div>
|
|
@@ -184,17 +316,18 @@ function routeLinks(job) {
|
|
|
184
316
|
return "";
|
|
185
317
|
return `<div class="group">Open</div>${links.join("")}`;
|
|
186
318
|
}
|
|
187
|
-
function actionsHtml(item) {
|
|
319
|
+
function actionsHtml(item, csrf) {
|
|
188
320
|
const id = encodeURIComponent(item.job.jobId);
|
|
321
|
+
const token = `<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">`;
|
|
189
322
|
const forms = [];
|
|
190
323
|
if (item.job.canApprove) {
|
|
191
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve"
|
|
324
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve">${token}<button type="submit">Approve</button></form>`);
|
|
192
325
|
}
|
|
193
326
|
if (item.job.canReject) {
|
|
194
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject"
|
|
327
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject">${token}<button type="submit">Reject</button></form>`);
|
|
195
328
|
}
|
|
196
329
|
if (item.retry) {
|
|
197
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry"
|
|
330
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry">${token}<button type="submit">Retry</button></form>`);
|
|
198
331
|
}
|
|
199
332
|
if (forms.length === 0)
|
|
200
333
|
return "";
|
|
@@ -268,10 +401,21 @@ function noteHtml(item) {
|
|
|
268
401
|
}
|
|
269
402
|
return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
|
|
270
403
|
}
|
|
404
|
+
function repoLabel(job) {
|
|
405
|
+
if (job.deliver === "local" || job.deliver === "pr")
|
|
406
|
+
return `${job.repo} · ${job.deliver}`;
|
|
407
|
+
return job.repo;
|
|
408
|
+
}
|
|
271
409
|
function noteText(item) {
|
|
272
410
|
const job = item.job;
|
|
273
|
-
if (job.state === "done")
|
|
274
|
-
|
|
411
|
+
if (job.state === "done") {
|
|
412
|
+
if (job.prNumber)
|
|
413
|
+
return `merged · PR #${job.prNumber}`;
|
|
414
|
+
if (safeUrl(job.prUrl))
|
|
415
|
+
return "merged · PR";
|
|
416
|
+
const branch = branchOf(job);
|
|
417
|
+
return branch ? `landed locally · ${branch}` : "landed locally";
|
|
418
|
+
}
|
|
275
419
|
return item.reason;
|
|
276
420
|
}
|
|
277
421
|
function dependsHtml(job) {
|
|
@@ -348,7 +492,7 @@ function document(options) {
|
|
|
348
492
|
${options.css}
|
|
349
493
|
</style>
|
|
350
494
|
</head>
|
|
351
|
-
<body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}">
|
|
495
|
+
<body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}">
|
|
352
496
|
${options.body}
|
|
353
497
|
${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
|
|
354
498
|
</body>
|
package/dist/serve.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
3
5
|
import { join, resolve, sep } from "node:path";
|
|
6
|
+
import { AUTH_PREFLIGHT_PREFIX } from "./auth.js";
|
|
7
|
+
import { loadKilnConfig } from "./config.js";
|
|
4
8
|
import { answerJob, readStatus, retryJob, runTick, startupRecovery } from "./driver.js";
|
|
9
|
+
import { browse } from "./fs-browse.js";
|
|
5
10
|
import { defaultHost } from "./host.js";
|
|
6
11
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
12
|
+
import { intakeJobs } from "./intake-http.js";
|
|
7
13
|
import { packageRoot } from "./paths.js";
|
|
8
14
|
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
9
15
|
import { renderConsole, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
16
|
+
import { readSettings, saveSettings } from "./settings.js";
|
|
17
|
+
import { listWorkflowNames } from "./workflow.js";
|
|
10
18
|
const BODY_LIMIT = 65_536;
|
|
11
19
|
const TEXT_LIMIT = 100_000;
|
|
12
20
|
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -17,14 +25,15 @@ const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
17
25
|
export function startServe(options) {
|
|
18
26
|
startupRecovery(options.config);
|
|
19
27
|
const css = loadServeCss();
|
|
20
|
-
const
|
|
28
|
+
const csrf = randomBytes(32).toString("hex");
|
|
29
|
+
const state = openLiveConfig(options.config);
|
|
21
30
|
let port = options.port;
|
|
22
31
|
return new Promise((resolvePromise, reject) => {
|
|
23
32
|
const server = createServer((req, res) => {
|
|
24
|
-
void handle(req, res,
|
|
33
|
+
void handle(req, res, state, () => port, css, csrf).catch((error) => {
|
|
25
34
|
const message = error instanceof Error ? error.message : String(error);
|
|
26
35
|
const status = statusCode(error) ?? 500;
|
|
27
|
-
sendJson(res, status, { error: redact(message, secrets) }, secrets);
|
|
36
|
+
sendJson(res, status, { error: redact(message, state.secrets) }, state.secrets);
|
|
28
37
|
});
|
|
29
38
|
});
|
|
30
39
|
server.on("error", reject);
|
|
@@ -56,7 +65,8 @@ export function startServe(options) {
|
|
|
56
65
|
const tick = () => {
|
|
57
66
|
if (stopped)
|
|
58
67
|
return;
|
|
59
|
-
|
|
68
|
+
refreshLiveConfig(state);
|
|
69
|
+
safeTick(state.config, state.secrets, false);
|
|
60
70
|
};
|
|
61
71
|
firstTick = setTimeout(tick, 0);
|
|
62
72
|
timer = setInterval(tick, options.tickSeconds * 1000);
|
|
@@ -73,7 +83,7 @@ export function loadServeCss() {
|
|
|
73
83
|
function safeTick(config, secrets, startup) {
|
|
74
84
|
try {
|
|
75
85
|
const report = runTick(config, defaultHost(), { startup });
|
|
76
|
-
if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX)) {
|
|
86
|
+
if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX) || report.startsWith(AUTH_PREFLIGHT_PREFIX)) {
|
|
77
87
|
const tickAt = report.indexOf("\ntick ");
|
|
78
88
|
const head = tickAt === -1 ? report : report.slice(0, tickAt);
|
|
79
89
|
process.stderr.write(`${redact(head.trimEnd(), secrets)}\n`);
|
|
@@ -84,7 +94,10 @@ function safeTick(config, secrets, startup) {
|
|
|
84
94
|
process.stderr.write(`tick: ${redact(message, secrets)}\n`);
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
|
-
async function handle(req, res,
|
|
97
|
+
async function handle(req, res, state, boundPort, css, csrf) {
|
|
98
|
+
refreshLiveConfig(state);
|
|
99
|
+
const config = state.config;
|
|
100
|
+
const secrets = state.secrets;
|
|
88
101
|
const method = req.method ?? "GET";
|
|
89
102
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
90
103
|
const path = url.pathname;
|
|
@@ -93,12 +106,60 @@ async function handle(req, res, config, boundPort, css, secrets) {
|
|
|
93
106
|
res.end();
|
|
94
107
|
return;
|
|
95
108
|
}
|
|
109
|
+
if (method === "GET" && path === "/api/settings") {
|
|
110
|
+
if (!originAllowed(req, boundPort())) {
|
|
111
|
+
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const result = readSettings(config.configPath);
|
|
115
|
+
if (!result.ok) {
|
|
116
|
+
sendJson(res, result.status, result.body, secrets);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
sendJson(res, 200, result.document, secrets);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (method === "GET" && path === "/api/fs") {
|
|
123
|
+
if (!originAllowed(req, boundPort())) {
|
|
124
|
+
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
sendJson(res, 200, browse(homedir(), url.searchParams.get("path") ?? undefined), secrets);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
+
sendJson(res, statusCode(error) ?? 500, { error: message }, secrets);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (method === "PUT" && path === "/api/settings") {
|
|
137
|
+
const raw = await readBody(req);
|
|
138
|
+
if (!originAllowed(req, boundPort())) {
|
|
139
|
+
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!csrfOk(csrf, req, raw)) {
|
|
143
|
+
sendJson(res, 403, { error: "CSRF token rejected." }, secrets);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
await putSettings(req, res, state, raw);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
96
149
|
if (method === "POST") {
|
|
97
|
-
await
|
|
150
|
+
const raw = await readBody(req);
|
|
98
151
|
if (!originAllowed(req, boundPort())) {
|
|
99
152
|
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
100
153
|
return;
|
|
101
154
|
}
|
|
155
|
+
if (!csrfOk(csrf, req, raw)) {
|
|
156
|
+
sendJson(res, 403, { error: "CSRF token rejected." }, secrets);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (path === "/api/jobs") {
|
|
160
|
+
await createJob(req, res, state, raw);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
102
163
|
const action = /^\/api\/jobs\/([^/]+)\/(approve|reject|retry)$/.exec(path);
|
|
103
164
|
if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject" && action[2] !== "retry")) {
|
|
104
165
|
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
@@ -121,11 +182,11 @@ async function handle(req, res, config, boundPort, css, secrets) {
|
|
|
121
182
|
return;
|
|
122
183
|
}
|
|
123
184
|
if (path === "/" || path === "" || path === "/board") {
|
|
124
|
-
sendHtml(res, 200, renderConsole(css, readStatus(config).attention), secrets);
|
|
185
|
+
sendHtml(res, 200, renderConsole(css, readStatus(config).attention, pageOptions(state, csrf)), secrets);
|
|
125
186
|
return;
|
|
126
187
|
}
|
|
127
|
-
if (path === "/api/jobs") {
|
|
128
|
-
sendJson(res, 200,
|
|
188
|
+
if (path === "/api/jobs" || path === "/api/status") {
|
|
189
|
+
sendJson(res, 200, statusDocument(state), secrets);
|
|
129
190
|
return;
|
|
130
191
|
}
|
|
131
192
|
const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
|
|
@@ -238,12 +299,15 @@ function findJob(config, jobId) {
|
|
|
238
299
|
}
|
|
239
300
|
}
|
|
240
301
|
/**
|
|
302
|
+
* Host must be localhost or 127.0.0.1 on the bound port.
|
|
241
303
|
* Browsers send Origin on POST. A cross-site page cannot use this server:
|
|
242
|
-
*
|
|
304
|
+
* Origin, when present, has to be `http://127.0.0.1:<port>` or `http://localhost:<port>`.
|
|
243
305
|
* Curl and other non-browser clients omit Origin and are allowed.
|
|
244
306
|
* `Sec-Fetch-Site: cross-site` is rejected even when Origin is absent.
|
|
245
307
|
*/
|
|
246
308
|
export function originAllowed(req, port) {
|
|
309
|
+
if (!hostAllowed(req, port))
|
|
310
|
+
return false;
|
|
247
311
|
const fetchSite = req.headers["sec-fetch-site"];
|
|
248
312
|
if (fetchSite === "cross-site")
|
|
249
313
|
return false;
|
|
@@ -252,7 +316,14 @@ export function originAllowed(req, port) {
|
|
|
252
316
|
return false;
|
|
253
317
|
if (origin === undefined)
|
|
254
318
|
return true;
|
|
255
|
-
return origin === `http://127.0.0.1:${port}`;
|
|
319
|
+
return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
|
|
320
|
+
}
|
|
321
|
+
function hostAllowed(req, port) {
|
|
322
|
+
const host = req.headers.host;
|
|
323
|
+
if (typeof host !== "string")
|
|
324
|
+
return false;
|
|
325
|
+
const value = host.trim().toLowerCase();
|
|
326
|
+
return value === `127.0.0.1:${port}` || value === `localhost:${port}`;
|
|
256
327
|
}
|
|
257
328
|
function redirectTarget(req) {
|
|
258
329
|
const referer = req.headers.referer;
|
|
@@ -316,16 +387,178 @@ function readInside(kilnDir, target) {
|
|
|
316
387
|
return text;
|
|
317
388
|
return `showing the last ${TEXT_LIMIT} characters\n\n${text.slice(text.length - TEXT_LIMIT)}`;
|
|
318
389
|
}
|
|
319
|
-
async function
|
|
390
|
+
async function readBody(req) {
|
|
391
|
+
const chunks = [];
|
|
320
392
|
let size = 0;
|
|
321
393
|
for await (const chunk of req) {
|
|
322
|
-
|
|
394
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
395
|
+
size += buf.length;
|
|
323
396
|
if (size > BODY_LIMIT) {
|
|
324
397
|
const error = new Error("Request body is too large.");
|
|
325
398
|
error.statusCode = 413;
|
|
326
399
|
throw error;
|
|
327
400
|
}
|
|
401
|
+
chunks.push(buf);
|
|
402
|
+
}
|
|
403
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
404
|
+
}
|
|
405
|
+
function csrfOk(expected, req, body) {
|
|
406
|
+
const header = req.headers["x-kiln-csrf"];
|
|
407
|
+
const fromHeader = typeof header === "string" ? header : undefined;
|
|
408
|
+
let fromForm;
|
|
409
|
+
if (headerValue(req.headers["content-type"]).includes("application/x-www-form-urlencoded")) {
|
|
410
|
+
fromForm = new URLSearchParams(body).get("csrf") ?? undefined;
|
|
411
|
+
}
|
|
412
|
+
const provided = fromHeader || fromForm;
|
|
413
|
+
if (!provided)
|
|
414
|
+
return false;
|
|
415
|
+
const left = Buffer.from(expected);
|
|
416
|
+
const right = Buffer.from(provided);
|
|
417
|
+
if (left.length !== right.length)
|
|
418
|
+
return false;
|
|
419
|
+
return timingSafeEqual(left, right);
|
|
420
|
+
}
|
|
421
|
+
async function putSettings(req, res, state, raw) {
|
|
422
|
+
const secrets = state.secrets;
|
|
423
|
+
if (!headerValue(req.headers["content-type"]).includes("application/json")) {
|
|
424
|
+
sendJson(res, 400, { error: "Content-Type must be application/json." }, secrets);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
let parsed;
|
|
428
|
+
if (!raw.trim()) {
|
|
429
|
+
sendJson(res, 400, { error: "Request body must be a JSON object." }, secrets);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
parsed = JSON.parse(raw);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
sendJson(res, 400, { error: "Request body is not JSON." }, secrets);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const result = saveSettings(state.config.configPath, parsed, homedir());
|
|
440
|
+
if (!result.ok) {
|
|
441
|
+
sendJson(res, result.status, result.body, secrets);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
state.mtimeMs = -1;
|
|
445
|
+
state.size = -1;
|
|
446
|
+
refreshLiveConfig(state);
|
|
447
|
+
sendJson(res, 200, result.document, state.secrets);
|
|
448
|
+
}
|
|
449
|
+
async function createJob(req, res, state, raw) {
|
|
450
|
+
const secrets = state.secrets;
|
|
451
|
+
if (!headerValue(req.headers["content-type"]).includes("application/json")) {
|
|
452
|
+
sendJson(res, 400, { error: "Content-Type must be application/json." }, secrets);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
let parsed;
|
|
456
|
+
if (!raw.trim()) {
|
|
457
|
+
sendJson(res, 400, { error: "Request body must be a JSON object." }, secrets);
|
|
458
|
+
return;
|
|
328
459
|
}
|
|
460
|
+
try {
|
|
461
|
+
parsed = JSON.parse(raw);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
sendJson(res, 400, { error: "Request body is not JSON." }, secrets);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const result = await intakeJobs(state.config, parsed);
|
|
469
|
+
sendJson(res, 200, result, state.secrets);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
473
|
+
sendJson(res, statusCode(error) ?? 400, { error: message }, state.secrets);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function openLiveConfig(config) {
|
|
477
|
+
const file = fileStamp(config.configPath);
|
|
478
|
+
return {
|
|
479
|
+
config,
|
|
480
|
+
secrets: secretValues(config),
|
|
481
|
+
mtimeMs: file?.mtimeMs ?? 0,
|
|
482
|
+
size: file?.size ?? -1,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Re-read the kiln config when the file's mtime or size changed.
|
|
487
|
+
* A file that does not parse keeps the last good config and records `configError`.
|
|
488
|
+
* The failed stamp is not stored, so the next request tries again.
|
|
489
|
+
*/
|
|
490
|
+
function refreshLiveConfig(state) {
|
|
491
|
+
const path = state.config.configPath;
|
|
492
|
+
const file = fileStamp(path);
|
|
493
|
+
if (!file) {
|
|
494
|
+
state.configError = redact(`Could not read kiln config ${path}: the file is missing.`, redactSecrets(state, path));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (file.mtimeMs === state.mtimeMs && file.size === state.size)
|
|
498
|
+
return;
|
|
499
|
+
try {
|
|
500
|
+
const next = loadKilnConfig(path);
|
|
501
|
+
state.config = next;
|
|
502
|
+
state.secrets = secretValues(next);
|
|
503
|
+
state.configError = undefined;
|
|
504
|
+
state.mtimeMs = file.mtimeMs;
|
|
505
|
+
state.size = file.size;
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
509
|
+
const named = message.includes(path) ? message : `${path}: ${message}`;
|
|
510
|
+
state.configError = redact(named, redactSecrets(state, path));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function fileStamp(path) {
|
|
514
|
+
try {
|
|
515
|
+
const stat = statSync(path);
|
|
516
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/** Last good secrets, plus a token sitting in a file that failed to load. */
|
|
523
|
+
function redactSecrets(state, configPath) {
|
|
524
|
+
const secrets = [...state.secrets];
|
|
525
|
+
const disk = tokenFromDisk(configPath);
|
|
526
|
+
if (disk && !secrets.includes(disk))
|
|
527
|
+
secrets.push(disk);
|
|
528
|
+
return secrets;
|
|
529
|
+
}
|
|
530
|
+
function tokenFromDisk(configPath) {
|
|
531
|
+
try {
|
|
532
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
533
|
+
const token = parsed.jira?.apiToken;
|
|
534
|
+
if (typeof token !== "string")
|
|
535
|
+
return undefined;
|
|
536
|
+
const trimmed = token.trim();
|
|
537
|
+
return trimmed.length >= 8 ? trimmed : undefined;
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function repoList(config) {
|
|
544
|
+
return Object.entries(config.repos)
|
|
545
|
+
.map(([name, repo]) => ({ name, path: repo.path }))
|
|
546
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
547
|
+
}
|
|
548
|
+
function pageOptions(state, csrf) {
|
|
549
|
+
return {
|
|
550
|
+
csrf,
|
|
551
|
+
repos: repoList(state.config),
|
|
552
|
+
workflows: listWorkflowNames(),
|
|
553
|
+
...(state.configError ? { configError: state.configError } : {}),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function statusDocument(state) {
|
|
557
|
+
return {
|
|
558
|
+
...readStatus(state.config),
|
|
559
|
+
repos: repoList(state.config),
|
|
560
|
+
configError: state.configError ?? null,
|
|
561
|
+
};
|
|
329
562
|
}
|
|
330
563
|
function statusCode(error) {
|
|
331
564
|
if (typeof error === "object" && error !== null && "statusCode" in error) {
|
|
@@ -362,7 +595,12 @@ function redirect(res, location) {
|
|
|
362
595
|
res.end();
|
|
363
596
|
}
|
|
364
597
|
function secretValues(config) {
|
|
365
|
-
const values = [
|
|
598
|
+
const values = [
|
|
599
|
+
config.jira?.apiToken,
|
|
600
|
+
process.env.JIRA_API_TOKEN,
|
|
601
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
602
|
+
process.env.ANTHROPIC_API_KEY,
|
|
603
|
+
];
|
|
366
604
|
const found = [];
|
|
367
605
|
for (const value of values) {
|
|
368
606
|
const trimmed = value?.trim();
|