cornfield 0.8.0 → 0.9.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/db.js CHANGED
@@ -15,6 +15,9 @@
15
15
  * thread_seen the (repo, issue, comment) triples whose thread.* hook event was sent, and each thread's last-seen time, so a
16
16
  * restart sends none twice and a look asks the forge only for what is new (server/hooks.js, server/threads.js);
17
17
  * a row with `issue = 0` says the repo's threads have been looked at once
18
+ * runs the agent runs cornfield asked for: the request, where it was posted, the latest report, the pull request found for
19
+ * it and when anything last happened (server/agent-runs.js, migration 7); review_tokens also keep a scope
20
+ * run_seen what has been seen of each run (a comment judged, a hook event sent), so a restart sends and judges nothing twice
18
21
  * sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
19
22
  *
20
23
  * The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
@@ -148,6 +151,56 @@ export const MIGRATIONS = Object.freeze([
148
151
  PRIMARY KEY (repo, issue, comment)
149
152
  ) STRICT;
150
153
  `,
154
+ // 7: agent runs (server/agent-runs.js, spec 0005): a table of the runs cornfield asked for, what it has seen of them, and a scope on
155
+ // the API tokens so one store holds review tokens and agent tokens
156
+ `
157
+ ALTER TABLE review_tokens ADD COLUMN scope TEXT NOT NULL DEFAULT 'review' CHECK (scope IN ('review', 'agent')); -- what the token may call: POST /api/reviews, or POST /api/agent-requests
158
+
159
+ CREATE TABLE runs (
160
+ run_id TEXT PRIMARY KEY, -- run-<ulid>, minted with the request
161
+ repo TEXT NOT NULL, -- 'owner/name'
162
+ kind TEXT NOT NULL,
163
+ profile TEXT,
164
+ issue INTEGER, -- what the run is about (at least one of the three)
165
+ proposal TEXT,
166
+ thread INTEGER,
167
+ target INTEGER NOT NULL, -- the issue or pull request the request, and the stop, were posted on
168
+ comment_id TEXT NOT NULL DEFAULT '', -- the request comment; '' while the post is in flight (the row already counts as open)
169
+ comment_url TEXT,
170
+ request TEXT NOT NULL CHECK (json_valid(request)), -- the request as posted: no credential in it, ever
171
+ requested_by TEXT NOT NULL,
172
+ requested_by_id TEXT NOT NULL,
173
+ requested_at TEXT NOT NULL,
174
+ via TEXT NOT NULL DEFAULT 'dashboard', -- 'dashboard' | 'api'
175
+ state TEXT NOT NULL DEFAULT 'requested' CHECK (state IN ('requested', 'claimed', 'working', 'done', 'failed', 'stopped')), -- the runner's latest report
176
+ reported_at TEXT,
177
+ reported_by TEXT,
178
+ note TEXT,
179
+ url TEXT,
180
+ tokens INTEGER,
181
+ cost REAL,
182
+ stop_requested_at TEXT,
183
+ stop_requested_by TEXT,
184
+ pull INTEGER, -- the pull request that carries the run's id
185
+ pull_state TEXT CHECK (pull_state IS NULL OR pull_state IN ('open', 'merged', 'closed')),
186
+ pull_url TEXT,
187
+ pull_by TEXT,
188
+ pull_trusted INTEGER NOT NULL DEFAULT 0, -- 1 when its author is a runner, a listed agent author or a maintainer: only then does it move the state
189
+ linked_via TEXT CHECK (linked_via IS NULL OR linked_via IN ('body', 'commit', 'branch')),
190
+ branch TEXT,
191
+ head_sha TEXT,
192
+ checks TEXT CHECK (checks IS NULL OR checks IN ('success', 'failure', 'running', 'none')),
193
+ last_activity TEXT NOT NULL -- the latest report, push or pull request activity (or the request itself)
194
+ ) STRICT;
195
+ CREATE INDEX runs_repo ON runs (repo, requested_at);
196
+
197
+ CREATE TABLE run_seen (
198
+ run_id TEXT NOT NULL, -- '' for what belongs to no run (a pull request's commits already read)
199
+ key TEXT NOT NULL, -- 'c:<comment>:<n>' a block judged, 'ev:…' a hook event sent, 'commits:<pull>@<sha>'
200
+ at TEXT NOT NULL,
201
+ PRIMARY KEY (run_id, key)
202
+ ) STRICT;
203
+ `,
151
204
  ]);
152
205
 
153
206
  /** The version a file is at: its `user_version` */
@@ -82,6 +82,9 @@ const FILE_STATUS = { added: 'added', copied: 'added', deleted: 'removed', remov
82
82
  const toFile = (f) => ({ path: f.filename, status: FILE_STATUS[f.status] || 'modified', previousPath: f.previous_filename || null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 });
83
83
 
84
84
  /** @returns {import('./forge.js').Comment} */
85
+ /** @returns {import('./forge.js').PullCommit} */
86
+ const toCommit = (c) => ({ sha: String(c.sha ?? ''), message: typeof c.commit?.message === 'string' ? c.commit.message : '' });
87
+
85
88
  const toComment = (c) => ({ id: String(c.id), author: author(c.user), body: c.body ?? '', url: c.html_url, createdAt: c.created_at, updatedAt: c.updated_at });
86
89
 
87
90
  // A warning does not count as passing, so it stands in the way like a failure
@@ -237,6 +240,10 @@ export function createForgejoForge(forge, { token = null, fetch, apiUrl, timeout
237
240
  return (await client.list(`repos/${repo}/pulls/${n}/files?limit=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
238
241
  },
239
242
 
243
+ async pullCommits(n) {
244
+ return (await client.list(`repos/${repo}/pulls/${n}/commits?limit=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
245
+ },
246
+
240
247
  async comments(n, { since } = {}) {
241
248
  return (await client.list(`repos/${repo}/issues/${n}/comments?limit=${PER_PAGE}${since ? `&since=${enc(since)}` : ''}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
242
249
  },
@@ -66,6 +66,9 @@ const toPull = (p) => ({
66
66
  url: p.html_url, createdAt: p.created_at, updatedAt: p.updated_at, mergedAt: p.merged_at ?? null,
67
67
  });
68
68
 
69
+ /** @returns {import('./forge.js').PullCommit} */
70
+ const toCommit = (c) => ({ sha: String(c.sha ?? ''), message: typeof c.commit?.message === 'string' ? c.commit.message : '' });
71
+
69
72
  const FILE_STATUS = { added: 'added', copied: 'added', removed: 'removed', renamed: 'renamed', modified: 'modified', changed: 'modified', unchanged: 'modified' };
70
73
  /** @returns {import('./forge.js').PullFile} */
71
74
  const toFile = (f) => ({ path: f.filename, status: FILE_STATUS[f.status] || 'modified', previousPath: f.previous_filename ?? null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 });
@@ -230,6 +233,10 @@ export function createGitHubForge(forge, { token = null, fetch, apiUrl, timeoutM
230
233
  return (await client.list(`repos/${repo}/pulls/${n}/files?per_page=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
231
234
  },
232
235
 
236
+ async pullCommits(n) {
237
+ return (await client.list(`repos/${repo}/pulls/${n}/commits?per_page=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
238
+ },
239
+
233
240
  async comments(n, { since } = {}) {
234
241
  return (await client.list(`repos/${repo}/issues/${n}/comments?per_page=${PER_PAGE}${since ? `&since=${enc(since)}` : ''}`, { what: `the comments on #${n} in ${forge.repo}` })).map(toComment);
235
242
  },
package/server/forge.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * issue(n) one issue (not a pull request: that is `not-found`)
15
15
  * searchIssues(words) the issues whose text holds the words (one page; the forge's search, which can lag a moment)
16
16
  * pullFiles(n) the files a pull request changes
17
+ * pullCommits(n) the commits of a pull request, oldest first: their shas and full messages (a run's trailer may be in one)
17
18
  * comments(n, { since }) the comments on an issue or pull request, oldest first; `since` (an ISO time) only those
18
19
  * created or edited after it, which is how a thread is followed without reading it all again
19
20
  * comment(n, body) posts one, and answers it
@@ -55,6 +56,7 @@
55
56
  * @typedef {{ number: number, title: string, body: string, state: 'open' | 'closed' | 'merged', draft: boolean, author: Author | null, labels: string[], head: { ref: string, sha: string }, base: { ref: string }, url: string, createdAt: string, updatedAt: string, mergedAt: string | null }} Pull
56
57
  * @typedef {{ path: string, status: 'added' | 'modified' | 'removed' | 'renamed', previousPath: string | null, additions: number, deletions: number }} PullFile
57
58
  * @typedef {{ id: string, author: Author | null, body: string, url: string, createdAt: string, updatedAt: string }} Comment
59
+ * @typedef {{ sha: string, message: string }} PullCommit
58
60
  * @typedef {'queued' | 'running' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled'} CheckState
59
61
  * @typedef {{ id: string, name: string, state: CheckState, url: string | null, rerunnable: boolean }} Check
60
62
  * @typedef {{ name: string, sha: string, protected: boolean }} Branch
@@ -83,6 +85,7 @@
83
85
  * createPull(pull: { title: string, body: string, head: string, base: string, draft: boolean }): Promise<Pull>,
84
86
  * createIssue(issue: { title: string, body: string, labels?: string[] }): Promise<Issue>,
85
87
  * pullFiles(n: number): Promise<PullFile[]>,
88
+ * pullCommits(n: number): Promise<PullCommit[]>,
86
89
  * comments(n: number, opts?: { since?: string }): Promise<Comment[]>,
87
90
  * comment(n: number, body: string): Promise<Comment>,
88
91
  * checks(ref: string): Promise<Check[]>,
@@ -114,7 +117,7 @@ import { ForgeError, FILE_MAX_BYTES } from './forge-http.js';
114
117
  export { ForgeError, rateLimitWords } from './forge-http.js';
115
118
 
116
119
  /** The calls of the interface (spec 0001 section 3, with `pull(n)`), and `rateLimit()`: what the last answer said is left */
117
- export const FORGE_METHODS = Object.freeze(['me', 'permission', 'issues', 'searchIssues', 'pulls', 'pull', 'issue', 'pullFiles', 'comments', 'comment', 'checks', 'rerun', 'merge', 'runs', 'branches', 'file', 'fileBytes', 'defaultBranch', 'tags', 'tag', 'createReleaseDraft', 'createBranch', 'createFile', 'createPull', 'createIssue', 'rateLimit']);
120
+ export const FORGE_METHODS = Object.freeze(['me', 'permission', 'issues', 'searchIssues', 'pulls', 'pull', 'issue', 'pullFiles', 'pullCommits', 'comments', 'comment', 'checks', 'rerun', 'merge', 'runs', 'branches', 'file', 'fileBytes', 'defaultBranch', 'tags', 'tag', 'createReleaseDraft', 'createBranch', 'createFile', 'createPull', 'createIssue', 'rateLimit']);
118
121
  export const PERMISSIONS = Object.freeze(['admin', 'maintain', 'write', 'triage', 'read', 'none']);
119
122
  /** A check that has finished and does not stand in the way: success, or one the forge says does not count */
120
123
  export const GREEN_STATES = Object.freeze(['success', 'neutral', 'skipped']);
@@ -276,6 +279,7 @@ export function wrap(a, forge) {
276
279
  return a.searchIssues(words.trim());
277
280
  },
278
281
  pullFiles: async (n) => a.pullFiles(number(n)),
282
+ pullCommits: async (n) => a.pullCommits(number(n)),
279
283
  comments: async (n, opts) => {
280
284
  if (opts !== undefined && (opts === null || typeof opts !== 'object' || Array.isArray(opts))) throw invalid('The options are an object.');
281
285
  const since = opts && opts.since;
package/server/hooks.js CHANGED
@@ -23,6 +23,11 @@
23
23
  * what is there and sends nothing; each (repo, thread, comment) is sent once, across restarts (`thread_seen`)
24
24
  * session.expiring a maintainer's sign-in ends within a day (`watchSessions`, below). Its body is its own:
25
25
  * `{ event, at, actor, expiresAt, mapUrl }`
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
+ * `request` being the whole request JSON of the contract (docs/agent-contract.md), which holds no credential by construction
28
+ * 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
+ * seen when cornfield looks (the spec poll, server/spec-poll.js), each once. Their body is their own:
30
+ * `{ event, at, runId, state, pull?, issue?, actor, mapUrl }`
26
31
  *
27
32
  * **A hook never gets in the way.** `emit` returns at once and never throws; the delivery runs in the background, from a
28
33
  * queue in memory. A failed delivery (a network error, a timeout, anything but a 2xx) is tried again after 1s, 5s and 30s
@@ -46,6 +51,7 @@ import { createHmac, randomUUID } from 'node:crypto';
46
51
  import { HOOK_EVENTS } from '../core/config.js';
47
52
  import { pullProposals, readPullProposal } from './proposal-pulls.js';
48
53
  import { proposalHash } from './reviews.js';
54
+ import { validateRequest } from '../core/agent-contract.js';
49
55
 
50
56
  /** @typedef {ReturnType<typeof createHooks>} Hooks */
51
57
 
@@ -194,6 +200,19 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
194
200
  function bodyOf(event, p) {
195
201
  // A session's end has no spec, pull request or proposal: its body is only who, until when, and where cornfield is
196
202
  if (event === 'session.expiring') return JSON.stringify({ event, at: new Date(now()).toISOString(), actor: p.actor ?? null, expiresAt: p.expiresAt ?? null, mapUrl: publicUrl || null });
203
+ // 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
+ if (event === 'agent.requested') {
205
+ const checked = validateRequest(p.request);
206
+ if (!checked.ok) throw new Error(checked.error);
207
+ return JSON.stringify({ event, at: new Date(now()).toISOString(), request: checked.value, actor: p.actor ?? null, mapUrl: publicUrl || null });
208
+ }
209
+ // The events of a run (claimed, state, stopRequested, finished) say which run, in what state, and about what
210
+ if (event.startsWith('run.')) {
211
+ return JSON.stringify({
212
+ event, at: new Date(now()).toISOString(), runId: p.runId ?? null, state: p.state ?? null,
213
+ ...(p.pull != null ? { pull: p.pull } : {}), ...(p.issue != null ? { issue: p.issue } : {}), actor: p.actor ?? null, mapUrl: publicUrl || null,
214
+ });
215
+ }
197
216
  // A thread's event is only which thread, which comment, who and what it links to: never the words said
198
217
  if (event.startsWith('thread.')) {
199
218
  const out = { event, at: new Date(now()).toISOString(), thread: p.thread ?? null };
@@ -8,6 +8,8 @@
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
12
+ * (`POST /api/agent-requests`, server/agent-api.js) and is no good here, as a review token is no good there
11
13
  *
12
14
  * **The call takes the token and nothing else.** It runs before the sign-in guard (a script has no cookie) and reads no
13
15
  * cookie: a session cookie is never a substitute for the token, and a request that sends only one is 401. Being no
@@ -41,6 +43,7 @@ import { Refused, countAttempt } from './throttle.js';
41
43
 
42
44
  export const REVIEWS_PATH = '/api/reviews';
43
45
  export const TOKENS_PATH = '/api/review-tokens';
46
+ export const AGENT_TOKENS_PATH = '/api/agent-tokens';
44
47
  /** Calls with no token, or one nobody holds, that are written down in a minute, all callers together */
45
48
  export const ANONYMOUS_PER_MINUTE = 20;
46
49
  /** What the audit row of a call nobody could name says in place of a login */
@@ -88,7 +91,7 @@ export async function serveReviewCall(req, res, ctx) {
88
91
  // The token, from the header alone; a cookie is not looked at
89
92
  const presented = bearerOf(req.headers.authorization);
90
93
  const db = presented ? await auth.store() : null;
91
- const found = presented && db ? findToken(db, presented, now()) : null;
94
+ const found = presented && db ? findToken(db, presented, now(), 'review') : null;
92
95
  if (!found) {
93
96
  req.resume();
94
97
  const reason = presented ? 'unknown-token' : 'no-token';
@@ -101,6 +104,15 @@ export async function serveReviewCall(req, res, ctx) {
101
104
 
102
105
  const { row, state } = found;
103
106
  const token = { id: row.id, label: row.label };
107
+ // An agent token asks for runs and is no review token: refused whatever its state, and not counted as used
108
+ if (state === 'wrong-scope') {
109
+ req.resume();
110
+ const flood = countAttempt(ctx, row.user_id, now());
111
+ 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' };
113
+ await audit(row.login, { reason: 'wrong-scope', token, scope: row.scope }, 403, body);
114
+ return send(res, 403, { ok: false, ...body });
115
+ }
104
116
  if (state !== 'active') {
105
117
  req.resume();
106
118
  const flood = countAttempt(ctx, row.user_id, now());
@@ -125,7 +137,7 @@ export async function serveReviewCall(req, res, ctx) {
125
137
  forge: () => auth.forgeForUser(row.user_id), where, repo, who: { login: row.login, userId: row.user_id },
126
138
  record: (entry) => auth.record(entry), limits: { auth, now: ctx.now }, via: { token }, requireSha256: true, hooks: ctx.hooks, lander: ctx.lander,
127
139
  stillAllowed: async () => {
128
- const again = findToken(await auth.store(), /** @type {string} */ (presented), now());
140
+ const again = findToken(await auth.store(), /** @type {string} */ (presented), now(), 'review');
129
141
  if (again && again.state === 'active') return null;
130
142
  return again && again.state === 'expired' ? 'This review token expired while the review was being checked; nothing was posted.' : 'This review token was revoked while the review was being checked; nothing was posted.';
131
143
  },
@@ -159,7 +171,7 @@ function inputOf(text) {
159
171
  // ---------------------------------------------------------------------------
160
172
 
161
173
  /** What a token looks like to the dashboard: no hash, no secret */
162
- const publicToken = ({ id, login, label, created_at, expires_at, revoked_at, last_used, state }) => ({ id, login, label, created: created_at, expires: expires_at, revoked: revoked_at, lastUsed: last_used, state });
174
+ const publicToken = ({ id, login, label, scope, created_at, expires_at, revoked_at, last_used, state }) => ({ id, login, label, scope, created: created_at, expires: expires_at, revoked: revoked_at, lastUsed: last_used, state });
163
175
 
164
176
  /**
165
177
  * The `/api/review-tokens` routes. The caller has passed the sign-in guard, so `session` is a maintainer's.
@@ -170,8 +182,10 @@ const publicToken = ({ id, login, label, created_at, expires_at, revoked_at, las
170
182
  * @returns {Promise<boolean>} whether the path was one of these
171
183
  */
172
184
  export async function serveTokenRoutes(req, res, reqPath, ctx) {
173
- const revoke = /^\/api\/review-tokens\/([1-9]\d{0,9})\/revoke$/.exec(reqPath);
174
- if (reqPath !== TOKENS_PATH && !revoke) return false;
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';
175
189
  const { config, auth, session } = ctx;
176
190
  const now = ctx.now ?? Date.now;
177
191
  const allow = revoke ? 'POST' : 'GET, POST';
@@ -183,12 +197,12 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
183
197
  }
184
198
  if (!session || !auth.store) {
185
199
  req.resume();
186
- send(res, 403, { ok: false, error: 'Review tokens belong to a signed-in maintainer: set up sign-in first (docs/sign-in.md).', code: 'forbidden' });
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' });
187
201
  return true;
188
202
  }
189
203
  const db = await auth.store();
190
204
  if (method === 'GET') {
191
- send(res, 200, { ok: true, tokens: listTokens(db, session.userId, now()).map(publicToken), defaultDays: DEFAULT_DAYS, maxDays: MAX_DAYS, maxActive: MAX_ACTIVE });
205
+ send(res, 200, { ok: true, tokens: listTokens(db, session.userId, now(), scope).map(publicToken), defaultDays: DEFAULT_DAYS, maxDays: MAX_DAYS, maxActive: MAX_ACTIVE });
192
206
  return true;
193
207
  }
194
208
 
@@ -200,7 +214,7 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
200
214
  return true;
201
215
  }
202
216
  const what = revoke ? 'revoke' : 'mint';
203
- const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(revoke ? { id: Number(revoke[1]) } : {}) });
217
+ const request = /** @type {Record<string, unknown>} */ ({ op: what, ...(scope === 'agent' ? { scope } : {}), ...(revoke ? { id: Number(revoke[2]) } : {}) });
204
218
  let rowId = null;
205
219
  /** @type {{ status: number, body: object }} */
206
220
  let out;
@@ -219,10 +233,10 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
219
233
  const forge = auth.forgeFor(session);
220
234
  const me = await forge.me();
221
235
  if (String(me.id) !== String(session.userId)) throw new Refused(403, 'The forge says this sign-in is someone else now; sign in again.', 'forbidden');
222
- if (!isMaintainer(await forge.permission(config.forge.repo, me.login))) throw new Refused(403, `Only the repo's maintainers make review tokens; ${me.login} is not one now.`, 'forbidden');
236
+ if (!isMaintainer(await forge.permission(config.forge.repo, me.login))) throw new Refused(403, `Only the repo's maintainers make ${scope} tokens; ${me.login} is not one now.`, 'forbidden');
223
237
  if (revoke) {
224
- const done = revokeToken(db, session.userId, Number(revoke[1]), now());
225
- if (!done) throw new Refused(404, 'You have no review token by that number.', 'not-found');
238
+ const done = revokeToken(db, session.userId, Number(revoke[2]), now(), scope);
239
+ if (!done) throw new Refused(404, `You have no ${scope} token by that number.`, 'not-found');
226
240
  request.label = done.label;
227
241
  out = { status: 200, body: { ok: true, token: publicToken(done) } };
228
242
  } else {
@@ -239,7 +253,7 @@ export async function serveTokenRoutes(req, res, reqPath, ctx) {
239
253
  Object.assign(request, { label: body.label, days });
240
254
  let made;
241
255
  try {
242
- made = mintToken(db, { userId: session.userId, login: me.login, label: body.label, days }, now());
256
+ made = mintToken(db, { userId: session.userId, login: me.login, label: body.label, days, scope }, now());
243
257
  } catch (err) {
244
258
  if (err instanceof RangeError) throw new Refused(409, err.message, 'conflict');
245
259
  throw err;
@@ -1,11 +1,16 @@
1
1
  /**
2
2
  * @module server/review-tokens
3
3
  *
4
- * Review tokens: what a maintainer hands a script or a chat bot so it can Approve or Send back a proposal through
5
- * `POST /api/reviews` (server/review-api.js). The store over the `review_tokens` table of `cornfield.db`
6
- * (server/db.js); nothing here knows about HTTP.
4
+ * API tokens: what a maintainer hands a script or a chat bot so it can Approve or Send back a proposal through
5
+ * `POST /api/reviews` (server/review-api.js), or ask for an agent run through `POST /api/agent-requests`
6
+ * (server/agent-api.js). The store over the `review_tokens` table of `cornfield.db` (server/db.js); nothing here knows
7
+ * about HTTP.
7
8
  *
8
- * - A token is `cf_rt_` and 32 random bytes in base64url. It is shown once, when it is made; only its SHA-256 is
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
12
+ * sight (and by a secret scanner). Rows from before scopes existed are review tokens.
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
9
14
  * kept. A presented token is found by the hash of what was presented (an indexed equality on a hash of a 256-bit
10
15
  * secret, so there is no secret-dependent comparison to time); the token itself is never stored, logged or
11
16
  * returned again.
@@ -17,14 +22,18 @@
17
22
  *
18
23
  * Times are ISO strings, as everywhere in the file; `now` is milliseconds and is passed in, so tests need no clock.
19
24
  *
20
- * @typedef {{ id: number, user_id: string, login: string, label: string, created_at: string, expires_at: string, revoked_at: string | null, last_used: string | null }} TokenRow
21
- * @typedef {'active' | 'expired' | 'revoked'} TokenState
25
+ * @typedef {'review' | 'agent'} TokenScope
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
+ * @typedef {'active' | 'expired' | 'revoked' | 'wrong-scope'} TokenState `wrong-scope` only from `findToken` asked for a scope
22
28
  */
23
29
 
24
30
  import { createHash, randomBytes } from 'node:crypto';
25
31
 
26
- /** What every token starts with, so a leaked one is recognised (by a secret scanner too) */
27
- export const TOKEN_PREFIX = 'cf_rt_';
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_' });
35
+ /** What a review token starts with (the original, kept for the callers that always had it) */
36
+ export const TOKEN_PREFIX = PREFIXES.review;
28
37
  /** Random bytes in a token */
29
38
  export const TOKEN_BYTES = 32;
30
39
  export const DEFAULT_DAYS = 30;
@@ -34,10 +43,10 @@ export const MAX_ACTIVE = 20;
34
43
  export const MAX_LABEL = 60;
35
44
  const DAY_MS = 24 * 60 * 60 * 1000;
36
45
  /** A token as presented: the prefix and base64url (43 characters for 32 bytes); anything else is not one */
37
- const TOKEN_SHAPE = /^cf_rt_[A-Za-z0-9_-]{43,128}$/;
46
+ const TOKEN_SHAPE = /^cf_(?:rt|at)_[A-Za-z0-9_-]{43,128}$/;
38
47
  /** A label is words: letters, digits, spaces and a little punctuation. No backtick, no @, no newline: the comment quotes it */
39
48
  const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} ._:/()-]*$/u;
40
- const COLUMNS = 'id, user_id, login, label, created_at, expires_at, revoked_at, last_used';
49
+ const COLUMNS = 'id, user_id, login, label, scope, created_at, expires_at, revoked_at, last_used';
41
50
 
42
51
  const iso = (ms) => new Date(ms).toISOString();
43
52
 
@@ -48,7 +57,7 @@ export function hashToken(token) {
48
57
 
49
58
  /**
50
59
  * The token a request presents: the value of `Authorization: Bearer <token>`, or null when there is none, or it is
51
- * not shaped like one of ours (the scheme is matched in any case, as RFC 9110 says).
60
+ * 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).
52
61
  * @param {string | string[] | undefined} header
53
62
  * @returns {string | null}
54
63
  */
@@ -96,21 +105,22 @@ export function stateOf(row, now) {
96
105
  * for a label or a number of days that is not allowed, or when the owner holds `MAX_ACTIVE` live tokens already.
97
106
  * Tokens that ended (expired or revoked) more than `MAX_DAYS` days ago are dropped on the way.
98
107
  * @param {import('node:sqlite').DatabaseSync} db
99
- * @param {{ userId: string, login: string, label: string, days?: number }} owner
108
+ * @param {{ userId: string, login: string, label: string, days?: number, scope?: TokenScope }} owner `scope`: `review` unless said
100
109
  * @param {number} now
101
110
  * @returns {{ token: string, row: TokenRow }}
102
111
  */
103
- export function mintToken(db, { userId, login, label, days }, now) {
112
+ export function mintToken(db, { userId, login, label, days, scope = 'review' }, now) {
113
+ if (!SCOPES.includes(scope)) throw new RangeError(`A token's scope is one of ${SCOPES.join(', ')}.`);
104
114
  const problem = labelProblem(label);
105
115
  if (problem) throw new RangeError(problem);
106
116
  const lasts = daysOf(days);
107
117
  const nowIso = iso(now);
108
118
  db.prepare('DELETE FROM review_tokens WHERE COALESCE(revoked_at, expires_at) <= ?').run(iso(now - MAX_DAYS * DAY_MS));
109
- const live = /** @type {{ n: number }} */ (db.prepare('SELECT COUNT(*) AS n FROM review_tokens WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?').get(String(userId), nowIso)).n;
110
- if (live >= MAX_ACTIVE) throw new RangeError(`You hold ${MAX_ACTIVE} live tokens; revoke one you do not use before making another.`);
111
- const token = `${TOKEN_PREFIX}${randomBytes(TOKEN_BYTES).toString('base64url')}`;
112
- const id = Number(db.prepare('INSERT INTO review_tokens (token_hash, user_id, login, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
113
- .run(hashToken(token), String(userId), login, label, nowIso, iso(now + lasts * DAY_MS)).lastInsertRowid);
119
+ const live = /** @type {{ n: number }} */ (db.prepare('SELECT COUNT(*) AS n FROM review_tokens WHERE user_id = ? AND scope = ? AND revoked_at IS NULL AND expires_at > ?').get(String(userId), scope, nowIso)).n;
120
+ if (live >= MAX_ACTIVE) throw new RangeError(`You hold ${MAX_ACTIVE} live ${scope === 'review' ? '' : `${scope} `}tokens; revoke one you do not use before making another.`);
121
+ const token = `${PREFIXES[scope]}${randomBytes(TOKEN_BYTES).toString('base64url')}`;
122
+ const id = Number(db.prepare('INSERT INTO review_tokens (token_hash, user_id, login, label, scope, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
123
+ .run(hashToken(token), String(userId), login, label, scope, nowIso, iso(now + lasts * DAY_MS)).lastInsertRowid);
114
124
  return { token, row: /** @type {TokenRow} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ?`).get(id)) };
115
125
  }
116
126
 
@@ -121,9 +131,12 @@ export function mintToken(db, { userId, login, label, days }, now) {
121
131
  * @param {number} now
122
132
  * @returns {{ row: TokenRow, state: TokenState } | null}
123
133
  */
124
- export function findToken(db, token, now) {
134
+ export function findToken(db, token, now, scope) {
125
135
  const row = /** @type {TokenRow | undefined} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE token_hash = ?`).get(hashToken(token)));
126
- return row ? { row, state: stateOf(row, now) } : null;
136
+ if (!row) return null;
137
+ // The scope is part of the lookup, not of each route: a token found for another API, or whose prefix is not its scope's, is no token here
138
+ if (scope !== undefined && (row.scope !== scope || !String(token).startsWith(PREFIXES[row.scope]))) return { row, state: /** @type {const} */ ('wrong-scope') };
139
+ return { row, state: stateOf(row, now) };
127
140
  }
128
141
 
129
142
  /** Notes that a token was presented (and valid) just now */
@@ -132,14 +145,17 @@ export function touchToken(db, id, now) {
132
145
  }
133
146
 
134
147
  /**
135
- * An owner's tokens, newest first, each with its state. Never the hash, never the token.
148
+ * An owner's tokens of one scope (all of them when none is named), newest first, each with its state. Never the hash, never the token.
136
149
  * @param {import('node:sqlite').DatabaseSync} db
137
150
  * @param {string} userId
138
151
  * @param {number} now
152
+ * @param {TokenScope} [scope]
139
153
  * @returns {(TokenRow & { state: TokenState })[]}
140
154
  */
141
- export function listTokens(db, userId, now) {
142
- return /** @type {TokenRow[]} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE user_id = ? ORDER BY id DESC`).all(String(userId)))
155
+ export function listTokens(db, userId, now, scope) {
156
+ return /** @type {TokenRow[]} */ (scope
157
+ ? db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE user_id = ? AND scope = ? ORDER BY id DESC`).all(String(userId), scope)
158
+ : db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE user_id = ? ORDER BY id DESC`).all(String(userId)))
143
159
  .map((row) => ({ ...row, state: stateOf(row, now) }));
144
160
  }
145
161
 
@@ -150,11 +166,12 @@ export function listTokens(db, userId, now) {
150
166
  * @param {string} userId
151
167
  * @param {number} id
152
168
  * @param {number} now
169
+ * @param {TokenScope} [scope] when named, a token of the other scope is no token to them here
153
170
  * @returns {(TokenRow & { state: TokenState }) | null} the token as it is now, or null when the owner has none by that id
154
171
  */
155
- export function revokeToken(db, userId, id, now) {
172
+ export function revokeToken(db, userId, id, now, scope) {
156
173
  const row = /** @type {TokenRow | undefined} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ? AND user_id = ?`).get(id, String(userId)));
157
- if (!row) return null;
174
+ if (!row || (scope && row.scope !== scope)) return null;
158
175
  if (!row.revoked_at) db.prepare('UPDATE review_tokens SET revoked_at = ? WHERE id = ?').run(iso(now), id);
159
176
  const after = /** @type {TokenRow} */ (db.prepare(`SELECT ${COLUMNS} FROM review_tokens WHERE id = ?`).get(id));
160
177
  return { ...after, state: stateOf(after, now) };
package/server/server.js CHANGED
@@ -22,7 +22,10 @@
22
22
  * POST /api/forge/release-drafts the release notes kept as a DRAFT release on the forge (never published, no tag made)
23
23
  * POST /api/reviews Approve or Send back with a review token (`Authorization: Bearer cf_rt_…`), no cookie: the one
24
24
  * route that answers before the sign-in guard, and takes the token alone (server/review-api.js)
25
- * * /api/review-tokens… make, list and revoke review tokens: the signed-in maintainer only (server/review-api.js)
25
+ * * /api/review-tokens…, /api/agent-tokens…
26
+ * make, list and revoke review tokens, and agent tokens: the signed-in maintainer only (server/review-api.js)
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
+ * * /api/agent-runs… the dashboard's runs: list, one, start, stop; the signed-in maintainer only (server/agent-api.js, server/agent-runs.js)
26
29
  * * /proposals… server/proposals.js (with sign-in, the open spec PRs too: server/proposal-pulls.js,
27
30
  * and a review is a comment on one: server/reviews.js)
28
31
  * * <proxy path>/… server/proxy.js, for each path the config's `proxy` names
@@ -42,11 +45,14 @@ import http from 'node:http';
42
45
  import path from 'node:path';
43
46
  import { createHash } from 'node:crypto';
44
47
  import { fileURLToPath } from 'node:url';
45
- import { browserSources, BUILTIN_SOURCES, specPollMs, ideaLabel } from '../core/config.js';
48
+ import { browserSources, BUILTIN_SOURCES, parseDuration, specPollMs, DEFAULTS, ideaLabel } from '../core/config.js';
46
49
  import { CLOUD_FILE } from '../core/cloud.js';
47
50
  import { decodePath, inside, notFound, sendFile } from './files.js';
48
51
  import { serveProposals } from './proposals.js';
49
52
  import { serveForge } from './forge-routes.js';
53
+ import { hasAdapter } from './forge.js';
54
+ import { createRuns } from './agent-runs.js';
55
+ import { REQUESTS_PATH, serveAgentCall, serveRunRoutes } from './agent-api.js';
50
56
  import { REVIEWS_PATH, serveReviewCall, serveTokenRoutes } from './review-api.js';
51
57
  import { proxyTo } from './proxy.js';
52
58
  import { HA_ENV, homeAssistantHouse } from '../plugins/house/home-assistant.js';
@@ -158,9 +164,16 @@ export function createCornfieldServer(config, opts = {}) {
158
164
  // What a restart left waiting: looked at again, in the background
159
165
  lander.resume().catch((err) => console.error(`[cornfield] pending landings could not be picked up: ${err instanceof Error ? err.message : err}`));
160
166
  }
161
- // Looks at the open spec PRs on a timer when a hook wants spec.ready (config `hookPoll`); started when the server listens
162
- const pollMs = hooks ? specPollMs(config) : null;
163
- const specPoll = pollMs ? createSpecPoll({ config, where, auth, hooks, intervalMs: pollMs, now: opts.now, timer: opts.specPollTimer }) : null;
167
+ // 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
+ const runs = config.forge && auth.enabled && hasAdapter(config.forge.type) ? createRuns({ config, where, auth, hooks, now: opts.now, onAsk: () => specPoll?.start() }) : null;
169
+ runs?.resume().catch((err) => console.error(`[cornfield] agent runs could not be picked up: ${err instanceof Error ? err.message : err}`));
170
+ // 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
+ // 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
172
+ const specMs = hooks ? specPollMs(config) : null;
173
+ const runsEvery = config.hookPoll === undefined ? DEFAULTS.hookPoll.specs : config.hookPoll.specs;
174
+ const pollMs = specMs ?? (runs && runsEvery !== false ? parseDuration(runsEvery) : null);
175
+ const specPoll = pollMs ? createSpecPoll({ config, where, auth, hooks, runs, specs: specMs !== null, intervalMs: pollMs, now: opts.now, timer: opts.specPollTimer }) : null;
176
+ let serverClosed = false;
164
177
  const proxies = Object.entries(config.proxy || {});
165
178
  const houseSource = config.sources.house && (config.sources.house === 'house' || config.sources.house.use === 'house') ? config.sources.house : null;
166
179
  const houseFile = houseSource && houseSource.options && houseSource.options.file ? inside(root, houseSource.options.file) : null;
@@ -198,8 +211,14 @@ export function createCornfieldServer(config, opts = {}) {
198
211
  else res.end();
199
212
  }
200
213
  });
201
- server.on('listening', () => specPoll?.start());
202
- server.on('close', () => { specPoll?.close(); lander?.close(); hooks?.close(); auth.close(); });
214
+ server.on('listening', () => {
215
+ if (specMs !== null) specPoll?.start();
216
+ else if (specPoll && runs) {
217
+ // Runs asked for before a restart are followed again from the start
218
+ runs.hasWork().then((work) => { if (work && !serverClosed) specPoll.start(); }).catch(() => {});
219
+ }
220
+ });
221
+ server.on('close', () => { serverClosed = true; specPoll?.close(); lander?.close(); hooks?.close(); auth.close(); });
203
222
  return server;
204
223
 
205
224
  async function answer(req, res) {
@@ -236,12 +255,15 @@ export function createCornfieldServer(config, opts = {}) {
236
255
  }
237
256
  // The review API takes a token, not a session: it is the one route before the guard (and it reads no cookie)
238
257
  if (reqPath === REVIEWS_PATH) return serveReviewCall(req, res, { config, where, auth, hooks, lander, now: opts.now });
258
+ // …and so does asking for a run: an agent token, not a session
259
+ if (reqPath === REQUESTS_PATH) return serveAgentCall(req, res, { config, auth, runs, now: opts.now });
239
260
  const gate = await auth.gate(req, res, reqPath, url);
240
261
  if (gate.done) return;
241
262
  const signedIn = gate.session ? { login: gate.session.login } : false;
242
263
 
243
264
  // Before the proxies: a repo's `/api/` proxy must not swallow the panel's own routes
244
265
  if (await serveTokenRoutes(req, res, reqPath, { config, where, auth, session: gate.session, now: opts.now, publicHost })) return;
266
+ if (await serveRunRoutes(req, res, reqPath, { config, auth, runs, session: gate.session, now: opts.now, publicHost })) return;
245
267
  if (await serveForge(req, res, reqPath, url, { config, where, session: gate.session, auth, hooks, lander, now: opts.now, publicHost })) return;
246
268
  for (const [prefix, target] of proxies) if (reqPath.startsWith(prefix)) return proxyTo(req, res, target);
247
269
  const session = gate.session;