cornfield 0.9.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/server/auth.js CHANGED
@@ -42,6 +42,7 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, ran
42
42
  import { createForge as defaultCreateForge, isMaintainer, ForgeError } from './forge.js';
43
43
  import { githubApiUrl } from './forge-github.js';
44
44
  import { sameOrigin } from './proposals.js';
45
+ import { revokeOwnerTokens } from './review-tokens.js';
45
46
 
46
47
  /** How often a session's permission is asked of the forge again */
47
48
  export const RECHECK_MS = 15 * 60 * 1000;
@@ -335,6 +336,8 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
335
336
  forgeFor: () => { throw new Error('Sign-in is not configured.'); },
336
337
  forgeForUser: async () => { throw new Error('Sign-in is not configured.'); },
337
338
  record: async () => { throw new Error('Sign-in is not configured.'); },
339
+ knownPermission: async () => null,
340
+ demoted: async () => {},
338
341
  begin: async () => { throw new Error('Sign-in is not configured.'); },
339
342
  finish: async () => { throw new Error('Sign-in is not configured.'); },
340
343
  store: null,
@@ -610,6 +613,19 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
610
613
  return true;
611
614
  };
612
615
  const due = () => expiresAt !== null && Date.parse(expiresAt) - REFRESH_SKEW_MS <= now();
616
+ /** Who the forge says this token is: the login a `permission` question about the owner names */
617
+ let ownLogin = row.login;
618
+ /**
619
+ * What the call answered, looked at once on its way out: when the forge says the owner themself does not maintain the repo, their API
620
+ * tokens (review, agent, runner) are revoked there and then, wherever in cornfield the question was asked
621
+ */
622
+ const noted = async (prop, args, result) => {
623
+ if (prop === 'me' && result && typeof result.login === 'string') ownLogin = result.login;
624
+ if (prop === 'permission' && args[0] === forgeConfig.repo && typeof args[1] === 'string' && args[1].toLowerCase() === ownLogin.toLowerCase() && !isMaintainer(result)) {
625
+ await demoted(row.user_id, ownLogin, result);
626
+ }
627
+ return result;
628
+ };
613
629
  return new Proxy({}, {
614
630
  get(_, prop) {
615
631
  const member = forge[prop];
@@ -625,18 +641,40 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
625
641
  }
626
642
  }
627
643
  try {
628
- return await forge[prop](...args);
644
+ return await noted(prop, args, await forge[prop](...args));
629
645
  } catch (err) {
630
646
  if (!(err instanceof ForgeError) || err.code !== 'unauthorized') throw err;
631
647
  // The forge refused the token: renew it (when it can be) and ask once more; else its refusal stands
632
648
  if (!(await renewNow())) throw err;
633
649
  }
634
- return forge[prop](...args);
650
+ return noted(prop, args, await forge[prop](...args));
635
651
  };
636
652
  },
637
653
  });
638
654
  }
639
655
 
