@popcorn245/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/core/agent-contract.js +2 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +7 -3
- package/explorer/index.html +1 -1
- package/explorer/review-tokens-model.js +10 -3
- package/explorer/review-tokens.js +4 -2
- package/package.json +1 -1
- package/server/agent-api.js +4 -3
- package/server/agent-runs.js +175 -38
- package/server/auth.js +58 -3
- package/server/db.js +31 -0
- 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 +15 -1
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)
|
|
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` */
|
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)
|
|
@@ -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;
|