@popcorn245/cornfield 0.9.0 → 0.11.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 +21 -4
- package/core/agent-contract.schema.json +40 -9
- package/core/config.js +40 -8
- package/explorer/forge-panel.js +7 -1
- package/explorer/index.html +2 -1
- package/explorer/review-tokens-model.js +10 -3
- package/explorer/review-tokens.js +4 -2
- package/explorer/runs-model.js +18 -0
- package/package.json +1 -1
- package/server/agent-api.js +4 -3
- package/server/agent-runs.js +214 -43
- package/server/auth.js +58 -3
- package/server/db.js +31 -0
- package/server/forge-actions.js +1 -1
- package/server/hooks.js +37 -3
- package/server/review-api.js +10 -8
- package/server/review-tokens.js +24 -7
- package/server/runner-api.js +270 -0
- package/server/server.js +26 -4
- package/server/spec-poll.js +26 -16
- package/server/threads.js +4 -2
- package/server/triggers.js +284 -0
package/server/hooks.js
CHANGED
|
@@ -25,10 +25,18 @@
|
|
|
25
25
|
* `{ event, at, actor, expiresAt, mapUrl }`
|
|
26
26
|
* agent.requested a maintainer asked for an agent run (server/agent-runs.js). Its body is its own: `{ event, at, request, actor, mapUrl }`,
|
|
27
27
|
* `request` being the whole request JSON of the contract (docs/agent-contract.md), which holds no credential by construction
|
|
28
|
+
* trigger.skipped an opt-in trigger (`agents.on`, server/triggers.js) was going to request a run and did not: the maintainer whose act caused it has no
|
|
29
|
+
* live sign-in (`reason: 'no-session'`), or the request was refused (the cap, the one-run-per-subject rule, the rate limit: the refusal's code).
|
|
30
|
+
* Its body is its own: `{ event, at, trigger: { event, by, ref }, by, reason, mapUrl }`
|
|
28
31
|
* run.claimed run.state run.stopRequested run.finished a run was claimed by its runner, changed state, was asked to stop, ended. They are
|
|
29
32
|
* seen when cornfield looks (the spec poll, server/spec-poll.js), each once. Their body is their own:
|
|
30
33
|
* `{ event, at, runId, state, pull?, issue?, actor, mapUrl }`
|
|
31
34
|
*
|
|
35
|
+
* **Triggers listen in-process.** `subscribe(fn)` hands a function the same moments the hooks fire at, with the payload resolved, whether or not
|
|
36
|
+
* any hook asks for them (server/triggers.js: `spec.approved`, `build.opened`, `thread.message`). A thread's payload then also holds the
|
|
37
|
+
* comment's text and times for that function alone; `bodyOf` picks fixed fields, so none of it can leave through a hook. A subscriber never
|
|
38
|
+
* slows or breaks a delivery, and a failing one is only logged.
|
|
39
|
+
*
|
|
32
40
|
* **A hook never gets in the way.** `emit` returns at once and never throws; the delivery runs in the background, from a
|
|
33
41
|
* queue in memory. A failed delivery (a network error, a timeout, anything but a 2xx) is tried again after 1s, 5s and 30s
|
|
34
42
|
* (four attempts in all) and every attempt is a row in the `actions` audit table (`action: 'hook'`). A hook that is
|
|
@@ -135,6 +143,9 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
135
143
|
if (typeof secret !== 'string' || secret.length < MIN_HOOK_SECRET) throw new Error(`${HOOK_SECRET_ENV} must be at least ${MIN_HOOK_SECRET} characters.`);
|
|
136
144
|
const targets = hooks.map((h, i) => ({ index: i, url: h.url, origin: originOf(h.url), events: h.events === '*' ? HOOK_EVENTS : h.events }));
|
|
137
145
|
const timers = new Set();
|
|
146
|
+
/** @type {{ fn: (event: string, payload: any) => unknown, events: string[] | null }[]} */
|
|
147
|
+
const subscribers = [];
|
|
148
|
+
const listeners = (event) => subscribers.filter((s) => s.events === null || s.events.includes(event));
|
|
138
149
|
let pending = 0;
|
|
139
150
|
let closed = false;
|
|
140
151
|
|
|
@@ -200,6 +211,11 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
200
211
|
function bodyOf(event, p) {
|
|
201
212
|
// A session's end has no spec, pull request or proposal: its body is only who, until when, and where cornfield is
|
|
202
213
|
if (event === 'session.expiring') return JSON.stringify({ event, at: new Date(now()).toISOString(), actor: p.actor ?? null, expiresAt: p.expiresAt ?? null, mapUrl: publicUrl || null });
|
|
214
|
+
// A skipped trigger says which trigger, who caused it and why it did nothing: never a brief or any text
|
|
215
|
+
if (event === 'trigger.skipped') {
|
|
216
|
+
const t = p.trigger && typeof p.trigger === 'object' ? { event: p.trigger.event ?? null, by: p.trigger.by ?? null, ref: p.trigger.ref ?? null } : null;
|
|
217
|
+
return JSON.stringify({ event, at: new Date(now()).toISOString(), trigger: t, by: p.by ?? null, reason: p.reason ?? null, mapUrl: publicUrl || null });
|
|
218
|
+
}
|
|
203
219
|
// A request goes out as it was posted, and is checked once more on the way: nothing like a credential can leave through a hook
|
|
204
220
|
if (event === 'agent.requested') {
|
|
205
221
|
const checked = validateRequest(p.request);
|
|
@@ -244,10 +260,17 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
244
260
|
try {
|
|
245
261
|
if (closed || !HOOK_EVENTS.includes(event)) return;
|
|
246
262
|
const to = targets.filter((t) => t.events.includes(event));
|
|
247
|
-
|
|
263
|
+
// Nobody wants it: the payload is not even worked out (it may read the forge)
|
|
264
|
+
const local = listeners(event);
|
|
265
|
+
if (!to.length && !local.length) return;
|
|
248
266
|
Promise.resolve()
|
|
249
267
|
.then(() => (typeof payload === 'function' ? payload() : payload))
|
|
250
268
|
.then((p) => {
|
|
269
|
+
// In-process listeners first, each on its own: they cannot hold up or break a delivery
|
|
270
|
+
for (const { fn } of local) {
|
|
271
|
+
Promise.resolve().then(() => fn(event, p || {})).catch((err) => console.error(`[cornfield] a listener of ${event} failed: ${err instanceof Error ? err.message : err}`));
|
|
272
|
+
}
|
|
273
|
+
if (!to.length) return;
|
|
251
274
|
const body = bodyOf(event, p || {});
|
|
252
275
|
for (const t of to) {
|
|
253
276
|
if (pending >= MAX_PENDING) {
|
|
@@ -267,6 +290,17 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
267
290
|
}
|
|
268
291
|
}
|
|
269
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Listens in-process to every event `emit` is given (a trigger does: server/triggers.js). `fn(event, payload)` gets the payload
|
|
295
|
+
* resolved; it may be async, is never awaited and its failure is only logged. With `events`, only those: an event nobody listens to and no
|
|
296
|
+
* hook asks for has its payload left unworked out.
|
|
297
|
+
* @param {(event: string, payload: any) => unknown} fn
|
|
298
|
+
* @param {string[]} [events]
|
|
299
|
+
*/
|
|
300
|
+
function subscribe(fn, events) {
|
|
301
|
+
if (typeof fn === 'function') subscribers.push({ fn, events: Array.isArray(events) ? [...events] : null });
|
|
302
|
+
}
|
|
303
|
+
|
|
270
304
|
/**
|
|
271
305
|
* `spec.ready`: the spec PRs a listing just showed. A (pull request, head, proposal) that is not a draft and was not
|
|
272
306
|
* seen before is announced once, and remembered in the SQLite file so a restart does not announce it again. A draft is
|
|
@@ -345,7 +379,7 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
345
379
|
emit('thread.linked', { thread: t.number, actor: t.opened.actor, nodes: t.opened.linked.nodes, proposal: t.opened.linked.proposal });
|
|
346
380
|
}
|
|
347
381
|
for (const m of t.messages) {
|
|
348
|
-
if (fresh(t.number, String(m.id), m.stamp)) emit('thread.message', { thread: t.number, comment: m.id, actor: m.actor });
|
|
382
|
+
if (fresh(t.number, String(m.id), m.stamp)) emit('thread.message', { thread: t.number, comment: m.id, actor: m.actor, message: m.text });
|
|
349
383
|
if (m.linked && fresh(t.number, `link:${m.id}`)) emit('thread.linked', { thread: t.number, comment: m.id, actor: m.actor, nodes: m.linked.nodes, proposal: m.linked.proposal });
|
|
350
384
|
}
|
|
351
385
|
// The last-update time moves with every look that read the thread, so the next look can skip it when nothing changed
|
|
@@ -395,7 +429,7 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
395
429
|
timers.clear();
|
|
396
430
|
}
|
|
397
431
|
|
|
398
|
-
return { emit, specsSeen, threadCursors, threadsSeen, watchSessions, close };
|
|
432
|
+
return { emit, subscribe, specsSeen, threadCursors, threadsSeen, watchSessions, close };
|
|
399
433
|
}
|
|
400
434
|
|
|
401
435
|
/** The SHA-256 of a proposal file as `read` gives it, or null when it cannot be read (an event still goes out) */
|
package/server/review-api.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* GET /api/review-tokens the signed-in maintainer's tokens: id, label, made, expires, revoked, last used (never a secret)
|
|
9
9
|
* POST /api/review-tokens { label, days? } -> the new token, in this answer only
|
|
10
10
|
* POST /api/review-tokens/<id>/revoke ends one of theirs for good
|
|
11
|
-
* * /api/agent-tokens… the same three for tokens of
|
|
11
|
+
* * /api/agent-tokens… the same three for tokens of another scope: an agent token asks for runs
|
|
12
12
|
* (`POST /api/agent-requests`, server/agent-api.js) and is no good here, as a review token is no good there
|
|
13
|
+
* * /api/runner-tokens… and for a runner token, which lists, claims and reports on runs (server/runner-api.js)
|
|
13
14
|
*
|
|
14
15
|
* **The call takes the token and nothing else.** It runs before the sign-in guard (a script has no cookie) and reads no
|
|
15
16
|
* cookie: a session cookie is never a substitute for the token, and a request that sends only one is 401. Being no
|
|
@@ -44,6 +45,7 @@ import { Refused, countAttempt } from './throttle.js';
|
|
|
44
45
|
export const REVIEWS_PATH = '/api/reviews';
|
|
45
46
|
export const TOKENS_PATH = '/api/review-tokens';
|
|
46
47
|
export const AGENT_TOKENS_PATH = '/api/agent-tokens';
|
|
48
|
+
export const RUNNER_TOKENS_PATH = '/api/runner-tokens';
|
|
47
49
|
/** Calls with no token, or one nobody holds, that are written down in a minute, all callers together */
|
|
48
50
|
export const ANONYMOUS_PER_MINUTE = 20;
|
|
49
51
|
/** What the audit row of a call nobody could name says in place of a login */
|
|
@@ -109,7 +111,7 @@ export async function serveReviewCall(req, res, ctx) {
|
|
|
109
111
|
req.resume();
|
|
110
112
|
const flood = countAttempt(ctx, row.user_id, now());
|
|
111
113
|
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
112
|
-
const body = { error: `This is an ${row.scope} token. Reviews take a review token; make one in the dashboard.`, code: 'forbidden' };
|
|
114
|
+
const body = { error: `This is ${row.scope === 'agent' ? 'an' : 'a'} ${row.scope} token. Reviews take a review token; make one in the dashboard.`, code: 'forbidden' };
|
|
113
115
|
await audit(row.login, { reason: 'wrong-scope', token, scope: row.scope }, 403, body);
|
|
114
116
|
return send(res, 403, { ok: false, ...body });
|
|
115
117
|
}
|
|
@@ -182,10 +184,10 @@ const publicToken = ({ id, login, label, scope, created_at, expires_at, revoked_
|
|
|
182
184
|
* @returns {Promise<boolean>} whether the path was one of these
|
|
183
185
|
*/
|
|
184
186
|
export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
185
|
-
const revoke = /^\/api\/(review|agent)-tokens\/([1-9]\d{0,9})\/revoke$/.exec(reqPath);
|
|
186
|
-
if (reqPath !== TOKENS_PATH && reqPath !== AGENT_TOKENS_PATH && !revoke) return false;
|
|
187
|
-
/** @type {'review'
|
|
188
|
-
const scope = revoke ? /** @type {'review'
|
|
187
|
+
const revoke = /^\/api\/(review|agent|runner)-tokens\/([1-9]\d{0,9})\/revoke$/.exec(reqPath);
|
|
188
|
+
if (reqPath !== TOKENS_PATH && reqPath !== AGENT_TOKENS_PATH && reqPath !== RUNNER_TOKENS_PATH && !revoke) return false;
|
|
189
|
+
/** @type {import('./review-tokens.js').TokenScope} the scope of the tokens this path is about */
|
|
190
|
+
const scope = revoke ? /** @type {import('./review-tokens.js').TokenScope} */ (revoke[1]) : reqPath === AGENT_TOKENS_PATH ? 'agent' : reqPath === RUNNER_TOKENS_PATH ? 'runner' : 'review';
|
|
189
191
|
const { config, auth, session } = ctx;
|
|
190
192
|
const now = ctx.now ?? Date.now;
|
|
191
193
|
const allow = revoke ? 'POST' : 'GET, POST';
|
|
@@ -197,7 +199,7 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
197
199
|
}
|
|
198
200
|
if (!session || !auth.store) {
|
|
199
201
|
req.resume();
|
|
200
|
-
send(res, 403, { ok: false, error: `${scope === 'agent' ? 'Agent' : 'Review'} tokens belong to a signed-in maintainer: set up sign-in first (docs/sign-in.md).`, code: 'forbidden' });
|
|
202
|
+
send(res, 403, { ok: false, error: `${scope === 'agent' ? 'Agent' : scope === 'runner' ? 'Runner' : 'Review'} tokens belong to a signed-in maintainer: set up sign-in first (docs/sign-in.md).`, code: 'forbidden' });
|
|
201
203
|
return true;
|
|
202
204
|
}
|
|
203
205
|
const db = await auth.store();
|
|
@@ -214,7 +216,7 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
|
|
|
214
216
|
return true;
|
|
215
217
|
}
|
|
216
218
|
const what = revoke ? 'revoke' : 'mint';
|
|
217
|
-
const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(scope
|
|
219
|
+
const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(scope !== 'review' ? { scope } : {}), ...(revoke ? { id: Number(revoke[2]) } : {}) });
|
|
218
220
|
let rowId = null;
|
|
219
221
|
/** @type {{ status: number, body: object }} */
|
|
220
222
|
let out;
|
package/server/review-tokens.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* (server/agent-api.js). The store over the `review_tokens` table of `cornfield.db` (server/db.js); nothing here knows
|
|
7
7
|
* about HTTP.
|
|
8
8
|
*
|
|
9
|
-
* - **A token has a scope,** `review` or `
|
|
10
|
-
*
|
|
11
|
-
* once and cannot drift. A review token is `cf_rt_…` and
|
|
9
|
+
* - **A token has a scope,** `review`, `agent` or `runner`, and is good for the one API its scope names; each API refuses the
|
|
10
|
+
* others' tokens (403) and says so. One store serves both, so hashing, expiry, revoking, `last_used` and the limits are written
|
|
11
|
+
* once and cannot drift. A review token is `cf_rt_…`, an agent token `cf_at_…` and a runner token `cf_nt_…`, so a leaked one is told apart on
|
|
12
12
|
* sight (and by a secret scanner). Rows from before scopes existed are review tokens.
|
|
13
13
|
* - A token is its prefix and 32 random bytes in base64url. It is shown once, when it is made; only its SHA-256 is
|
|
14
14
|
* kept. A presented token is found by the hash of what was presented (an indexed equality on a hash of a 256-bit
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* Times are ISO strings, as everywhere in the file; `now` is milliseconds and is passed in, so tests need no clock.
|
|
24
24
|
*
|
|
25
|
-
* @typedef {'review' | 'agent'} TokenScope
|
|
25
|
+
* @typedef {'review' | 'agent' | 'runner'} TokenScope
|
|
26
26
|
* @typedef {{ id: number, user_id: string, login: string, label: string, scope: TokenScope, created_at: string, expires_at: string, revoked_at: string | null, last_used: string | null }} TokenRow
|
|
27
27
|
* @typedef {'active' | 'expired' | 'revoked' | 'wrong-scope'} TokenState `wrong-scope` only from `findToken` asked for a scope
|
|
28
28
|
*/
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
import { createHash, randomBytes } from 'node:crypto';
|
|
31
31
|
|
|
32
32
|
/** The scopes a token may have, and what each one's tokens start with, so a leaked one is recognised (by a secret scanner too) */
|
|
33
|
-
export const SCOPES = Object.freeze(['review', 'agent']);
|
|
34
|
-
export const PREFIXES = Object.freeze({ review: 'cf_rt_', agent: 'cf_at_' });
|
|
33
|
+
export const SCOPES = Object.freeze(['review', 'agent', 'runner']);
|
|
34
|
+
export const PREFIXES = Object.freeze({ review: 'cf_rt_', agent: 'cf_at_', runner: 'cf_nt_' });
|
|
35
35
|
/** What a review token starts with (the original, kept for the callers that always had it) */
|
|
36
36
|
export const TOKEN_PREFIX = PREFIXES.review;
|
|
37
37
|
/** Random bytes in a token */
|
|
@@ -43,7 +43,7 @@ export const MAX_ACTIVE = 20;
|
|
|
43
43
|
export const MAX_LABEL = 60;
|
|
44
44
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
45
45
|
/** A token as presented: the prefix and base64url (43 characters for 32 bytes); anything else is not one */
|
|
46
|
-
const TOKEN_SHAPE = /^cf_(?:rt|at)_[A-Za-z0-9_-]{43,128}$/;
|
|
46
|
+
const TOKEN_SHAPE = /^cf_(?:rt|at|nt)_[A-Za-z0-9_-]{43,128}$/;
|
|
47
47
|
/** A label is words: letters, digits, spaces and a little punctuation. No backtick, no @, no newline: the comment quotes it */
|
|
48
48
|
const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} ._:/()-]*$/u;
|
|
49
49
|
const COLUMNS = 'id, user_id, login, label, scope, created_at, expires_at, revoked_at, last_used';
|
|
@@ -55,6 +55,12 @@ export function hashToken(token) {
|
|
|
55
55
|
return createHash('sha256').update(String(token)).digest('hex');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** Which scope a presented token's shape names (by its prefix; the row's `scope` is what counts), or null when it is not shaped like one of ours */
|
|
59
|
+
export function scopeOfShape(token) {
|
|
60
|
+
if (typeof token !== 'string' || !TOKEN_SHAPE.test(token)) return null;
|
|
61
|
+
return /** @type {TokenScope} */ (Object.keys(PREFIXES).find((k) => token.startsWith(PREFIXES[k])) ?? null);
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
/**
|
|
59
65
|
* The token a request presents: the value of `Authorization: Bearer <token>`, or null when there is none, or it is
|
|
60
66
|
* not shaped like one of ours (either scope's; which API it is good for is the row's `scope`) (the scheme is matched in any case, as RFC 9110 says).
|
|
@@ -159,6 +165,17 @@ export function listTokens(db, userId, now, scope) {
|
|
|
159
165
|
.map((row) => ({ ...row, state: stateOf(row, now) }));
|
|
160
166
|
}
|
|
161
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Revokes every live token of an owner, of every scope (review, agent, runner): what is done when cornfield learns they no longer maintain the
|
|
170
|
+
* repo. A token revoked already keeps its first time. Answers how many were revoked.
|
|
171
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
172
|
+
* @param {string} userId
|
|
173
|
+
* @param {number} now
|
|
174
|
+
*/
|
|
175
|
+
export function revokeOwnerTokens(db, userId, now) {
|
|
176
|
+
return Number(db.prepare('UPDATE review_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL').run(iso(now), String(userId)).changes);
|
|
177
|
+
}
|
|
178
|
+
|
|
162
179
|
/**
|
|
163
180
|
* Revokes one of an owner's tokens, for good. Another owner's id is no token to them. Revoking a token that is
|
|
164
181
|
* revoked already keeps the first time.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module server/runner-api
|
|
3
|
+
*
|
|
4
|
+
* The runner's side of the pull and push transports (issue #81, spec 0005 D2, D3, D5). The engine is server/agent-runs.js, the contract is
|
|
5
|
+
* core/agent-contract.js, and docs/agents.md says how a runner uses these routes. A runner behind NAT asks cornfield for work, claims it, and
|
|
6
|
+
* reports on it; a runner that was pushed a request (the `agent.requested` hook event) reports the same way.
|
|
7
|
+
*
|
|
8
|
+
* GET /api/agent-requests?state=queued the queued requests (the contract's request JSON), oldest first, at most `QUEUE_LIMIT`
|
|
9
|
+
* POST /api/agent-requests/<runId>/claim claims one; a second claim of it is 409. Answers the request and the run
|
|
10
|
+
* GET /api/runs/<runId> { state, stopRequested, … } of a run this token holds: how a runner learns it was asked to stop
|
|
11
|
+
* POST /api/runs/<runId> the report JSON ({ state, note?, url?, tokens?, cost? }), checked by the contract's `validateReport`
|
|
12
|
+
*
|
|
13
|
+
* **These routes take a runner token (`Authorization: Bearer cf_nt_…`) and nothing else,** as the review API takes a review token
|
|
14
|
+
* (server/review-api.js): they run before the sign-in guard (a runner has no cookie), read no cookie (a cookie is never a substitute, and a
|
|
15
|
+
* request that sends only one is 401), need no same-origin check (a browser never adds an `Authorization` header itself), and keep the `Host`
|
|
16
|
+
* check in front of every route. **The three scopes are kept apart both ways:** a review or an agent token here is 403 whatever else is true
|
|
17
|
+
* of it, and a runner token on any other route (the review API, the agent API, the dashboard's routes, the page itself) is 403 too
|
|
18
|
+
* (`serveStrayRunner`, which `server.js` calls before anything else is looked at).
|
|
19
|
+
*
|
|
20
|
+
* What a runner token may do, and nothing more: read the queue, claim, and read and report on **the runs it holds**. A pull run is held by the
|
|
21
|
+
* token that claimed it; a push run by the first token to report on it (nothing else tells a push run apart: the hook went to a URL, not a
|
|
22
|
+
* token). A run asked for on the forge is nobody's: its reports are `cornfield-run` comments. A token never reads a request it did not claim,
|
|
23
|
+
* and never anything of cornfield's besides.
|
|
24
|
+
*
|
|
25
|
+
* **Token states, the anonymous flood ceiling and the rows in `actions` follow the review API.** Every call is a row (`runner-list`,
|
|
26
|
+
* `runner-claim`, `runner-run`, `runner-report`, and `runner-call` for a token on the wrong route), with the token's id and label and the
|
|
27
|
+
* run id in the request, and the status and error in the answer, **never the token and never a note's words**. Two bounds keep the table from
|
|
28
|
+
* growing without end: past `RUNNER_PER_MINUTE` calls a minute a token is 429 and nothing is written, and an empty queue read is written down
|
|
29
|
+
* at most once every `EMPTY_LIST_ROW_MS` per token (a runner polls; a poll that finds nothing says little).
|
|
30
|
+
*
|
|
31
|
+
* 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
|
|
32
|
+
* a review or an agent token 403, under its owner
|
|
33
|
+
* a token that is revoked or expired 403, under its owner
|
|
34
|
+
* a live runner token whatever the engine answers: 200, or 400, 403, 404, 409, 429
|
|
35
|
+
*
|
|
36
|
+
* **The token is not asked of the forge.** A runner has its own identity, and cornfield's forge sign-in of the token's owner may well have
|
|
37
|
+
* ended by the time a runner polls. What bounds a runner token is its scope, its expiry (90 days at most), that its owner revokes it in
|
|
38
|
+
* the dashboard at once, and that **an owner who stops maintaining the repo loses it**: whenever cornfield learns that (their sign-in, the
|
|
39
|
+
* 15-minute re-check, any question it puts to the forge with their token) all their API tokens are revoked (`auth.demoted`), and each call
|
|
40
|
+
* here also refuses, and revokes, when the owner's newest sign-in says they are not a maintainer (`auth.knownPermission`). No sign-in on
|
|
41
|
+
* record is no answer: the token goes on. (Review and agent tokens, which act as their owner on the forge, are checked against the forge on
|
|
42
|
+
* every call.)
|
|
43
|
+
*
|
|
44
|
+
* Nothing in an answer is a credential: the request is the contract's (validated again on the way out), and a run shows its state.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { readBody } from './proposals.js';
|
|
48
|
+
import { ANONYMOUS_PER_MINUTE, NO_TOKEN_LOGIN } from './review-api.js';
|
|
49
|
+
import { bearerOf, findToken, scopeOfShape, touchToken } from './review-tokens.js';
|
|
50
|
+
import { QUEUE_LIMIT } from './agent-runs.js';
|
|
51
|
+
import { refusal } from './submit-review.js';
|
|
52
|
+
import { Refused, countAttempt } from './throttle.js';
|
|
53
|
+
import { ForgeError, isMaintainer } from './forge.js';
|
|
54
|
+
|
|
55
|
+
export const REQUESTS_PATH = '/api/agent-requests';
|
|
56
|
+
/** Calls one runner token may make in a minute: a runner polls, so this is well over a person's ceiling */
|
|
57
|
+
export const RUNNER_PER_MINUTE = 120;
|
|
58
|
+
/** An empty queue read is written down at most this often per token */
|
|
59
|
+
export const EMPTY_LIST_ROW_MS = 10 * 60_000;
|
|
60
|
+
/** A report's body, in bytes: a note, a link and two numbers, with room */
|
|
61
|
+
export const REPORT_LIMIT = 16 * 1024;
|
|
62
|
+
const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
|
63
|
+
const RUN_ID = '(run-[0-9A-Za-z]{26})';
|
|
64
|
+
const CLAIM = new RegExp(`^/api/agent-requests/${RUN_ID}/claim$`);
|
|
65
|
+
const RUN = new RegExp(`^/api/runs/${RUN_ID}$`);
|
|
66
|
+
|
|
67
|
+
function send(res, status, body, headers = {}) {
|
|
68
|
+
res.writeHead(status, { ...HEADERS, ...headers });
|
|
69
|
+
res.end(JSON.stringify(body));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The routes of this module: the action each is audited as, the run it names, and the methods it answers. Null for any other path. */
|
|
73
|
+
export function runnerRoute(reqPath) {
|
|
74
|
+
let m;
|
|
75
|
+
if (reqPath === REQUESTS_PATH) return { action: 'runner-list', runId: null, allow: ['GET', 'HEAD'] };
|
|
76
|
+
if ((m = CLAIM.exec(reqPath))) return { action: 'runner-claim', runId: m[1], allow: ['POST'] };
|
|
77
|
+
if ((m = RUN.exec(reqPath))) return { action: 'runner-run', runId: m[1], allow: ['GET', 'HEAD', 'POST'] };
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a path is one that takes a bearer token of some scope (so a runner token on it is answered by that route, not as a stray) */
|
|
82
|
+
export const takesTokens = (reqPath, { reviews }) => reqPath === reviews || reqPath === REQUESTS_PATH || runnerRoute(reqPath) !== null;
|
|
83
|
+
|
|
84
|
+
/** The scope of a bearer token as the request presents it, by its shape alone (the row decides what it is worth) */
|
|
85
|
+
export const presentedScope = (req) => scopeOfShape(bearerOf(req.headers.authorization));
|
|
86
|
+
|
|
87
|
+
/** @type {WeakMap<object, Map<number, number>>} when each token's empty queue read was last written down */
|
|
88
|
+
const emptyReads = new WeakMap();
|
|
89
|
+
|
|
90
|
+
function emptyReadDue(auth, id, at) {
|
|
91
|
+
let m = emptyReads.get(auth);
|
|
92
|
+
if (!m) emptyReads.set(auth, (m = new Map()));
|
|
93
|
+
for (const [k, when] of m) if (at - when >= EMPTY_LIST_ROW_MS) m.delete(k);
|
|
94
|
+
if (m.has(id)) return false;
|
|
95
|
+
m.set(id, at);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 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 */
|
|
100
|
+
function answerFor(err) {
|
|
101
|
+
if (err instanceof Refused || err instanceof ForgeError) return refusal(err);
|
|
102
|
+
console.error(`[cornfield] a runner call failed: ${err instanceof Error ? err.stack || err.message : err}`);
|
|
103
|
+
return { status: 500, body: { error: 'Something went wrong in cornfield; the log says what.', code: 'unavailable' }, headers: {} };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @typedef {{ config: any, auth: any, runs: import('./agent-runs.js').Runs | null, now?: () => number }} RunnerContext */
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The runner routes. The caller has found the path to be one of `runnerRoute`'s; this runs before the sign-in guard.
|
|
110
|
+
* @param {import('node:http').IncomingMessage} req
|
|
111
|
+
* @param {import('node:http').ServerResponse} res
|
|
112
|
+
* @param {NonNullable<ReturnType<typeof runnerRoute>>} route
|
|
113
|
+
* @param {URL} url
|
|
114
|
+
* @param {RunnerContext} ctx
|
|
115
|
+
*/
|
|
116
|
+
export async function serveRunnerCall(req, res, route, url, ctx) {
|
|
117
|
+
const { config, auth, runs } = ctx;
|
|
118
|
+
const now = ctx.now ?? Date.now;
|
|
119
|
+
const method = req.method || 'GET';
|
|
120
|
+
if (!route.allow.includes(method)) {
|
|
121
|
+
req.resume();
|
|
122
|
+
return send(res, 405, { ok: false, error: 'Method not allowed.', code: 'invalid' }, { Allow: route.allow.join(', ') });
|
|
123
|
+
}
|
|
124
|
+
if (!auth.enabled || !runs) {
|
|
125
|
+
req.resume();
|
|
126
|
+
return send(res, 403, { ok: false, error: 'Runner tokens need sign-in and a forge: set them up first (docs/sign-in.md, docs/config.md#forge).', code: 'forbidden' });
|
|
127
|
+
}
|
|
128
|
+
const repo = config.forge.repo;
|
|
129
|
+
const audit = async (login, request, status, answer, target = repo) => {
|
|
130
|
+
try {
|
|
131
|
+
await auth.record({ login, action: route.action, target, request: { via: 'api', ...(route.runId ? { runId: route.runId } : {}), ...request }, status, answer });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error(`[cornfield] a runner call could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const refuse = async (login, request, status, body, headers = {}) => {
|
|
137
|
+
await audit(login, request, status, { error: body.error, code: body.code });
|
|
138
|
+
return send(res, status, { ok: false, ...body }, headers);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const presented = bearerOf(req.headers.authorization);
|
|
142
|
+
const db = presented ? await auth.store() : null;
|
|
143
|
+
const found = presented && db ? findToken(db, presented, now(), 'runner') : null;
|
|
144
|
+
if (!found) {
|
|
145
|
+
req.resume();
|
|
146
|
+
const flood = countAttempt(ctx, NO_TOKEN_LOGIN, now(), ANONYMOUS_PER_MINUTE);
|
|
147
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
148
|
+
return refuse(NO_TOKEN_LOGIN, { reason: presented ? 'unknown-token' : 'no-token' }, 401,
|
|
149
|
+
{ error: 'A runner token is needed: send "Authorization: Bearer cf_nt_…". A token is made in the dashboard; a sign-in is not taken in its place.', code: 'unauthorized' },
|
|
150
|
+
{ 'WWW-Authenticate': 'Bearer realm="cornfield"' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { row, state } = found;
|
|
154
|
+
const token = { id: row.id, label: row.label };
|
|
155
|
+
if (state !== 'active') {
|
|
156
|
+
req.resume();
|
|
157
|
+
const flood = countAttempt(ctx, row.user_id, now());
|
|
158
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
159
|
+
const wrong = state === 'wrong-scope';
|
|
160
|
+
return refuse(row.login, { reason: wrong ? 'wrong-scope' : state, token, ...(wrong ? { scope: row.scope } : {}) }, 403, {
|
|
161
|
+
error: wrong ? `This is ${row.scope === 'agent' ? 'an' : 'a'} ${row.scope} token. Runners take a runner token; make one in the dashboard.`
|
|
162
|
+
: state === 'revoked' ? 'This runner token was revoked; make a new one in the dashboard.' : 'This runner token has expired; make a new one in the dashboard.',
|
|
163
|
+
code: 'forbidden',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const flood = countAttempt(ctx, `runner|${row.id}`, now(), RUNNER_PER_MINUTE);
|
|
167
|
+
if (flood) {
|
|
168
|
+
req.resume();
|
|
169
|
+
return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
170
|
+
}
|
|
171
|
+
// The cheap guard: what cornfield last heard of the owner's permission (their newest sign-in's). A runner needs no live sign-in, so no sign-in
|
|
172
|
+
// at all is no answer; one that says they no longer maintain the repo refuses the call and revokes their tokens there and then
|
|
173
|
+
const known = await auth.knownPermission(row.user_id);
|
|
174
|
+
if (known !== null && !isMaintainer(known)) {
|
|
175
|
+
req.resume();
|
|
176
|
+
await auth.demoted(row.user_id, row.login, known);
|
|
177
|
+
return refuse(row.login, { reason: 'owner-not-maintainer', token }, 403, { error: `This runner token's owner, ${row.login}, does not maintain ${repo} any more, so it is revoked.`, code: 'forbidden' });
|
|
178
|
+
}
|
|
179
|
+
touchToken(/** @type {import('node:sqlite').DatabaseSync} */ (db), row.id, now());
|
|
180
|
+
const who = row.login;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
if (route.action === 'runner-list') {
|
|
184
|
+
req.resume();
|
|
185
|
+
if (url.searchParams.get('state') !== 'queued') throw new Refused(400, 'The only listing is ?state=queued: the requests waiting for a runner.', 'invalid');
|
|
186
|
+
const requests = await runs.queued();
|
|
187
|
+
if (requests.length || emptyReadDue(auth, row.id, now())) await audit(who, { token }, 200, { ok: true, count: requests.length });
|
|
188
|
+
return send(res, 200, { ok: true, requests, limit: QUEUE_LIMIT });
|
|
189
|
+
}
|
|
190
|
+
if (route.action === 'runner-claim') {
|
|
191
|
+
req.resume();
|
|
192
|
+
const claimed = await runs.claim(token, route.runId);
|
|
193
|
+
await audit(who, { token }, 200, { ok: true, claimed: true }, `${repo}#${claimed.row.target}`);
|
|
194
|
+
return send(res, 200, { ok: true, request: claimed.request, run: claimed.run });
|
|
195
|
+
}
|
|
196
|
+
if (method === 'POST') {
|
|
197
|
+
let body;
|
|
198
|
+
let problem = null;
|
|
199
|
+
try {
|
|
200
|
+
const read = await readBody(req, REPORT_LIMIT);
|
|
201
|
+
if (read.tooLarge) problem = new Refused(413, `A report is at most ${REPORT_LIMIT / 1024} KB.`, 'invalid');
|
|
202
|
+
else {
|
|
203
|
+
try {
|
|
204
|
+
body = JSON.parse(read.text);
|
|
205
|
+
} catch {
|
|
206
|
+
// Not the parser's words: they quote the body, which the audit row would keep (a credential pasted in by mistake)
|
|
207
|
+
problem = new Refused(400, 'The body is not valid JSON.', 'invalid');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
problem = new Refused(400, 'The report could not be read.', 'invalid');
|
|
212
|
+
}
|
|
213
|
+
const shape = body && typeof body === 'object' && !Array.isArray(body) ? { ...(typeof body.state === 'string' && body.state.length <= 20 ? { state: body.state } : {}), ...(typeof body.note === 'string' ? { noteChars: body.note.length } : {}) } : {};
|
|
214
|
+
try {
|
|
215
|
+
if (problem) throw problem;
|
|
216
|
+
const done = await runs.report(token, route.runId, body);
|
|
217
|
+
await audit(who, { token, ...shape }, 200, { ok: true, state: done.row.state }, `${repo}#${done.row.target}`);
|
|
218
|
+
return send(res, 200, { ok: true, run: done.run });
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const out = answerFor(err);
|
|
221
|
+
await audit(who, { token, ...shape }, out.status, { error: out.body.error, code: out.body.code });
|
|
222
|
+
return send(res, out.status, { ok: false, ...out.body }, out.headers);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
req.resume();
|
|
226
|
+
const view = await runs.runnerGet(token, route.runId);
|
|
227
|
+
await audit(who, { token }, 200, { ok: true, state: view.state });
|
|
228
|
+
return send(res, 200, { ok: true, run: view });
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const out = answerFor(err);
|
|
231
|
+
await audit(who, { token }, out.status, { error: out.body.error, code: out.body.code });
|
|
232
|
+
return send(res, out.status, { ok: false, ...out.body }, out.headers);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A runner token on a route that is not a runner's: 403, whatever the route would have said (even a path that does not exist). Called by
|
|
238
|
+
* `server.js` for any request that presents something shaped like a runner token on a path outside `takesTokens`, before the guard, the proxies
|
|
239
|
+
* and the page. A token that is a live runner's is counted under its own id, anything else under the anonymous ceiling; either way one row.
|
|
240
|
+
* @param {import('node:http').IncomingMessage} req
|
|
241
|
+
* @param {import('node:http').ServerResponse} res
|
|
242
|
+
* @param {string} reqPath
|
|
243
|
+
* @param {RunnerContext} ctx
|
|
244
|
+
*/
|
|
245
|
+
export async function serveStrayRunner(req, res, reqPath, ctx) {
|
|
246
|
+
const { config, auth } = ctx;
|
|
247
|
+
const now = ctx.now ?? Date.now;
|
|
248
|
+
req.resume();
|
|
249
|
+
const body = { ok: false, error: 'A runner token is good for the runner routes only (docs/agents.md#pull): listing, claiming and reporting on runs. Nothing else of cornfield takes it.', code: 'forbidden' };
|
|
250
|
+
const presented = bearerOf(req.headers.authorization);
|
|
251
|
+
let owner = null;
|
|
252
|
+
let token;
|
|
253
|
+
if (presented && auth.enabled) {
|
|
254
|
+
const found = findToken(await auth.store(), presented, now(), 'runner');
|
|
255
|
+
if (found && found.state !== 'wrong-scope') { owner = found.row.login; token = { id: found.row.id, label: found.row.label }; }
|
|
256
|
+
}
|
|
257
|
+
const flood = owner === null ? countAttempt(ctx, NO_TOKEN_LOGIN, now(), ANONYMOUS_PER_MINUTE) : countAttempt(ctx, `runner|${token.id}`, now(), RUNNER_PER_MINUTE);
|
|
258
|
+
if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
|
|
259
|
+
if (auth.enabled) {
|
|
260
|
+
try {
|
|
261
|
+
await auth.record({
|
|
262
|
+
login: owner ?? NO_TOKEN_LOGIN, action: 'runner-call', target: config.forge?.repo ?? '',
|
|
263
|
+
request: { via: 'api', reason: 'wrong-route', method: String(req.method).slice(0, 10), path: reqPath.slice(0, 80), ...(token ? { token } : {}) }, status: 403, answer: { error: body.error, code: body.code },
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error(`[cornfield] a runner call could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return send(res, 403, body);
|
|
270
|
+
}
|
package/server/server.js
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* * /api/review-tokens…, /api/agent-tokens…
|
|
26
26
|
* make, list and revoke review tokens, and agent tokens: the signed-in maintainer only (server/review-api.js)
|
|
27
27
|
* POST /api/agent-requests ask for an agent run with an agent token (`Authorization: Bearer cf_at_…`), no cookie: before the guard too (server/agent-api.js)
|
|
28
|
+
* GET /api/agent-requests, POST /api/agent-requests/<run>/claim, GET and POST /api/runs/<run>
|
|
29
|
+
* a runner's: list the queue, claim, read and report, with a runner token (`cf_nt_…`) and no cookie, before the guard
|
|
30
|
+
* too; a runner token on any other route is 403 (server/runner-api.js)
|
|
28
31
|
* * /api/agent-runs… the dashboard's runs: list, one, start, stop; the signed-in maintainer only (server/agent-api.js, server/agent-runs.js)
|
|
29
32
|
* * /proposals… server/proposals.js (with sign-in, the open spec PRs too: server/proposal-pulls.js,
|
|
30
33
|
* and a review is a comment on one: server/reviews.js)
|
|
@@ -43,9 +46,9 @@
|
|
|
43
46
|
import fs from 'node:fs';
|
|
44
47
|
import http from 'node:http';
|
|
45
48
|
import path from 'node:path';
|
|
46
|
-
import { createHash } from 'node:crypto';
|
|
49
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
47
50
|
import { fileURLToPath } from 'node:url';
|
|
48
|
-
import { browserSources, BUILTIN_SOURCES, parseDuration, specPollMs, DEFAULTS, ideaLabel } from '../core/config.js';
|
|
51
|
+
import { browserSources, BUILTIN_SOURCES, parseDuration, specPollMs, triggersOf, DEFAULTS, hookWants, ideaLabel } from '../core/config.js';
|
|
49
52
|
import { CLOUD_FILE } from '../core/cloud.js';
|
|
50
53
|
import { decodePath, inside, notFound, sendFile } from './files.js';
|
|
51
54
|
import { serveProposals } from './proposals.js';
|
|
@@ -53,6 +56,7 @@ import { serveForge } from './forge-routes.js';
|
|
|
53
56
|
import { hasAdapter } from './forge.js';
|
|
54
57
|
import { createRuns } from './agent-runs.js';
|
|
55
58
|
import { REQUESTS_PATH, serveAgentCall, serveRunRoutes } from './agent-api.js';
|
|
59
|
+
import { presentedScope, runnerRoute, serveRunnerCall, serveStrayRunner, takesTokens } from './runner-api.js';
|
|
56
60
|
import { REVIEWS_PATH, serveReviewCall, serveTokenRoutes } from './review-api.js';
|
|
57
61
|
import { proxyTo } from './proxy.js';
|
|
58
62
|
import { HA_ENV, homeAssistantHouse } from '../plugins/house/home-assistant.js';
|
|
@@ -61,6 +65,7 @@ import { createAuth, signInSettings } from './auth.js';
|
|
|
61
65
|
import { createHooks, hooksSettings } from './hooks.js';
|
|
62
66
|
import { createLander } from './lander.js';
|
|
63
67
|
import { createSpecPoll } from './spec-poll.js';
|
|
68
|
+
import { createTriggers } from './triggers.js';
|
|
64
69
|
|
|
65
70
|
export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
66
71
|
const EXPLORER_DIR = path.join(CORNFIELD_DIR, 'explorer');
|
|
@@ -146,8 +151,13 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
146
151
|
const auth = createAuth(config, opts);
|
|
147
152
|
// Hooks listed without a secret (or without sign-in) are a startup error, like a half-configured sign-in
|
|
148
153
|
const hookSettings = hooksSettings(config, env, auth.enabled);
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
// Opt-in triggers (agents.on, server/triggers.js) act as the maintainer whose act caused them and read the forge: both need sign-in and a forge
|
|
155
|
+
const triggerList = triggersOf(config);
|
|
156
|
+
if (triggerList.length && !auth.enabled) throw new Error('The config says agents.on, but sign-in is not set up. A trigger requests a run as the maintainer whose act caused it, with their own sign-in, so without sign-in nothing would ever fire: set up sign-in (docs/sign-in.md), or remove agents.on.');
|
|
157
|
+
if (triggerList.length && !(config.forge && hasAdapter(config.forge.type))) throw new Error('The config says agents.on, but there is no forge to ask on. Set up `forge` (docs/config.md#forge), or remove agents.on.');
|
|
158
|
+
// With triggers and no hook listed, the hooks object has no destination: it is only the moments the triggers subscribe to (and the thread look's memory)
|
|
159
|
+
const hooks = hookSettings || triggerList.length
|
|
160
|
+
? createHooks({ hooks: hookSettings ? config.hooks : [], secret: hookSettings ? hookSettings.secret : randomBytes(32).toString('hex'), fetch: opts.hookFetch, record: (entry) => auth.record(entry), store: auth.store, now: opts.now, publicUrl: config.publicUrl, timer: opts.hookTimer })
|
|
151
161
|
: null;
|
|
152
162
|
hooks?.watchSessions();
|
|
153
163
|
// Landing on approval needs somebody signed in to land as, like the hooks' events: a half-set key is a startup error
|
|
@@ -166,6 +176,12 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
166
176
|
}
|
|
167
177
|
// Agent runs (server/agent-runs.js) need a signed-in maintainer to ask as and a forge to ask on; the runs are followed on the spec poll's tick
|
|
168
178
|
const runs = config.forge && auth.enabled && hasAdapter(config.forge.type) ? createRuns({ config, where, auth, hooks, now: opts.now, onAsk: () => specPoll?.start() }) : null;
|
|
179
|
+
// Push and pull need the same two things a request on the forge does (a maintainer's sign-in to ask as, a forge to check against), and push needs the hook that delivers it
|
|
180
|
+
const transport = config.agents?.transport ?? 'forge';
|
|
181
|
+
if (transport !== 'forge' && !runs) throw new Error(`agents.transport is "${transport}", which needs sign-in and a forge to ask as a maintainer and to check them: set them up (docs/sign-in.md, docs/config.md#forge).`);
|
|
182
|
+
if (transport === 'push' && !hookWants(config, ['agent.requested'])) throw new Error('agents.transport is "push", so a hook must subscribe to agent.requested: add { url, events: ["agent.requested"] } to "hooks" (docs/config.md#hooks). Without one a request would go nowhere.');
|
|
183
|
+
// Triggers subscribe to the hooks' moments and call the runs' own request path; nothing a run produces is wired to them (server/triggers.js)
|
|
184
|
+
if (runs && hooks && triggerList.length) createTriggers({ config, auth, runs, hooks, now: opts.now });
|
|
169
185
|
runs?.resume().catch((err) => console.error(`[cornfield] agent runs could not be picked up: ${err instanceof Error ? err.message : err}`));
|
|
170
186
|
// Looks at the open spec PRs on a timer when a hook wants spec.ready (config `hookPoll`); started when the server listens. The same timer follows
|
|
171
187
|
// agent runs: it is started for them when there is a run to follow, or as one is asked for, and does nothing while there is none
|
|
@@ -249,6 +265,9 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
249
265
|
return res.end('400 Bad Request');
|
|
250
266
|
}
|
|
251
267
|
|
|
268
|
+
// A runner token is good for the runner routes only: on any other route it is refused here, before anything else is looked at
|
|
269
|
+
if (presentedScope(req) === 'runner' && !takesTokens(reqPath, { reviews: REVIEWS_PATH })) return serveStrayRunner(req, res, reqPath, { config, auth, now: opts.now });
|
|
270
|
+
|
|
252
271
|
if (reqPath === '/healthz') {
|
|
253
272
|
req.resume();
|
|
254
273
|
return sendJson(res, 200, { ok: true });
|
|
@@ -256,6 +275,9 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
256
275
|
// The review API takes a token, not a session: it is the one route before the guard (and it reads no cookie)
|
|
257
276
|
if (reqPath === REVIEWS_PATH) return serveReviewCall(req, res, { config, where, auth, hooks, lander, now: opts.now });
|
|
258
277
|
// …and so does asking for a run: an agent token, not a session
|
|
278
|
+
// …and a runner asks for work and reports on it with a runner token (the GET of the same path lists the queue)
|
|
279
|
+
const runner = runnerRoute(reqPath);
|
|
280
|
+
if (runner && !(reqPath === REQUESTS_PATH && req.method !== 'GET' && req.method !== 'HEAD')) return serveRunnerCall(req, res, runner, url, { config, auth, runs, now: opts.now });
|
|
259
281
|
if (reqPath === REQUESTS_PATH) return serveAgentCall(req, res, { config, auth, runs, now: opts.now });
|
|
260
282
|
const gate = await auth.gate(req, res, reqPath, url);
|
|
261
283
|
if (gate.done) return;
|