656
+ /**
657
+ * A token owner the forge says no longer maintains the repo: every API token of theirs (review, agent and runner) is revoked at once, and
658
+ * the revoking is a row in the audit table. Never throws: what learned it goes on with its own answer.
659
+ * @param {string} userId
660
+ * @param {string} login
661
+ * @param {string} permission what the forge said
662
+ */
663
+ async function demoted(userId, login, permission) {
664
+ try {
665
+ const n = revokeOwnerTokens(await store(), userId, now());
666
+ if (!n) return;
667
+ try {
668
+ (await store()).prepare('INSERT INTO actions (at, login, action, target, request, status, answer) VALUES (?, ?, ?, ?, ?, ?, ?)')
669
+ .run(iso(now()), login, 'token-revoke', forgeConfig.repo, JSON.stringify({ reason: 'owner-not-maintainer', permission }), null, JSON.stringify({ revoked: n }));
670
+ } catch (err) {
671
+ console.error(`[cornfield] the revoking of ${login}'s tokens could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
672
+ }
673
+ } catch (err) {
674
+ console.error(`[cornfield] the tokens of ${login}, no longer a maintainer, could not be revoked: ${err instanceof Error ? err.message : err}`);
675
+ }
676
+ }
677
+
640
678
  async function callback(req, res, url) {
641
679
  // The state cookie is cleared only by an answer to the sign-in it belongs to: a link from anywhere else
642
680
  // (`/auth/callback?error=x`) cannot cancel one in progress
@@ -668,7 +706,11 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
668
706
  file.prepare('INSERT INTO sessions (id, forge, user_id, login, token, permission, created_at, checked_at, expires_at, refresh_token, token_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
669
707
  .run(id, forgeConfig.type, String(user.id), user.login, keep ? sealFor(id, 'token', token) : NO_TOKEN, permission, iso(at), iso(at), iso(at + sessionMs),
670
708
  keep && given.refreshToken !== '' ? sealFor(id, 'refresh_token', given.refreshToken) : '', keep ? given.expiresAt : null);
671
- if (!keep) await revoke(token);
709
+ if (!keep) {
710
+ await revoke(token);
711
+ // Signed in, and not a maintainer: whatever API tokens they made while they were one stop working now
712
+ await demoted(String(user.id), user.login, permission);
713
+ }
672
714
  redirect(res, '/', [cookie(SESSION_COOKIE, value, { maxAge: sessionMs / 1000, secure }), clearState]);
673
715
  }
674
716
 
@@ -740,6 +782,19 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
740
782
  * decides what happens when this throws (the action it records has usually happened already).
741
783
  * @param {{ login: string, action: string, target: string, request?: object, status?: number | null, answer?: object | null }} entry
742
784
  */
785
+ /**
786
+ * What cornfield last heard of a user's permission on the repo: their newest sign-in's (asked of the forge at sign-in and every 15 minutes
787
+ * while they use cornfield), or null when it has no sign-in of theirs at all. A runner token is not asked of the forge per call; this is its
788
+ * cheap guard (server/runner-api.js).
789
+ * @param {string} userId
790
+ * @returns {Promise<string | null>}
791
+ */
792
+ async knownPermission(userId) {
793
+ const row = /** @type {{ permission: string } | undefined} */ ((await store()).prepare('SELECT permission FROM sessions WHERE user_id = ? ORDER BY checked_at DESC, rowid DESC LIMIT 1').get(String(userId)));
794
+ return row ? row.permission : null;
795
+ },
796
+ /** Revokes a user's API tokens when cornfield has learned they no longer maintain the repo (see `demoted`) */
797
+ demoted,
743
798
  async record({ login, action, target, request = {}, status = null, answer = null }) {
744
799
  (await store()).prepare('INSERT INTO actions (at, login, action, target, request, status, answer) VALUES (?, ?, ?, ?, ?, ?, ?)')
745
800
  .run(iso(now()), login, action, target, JSON.stringify(request), status, answer === null ? null : JSON.stringify(answer));
package/server/db.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * a row with `issue = 0` says the repo's threads have been looked at once
18
18
  * runs the agent runs cornfield asked for: the request, where it was posted, the latest report, the pull request found for
19
19
  * it and when anything last happened (server/agent-runs.js, migration 7); review_tokens also keep a scope
20
+ * (review, agent, or runner: migration 8, which also gives a run its transport and the runner token that claimed it)
20
21
  * run_seen what has been seen of each run (a comment judged, a hook event sent), so a restart sends and judges nothing twice
21
22
  * sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
22
23
  *
@@ -201,6 +202,36 @@ export const MIGRATIONS = Object.freeze([
201
202
  PRIMARY KEY (run_id, key)
202
203
  ) STRICT;
203
204
  `,
205
+ // 8: push and pull transports, and runner tokens (server/agent-runs.js, server/runner-api.js, spec 0005 D2, D5): a run records how its request
206
+ // travels and which runner token claimed it; the token scope check gains 'runner'. SQLite cannot change a CHECK, so review_tokens is rebuilt
207
+ // (the documented way: a new table, the rows copied with their ids, the old one dropped, the new one renamed) and keeps its id sequence
208
+ `
209
+ ALTER TABLE runs ADD COLUMN transport TEXT NOT NULL DEFAULT 'forge' CHECK (transport IN ('forge', 'push', 'pull')); -- how the request reaches the runner; a push or pull run has no request comment (comment_id stays '')
210
+ ALTER TABLE runs ADD COLUMN claimed_by INTEGER; -- the runner token (review_tokens.id) that claimed a pull run, or that first reported on a push run
211
+ ALTER TABLE runs ADD COLUMN claimed_at TEXT;
212
+ CREATE INDEX runs_queue ON runs (repo, transport, state, requested_at);
213
+
214
+ CREATE TABLE review_tokens_new (
215
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
216
+ token_hash TEXT NOT NULL UNIQUE,
217
+ user_id TEXT NOT NULL,
218
+ login TEXT NOT NULL,
219
+ label TEXT NOT NULL,
220
+ created_at TEXT NOT NULL,
221
+ expires_at TEXT NOT NULL,
222
+ revoked_at TEXT,
223
+ last_used TEXT,
224
+ scope TEXT NOT NULL DEFAULT 'review' CHECK (scope IN ('review', 'agent', 'runner')) -- what the token may call: POST /api/reviews, POST /api/agent-requests, or the runner routes
225
+ ) STRICT;
226
+ INSERT INTO review_tokens_new (id, token_hash, user_id, login, label, created_at, expires_at, revoked_at, last_used, scope)
227
+ SELECT id, token_hash, user_id, login, label, created_at, expires_at, revoked_at, last_used, scope FROM review_tokens;
228
+ UPDATE sqlite_sequence SET seq = MAX(seq, COALESCE((SELECT seq FROM sqlite_sequence WHERE name = 'review_tokens'), 0)) WHERE name = 'review_tokens_new';
229
+ INSERT INTO sqlite_sequence (name, seq) SELECT 'review_tokens_new', seq FROM sqlite_sequence WHERE name = 'review_tokens'
230
+ AND NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = 'review_tokens_new');
231
+ DROP TABLE review_tokens;
232
+ ALTER TABLE review_tokens_new RENAME TO review_tokens;
233
+ CREATE INDEX review_tokens_user_id ON review_tokens (user_id);
234
+ `,
204
235
  ]);
205
236
 
206
237
  /** The version a file is at: its `user_version` */
@@ -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)
@@ -45,7 +48,7 @@ import http from 'node:http';
45
48
  import path from 'node:path';
46
49
  import { createHash } 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, 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';
@@ -166,6 +170,10 @@ export function createCornfieldServer(config, opts = {}) {
166
170
  }
167
171
  // 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
172
  const runs = config.forge && auth.enabled && hasAdapter(config.forge.type) ? createRuns({ config, where, auth, hooks, now: opts.now, onAsk: () => specPoll?.start() }) : null;
173
+ // 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
174
+ const transport = config.agents?.transport ?? 'forge';
175
+ 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).`);
176
+ 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.');
169
177
  runs?.resume().catch((err) => console.error(`[cornfield] agent runs could not be picked up: ${err instanceof Error ? err.message : err}`));
170
178
  // 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
179
  // 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 +257,9 @@ export function createCornfieldServer(config, opts = {}) {
249
257
  return res.end('400 Bad Request');
250
258
  }
251
259
 
260
+ // A runner token is good for the runner routes only: on any other route it is refused here, before anything else is looked at
261
+ if (presentedScope(req) === 'runner' && !takesTokens(reqPath, { reviews: REVIEWS_PATH })) return serveStrayRunner(req, res, reqPath, { config, auth, now: opts.now });
262
+
252
263
  if (reqPath === '/healthz') {
253
264
  req.resume();
254
265
  return sendJson(res, 200, { ok: true });
@@ -256,6 +267,9 @@ export function createCornfieldServer(config, opts = {}) {
256
267
  // The review API takes a token, not a session: it is the one route before the guard (and it reads no cookie)
257
268
  if (reqPath === REVIEWS_PATH) return serveReviewCall(req, res, { config, where, auth, hooks, lander, now: opts.now });
258
269
  // …and so does asking for a run: an agent token, not a session
270
+ // …and a runner asks for work and reports on it with a runner token (the GET of the same path lists the queue)
271
+ const runner = runnerRoute(reqPath);
272
+ if (runner && !(reqPath === REQUESTS_PATH && req.method !== 'GET' && req.method !== 'HEAD')) return serveRunnerCall(req, res, runner, url, { config, auth, runs, now: opts.now });
259
273
  if (reqPath === REQUESTS_PATH) return serveAgentCall(req, res, { config, auth, runs, now: opts.now });
260
274
  const gate = await auth.gate(req, res, reqPath, url);
261
275
  if (gate.done) return;