@popcorn245/cornfield 0.8.0 → 0.10.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/core/agent-contract.js +5 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +63 -5
- package/explorer/explorer.css +3 -1
- package/explorer/forge-panel.js +203 -5
- package/explorer/index.html +28 -0
- package/explorer/review-tokens-model.js +12 -1
- package/explorer/review-tokens.js +13 -7
- package/explorer/runs-model.js +107 -0
- package/package.json +1 -1
- package/server/agent-api.js +228 -0
- package/server/agent-runs.js +906 -0
- package/server/auth.js +58 -3
- package/server/db.js +84 -0
- package/server/forge-forgejo.js +7 -0
- package/server/forge-github.js +7 -0
- package/server/forge.js +5 -1
- package/server/hooks.js +19 -0
- package/server/review-api.js +28 -12
- package/server/review-tokens.js +59 -25
- package/server/runner-api.js +270 -0
- package/server/server.js +43 -7
- package/server/spec-poll.js +41 -15
package/explorer/index.html
CHANGED
|
@@ -265,6 +265,13 @@
|
|
|
265
265
|
</div>
|
|
266
266
|
</div>
|
|
267
267
|
</section>
|
|
268
|
+
<!-- Agent runs (forge-panel.js, runs-model.js): shown on the Agents tab; each run's state, what it is about, links, and Stop -->
|
|
269
|
+
<section id="forge-runs" class="hx-forge-runs" aria-labelledby="forge-runs-title" hidden>
|
|
270
|
+
<h3 id="forge-runs-title" class="hx-forge-runs-title">Agent runs</h3>
|
|
271
|
+
<p id="forge-runs-status" class="hx-forge-meta hx-faint"></p>
|
|
272
|
+
<p id="forge-runs-note" class="hx-forge-notice" hidden></p>
|
|
273
|
+
<ul id="forge-runs-list" class="hx-forge-files" aria-label="Runs"></ul>
|
|
274
|
+
</section>
|
|
268
275
|
<section id="forge-detail" class="hx-forge-detail" aria-label="Selected pull request" hidden></section>
|
|
269
276
|
<ul id="forge-list" class="hx-forge-list" aria-label="Results"></ul>
|
|
270
277
|
<p id="forge-rate" class="hx-forge-rate hx-faint"></p>
|
|
@@ -339,6 +346,26 @@
|
|
|
339
346
|
</div>
|
|
340
347
|
</div>
|
|
341
348
|
|
|
349
|
+
<!-- Start an agent (forge-panel.js): kind, profile, a brief, and a confirm step; a modal dialog over a scrim, the focus stays in it -->
|
|
350
|
+
<div id="agent-start" class="hx-confirm-scrim" hidden>
|
|
351
|
+
<div class="hx-confirm glass" role="dialog" aria-modal="true" aria-labelledby="agent-start-title" aria-describedby="agent-start-words">
|
|
352
|
+
<h2 id="agent-start-title" class="hx-confirm-title">Start an agent</h2>
|
|
353
|
+
<p id="agent-start-about" class="hx-forge-meta"></p>
|
|
354
|
+
<form id="agent-start-form" class="hx-forge-confirm" novalidate>
|
|
355
|
+
<label class="hx-forge-field">Kind of run<select id="agent-kind" class="hx-forge-select"></select></label>
|
|
356
|
+
<label id="agent-profile-field" class="hx-forge-field" hidden>Profile<select id="agent-profile" class="hx-forge-select"></select></label>
|
|
357
|
+
<label class="hx-forge-field">Brief for the runner<textarea id="agent-brief" class="hx-forge-preview" rows="6" maxlength="20000" spellcheck="true"></textarea></label>
|
|
358
|
+
<label id="agent-force-field" class="hx-forge-toggle" hidden><input type="checkbox" id="agent-force" /> Ask for another run on purpose</label>
|
|
359
|
+
<p id="agent-start-words" class="hx-confirm-note"></p>
|
|
360
|
+
<p id="agent-start-msg" class="hx-forge-msg hx-forge-msg--bad" role="alert" hidden></p>
|
|
361
|
+
<div class="hx-confirm-actions">
|
|
362
|
+
<button type="button" class="hx-btn" data-action="agent-start-cancel">Cancel</button>
|
|
363
|
+
<button type="submit" class="hx-btn hx-btn--approve" id="agent-start-go">Ask the runner</button>
|
|
364
|
+
</div>
|
|
365
|
+
</form>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
342
369
|
<!-- Review tokens (review-tokens.js): make one (shown once), list them, revoke; a modal dialog over a scrim, the focus stays in it -->
|
|
343
370
|
<div id="tokens-dialog" class="hx-confirm-scrim" hidden>
|
|
344
371
|
<div class="hx-confirm glass" role="dialog" aria-modal="true" aria-labelledby="tokens-title" aria-describedby="tokens-intro">
|
|
@@ -354,6 +381,7 @@
|
|
|
354
381
|
</div>
|
|
355
382
|
<ul id="tokens-list" class="hx-tokens-list" aria-label="Your review tokens"></ul>
|
|
356
383
|
<form id="tokens-form" class="hx-tokens-form" autocomplete="off">
|
|
384
|
+
<label class="hx-forge-field">Kind of token<select id="tokens-scope" class="hx-forge-select"><option value="review">Review: approve or send back proposals</option><option value="agent">Agent: ask for agent runs (POST /api/agent-requests)</option><option value="runner">Runner: pull, claim and report on runs (GET /api/agent-requests)</option></select></label>
|
|
357
385
|
<label class="hx-forge-field">Label<input id="tokens-label" class="hx-forge-input" type="text" maxlength="60" placeholder="chat bot" /></label>
|
|
358
386
|
<label class="hx-forge-field">Days<input id="tokens-days" class="hx-forge-input" type="text" inputmode="numeric" maxlength="2" aria-describedby="tokens-days-hint" /></label>
|
|
359
387
|
<span id="tokens-days-hint" class="hx-sr-only"></span>
|
|
@@ -25,8 +25,19 @@ const DAY = 24 * 60 * 60 * 1000;
|
|
|
25
25
|
const HOUR = 60 * 60 * 1000;
|
|
26
26
|
const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} ._:/()-]*$/u;
|
|
27
27
|
|
|
28
|
+
/** Tokens come in three scopes (server/review-tokens.js): `review` approves proposals, `agent` asks for agent runs, `runner` pulls and reports on them. Each has its own address. */
|
|
29
|
+
export const SCOPES = Object.freeze([['review', 'Review: approve or send back proposals'], ['agent', 'Agent: ask for agent runs'], ['runner', 'Runner: pull, claim and report on runs']]);
|
|
30
|
+
export const tokensUrl = (scope) => (scope === 'agent' ? '/api/agent-tokens' : scope === 'runner' ? '/api/runner-tokens' : TOKENS_URL);
|
|
31
|
+
|
|
32
|
+
/** What the dialog says a token of this scope is for */
|
|
33
|
+
export function introWords(scope) {
|
|
34
|
+
if (scope === 'agent') return 'An agent token lets a script or a chat bot ask for an agent run as you (POST /api/agent-requests). It works while you are signed in here and still maintain the repo; it expires, and you can revoke it below.';
|
|
35
|
+
if (scope === 'runner') return 'A runner token lets your own runner, wherever it runs, list the queued requests, claim one and report on it (GET /api/agent-requests, POST /api/agent-requests/<run>/claim, POST /api/runs/<run>). It reaches nothing else, and works until it expires or you revoke it below, even after you sign out.';
|
|
36
|
+
return 'A review token lets a script or a chat bot approve or send back a proposal as you (POST /api/reviews). It works while you are signed in here and still maintain the repo; it expires, and you can revoke it below.';
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
/** The address that revokes token `id` */
|
|
29
|
-
export const revokeUrl = (id) => `${
|
|
40
|
+
export const revokeUrl = (id, scope = 'review') => `${tokensUrl(scope)}/${encodeURIComponent(String(id))}/revoke`;
|
|
30
41
|
|
|
31
42
|
/**
|
|
32
43
|
* What the form sends: the label, and the days when they were given. The server checks everything again.
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
|
|
21
21
|
import { escHtml } from './state.js';
|
|
22
22
|
import { announce } from './keyboard.js';
|
|
23
|
-
import {
|
|
23
|
+
import { tokensUrl, introWords, DEFAULT_DAYS, MAX_DAYS, makeRequest, revokeUrl, tokenLine, errorWords } from './review-tokens-model.js';
|
|
24
24
|
|
|
25
25
|
const $ = (id) => document.getElementById(id);
|
|
26
26
|
let wired = false;
|
|
27
27
|
let now = () => Date.now();
|
|
28
|
-
const ui = { open: false, busy: false, tokens: [], loaded: false, message: null };
|
|
28
|
+
const ui = { open: false, busy: false, tokens: [], loaded: false, message: null, scope: 'review' };
|
|
29
29
|
|
|
30
30
|
const dialog = () => $('tokens-dialog');
|
|
31
31
|
|
|
@@ -51,13 +51,15 @@ function paint() {
|
|
|
51
51
|
if (!dialog()) return;
|
|
52
52
|
const list = $('tokens-list');
|
|
53
53
|
if (!ui.loaded) list.innerHTML = '<li class="hx-faint">Loading …</li>';
|
|
54
|
-
else if (!ui.tokens.length) list.innerHTML =
|
|
54
|
+
else if (!ui.tokens.length) list.innerHTML = `<li class="hx-faint">You have no ${ui.scope} tokens yet.</li>`;
|
|
55
55
|
else {
|
|
56
56
|
list.innerHTML = ui.tokens.map((t) => {
|
|
57
57
|
const line = tokenLine(t, now());
|
|
58
58
|
return `<li class="hx-tokens-item" data-state="${line.state}"><span class="hx-tokens-label">${escHtml(line.label)}</span> <span class="hx-tokens-meta hx-faint">${escHtml(line.status)} · ${escHtml(line.used)}</span> ${line.canRevoke ? `<button type="button" class="hx-btn hx-btn--quiet" data-revoke="${escHtml(String(line.id))}" aria-label="Revoke ${escHtml(line.label)}"${ui.busy ? ' disabled' : ''}>Revoke</button>` : ''}</li>`;
|
|
59
59
|
}).join('');
|
|
60
60
|
}
|
|
61
|
+
const intro = $('tokens-intro');
|
|
62
|
+
if (intro) intro.textContent = introWords(ui.scope);
|
|
61
63
|
const msg = $('tokens-msg');
|
|
62
64
|
msg.hidden = !ui.message;
|
|
63
65
|
msg.textContent = ui.message ? ui.message.text : '';
|
|
@@ -66,7 +68,7 @@ function paint() {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
async function load() {
|
|
69
|
-
const res = await request('GET',
|
|
71
|
+
const res = await request('GET', tokensUrl(ui.scope));
|
|
70
72
|
if (res.ok && Array.isArray(res.body.tokens)) {
|
|
71
73
|
ui.tokens = res.body.tokens;
|
|
72
74
|
ui.loaded = true;
|
|
@@ -92,7 +94,7 @@ async function make(e) {
|
|
|
92
94
|
clearSecret();
|
|
93
95
|
ui.busy = true;
|
|
94
96
|
say('');
|
|
95
|
-
const res = await request('POST',
|
|
97
|
+
const res = await request('POST', tokensUrl(ui.scope), checked.body);
|
|
96
98
|
ui.busy = false;
|
|
97
99
|
if (!res.ok || typeof res.body.secret !== 'string') { say(errorWords(res.status, res.body), false); return; }
|
|
98
100
|
// The one time the secret is seen: in the field, and not in a live region
|
|
@@ -110,7 +112,7 @@ async function revoke(id) {
|
|
|
110
112
|
if (ui.busy) return;
|
|
111
113
|
ui.busy = true;
|
|
112
114
|
paint();
|
|
113
|
-
const res = await request('POST', revokeUrl(id), {});
|
|
115
|
+
const res = await request('POST', revokeUrl(id, ui.scope), {});
|
|
114
116
|
ui.busy = false;
|
|
115
117
|
if (!res.ok) say(errorWords(res.status, res.body), false);
|
|
116
118
|
else say(`Revoked "${res.body.token.label}". It no longer works.`);
|
|
@@ -203,6 +205,8 @@ export function initReviewTokens(config, opts = {}) {
|
|
|
203
205
|
dialog().addEventListener('click', onClick);
|
|
204
206
|
dialog().addEventListener('keydown', onKey);
|
|
205
207
|
$('tokens-form').addEventListener('submit', make);
|
|
208
|
+
const scope = $('tokens-scope');
|
|
209
|
+
if (scope) scope.addEventListener('change', () => { ui.scope = scope.value === 'agent' || scope.value === 'runner' ? scope.value : 'review'; ui.loaded = false; ui.tokens = []; clearSecret(); paint(); load(); });
|
|
206
210
|
}
|
|
207
211
|
return true;
|
|
208
212
|
}
|
|
@@ -212,5 +216,7 @@ export function resetReviewTokensForTests(clock = () => Date.now()) {
|
|
|
212
216
|
if (ui.open) closeTokens({ restoreFocus: false });
|
|
213
217
|
wired = false;
|
|
214
218
|
now = clock;
|
|
215
|
-
Object.assign(ui, { open: false, busy: false, tokens: [], loaded: false, message: null });
|
|
219
|
+
Object.assign(ui, { open: false, busy: false, tokens: [], loaded: false, message: null, scope: 'review' });
|
|
220
|
+
const scope = $('tokens-scope');
|
|
221
|
+
if (scope) scope.value = 'review';
|
|
216
222
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module explorer/runs-model
|
|
3
|
+
*
|
|
4
|
+
* Agent runs in the issues and PRs panel, the pure half (issue #80, spec 0005): no DOM, no `window`, no clock. It words what the server
|
|
5
|
+
* (server/agent-runs.js, server/agent-api.js) answers, and makes the body of a start. cornfield only asks: a run is a request on the
|
|
6
|
+
* forge, the runner does the work, and these words say so.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { relativeTime } from './review-model.js';
|
|
10
|
+
|
|
11
|
+
export const RUNS_URL = '/api/agent-runs';
|
|
12
|
+
export const runsUrl = (follow) => (follow ? `${RUNS_URL}?follow=1` : RUNS_URL);
|
|
13
|
+
export const stopUrl = (runId) => `${RUNS_URL}/${encodeURIComponent(runId)}/stop`;
|
|
14
|
+
|
|
15
|
+
/** The kinds of run (the contract's), and what each asks the runner for */
|
|
16
|
+
export const KINDS = Object.freeze([
|
|
17
|
+
['code', 'Code: a branch and a pull request'],
|
|
18
|
+
['summary', 'Summary of the conversation'],
|
|
19
|
+
['brief', 'A brief for another run'],
|
|
20
|
+
['proposal', 'A proposal drafted from the thread'],
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/** What a run is about, in words: "issue #12", "proposal 0040-meals" */
|
|
24
|
+
export function aboutWords(refs) {
|
|
25
|
+
const out = [];
|
|
26
|
+
if (refs && refs.issue != null) out.push(`issue #${refs.issue}`);
|
|
27
|
+
if (refs && refs.thread != null && refs.thread !== refs.issue) out.push(`thread #${refs.thread}`);
|
|
28
|
+
if (refs && refs.proposal != null) out.push(`proposal ${refs.proposal}`);
|
|
29
|
+
return out.join(' and ') || 'nothing';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The state of a run, said short */
|
|
33
|
+
export function statusWords(run) {
|
|
34
|
+
const words = {
|
|
35
|
+
requested: 'Requested', claimed: 'Claimed', working: 'Working', done: 'Done', failed: 'Failed', stopped: 'Stopped',
|
|
36
|
+
'stop-requested': 'Stop requested', quiet: 'Quiet', merged: 'Merged', closed: 'Closed',
|
|
37
|
+
};
|
|
38
|
+
return words[run.status] || run.status;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Why a state is what it is, for people who ask: one line under the state */
|
|
42
|
+
export function statusWhy(run) {
|
|
43
|
+
switch (run.status) {
|
|
44
|
+
case 'quiet': return 'Nothing reported or pushed for a while, so it no longer counts toward the limit. A report or a push brings it back.';
|
|
45
|
+
case 'stop-requested': return `${run.stopRequested && run.stopRequested.by ? run.stopRequested.by : 'A maintainer'} asked it to stop. The runner decides what that means; this stays until it says stopped, or the run goes quiet.`;
|
|
46
|
+
case 'requested': return 'Waiting for a runner to pick it up. cornfield only asks; it starts nothing.';
|
|
47
|
+
default: return '';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A line of facts: who asked and when, the profile, the limits asked for beside what the runner reported */
|
|
52
|
+
export function runMeta(run, nowMs) {
|
|
53
|
+
const parts = [`asked ${relativeTime(run.requestedAt, nowMs)} by ${run.requestedBy}`];
|
|
54
|
+
if (run.profile) parts.push(`profile ${run.profile}`);
|
|
55
|
+
const l = run.limits || {};
|
|
56
|
+
const limits = [l.minutes != null && `${l.minutes} min`, l.turns != null && `${l.turns} turns`, l.cost != null && `cost ${l.cost}`].filter(Boolean);
|
|
57
|
+
if (limits.length) parts.push(`limits ${limits.join(', ')}`);
|
|
58
|
+
const r = run.report;
|
|
59
|
+
if (r) {
|
|
60
|
+
const said = [r.tokens != null && `${r.tokens} tokens`, r.cost != null && `cost ${r.cost}`].filter(Boolean);
|
|
61
|
+
if (said.length) parts.push(`reported ${said.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
if (run.checks && run.checks !== 'none') parts.push(`checks ${run.checks === 'success' ? 'passing' : run.checks === 'failure' ? 'failing' : 'running'}`);
|
|
64
|
+
return parts.join(' · ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether a Stop is offered: the run is open and nobody has asked it to stop yet */
|
|
68
|
+
export const canStop = (run) => run.open === true && !run.stopRequested;
|
|
69
|
+
|
|
70
|
+
/** The summary line over the list: "2 open of 3 allowed, 5 runs" */
|
|
71
|
+
export function runsStatus(data) {
|
|
72
|
+
const n = data.runs.length;
|
|
73
|
+
return `${data.open} open of ${data.maxOpen} allowed, ${n} ${n === 1 ? 'run' : 'runs'}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The body of a start. Only what the person chose; the server writes the run id, the time and who asked, and checks the rest.
|
|
78
|
+
* @param {{ kind: string, profile?: string, brief?: string, refs: object, force?: boolean }} input
|
|
79
|
+
*/
|
|
80
|
+
export function startBody({ kind, profile, brief, refs, force }) {
|
|
81
|
+
const out = { kind, refs, brief: (brief || '').trim() };
|
|
82
|
+
if (profile) out.profile = profile;
|
|
83
|
+
if (force) out.force = true;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What a start came to, in words */
|
|
88
|
+
export function startWords(status, body) {
|
|
89
|
+
if (status === null) return 'cornfield did not answer; nothing was asked.';
|
|
90
|
+
if (status === 401) return 'Your sign-in has ended. Sign in again to start an agent.';
|
|
91
|
+
if (body && body.ok) return `Asked. The request is posted on the forge as run ${body.request.runId}; a runner picks it up from there.`;
|
|
92
|
+
return (body && body.error) || `The request was refused (${status}).`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What a stop came to */
|
|
96
|
+
export function stopWords(status, body, runId) {
|
|
97
|
+
if (status === null) return 'cornfield did not answer; nothing was asked.';
|
|
98
|
+
if (status === 401) return 'Your sign-in has ended. Sign in again to stop a run.';
|
|
99
|
+
if (body && body.ok) return `Asked ${runId} to stop. The runner decides what that means.`;
|
|
100
|
+
return (body && body.error) || `The stop was refused (${status}).`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What the dialog tells the person before they confirm: where the request goes and what it does and does not do */
|
|
104
|
+
export function confirmStartWords(refs, forgeName) {
|
|
105
|
+
const where = refs.proposal != null ? `the spec pull request of proposal ${refs.proposal}` : refs.thread != null ? `thread #${refs.thread}` : `issue #${refs.issue}`;
|
|
106
|
+
return `This posts a request as a comment on ${where} on ${forgeName}, as you. Your runner picks it up. cornfield starts nothing and sends no credentials.`;
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popcorn245/cornfield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A self-hostable dev dashboard with the topology map at its centre: proposals on the map, scenarios, outages and what-if edits, for any repo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "popcorn245 (https://github.com/popcorn245)",
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module server/agent-api
|
|
3
|
+
*
|
|
4
|
+
* The routes that ask for agent runs and show them (issue #80, spec 0005 D10; the engine is server/agent-runs.js).
|
|
5
|
+
*
|
|
6
|
+
* POST /api/agent-requests { kind, refs, profile?, brief?, branch?, limits?, nodes?, force? }, with `Authorization: Bearer cf_at_…`
|
|
7
|
+
* GET /api/agent-requests the queue, for a runner token: server/runner-api.js (and the pull transport's claim and report routes)
|
|
8
|
+
* GET /api/agent-tokens the signed-in maintainer's agent tokens (and POST, and /<id>/revoke: server/review-api.js)
|
|
9
|
+
* GET /api/agent-runs[?follow=1] the runs, newest first, with the cap and how many are open; `follow=1` looks at the forge first
|
|
10
|
+
* GET /api/agent-runs/<runId> one run
|
|
11
|
+
* POST /api/agent-runs the dashboard's Start an agent: the same body as the API
|
|
12
|
+
* POST /api/agent-runs/<runId>/stop { note? } asks the runner to stop
|
|
13
|
+
*
|
|
14
|
+
* **The call takes an agent token and nothing else,** exactly as the review API takes a review token (server/review-api.js): it runs
|
|
15
|
+
* before the sign-in guard (a script has no cookie), reads no cookie, needs no same-origin check (a browser never adds an `Authorization`
|
|
16
|
+
* header itself), and keeps the `Host` check in front of every route. **The two scopes are kept apart:** a review token on this route is 403
|
|
17
|
+
* (and an agent token on `POST /api/reviews` is 403, and a runner token on either), whatever else is true of it. Token states, the anonymous flood ceiling and the rows
|
|
18
|
+
* in `actions` are the review API's, under the action `agent-request`:
|
|
19
|
+
*
|
|
20
|
+
* no token, or one that no row has 401 (`WWW-Authenticate: Bearer`), logged under `(no token)`; past `ANONYMOUS_PER_MINUTE` a minute 429 and no row
|
|
21
|
+
* a review token 403, under its owner
|
|
22
|
+
* a token that is revoked or expired 403, under its owner
|
|
23
|
+
* a live agent token whatever `runs.request` answers: 201 with the run, or 400, 401, 403, 404, 409, 429, 502
|
|
24
|
+
*
|
|
25
|
+
* The request is made **as the token's owner**: the forge is asked now who that is and whether they still maintain the repo (a demoted owner
|
|
26
|
+
* is 403), and the request comment is posted with their own kept sign-in (`auth.forgeForUser`; without a live one, 401). `requestedBy` is
|
|
27
|
+
* theirs and is never taken from the body. A body that carries a credential, by key or by value, is 400 and nothing is posted.
|
|
28
|
+
*
|
|
29
|
+
* The `/api/agent-runs` routes are the signed-in maintainer's, behind the sign-in guard like every other action. The POSTs take a request
|
|
30
|
+
* from cornfield's own page only (`sameOrigin`) with a JSON body and count toward the flood ceiling (server/throttle.js); a token is no way
|
|
31
|
+
* to them (the guard reads cookies, never `Authorization`). They post with the session's own forge token (`auth.forgeFor`).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { isRunId } from '../core/agent-contract.js';
|
|
35
|
+
import { sameOrigin, readBody } from './proposals.js';
|
|
36
|
+
import { ANONYMOUS_PER_MINUTE, NO_TOKEN_LOGIN } from './review-api.js';
|
|
37
|
+
import { bearerOf, findToken, touchToken } from './review-tokens.js';
|
|
38
|
+
import { refusal } from './submit-review.js';
|
|
39
|
+
import { Refused, countAttempt } from './throttle.js';
|
|
40
|
+
import { ForgeError } from './forge.js';
|
|
41
|
+
|
|
42
|
+
export const REQUESTS_PATH = '/api/agent-requests';
|
|
43
|
+
export const RUNS_PATH = '/api/agent-runs';
|
|
44
|
+
/** A request's body, in bytes: a brief of the contract's size, with room */
|
|
45
|
+
export const REQUEST_LIMIT = 96 * 1024;
|
|
46
|
+
const STOP_LIMIT = 8 * 1024;
|
|
47
|
+
const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
|
48
|
+
|
|
49
|
+
function send(res, status, body, headers = {}) {
|
|
50
|
+
res.writeHead(status, { ...HEADERS, ...headers });
|
|
51
|
+
res.end(JSON.stringify(body));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What to answer for an error out of the engine: a refusal in words, or a plain 500 that says nothing of what went wrong */
|
|
55
|
+
function answerFor(err) {
|
|
56
|
+
if (err instanceof Refused || err instanceof ForgeError) return refusal(err);
|
|
57
|
+
return { status: 500, body: { error: 'Something went wrong in cornfield; the log says what.', code: 'unavailable' }, headers: {} };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A JSON object from a body text, or a `problem` the engine refuses with a row */
|
|
61
|
+
function parseBody(body, what) {
|
|
62
|
+
if (body.tooLarge) return { problem: { status: 413, message: `${what} is too large.` } };
|
|
63
|
+
try {
|
|
64
|
+
return { body: JSON.parse(body.text) };
|
|
65
|
+
} catch {
|
|
66
|
+
// Not the parser's words: they quote the body, which a row would then keep (a credential pasted in by mistake)
|
|
67
|
+
return { problem: { status: 400, message: 'The body is not valid JSON.' } };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @typedef {{ config: any, auth: any, runs: import('./agent-runs.js').Runs | null, now?: () => number, publicHost?: string | null }} AgentContext */
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `POST /api/agent-requests`. The caller has found the path to be this one; this runs before the sign-in guard.
|
|
75
|
+
* @param {import('node:http').IncomingMessage} req
|
|
76
|
+
* @param {import('node:http').ServerResponse} res
|
|
77
|
+
* @param {AgentContext} ctx
|
|
78
|
+
*/
|
|
79
|
+
export async function serveAgentCall(req, res, ctx) {
|
|
80
|
+
const { config, auth, runs } = ctx;
|
|
81
|
+
const now = ctx.now ?? Date.now;
|
|
82
|
+
if (!auth.enabled || !runs) {
|
|
83
|
+
req.resume();
|
|
84
|
+
return send(res, 403, { ok: false, error: 'Agent tokens need sign-in and a forge: set them up first (docs/sign-in.md, docs/config.md#forge).', code: 'forbidden' });
|
|
85
|
+
}
|
|
86
|
+
if (req.method !== 'POST') {
|
|
87
|
+
req.resume();
|
|
88
|
+
return send(res, 405, { ok: false, error: 'A run is asked for with POST (an agent token); a runner lists the queue with GET (a runner token).', code: 'invalid' }, { Allow: 'GET, HEAD, POST' });
|
|
89
|
+
}
|
|
90
|
+
const repo = config.forge.repo;
|
|
91
|
+
const audit = async (login, request, status, body) => {
|
|
92
|
+
try {
|
|
93
|
+
await auth.record({ login, action: 'agent-request', target: repo, request: { via: 'api', ...request }, status, answer: { error: body.error, code: body.code } });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(`[cornfield] a refused agent request could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const presented = bearerOf(req.headers.authorization);
|
|
100
|
+
const db = presented ? await auth.store() : null;
|
|
101
|
+
const found = presented && db ? findToken(db, presented, now(), 'agent') : null;
|
|
102
|
+
if (!found) {
|
|
103
|
+
req.resume();
|
|
104
|
+
const flood = countAttempt(ctx, NO_TOKEN_LOGIN, now(), ANONYMOUS_PER_MINUTE);
|
|
105
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
106
|
+
const body = { error: 'An agent token is needed: send "Authorization: Bearer cf_at_…". A token is made in the dashboard; a sign-in is not taken in its place.', code: 'unauthorized' };
|
|
107
|
+
await audit(NO_TOKEN_LOGIN, { reason: presented ? 'unknown-token' : 'no-token' }, 401, body);
|
|
108
|
+
return send(res, 401, { ok: false, ...body }, { 'WWW-Authenticate': 'Bearer realm="cornfield"' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { row, state } = found;
|
|
112
|
+
const token = { id: row.id, label: row.label };
|
|
113
|
+
if (state !== 'active') {
|
|
114
|
+
req.resume();
|
|
115
|
+
const flood = countAttempt(ctx, row.user_id, now());
|
|
116
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
117
|
+
const wrong = state === 'wrong-scope';
|
|
118
|
+
const body = {
|
|
119
|
+
error: wrong ? `This is ${row.scope === 'agent' ? 'an' : 'a'} ${row.scope} token. Asking for a run takes an agent token; make one in the dashboard.`
|
|
120
|
+
: state === 'revoked' ? 'This agent token was revoked; make a new one in the dashboard.' : 'This agent token has expired; make a new one in the dashboard.',
|
|
121
|
+
code: 'forbidden',
|
|
122
|
+
};
|
|
123
|
+
await audit(row.login, { reason: wrong ? 'wrong-scope' : state, token, ...(wrong ? { scope: row.scope } : {}) }, 403, body);
|
|
124
|
+
return send(res, 403, { ok: false, ...body });
|
|
125
|
+
}
|
|
126
|
+
touchToken(/** @type {import('node:sqlite').DatabaseSync} */ (db), row.id, now());
|
|
127
|
+
|
|
128
|
+
let input;
|
|
129
|
+
try {
|
|
130
|
+
input = parseBody(await readBody(req, REQUEST_LIMIT), 'A request');
|
|
131
|
+
} catch {
|
|
132
|
+
input = { problem: { status: 400, message: 'The request could not be read.' } };
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const made = await runs.request({
|
|
136
|
+
forge: () => auth.forgeForUser(row.user_id), who: { login: row.login, userId: row.user_id }, limits: { auth, now: ctx.now }, via: { token },
|
|
137
|
+
stillAllowed: async () => {
|
|
138
|
+
const again = findToken(await auth.store(), /** @type {string} */ (presented), now(), 'agent');
|
|
139
|
+
if (again && again.state === 'active') return null;
|
|
140
|
+
return again && again.state === 'expired' ? 'This agent token expired while the request was being checked; nothing was posted.' : 'This agent token was revoked while the request was being checked; nothing was posted.';
|
|
141
|
+
},
|
|
142
|
+
}, input);
|
|
143
|
+
return send(res, 201, { ok: true, ...made });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const out = answerFor(err);
|
|
146
|
+
return send(res, out.status, { ok: false, ...out.body }, out.headers);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The `/api/agent-runs` routes. The caller has passed the sign-in guard, so `session` is a maintainer's.
|
|
152
|
+
* @param {import('node:http').IncomingMessage} req
|
|
153
|
+
* @param {import('node:http').ServerResponse} res
|
|
154
|
+
* @param {string} reqPath
|
|
155
|
+
* @param {AgentContext & { session: { login: string, userId: string } | null }} ctx
|
|
156
|
+
* @returns {Promise<boolean>} whether the path was one of these
|
|
157
|
+
*/
|
|
158
|
+
export async function serveRunRoutes(req, res, reqPath, ctx) {
|
|
159
|
+
const m = /^\/api\/agent-runs(?:\/(run-[0-9A-Za-z]{26}))?(\/stop)?$/.exec(reqPath);
|
|
160
|
+
if (!m || (m[2] && !m[1])) return false;
|
|
161
|
+
const { auth, runs, session } = ctx;
|
|
162
|
+
const runId = m[1] ?? null;
|
|
163
|
+
const allow = m[2] ? 'POST' : runId ? 'GET, HEAD' : 'GET, HEAD, POST';
|
|
164
|
+
if (!allow.split(', ').includes(req.method || '')) {
|
|
165
|
+
req.resume();
|
|
166
|
+
send(res, 405, { ok: false, error: 'Method not allowed.', code: 'invalid' }, { Allow: allow });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (!session || !auth.store || !runs) {
|
|
170
|
+
req.resume();
|
|
171
|
+
send(res, 403, { ok: false, error: 'Agent runs belong to a signed-in maintainer on a repo with a forge: set up sign-in first (docs/sign-in.md).', code: 'forbidden' });
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
176
|
+
req.resume();
|
|
177
|
+
if (runId) {
|
|
178
|
+
const run = isRunId(runId) ? await runs.get(runId) : null;
|
|
179
|
+
send(res, run ? 200 : 404, run ? { ok: true, run } : { ok: false, error: 'cornfield has no such run.', code: 'not-found' });
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
// Refresh: look at the forge first, as the person asking, so the list is as of now (never an error: the list is still the list)
|
|
183
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
184
|
+
if (url.searchParams.get('follow') === '1') {
|
|
185
|
+
try {
|
|
186
|
+
await runs.follow(auth.forgeFor(session));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error(`[cornfield] agent runs: the dashboard's look at the forge could not begin: ${err instanceof Error ? err.message : err}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
send(res, 200, { ok: true, ...(await runs.list()) });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A change: from cornfield's own page, as JSON; the engine counts the flood first and writes a row for everything after
|
|
196
|
+
const ask = { forge: () => auth.forgeFor(session), who: { login: session.login, userId: session.userId }, limits: { auth, now: ctx.now } };
|
|
197
|
+
let problem;
|
|
198
|
+
let body = {};
|
|
199
|
+
if (!sameOrigin(req, ctx.publicHost ?? null)) {
|
|
200
|
+
req.resume();
|
|
201
|
+
problem = { status: 403, message: 'Do this from cornfield\'s own page.' };
|
|
202
|
+
} else if (!/^application\/json(?:\s*;|$)/i.test(String(req.headers['content-type'] || ''))) {
|
|
203
|
+
req.resume();
|
|
204
|
+
problem = { status: 415, message: 'Send the request as JSON (Content-Type: application/json).' };
|
|
205
|
+
} else {
|
|
206
|
+
try {
|
|
207
|
+
const parsed = parseBody(await readBody(req, runId ? STOP_LIMIT : REQUEST_LIMIT), 'The request');
|
|
208
|
+
if (parsed.problem) problem = parsed.problem;
|
|
209
|
+
else body = parsed.body;
|
|
210
|
+
} catch {
|
|
211
|
+
problem = { status: 400, message: 'The request could not be read.' };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (runId && !problem && (body === null || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some((k) => k !== 'note'))) problem = { status: 400, message: 'A stop request is { note? }.' };
|
|
215
|
+
try {
|
|
216
|
+
if (runId) {
|
|
217
|
+
const stopped = await runs.stop(ask, { runId, note: /** @type {any} */ (body)?.note, problem });
|
|
218
|
+
send(res, 200, { ok: true, ...stopped });
|
|
219
|
+
} else {
|
|
220
|
+
const made = await runs.request(ask, { body, problem });
|
|
221
|
+
send(res, 201, { ok: true, ...made });
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
const out = answerFor(err);
|
|
225
|
+
send(res, out.status, { ok: false, ...out.body }, out.headers);
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|