@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/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
- if (!to.length) return;
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) */
@@ -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 the other scope: an agent token asks for runs
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' | 'agent'} the scope of the tokens this path is about */
188
- const scope = revoke ? /** @type {'review' | 'agent'} */ (revoke[1]) : reqPath === AGENT_TOKENS_PATH ? 'agent' : '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 === 'agent' ? { scope } : {}), ...(revoke ? { id: Number(revoke[2]) } : {}) });
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;
@@ -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 `agent`, and is good for the one API its scope names; each API refuses the other's
10
- * token (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_…` and an agent token `cf_at_…`, so a leaked one is told apart on
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
- const hooks = hookSettings
150
- ? createHooks({ hooks: config.hooks, secret: hookSettings.secret, fetch: opts.hookFetch, record: (entry) => auth.record(entry), store: auth.store, now: opts.now, publicUrl: config.publicUrl, timer: opts.hookTimer })
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;