@agent-flow-board/bridge 0.3.0 → 0.4.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/README.md CHANGED
@@ -34,15 +34,17 @@ Needs Node 20 or later. Run it with no arguments to see its usage; the list shou
34
34
 
35
35
  More check commands:
36
36
 
37
- | Command | What it does |
38
- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
- | `agent-flow-board check project <NAME> --org <slug> --repo <dir> [--app-dir web]` | Registers a repo folder (and an optional app folder) for a board project, or a live site with `--url`. A repo must be inside a folder the owner approved. |
40
- | `agent-flow-board check approve --action <roots\|register\|secrets\|adopt-env\|pictures>` | The owner, on a terminal, gets a single-use code (10 minutes). Agents cannot make one. |
41
- | `agent-flow-board check roots add\|list\|remove` | The owner-approved parent folders a registration may live in (at most 5). |
42
- | `agent-flow-board check secrets import\|list\|forget` | Seals test Supabase and mail values per registration (owner's code needed). Names only are ever printed. |
43
- | `agent-flow-board check login <NAME> --org <slug> --seeded` | Saves a sign in for a registration from `AFB_CHECKS_<NAME>_EMAIL` and `_PASSWORD`. |
44
- | `agent-flow-board check service install\|status\|uninstall` | Windows: starts the worker when you sign in. |
45
- | `agent-flow-board check import-legacy --from <afb-checks folder> --org <slug>` | Read only: re-registers the old tool's projects and copies its check scripts. |
37
+ | Command | What it does |
38
+ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `agent-flow-board check project <NAME> --org <slug> --repo <dir> [--app-dir web]` | Registers a repo folder (and an optional app folder) for a board project, or a live site with `--url`. A repo must be inside a folder the owner approved. |
40
+ | `agent-flow-board check approve --action <roots\|register\|secrets\|adopt-env\|pictures>` | The owner, on a terminal, gets a single-use code (10 minutes). Agents cannot make one. `--many "roots:<dir>;register:<NAME>@<host>"` makes up to 15 at once. |
41
+ | `agent-flow-board check approve --request --action <x> --target <t> [--why "..."] [--wait <min>]` | Asks the board for the owner's approval, no terminal. When the person has turned on "Let my agent handle approvals on its own" (Settings, Agent access) it is approved at once and the single-use code is printed. `--request-many "<action>:<target>;..."` (up to 20) is one ask; `--resume <id,id>` picks a wait up again. Exit 3 = still waiting. |
42
+ | `agent-flow-board check login <NAME> --org <slug> --approval <code>` | Opens a window on the owner's own computer; the owner signs in once with whatever the site asks for (magic link, code, Google, two-factor). The session is saved sealed on this computer only (exit 4 window closed, 5 timed out). |
43
+ | `agent-flow-board check roots add\|list\|remove` | The owner-approved parent folders a registration may live in (at most 5). |
44
+ | `agent-flow-board check secrets import\|list\|forget` | Seals test Supabase and mail values per registration (owner's code needed). Names only are ever printed. |
45
+ | `agent-flow-board check login <NAME> --org <slug> --seeded` | Saves a sign in for a registration from `AFB_CHECKS_<NAME>_EMAIL` and `_PASSWORD`. |
46
+ | `agent-flow-board check service install\|status\|uninstall` | Windows: starts the worker when you sign in. |
47
+ | `agent-flow-board check import-legacy --from <afb-checks folder> --org <slug>` | Read only: re-registers the old tool's projects and copies its check scripts. |
46
48
 
47
49
  Runs share the machine: a slot set, one browser per host set, one database container with a clone per run, and kept dependencies and builds.
48
50
 
@@ -61,3 +63,11 @@ The easy way to set everything up is the **Editor status** tab on the board's **
61
63
  ## Licence
62
64
 
63
65
  MIT. See `LICENSE`.
66
+
67
+ ## What the board approval channel protects, and what it does not
68
+
69
+ - The person turns the switch on for themselves, signed in on the board with the manage role, after one warning. No MCP tool, no `/api/v1` call and no token can turn it on. It covers the agents on the computers that person owns, and the person loses it by losing manage. Each approval is still single use, for one action and one target on one machine, and lives 10 minutes.
70
+ - The risk the person accepts: anything that holds their manage token, or any hidden instruction in text the agent reads (a file, a comment), can make the agent add folders, register live sites, import test keys or adopt a `.env` without anyone being asked. The board's history (an event for each batch, the rows for 30 days) shows what was done. A bridge-side refusal of drive roots, the home folder and secret folders stays.
71
+ - The local audit line says `decided_via` and who, but it is a file the same user can write; the board's history is the authoritative audit.
72
+ - Honest limit: an agent that runs as the owner's own OS user can read the local approval key. The switch stops an agent that goes through the CLI and the board, not one that edits local files.
73
+ - Switch off or older board: `--request` says so once and the terminal path (`check approve --action ... --target ...`) still works.
@@ -17,11 +17,15 @@ export const APPROVAL_ACTIONS = [
17
17
  'secrets',
18
18
  'adopt-env',
19
19
  'roots',
20
- 'pictures'
20
+ 'pictures',
21
+ 'login'
21
22
  ];
22
23
  /** A code is valid for ten minutes. */
23
24
  export const APPROVAL_TTL_MS = 10 * 60 * 1000;
25
+ /** The most codes the file holds: the board's batch cap, so a whole batch is valid at once. */
24
26
  const MAX_CODES = 20;
27
+ /** The decisions this module's own claim function returned. A decision that is not in here is never minted into a code. */
28
+ const claimedDecisions = new WeakSet();
25
29
  const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
26
30
  const CODE_LENGTH = 12;
27
31
  // -- FUNCTIONS -- //
@@ -121,13 +125,23 @@ function readEntries() {
121
125
  for (const e of list) {
122
126
  if (!e || typeof e !== 'object')
123
127
  continue;
124
- const { h, action, target, exp } = e;
128
+ const { h, action, target, exp, note } = e;
125
129
  if (typeof h === 'string' &&
126
130
  typeof action === 'string' &&
127
131
  isApprovalAction(action) &&
128
132
  typeof target === 'string' &&
129
- typeof exp === 'number')
130
- out.push({ h, action, target, exp });
133
+ typeof exp === 'number') {
134
+ const n = note;
135
+ out.push({
136
+ h,
137
+ action,
138
+ target,
139
+ exp,
140
+ ...(n && typeof n.by === 'string' && typeof n.at === 'string'
141
+ ? { note: { by: n.by.slice(0, 80), at: n.at.slice(0, 40) } }
142
+ : {})
143
+ });
144
+ }
131
145
  }
132
146
  return out;
133
147
  }
@@ -143,6 +157,34 @@ const writeEntries = (codes) => writeFileAtomic(approvalsFile(), JSON.stringify(
143
157
  export function mintApproval(action, target, opts) {
144
158
  if (!opts.isTTY)
145
159
  throw new Refusal('An approval code is made by the owner on an interactive terminal. This is not one, so no code was made.');
160
+ return mintCode(action, target, opts.now);
161
+ }
162
+ /**
163
+ * Makes a code from a board decision that this module's claim function returned. This is the only way besides a terminal: a decision that was not claimed through the
164
+ * board (a made-up object, a flag, a file) is refused. The code is for the decision's own action and target, and nothing else.
165
+ */
166
+ export function mintFromDecision(claim, opts = {}) {
167
+ if (!claimedDecisions.has(claim))
168
+ throw new Refusal('An approval code is made from a decision the board gave, and this is not one.');
169
+ return mintCode(claim.action, claim.target, opts.now, {
170
+ by: claim.decidedBy,
171
+ at: claim.decidedAt
172
+ });
173
+ }
174
+ /** Claims the named approved decisions on the board (once) and marks what came back as claimed. A decision the board did not return is not here. */
175
+ export async function claimBoardDecisions(board, ids) {
176
+ const got = await board.claimApprovals(ids);
177
+ const out = got.map((d) => ({ ...d }));
178
+ for (const d of out)
179
+ claimedDecisions.add(d);
180
+ return out;
181
+ }
182
+ /** The sentence for the owner's word when a code came from the board, or null when it came from a terminal. */
183
+ export function boardSentence(note) {
184
+ return note ? `Approved by ${note.by} on the board at ${note.at}.` : null;
185
+ }
186
+ function mintCode(action, target, nowMs, note) {
187
+ const opts = { now: nowMs };
146
188
  if (!isApprovalAction(action))
147
189
  throw new Refusal(`"${action.slice(0, 30)}" is not an action (${APPROVAL_ACTIONS.join(', ')}).`);
148
190
  const now = opts.now ?? Date.now();
@@ -153,10 +195,21 @@ export function mintApproval(action, target, opts) {
153
195
  const exp = now + APPROVAL_TTL_MS;
154
196
  withLockSync('approvals', () => {
155
197
  const kept = readEntries().filter((e) => e.exp > now && !(e.action === action && e.target === norm));
156
- kept.push({ h: macOf(action, norm, code), action, target: norm, exp });
198
+ kept.push({
199
+ h: macOf(action, norm, code),
200
+ action,
201
+ target: norm,
202
+ exp,
203
+ ...(note ? { note } : {})
204
+ });
157
205
  writeEntries(kept.slice(-MAX_CODES));
158
206
  });
159
- appendAudit('approval minted', { action, target: norm, minutes: APPROVAL_TTL_MS / 60000 });
207
+ appendAudit('approval minted', {
208
+ action,
209
+ target: norm,
210
+ minutes: APPROVAL_TTL_MS / 60000,
211
+ ...(note ? { decided_via: 'board', decided_by: note.by } : {})
212
+ });
160
213
  return {
161
214
  code: `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`,
162
215
  expiresAt: new Date(exp).toISOString()
@@ -184,30 +237,38 @@ export function peekApproval(action, target, code, now = Date.now()) {
184
237
  }
185
238
  /** True once for a valid code of this action and target, and false for a wrong action, a wrong target, an expired, a used or an unknown code. */
186
239
  export function consumeApproval(action, target, code, now = Date.now()) {
240
+ return consumeApprovalNote(action, target, code, now).ok;
241
+ }
242
+ /** Like consumeApproval, and says who decided when the code came from the board (for the owner sentence and the audit line). */
243
+ export function consumeApprovalNote(action, target, code, now = Date.now()) {
187
244
  if (!isApprovalAction(action))
188
- return false;
245
+ return { ok: false };
189
246
  const plain = normaliseCode(code);
190
247
  if (!plain)
191
- return false;
248
+ return { ok: false };
192
249
  let norm;
193
250
  try {
194
251
  norm = normaliseTarget(action, target);
195
252
  }
196
253
  catch {
197
- return false;
254
+ return { ok: false };
198
255
  }
199
256
  const want = macOf(action, norm, plain);
200
- let hit = false;
257
+ let found;
201
258
  withLockSync('approvals', () => {
202
259
  const all = readEntries();
203
260
  const live = all.filter((e) => e.exp > now);
204
- const found = live.find((e) => e.action === action && sameMac(e.h, want));
205
- hit = found !== undefined;
261
+ found = live.find((e) => e.action === action && sameMac(e.h, want));
206
262
  const rest = found ? live.filter((e) => e !== found) : live;
207
- if (hit || rest.length !== all.length)
263
+ if (found || rest.length !== all.length)
208
264
  writeEntries(rest);
209
265
  });
266
+ const hit = found;
210
267
  if (hit)
211
- appendAudit('approved with code', { action, target: norm });
212
- return hit;
268
+ appendAudit('approved with code', {
269
+ action,
270
+ target: norm,
271
+ ...(hit.note ? { decided_via: 'board', decided_by: hit.note.by } : {})
272
+ });
273
+ return hit ? { ok: true, ...(hit.note ? { note: hit.note } : {}) } : { ok: false };
213
274
  }
@@ -0,0 +1,143 @@
1
+ // ASKING THE OWNER ON THE BOARD: THE AGENT FILES A REQUEST, THE OWNER CLICKS ONCE, THE BRIDGE CLAIMS THE ANSWER ONCE AND MAKES THE SAME LOCAL SINGLE-USE CODE A TERMINAL MAKES
2
+ // Nothing here reads a terminal. A code is made only from a decision the board returned to the claim call (approve.ts keeps the list), never from an argument.
3
+ // The board is injected, so the tests use a fake one. A board that does not know the path, or has approvals switched off, gives a plain message with the terminal fallback.
4
+ // -- MODULES -- //
5
+ import { APPROVAL_ACTIONS, Refusal, UsageError, claimBoardDecisions, isApprovalAction, mintFromDecision, normaliseTarget } from './approve.js';
6
+ import { BoardError } from './board.js';
7
+ // -- CONSTANTS -- //
8
+ /** The most entries one ask holds (the board's cap). */
9
+ export const ASK_LIMIT = 20;
10
+ const FALLBACK = 'The owner can still make a code on a terminal: agent-flow-board check approve --action <action> --target <target>.';
11
+ // -- FUNCTIONS -- //
12
+ /** Reads `<action>:<target>;<action>:<target>` into entries. All of them are checked before anything is asked. */
13
+ export function parseEntries(text) {
14
+ const entries = text
15
+ .split(';')
16
+ .map((e) => e.trim())
17
+ .filter(Boolean)
18
+ .map((e) => {
19
+ const at = e.indexOf(':');
20
+ const kind = at > 0 ? e.slice(0, at).trim() : '';
21
+ const target = at > 0 ? e.slice(at + 1).trim() : '';
22
+ if (!isApprovalAction(kind) || !target)
23
+ throw new UsageError(`"${e.slice(0, 60)}" is not <action>:<target>. Actions: ${APPROVAL_ACTIONS.join(', ')}.`);
24
+ return { action: kind, target };
25
+ });
26
+ if (!entries.length)
27
+ throw new UsageError('--request-many needs at least one <action>:<target>.');
28
+ if (entries.length > ASK_LIMIT)
29
+ throw new UsageError(`--request-many asks for at most ${ASK_LIMIT} things at once.`);
30
+ return entries;
31
+ }
32
+ /** A board that cannot take the ask says so in words an agent can pass on, with the terminal path. */
33
+ function explain(e) {
34
+ if (e instanceof BoardError) {
35
+ if (e.status === 404)
36
+ throw new Refusal(`This board is older and cannot take approvals yet. ${FALLBACK}`);
37
+ if (e.status === 403)
38
+ throw new Refusal(`${e.message} ${FALLBACK}`);
39
+ if (e.status === 429)
40
+ throw new Refusal(e.message);
41
+ }
42
+ if (e instanceof BoardError ||
43
+ e instanceof TypeError ||
44
+ e?.name === 'TimeoutError')
45
+ throw new Refusal(`The board could not be reached (${e instanceof Error ? e.message.slice(0, 120) : 'no answer'}). ${FALLBACK}`);
46
+ throw e;
47
+ }
48
+ const REFUSED = {
49
+ refused: 'The owner refused this.',
50
+ expired: 'The request ran out of time before the owner decided. Ask again.',
51
+ cancelled: 'The request was withdrawn. Ask again.',
52
+ claimed: 'This approval was already used. Ask again if it is needed again.'
53
+ };
54
+ /**
55
+ * Asks the owner on the board and waits for the click (up to `waitMinutes`). Returns the codes, one per approved entry and no more, or `waiting` with the ids to resume.
56
+ * A refusal, an expiry and a cancel throw a Refusal with one plain sentence. An abort withdraws the request first so no orphan card stays.
57
+ */
58
+ export async function askOwner(board, o) {
59
+ const now = o.now ?? Date.now;
60
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
61
+ let ids = o.resume ?? [];
62
+ let link = null;
63
+ if (!ids.length) {
64
+ if (!o.entries.length)
65
+ throw new UsageError('Say what to ask for.');
66
+ if (o.entries.length > ASK_LIMIT)
67
+ throw new UsageError(`At most ${ASK_LIMIT} things can be asked in one request.`);
68
+ const entries = o.entries.map((e) => {
69
+ if (!isApprovalAction(e.action))
70
+ throw new UsageError(`"${e.action.slice(0, 30)}" is not an action (${APPROVAL_ACTIONS.join(', ')}).`);
71
+ return { action: e.action, target: normaliseTarget(e.action, e.target) };
72
+ });
73
+ let asked;
74
+ try {
75
+ asked = await board.requestApprovals({
76
+ requestId: o.requestId ?? `r${now().toString(36)}${Math.random().toString(36).slice(2, 12)}`,
77
+ entries,
78
+ ...(o.why ? { why: o.why.slice(0, 2000) } : {}),
79
+ ...(o.project ? { project: o.project } : {})
80
+ });
81
+ }
82
+ catch (e) {
83
+ return explain(e);
84
+ }
85
+ ids = asked.requests.map((r) => r.id);
86
+ link = asked.link;
87
+ // With the person's switch on the board approves at once and there is nothing to say; a row that is still open is waiting for the switch
88
+ if (asked.requests.some((r) => r.status === 'pending'))
89
+ o.say(link
90
+ ? `Waiting for the owner. Ask them to turn on "Let my agent handle approvals on its own" at ${link}.`
91
+ : 'Waiting for the owner. Ask them to turn on "Let my agent handle approvals on its own" under Settings, Agent access.');
92
+ }
93
+ const deadline = now() + Math.max(0, o.waitMinutes) * 60_000;
94
+ let states;
95
+ for (;;) {
96
+ if (o.signal?.aborted) {
97
+ await board.cancelApprovals(ids).catch(() => undefined);
98
+ throw new Refusal('Stopped. The request was withdrawn from the board.');
99
+ }
100
+ try {
101
+ states = await board.pollApprovals(ids);
102
+ }
103
+ catch (e) {
104
+ return explain(e);
105
+ }
106
+ if (states.length !== ids.length)
107
+ throw new Refusal('The board no longer has this request. Ask again.');
108
+ const bad = states.find((s) => REFUSED[s.status]);
109
+ if (bad)
110
+ throw new Refusal(REFUSED[bad.status]);
111
+ if (states.every((s) => s.status === 'approved'))
112
+ break;
113
+ if (now() >= deadline)
114
+ return { state: 'waiting', ids, link };
115
+ await sleep(o.pollMs ?? 3000);
116
+ }
117
+ let claimed;
118
+ try {
119
+ claimed = await claimBoardDecisions(board, ids);
120
+ }
121
+ catch (e) {
122
+ return explain(e);
123
+ }
124
+ if (claimed.length !== ids.length)
125
+ throw new Refusal('The approval was used or ran out just now. Ask again.');
126
+ // Each claimed decision must be one of the rows asked about: the board's row for this action and target, nothing else
127
+ for (const c of claimed) {
128
+ const asked = states.find((s) => s.id === c.id);
129
+ if (!asked || asked.action !== c.action || asked.target !== c.target)
130
+ throw new Refusal('The board answered with something that was not asked for, so no code was made.');
131
+ }
132
+ const codes = claimed.map((c) => {
133
+ const made = mintFromDecision(c);
134
+ return {
135
+ action: c.action,
136
+ target: c.target,
137
+ code: made.code,
138
+ expiresAt: made.expiresAt,
139
+ by: c.decidedBy
140
+ };
141
+ });
142
+ return { state: 'approved', codes };
143
+ }
@@ -101,6 +101,36 @@ export class RunnerBoard {
101
101
  const out = await this.call('POST', `/runs/${encodeURIComponent(runId)}/artifacts?${q}`, file.bytes, 'application/octet-stream');
102
102
  return out.attachmentId;
103
103
  }
104
+ // -- OWNER APPROVALS ASKED ON THE BOARD -- //
105
+ /** Files a request (one entry, or up to 20 as one batch). The board normalises each target and returns the rows and the page link. */
106
+ requestApprovals(input) {
107
+ return this.call('POST', '/approvals', JSON.stringify(input), 'application/json');
108
+ }
109
+ /** The state of requests of this machine. */
110
+ async pollApprovals(ids) {
111
+ return (await this.call('POST', '/approvals/poll', JSON.stringify({ ids }), 'application/json')).requests;
112
+ }
113
+ /** Withdraws waiting requests, so no card stays for an agent that gave up. */
114
+ cancelApprovals(ids) {
115
+ return this.call('POST', '/approvals/cancel', JSON.stringify({ ids }), 'application/json');
116
+ }
117
+ /** Claims approved decisions, once: the board answers only the rows that were approved, unexpired and not claimed. */
118
+ async claimApprovals(ids) {
119
+ return (await this.call('POST', '/approvals/claim', JSON.stringify({ ids }), 'application/json')).claimed;
120
+ }
121
+ // -- TEST ACCOUNT SIGN INS TYPED ON THE BOARD -- //
122
+ /** Publishes this machine's public sign in key (only this runner token may). */
123
+ publishSigninKey(input) {
124
+ return this.call('POST', '/signins/key', JSON.stringify(input), 'application/json');
125
+ }
126
+ /** Leases the waiting ciphertext of one registration for two minutes. */
127
+ leaseSignins(registration) {
128
+ return this.call('POST', '/signins/lease', JSON.stringify({ registration }), 'application/json');
129
+ }
130
+ /** Says the attempt is over; the board keeps pass or fail only and wipes the ciphertext. */
131
+ ackSignin(input) {
132
+ return this.call('POST', '/signins/ack', JSON.stringify(input), 'application/json');
133
+ }
104
134
  finish(runId, report) {
105
135
  return this.call('POST', `/runs/${encodeURIComponent(runId)}/finish`, JSON.stringify(report), 'application/json');
106
136
  }
@@ -9,7 +9,9 @@ import { dirname, relative, resolve } from 'node:path';
9
9
  import { bindFolder } from './bind.js';
10
10
  import { ensureBrowser } from './browser.js';
11
11
  import { connectChecks, loadRunnerToken, runnerAccount } from './connect.js';
12
- import { forgetConnection, recordConnection } from './connections.js';
12
+ import { forgetConnection, listConnections, recordConnection } from './connections.js';
13
+ import { RunnerBoard } from './board.js';
14
+ import { publishSigninKey } from './signinKey.js';
13
15
  import { ORG_SLUG, scriptPath, scriptsDir } from './paths.js';
14
16
  import { pinFiles, resolveProjectConfig } from './recipe.js';
15
17
  import { runCheck } from './run.js';
@@ -30,7 +32,9 @@ export const CHECK_USAGE = [
30
32
  ' watch [--parallel N] [--once] take queued runs from the boards this machine is connected to',
31
33
  ' run ... also takes [--registration NAME] [--file <script inside the scripts folder>] [--headed]',
32
34
  ' project <NAME> --org <slug> (--repo <dir> [--app-dir web] [--board-project KEY] [--recipe] | --url <address> [--extra-hosts a,b]) [--adopt-env --adopt-env-approval <code>] [--upload-pictures --pictures-approval <code>] [--approval <code>] | project list|forget|pin',
33
- ' approve --action <roots|register|secrets|adopt-env|pictures> --target <name> (the owner, on a terminal) | roots add|list|remove <dir> [--approval <code>]',
35
+ ' approve --request --action <roots|register|secrets|adopt-env|pictures|login> --target <name> [--why "<sentence>"] [--wait <minutes>] (asks the owner on the board; no terminal)',
36
+ ' approve --request-many "<action>:<target>;<action>:<target>" | approve --resume <id,id> [--wait <minutes>] (exit 3 = still waiting)',
37
+ ' approve --action <roots|register|secrets|adopt-env|pictures|login> --target <name> (the owner, on a terminal) | roots add|list|remove <dir> [--approval <code>]',
34
38
  ' secrets import|list|forget --org <slug> --project <NAME> [--from <file> --keys A,B --test-ref <ref> --test-inbox <email> --owner-said "<sentence>" --allow-service-role --approval <code>]',
35
39
  ' login <NAME> --org <slug> --seeded | service install|uninstall|status [--no-start] | import-legacy --from <afb-checks folder> --org <slug> [--dry-run]',
36
40
  ' status [--json] | doctor [--fix] [--json] | clear [runs|profiles|auth|dbs|previews|all] [--all] [--yes] | setup | disconnect --server <url> --org <slug>'
@@ -97,6 +101,10 @@ export async function checkMain(ctx) {
97
101
  out
98
102
  });
99
103
  recordConnection({ server: s, org: o, name: done.name });
104
+ // The sign in key of this machine goes to the board with the connection. A board that does not know it yet, or a failure, never stops a connect
105
+ const runnerToken = loadRunnerToken(ctx.store, s, o);
106
+ if (runnerToken)
107
+ await publishSigninKey(new RunnerBoard(s, runnerToken, ctx.fetch)).catch(() => undefined);
100
108
  return 0;
101
109
  }
102
110
  if (sub === 'disconnect') {
@@ -206,12 +214,56 @@ export async function checkMain(ctx) {
206
214
  // The registration, approval, sign in and service commands live in their own modules; each takes the arguments after its word
207
215
  if (sub === 'project')
208
216
  return await projectCommand(args.slice(1), { log, out, json });
209
- if (sub === 'approve')
210
- return await approveCommand(args.slice(1), { log, out, json });
217
+ if (sub === 'approve') {
218
+ // Asking on the board uses this machine's own runner connection; with one connection --server and --org may be left out
219
+ const only = listConnections();
220
+ const board = () => {
221
+ const s = flag('--server') ??
222
+ process.env.AFB_SERVER ??
223
+ (only.length === 1 ? only[0]?.server : undefined);
224
+ const o = flag('--org') ?? (only.length === 1 ? only[0]?.org : undefined);
225
+ if (!s || !o || !ORG_SLUG.test(o))
226
+ throw new Error('Say which board: --server <url> --org <slug> (or connect this machine to one board only).');
227
+ const token = loadRunnerToken(ctx.store, s.replace(/\/$/, ''), o);
228
+ if (!token)
229
+ throw new Error(`This machine is not connected to ${o} on ${s}. Run: agent-flow-board check connect --server ${s} --org ${o} --json`);
230
+ return new RunnerBoard(s, token, ctx.fetch);
231
+ };
232
+ // Ctrl-C or a kill withdraws the request, so no card is left on the board for an agent that is gone
233
+ const ac = new AbortController();
234
+ const stop = () => ac.abort();
235
+ process.once('SIGINT', stop);
236
+ process.once('SIGTERM', stop);
237
+ try {
238
+ return await approveCommand(args.slice(1), { log, out, json, board, signal: ac.signal });
239
+ }
240
+ finally {
241
+ process.off('SIGINT', stop);
242
+ process.off('SIGTERM', stop);
243
+ }
244
+ }
211
245
  if (sub === 'roots')
212
246
  return await rootsCommand(args.slice(1), { log, out, json });
213
- if (sub === 'login')
214
- return await loginCommand(args.slice(1), { log, out, json });
247
+ if (sub === 'login') {
248
+ const only = listConnections();
249
+ const board = () => {
250
+ const s = flag('--server') ??
251
+ process.env.AFB_SERVER ??
252
+ (only.length === 1 ? only[0]?.server : undefined);
253
+ const o = flag('--org') ?? (only.length === 1 ? only[0]?.org : undefined);
254
+ if (!s || !o || !ORG_SLUG.test(o))
255
+ throw new Error('Say which board: --server <url> --org <slug>.');
256
+ const token = loadRunnerToken(ctx.store, s.replace(/\/$/, ''), o);
257
+ if (!token)
258
+ throw new Error('This machine is not connected to that board. Run: agent-flow-board check connect --server ' +
259
+ s +
260
+ ' --org ' +
261
+ o +
262
+ ' --json');
263
+ return new RunnerBoard(s, token, ctx.fetch);
264
+ };
265
+ return await loginCommand(args.slice(1), { log, out, json, board });
266
+ }
215
267
  if (sub === 'service')
216
268
  return await serviceCommand(args.slice(1), { log, out, json });
217
269
  if (sub === 'secrets')
@@ -7,14 +7,18 @@ import { isatty } from 'node:tty';
7
7
  import { APPROVAL_ACTIONS, Refusal, UsageError, isApprovalAction, mintApproval } from './approve.js';
8
8
  import { forgetRegistration, hasState, pinRegistration, pinState, readRegistrations, registerProject } from './registry.js';
9
9
  import { addRoot, listRoots, readRoots, removeRoot } from './roots.js';
10
+ import { ASK_LIMIT, askOwner, parseEntries } from './askBoard.js';
10
11
  // -- CONSTANTS -- //
11
12
  const BOOLEAN_FLAGS = new Set([
12
13
  '--recipe',
13
14
  '--remove',
14
15
  '--adopt-env',
15
16
  '--upload-pictures',
17
+ '--request',
16
18
  '--json'
17
19
  ]);
20
+ /** Most codes one `check approve --many` makes (the approvals file keeps 20). */
21
+ const MANY_LIMIT = 15;
18
22
  // -- FUNCTIONS -- //
19
23
  function parse(args) {
20
24
  const pos = [];
@@ -168,16 +172,52 @@ export async function projectCommand(args, ctx) {
168
172
  }
169
173
  /**
170
174
  * `check approve --action <register|secrets|adopt-env|roots|pictures> --target <name or folder>`: the owner's single-use code. Refused without an interactive terminal.
175
+ * `check approve --many "<action>:<target>;<action>:<target>"` makes several codes in one call (at most 15) under the same rule.
171
176
  * `env.isTTY` is for the tests; the command itself passes the real flag of the terminal.
172
177
  */
173
178
  export async function approveCommand(args, ctx, env = {}) {
174
- return guarded(ctx, () => {
179
+ return guarded(ctx, async () => {
175
180
  const p = parse(args);
181
+ const isTTY = env.isTTY ?? (isatty(0) && isatty(1));
182
+ // ASKING THE OWNER ON THE BOARD (no terminal): --request, --request-many or --resume
183
+ if (p.flags.has('--request') || p.flags.has('--request-many') || p.flags.has('--resume'))
184
+ return await requestOnBoard(p, ctx);
185
+ // MANY CODES IN ONE CALL
186
+ // Same rules as one code: a terminal only, one action and one target per code, single use, ten minutes. All entries are checked before any code is made.
187
+ const many = text(p, '--many');
188
+ if (many !== undefined) {
189
+ if (p.flags.has('--action') || p.flags.has('--target'))
190
+ throw new UsageError('Use --many on its own, or --action with --target.');
191
+ const entries = many
192
+ .split(';')
193
+ .map((e) => e.trim())
194
+ .filter(Boolean)
195
+ .map((e) => {
196
+ const at = e.indexOf(':');
197
+ const kind = at > 0 ? e.slice(0, at).trim() : '';
198
+ const target = at > 0 ? e.slice(at + 1).trim() : '';
199
+ if (!isApprovalAction(kind) || !target)
200
+ throw new UsageError(`"${e.slice(0, 60)}" is not <action>:<target>. Actions: ${APPROVAL_ACTIONS.join(', ')}.`);
201
+ return { action: kind, target };
202
+ });
203
+ if (!entries.length)
204
+ throw new UsageError('--many needs at least one <action>:<target>.');
205
+ if (entries.length > MANY_LIMIT)
206
+ throw new UsageError(`--many makes at most ${MANY_LIMIT} codes at once.`);
207
+ const codes = entries.map((e) => {
208
+ const made = mintApproval(e.action, e.target, { isTTY });
209
+ return { action: e.action, target: e.target, code: made.code, expiresAt: made.expiresAt };
210
+ });
211
+ show(ctx, { codes }, [
212
+ ...codes.map((c) => `Approval code for ${c.action} on ${c.target}: ${c.code}`),
213
+ `Each works once and expires at ${codes[0]?.expiresAt ?? ''}. Give each to the command that asked for it, with --approval.`
214
+ ]);
215
+ return 0;
216
+ }
176
217
  const action = need(p, '--action');
177
218
  if (!isApprovalAction(action))
178
219
  throw new UsageError(`--action is one of ${APPROVAL_ACTIONS.join(', ')}.`);
179
220
  const target = need(p, '--target');
180
- const isTTY = env.isTTY ?? (isatty(0) && isatty(1));
181
221
  const made = mintApproval(action, target, { isTTY });
182
222
  show(ctx, { action, target, code: made.code, expiresAt: made.expiresAt }, [
183
223
  `Approval code for ${action} on ${target}: ${made.code}`,
@@ -186,6 +226,62 @@ export async function approveCommand(args, ctx, env = {}) {
186
226
  return 0;
187
227
  });
188
228
  }
229
+ /**
230
+ * `check approve --request --action <x> --target <t> [--why "<sentence>"] [--wait <minutes>]`, `--request-many "<action>:<target>;..."` and `--resume <id,id>`.
231
+ * Files the request on the board, prints the page link, waits for the owner's click (default 10 minutes) and prints the codes. Exit 0 approved, 1 refused or expired,
232
+ * 2 could not ask, 3 still waiting (the ids to resume are printed).
233
+ */
234
+ async function requestOnBoard(p, ctx) {
235
+ if (!ctx.board)
236
+ throw new UsageError('Asking on the board needs the check command with --server and --org.');
237
+ const many = text(p, '--request-many');
238
+ const resume = text(p, '--resume');
239
+ const minutes = text(p, '--wait') === undefined ? 10 : Number(text(p, '--wait'));
240
+ if (!Number.isInteger(minutes) || minutes < 0 || minutes > 60)
241
+ throw new UsageError('--wait takes a whole number of minutes from 0 to 60.');
242
+ let entries = [];
243
+ if (resume === undefined) {
244
+ if (many !== undefined) {
245
+ if (p.flags.has('--action') || p.flags.has('--target'))
246
+ throw new UsageError('Use --request-many on its own, or --request with --action and --target.');
247
+ entries = parseEntries(many);
248
+ }
249
+ else {
250
+ const action = need(p, '--action');
251
+ if (!isApprovalAction(action))
252
+ throw new UsageError(`--action is one of ${APPROVAL_ACTIONS.join(', ')}.`);
253
+ entries = [{ action, target: need(p, '--target') }];
254
+ }
255
+ }
256
+ const ids = resume
257
+ ?.split(',')
258
+ .map((s) => s.trim())
259
+ .filter(Boolean);
260
+ if (ids &&
261
+ (!ids.length || ids.length > ASK_LIMIT || ids.some((i) => !/^[0-9a-f-]{36}$/i.test(i))))
262
+ throw new UsageError('--resume takes the ids that were printed, separated by commas.');
263
+ const done = await askOwner(ctx.board(), {
264
+ entries,
265
+ why: text(p, '--why'),
266
+ project: text(p, '--project'),
267
+ waitMinutes: minutes,
268
+ resume: ids,
269
+ say: (line) => ctx.log(line),
270
+ signal: ctx.signal
271
+ });
272
+ if (done.state === 'waiting') {
273
+ const list = done.ids.join(',');
274
+ show(ctx, { state: 'waiting', ids: done.ids, link: done.link, resume: list }, [
275
+ `Still waiting for the owner. Pick it up again with: agent-flow-board check approve --resume ${list} --wait 10`
276
+ ]);
277
+ return 3;
278
+ }
279
+ show(ctx, { state: 'approved', codes: done.codes }, [
280
+ ...done.codes.map((c) => `Approval code for ${c.action} on ${c.target}: ${c.code}`),
281
+ `Each works once and expires at ${done.codes[0]?.expiresAt ?? ''}. Give each to the command that asked for it, with --approval.`
282
+ ]);
283
+ return 0;
284
+ }
189
285
  /** `check roots add <dir> --approval <code> | list | remove <dir> --approval <code>`. */
190
286
  export async function rootsCommand(args, ctx) {
191
287
  return guarded(ctx, () => {
@@ -8,6 +8,7 @@ import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, statSync } fr
8
8
  import { basename, join } from 'node:path';
9
9
  // -- MODULES -- //
10
10
  import { verifyAudit } from './audit.js';
11
+ import { localFingerprint } from './signinKey.js';
11
12
  import { machineTotals } from './broker.js';
12
13
  import { listBrowserMarkers, sweepBrowserMarkers } from './browserpool.js';
13
14
  import { ensureBrowser, playwrightRuntimeDir } from './browser.js';
@@ -379,7 +380,7 @@ export async function runDoctor(ctx, opts, deps = {}) {
379
380
  if (!existsSync(r.repo))
380
381
  found(`${id}: the repo folder is not there any more.`, `Run: agent-flow-board check project forget ${r.name} --org ${r.org} --remove, then register it again.`);
381
382
  else if (!rootHolding(r.repo))
382
- found(`${id}: the repo is not inside a folder the owner approved.`, 'Ask the owner: check approve --action roots --target <parent folder>, then check roots add <folder> --approval <code>.');
383
+ found(`${id}: the repo is not inside a folder the owner approved.`, 'Ask the owner: check approve --request --action roots --target <parent folder>, then check roots add <folder> --approval <code>.');
383
384
  else {
384
385
  const pin = pinState(r);
385
386
  if (pin === 'changed') {
@@ -534,6 +535,11 @@ export async function healthCommand(args, ctx, deps = {}) {
534
535
  `Slots: ${s.slots ? `${s.slots.busy} of ${s.slots.slots} busy` : 'unknown'}`,
535
536
  `Service: ${s.service ? s.service.state : 'not installed'}`,
536
537
  `Connections: ${s.connections.map((c) => `${c.org}@${c.server}${c.connected === false ? ' (no token)' : ''}`).join(', ') || 'none'}`,
538
+ ...(localFingerprint()
539
+ ? [
540
+ `Sign-in key: ${localFingerprint()} (compare with the code on the board before typing a password there)`
541
+ ]
542
+ : []),
537
543
  `Audit chain: ${s.audit.ok ? 'ok' : 'BROKEN'} (${s.audit.lines} lines)`,
538
544
  ...s.problems.map((p) => `Problem: ${p.problem} Next: ${p.next}`),
539
545
  s.problems.length ? '' : 'Nothing is wrong.'
@@ -86,7 +86,7 @@ function importProject(org, name, old, dryRun) {
86
86
  // A live site needs the owner's code; it is never made here
87
87
  if (old.url) {
88
88
  const hosts = old.extraHosts?.length ? ` --extra-hosts ${old.extraHosts.join(',')}` : '';
89
- return skip('a live site is registered only with a code from the owner', `check approve --action register --target ${name}, then check project ${name} --org ${org} --board-project ${boardProject} --url ${old.url}${hosts} --approval <code>`);
89
+ return skip('a live site is registered only with a code from the owner', `check approve --request --action register --target ${name}, then check project ${name} --org ${org} --board-project ${boardProject} --url ${old.url}${hosts} --approval <code>`);
90
90
  }
91
91
  if (!old.repo)
92
92
  return skip('the project file names neither a repo folder nor a live site');
@@ -96,7 +96,7 @@ function importProject(org, name, old, dryRun) {
96
96
  const root = rootHolding(folder);
97
97
  if (!root) {
98
98
  const parent = dirname(folder);
99
- return skip('its repo folder is not inside a folder the owner approved', `check approve --action roots --target "${parent}", then check roots add "${parent}" --approval <code>, then import again`);
99
+ return skip('its repo folder is not inside a folder the owner approved', `check approve --request --action roots --target "${parent}", then check roots add "${parent}" --approval <code>, then import again`);
100
100
  }
101
101
  const input = {
102
102
  org,
@@ -229,7 +229,7 @@ export function importLegacy(opts) {
229
229
  if (s.next)
230
230
  todo.push(`${s.name}: ${s.next}`);
231
231
  if (projects.registered.length || projects.unchanged.length) {
232
- todo.push(`Secrets are not moved (they are sealed with the old key). Import them again with an approval code: ${[...projects.registered, ...projects.unchanged].map((n) => `check approve --action secrets --target ${n}`).join('; ')}, then check secrets import ... --approval <code>.`);
232
+ todo.push(`Secrets are not moved (they are sealed with the old key). Import them again with an approval code: ${[...projects.registered, ...projects.unchanged].map((n) => `check approve --request --action secrets --target ${n}`).join('; ')}, then check secrets import ... --approval <code>.`);
233
233
  todo.push(`Make the sign-ins again: ${[...projects.registered, ...projects.unchanged].map((n) => `check login ${n} --org ${org}`).join('; ')}.`);
234
234
  }
235
235
  if (scripts.conflicts.length)
@@ -1,21 +1,26 @@
1
1
  // `check login <NAME> --org <slug> [--seeded]`: SAVING A SIGN-IN FOR A REGISTRATION'S SITE, AND THE RULES FOR USING ONE
2
2
  // --seeded signs in with a seeded account (AFB_CHECKS_<NAME>_EMAIL and _PASSWORD) or the recipe's test user, through the app's own form.
3
- // Without --seeded a person signs in by hand in a visible browser window: that needs an interactive terminal, and is refused without one.
4
- // This file is the ONE place in the bridge that reads a terminal, and only in waitForEnter, only after the TTY check.
3
+ // Without --seeded a person signs in by hand in a visible browser window on their own desktop. It needs the owner's `login` approval (a code, from the board or a terminal),
4
+ // used up before the window opens, and a desktop session to show the window on. The window is a fresh temporary browser profile with no debugging port, deleted afterwards,
5
+ // and the bridge does not return what is typed into it to the agent. The wait ends when a new session cookie of the site appears, the window is closed, or the time runs out.
5
6
  // Nothing here starts a preview itself: the caller injects `target`, and the sign in function is injected too, so tests launch no browser.
6
7
  // A cookie, a password or a token is never printed.
7
8
  // -- DEP-MODULES -- //
8
- import { readFileSync } from 'node:fs';
9
- import { createInterface } from 'node:readline';
9
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
10
11
  import { join } from 'node:path';
11
12
  // -- MODULES -- //
13
+ import { consumeApproval, peekApproval } from './approve.js';
12
14
  import { loadPlaywright } from './browser.js';
13
15
  import { ORG_SLUG, registrationsDir } from './paths.js';
14
16
  import { expiryNote, loadProfileChecked, saveProfile } from './profiles.js';
15
17
  import { redact } from './redact.js';
18
+ import { openSignin } from './signinKey.js';
16
19
  import { signInWithPassword } from './signin.js';
17
20
  // -- CONSTANTS -- //
18
21
  const NAME = /^[A-Za-z0-9_-]{1,32}$/;
22
+ /** The window waits this long for a sign in (minutes), unless --timeout says otherwise. */
23
+ const DEFAULT_TIMEOUT_MINUTES = 5;
19
24
  // -- FUNCTIONS -- //
20
25
  /** The variable names of a registration: AFB_CHECKS_<NAME upper, dash to underscore>_EMAIL and _PASSWORD. */
21
26
  export function envNames(name) {
@@ -77,32 +82,67 @@ export function keepRelated(state, hosts) {
77
82
  })
78
83
  };
79
84
  }
80
- /** The one terminal read of the bridge: waits for the person to press Enter. Used only by the headed login, after the TTY check. */
81
- function waitForEnter(question) {
82
- return new Promise((resolve) => {
83
- const rl = createInterface({ input: process.stdin, output: process.stdout });
84
- rl.question(question, () => {
85
- rl.close();
86
- resolve();
87
- });
88
- });
85
+ /**
86
+ * Whether this process can show a window to a person: null when it can. On Windows a service has no console session name; on Linux a desktop sets DISPLAY or WAYLAND_DISPLAY;
87
+ * on a Mac a shell opened over SSH is not the desktop. Otherwise one plain sentence says why a window cannot be shown.
88
+ */
89
+ export function desktopProblem(env = process.env, platform = process.platform) {
90
+ const none = 'This process has no desktop to show a window on (for example it runs as a service or over a remote shell). Run the login from a terminal opened on the computer itself.';
91
+ if (platform === 'win32')
92
+ return env.SESSIONNAME ? null : none;
93
+ if (platform === 'darwin')
94
+ return env.SSH_CONNECTION || env.SSH_TTY ? none : null;
95
+ return env.DISPLAY || env.WAYLAND_DISPLAY ? null : none;
89
96
  }
90
- /** The headed login: a visible browser the person drives, then Enter. Only cookies and origins of the site are kept. */
91
- async function headedLogin(target, say) {
97
+ /** A cookie is told apart by its name and domain, never by its value. */
98
+ const cookieId = (c) => {
99
+ const k = c;
100
+ return `${k.domain ?? ''}|${k.name ?? ''}`;
101
+ };
102
+ /**
103
+ * The headed login: a visible browser in a fresh temporary profile (no debugging port, deleted afterwards). It ends when a session cookie of the site appears that was not there
104
+ * when the page loaded, when the window is closed, or when the time runs out. Only cookies and origins of the site are kept.
105
+ */
106
+ async function headedLogin(target, say, timeoutMs) {
92
107
  const { chromium } = await loadPlaywright();
93
- const browser = await chromium.launch({ headless: false });
108
+ const profile = mkdtempSync(join(tmpdir(), 'afb-login-'));
109
+ const hosts = [new URL(target.baseUrl).hostname, ...(target.hosts ?? [])];
110
+ let context = null;
94
111
  try {
95
- const ctx = await browser.newContext({ baseURL: target.baseUrl, viewport: null });
96
- const page = await ctx.newPage();
112
+ context = await chromium.launchPersistentContext(profile, {
113
+ headless: false,
114
+ viewport: null,
115
+ baseURL: target.baseUrl
116
+ });
117
+ let closed = false;
118
+ context.on('close', () => void (closed = true));
119
+ const page = context.pages()[0] ?? (await context.newPage());
97
120
  await page.goto(target.baseUrl);
98
- say(`A browser window is open on ${target.baseUrl}. Sign in there the way you normally do (a password or Google).`);
99
- say('When you can see you are signed in, come back here and press Enter. Do not close the window first.');
100
- await waitForEnter('Press Enter when signed in... ');
101
- const hosts = [new URL(target.baseUrl).hostname, ...(target.hosts ?? [])];
102
- return keepRelated(await ctx.storageState(), hosts);
121
+ const before = new Set(keepRelated(await context.storageState(), hosts).cookies.map(cookieId));
122
+ say(`A browser window is open on ${target.baseUrl}. Sign in there the way you normally do (a password or Google). The bridge does not return what you type to your agent.`);
123
+ const deadline = Date.now() + timeoutMs;
124
+ while (!closed && Date.now() < deadline) {
125
+ await new Promise((r) => setTimeout(r, 1000));
126
+ if (closed)
127
+ break;
128
+ let kept;
129
+ try {
130
+ kept = keepRelated(await context.storageState(), hosts);
131
+ }
132
+ catch {
133
+ break;
134
+ }
135
+ if (kept.cookies.some((c) => !before.has(cookieId(c)))) {
136
+ // A short settle, so a sign in that sets several cookies is kept whole
137
+ await new Promise((r) => setTimeout(r, 2000));
138
+ return { state: 'signed-in', storage: keepRelated(await context.storageState(), hosts) };
139
+ }
140
+ }
141
+ return closed ? { state: 'closed' } : { state: 'timeout' };
103
142
  }
104
143
  finally {
105
- await browser.close().catch(() => undefined);
144
+ await context?.close().catch(() => undefined);
145
+ rmSync(profile, { recursive: true, force: true });
106
146
  }
107
147
  }
108
148
  function loadRegistrationFile(org, name) {
@@ -132,7 +172,11 @@ export async function loginCommand(args, ctx) {
132
172
  const deps = {
133
173
  signIn: (baseUrl, account) => signInWithPassword(baseUrl, account),
134
174
  headed: headedLogin,
135
- isTTY: Boolean(process.stdin.isTTY),
175
+ desktop: () => desktopProblem(),
176
+ approval: {
177
+ peek: (target, code) => peekApproval('login', target, code),
178
+ consume: (target, code) => consumeApproval('login', target, code)
179
+ },
136
180
  env: process.env,
137
181
  loadRegistration: loadRegistrationFile,
138
182
  target: defaultTarget,
@@ -145,19 +189,39 @@ export async function loginCommand(args, ctx) {
145
189
  const name = positional[0];
146
190
  const org = flagValue(args, '--org');
147
191
  const seeded = args.includes('--seeded');
192
+ // The sign in the owner typed on the board, encrypted to this machine: opened here in memory, never shown to the agent
193
+ const fromBoard = args.includes('--from-board');
148
194
  if (!name || !org) {
149
- say('Usage: check login <NAME> --org <slug> [--seeded]');
195
+ say('Usage: check login <NAME> --org <slug> [--seeded | --from-board | --approval <code>]');
150
196
  return 1;
151
197
  }
152
198
  if (!NAME.test(name) || !ORG_SLUG.test(org)) {
153
199
  say('The registration name or the organisation is not a plain name.');
154
200
  return 1;
155
201
  }
156
- // A headed login waits for a person at a screen: without a terminal it would wait for ever, so it stops at once
157
- if (!seeded && !deps.isTTY) {
158
- say('Signing in by hand needs a person at a terminal (it opens a browser window and waits), so it is refused here. Use --seeded with AFB_CHECKS_<NAME>_EMAIL and AFB_CHECKS_<NAME>_PASSWORD set, or a registration whose recipe has a test user.');
202
+ // A headed login shows a window to a person: it needs the owner's login approval and a desktop to show it on, and stops at once without either
203
+ const approval = flagValue(args, '--approval');
204
+ const minutes = flagValue(args, '--timeout');
205
+ const timeoutMinutes = minutes === undefined ? DEFAULT_TIMEOUT_MINUTES : Number(minutes);
206
+ if (fromBoard && !ctx.board) {
207
+ say('Reading a sign in from the board needs the check command with --server and --org.');
159
208
  return 1;
160
209
  }
210
+ if (!seeded && !fromBoard) {
211
+ if (!Number.isFinite(timeoutMinutes) || timeoutMinutes < 1 || timeoutMinutes > 30) {
212
+ say('--timeout takes a number of minutes from 1 to 30.');
213
+ return 1;
214
+ }
215
+ const problem = deps.desktop();
216
+ if (problem) {
217
+ say(problem);
218
+ return 1;
219
+ }
220
+ if (!approval || !deps.approval.peek(`${org}/${name}`, approval)) {
221
+ say(`Signing in by hand needs the owner's approval. Ask for it: agent-flow-board check approve --request --action login --target ${org}/${name}, then pass --approval <code>. Or use --seeded with AFB_CHECKS_<NAME>_EMAIL and AFB_CHECKS_<NAME>_PASSWORD set.`);
222
+ return 1;
223
+ }
224
+ }
161
225
  const registration = deps.loadRegistration(org, name);
162
226
  if (!registration || registration.org !== org) {
163
227
  say(`No registration "${name}" in ${org}.`);
@@ -169,7 +233,33 @@ export async function loginCommand(args, ctx) {
169
233
  target = await deps.target(registration);
170
234
  const bindKey = new URL(target.baseUrl).origin;
171
235
  let state;
172
- if (seeded) {
236
+ if (fromBoard && ctx.board) {
237
+ const board = ctx.board();
238
+ const leased = await board.leaseSignins(name);
239
+ const envelope = leased.signins.find((e) => e.registration.toLowerCase() === name.toLowerCase());
240
+ if (!envelope) {
241
+ say(`No sign in is waiting on the board for ${name}. Run the login without --from-board to sign in in a window on this computer.`);
242
+ return 1;
243
+ }
244
+ let account = null;
245
+ try {
246
+ account = await openSignin(envelope, { orgId: leased.orgId, runnerId: leased.runnerId });
247
+ secrets.push(account.password);
248
+ state = await deps.signIn(target.baseUrl, account);
249
+ }
250
+ catch (e) {
251
+ // Only pass or fail goes back to the board, and the message never holds what was typed
252
+ await board.ackSignin({ id: envelope.id, result: 'fail' }).catch(() => undefined);
253
+ say(`That did not sign in. ${redact(e instanceof Error ? e.message : '', secrets).slice(0, 160)}`);
254
+ return 1;
255
+ }
256
+ finally {
257
+ if (account)
258
+ account.password = '';
259
+ }
260
+ await board.ackSignin({ id: envelope.id, result: 'pass' }).catch(() => undefined);
261
+ }
262
+ else if (seeded) {
173
263
  const account = seededAccount(name, deps.env) ?? target.testUser ?? null;
174
264
  if (!account) {
175
265
  const n = envNames(name);
@@ -180,7 +270,21 @@ export async function loginCommand(args, ctx) {
180
270
  state = await deps.signIn(target.baseUrl, account);
181
271
  }
182
272
  else {
183
- state = await deps.headed(target, say);
273
+ // The approval is used up before the window opens, so a window never opens twice on one code
274
+ if (!deps.approval.consume(`${org}/${name}`, approval ?? '')) {
275
+ say('The owner code was used or ran out just now. Ask for a new one.');
276
+ return 1;
277
+ }
278
+ const outcome = await deps.headed(target, say, timeoutMinutes * 60_000);
279
+ if (outcome.state === 'closed') {
280
+ say('The window was closed before a sign in was seen, so nothing was saved.');
281
+ return 4;
282
+ }
283
+ if (outcome.state === 'timeout') {
284
+ say(`No sign in was seen within ${timeoutMinutes} minutes, so nothing was saved.`);
285
+ return 5;
286
+ }
287
+ state = outcome.storage;
184
288
  }
185
289
  const kept = state;
186
290
  if (!kept || (!(kept.cookies?.length ?? 0) && !(kept.origins?.length ?? 0))) {
@@ -457,7 +457,7 @@ export function registerProject(input, approvals = {}) {
457
457
  throw new Refusal(`${repo} is not the root of a git project (no .git there). Give the git root as --repo and the app folder as --app-dir.`);
458
458
  }
459
459
  if (!rootHolding(repo))
460
- throw new Refusal(`${repo} is not inside a folder the owner approved. The owner approves its parent folder: check approve --action roots --target <folder>, then check roots add <folder> --approval <code>.`);
460
+ throw new Refusal(`${repo} is not inside a folder the owner approved. The owner approves its parent folder: check approve --request --action roots --target <folder>, then check roots add <folder> --approval <code>.`);
461
461
  const elsewhere = repoHeldElsewhere(org, repo);
462
462
  if (elsewhere)
463
463
  throw new Refusal(`That folder is already held by ${elsewhere}, in another organisation. A folder belongs to one organisation.`);
@@ -486,12 +486,12 @@ export function registerProject(input, approvals = {}) {
486
486
  const registerTarget = reg.url ? `${name}@${new URL(reg.url).hostname}` : name;
487
487
  if (needRegister && !approvals.register)
488
488
  throw new Refusal(reg.url
489
- ? `Registering a live site, or changing the site or its hosts, needs a code from the owner: check approve --action register --target ${registerTarget}, on an interactive terminal, then --approval <code>.`
490
- : `Changing the folder or hosts of ${name}, which keeps an adopted .env or saved state, needs a code from the owner: check approve --action register --target ${registerTarget}, on an interactive terminal, then --approval <code>.`);
489
+ ? `Registering a live site, or changing the site or its hosts, needs a code from the owner: check approve --request --action register --target ${registerTarget}, then --approval <code>.`
490
+ : `Changing the folder or hosts of ${name}, which keeps an adopted .env or saved state, needs a code from the owner: check approve --request --action register --target ${registerTarget}, then --approval <code>.`);
491
491
  if (needAdopt && !approvals.adoptEnv)
492
- throw new Refusal(`Adopting the whole repo .env needs a code from the owner: check approve --action adopt-env --target ${name}, then --adopt-env-approval <code>.`);
492
+ throw new Refusal(`Adopting the whole repo .env needs a code from the owner: check approve --request --action adopt-env --target ${name}, then --adopt-env-approval <code>.`);
493
493
  if (needPictures && !approvals.pictures)
494
- throw new Refusal(`Uploading pictures of a signed-in run needs a code from the owner: check approve --action pictures --target ${name}, then --pictures-approval <code>.`);
494
+ throw new Refusal(`Uploading pictures of a signed-in run needs a code from the owner: check approve --request --action pictures --target ${name}, then --pictures-approval <code>.`);
495
495
  if (needRegister && !peekApproval('register', registerTarget, approvals.register ?? ''))
496
496
  throw new Refusal('That approval code is wrong, used or expired. Nothing was changed.');
497
497
  if (needAdopt && !peekApproval('adopt-env', name, approvals.adoptEnv ?? ''))
@@ -121,7 +121,7 @@ export function addRoot(dir, approval) {
121
121
  if (roots.length >= MAX_ROOTS)
122
122
  throw new Refusal(`There are already ${MAX_ROOTS} roots. Remove one first (check roots remove <folder> --approval <code>).`);
123
123
  if (!approval || !consumeApproval('roots', real, approval))
124
- throw new Refusal(`Adding a root needs a code from the owner: check approve --action roots --target "${real}", on an interactive terminal, then --approval <code>.`);
124
+ throw new Refusal(`Adding a root needs a code from the owner: check approve --request --action roots --target "${real}", then --approval <code>.`);
125
125
  writeRoots([...roots, real]);
126
126
  appendAudit('root added', { folder: real, count: roots.length + 1 });
127
127
  return real;
@@ -136,7 +136,7 @@ export function removeRoot(dir, approval) {
136
136
  if (!hit)
137
137
  throw new Refusal(`${real} is not a root.`);
138
138
  if (!approval || !consumeApproval('roots', real, approval))
139
- throw new Refusal(`Removing a root needs a code from the owner: check approve --action roots --target "${real}", on an interactive terminal, then --approval <code>.`);
139
+ throw new Refusal(`Removing a root needs a code from the owner: check approve --request --action roots --target "${real}", then --approval <code>.`);
140
140
  writeRoots(roots.filter((r) => r !== hit));
141
141
  appendAudit('root removed', { folder: hit, count: roots.length - 1 });
142
142
  return hit;
@@ -11,7 +11,7 @@ import { createHash } from 'node:crypto';
11
11
  import { closeSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { basename, join, resolve } from 'node:path';
13
13
  // -- MODULES -- //
14
- import { consumeApproval, peekApproval } from './approve.js';
14
+ import { boardSentence, consumeApprovalNote, peekApproval } from './approve.js';
15
15
  import { appendAudit } from './audit.js';
16
16
  import { withLockSync } from './lock.js';
17
17
  import { ORG_SLUG, ensureDir, isInside, stateDirFor } from './paths.js';
@@ -37,7 +37,7 @@ const refOf = (org, name) => {
37
37
  };
38
38
  const fileOf = (org, name) => join(stateDirFor(org, name), 'secrets.sealed');
39
39
  const lockOf = (ref) => `secrets-${createHash('sha256').update(ref.toLowerCase()).digest('hex').slice(0, 20)}`;
40
- const helpOf = (ref) => `agent-flow-board check secrets import --project ${ref.split('/')[1] ?? ''} --org ${ref.split('/')[0] ?? ''} --from <env file in the repo> --keys <NAME,NAME> --test-ref <supabase project ref> --approval <code from check approve --action secrets --target ${ref}> (mail settings: --keys POSTMARK_SERVER_TOKEN,POSTMARK_FROM_EMAIL,POSTMARK_MESSAGE_STREAM --test-inbox <the one inbox a preview may mail> --owner-said "<says the token is a test token>")`;
40
+ const helpOf = (ref) => `agent-flow-board check secrets import --project ${ref.split('/')[1] ?? ''} --org ${ref.split('/')[0] ?? ''} --from <env file in the repo> --keys <NAME,NAME> --test-ref <supabase project ref> --approval <code from check approve --request --action secrets --target ${ref}> (mail settings: --keys POSTMARK_SERVER_TOKEN,POSTMARK_FROM_EMAIL,POSTMARK_MESSAGE_STREAM --test-inbox <the one inbox a preview may mail> --owner-said "<says the token is a test token>")`;
41
41
  /** NAME=value lines of an env file. `export ` and one pair of quotes are allowed; a value that opens a quote it does not close on the same line is refused (names the variable). */
42
42
  export function parseImportEnv(text) {
43
43
  const out = new Map();
@@ -183,7 +183,7 @@ export function importSecrets(input) {
183
183
  throw new Error(`${fileName} is not inside the repo folder of ${ref}.`);
184
184
  // -- THE OWNER'S APPROVAL -- //
185
185
  if (!input.approval || !peekApproval(SECRETS_ACTION, ref, input.approval))
186
- throw new Error(`The owner has not approved this import. Run \`agent-flow-board check approve --action secrets --target ${ref}\` in a terminal and pass the code with --approval.`);
186
+ throw new Error(`The owner has not approved this import. Run \`agent-flow-board check approve --request --action secrets --target ${ref}\` and pass the code with --approval.`);
187
187
  // -- THE READ: ONE HANDLE FOR THE CHECKS AND THE READ, A FILE WITH A SECOND NAME REFUSED -- //
188
188
  let text;
189
189
  const fd = openSync(resolved, 'r');
@@ -244,10 +244,12 @@ export function importSecrets(input) {
244
244
  entries[k] = { v, service: svc };
245
245
  }
246
246
  // -- THE CODE IS USED LAST -- //
247
- if (!consumeApproval(SECRETS_ACTION, ref, input.approval ?? ''))
247
+ const used = consumeApprovalNote(SECRETS_ACTION, ref, input.approval ?? '');
248
+ if (!used.ok)
248
249
  throw new Error('The owner code was used or expired just now, so nothing was imported. The code is spent: ask the owner for a new one.');
249
250
  // -- THE WRITE, UNDER A LOCK -- //
250
- const statement = String(input.ownerSaid ?? '').slice(0, 300);
251
+ // A code that came from the board carries who approved it and when; that fills the owner sentence when none was given
252
+ const statement = String(input.ownerSaid ?? boardSentence(used.note) ?? '').slice(0, 300);
251
253
  withLockSync(lockOf(ref), () => {
252
254
  const before = readStoreSafe(input.org, input.name);
253
255
  // Values of an earlier import stay, unless they belong to another Supabase project (mail values do not depend on one)
@@ -1,5 +1,5 @@
1
1
  // `check secrets import|list|forget`: THE COMMAND SIDE OF THE SEALED STORE (secrets.ts holds every rule)
2
- // Import needs the owner's approval code (check approve --action secrets --target <org>/<NAME>): an agent cannot mint one. Names only are ever printed.
2
+ // Import needs the owner's approval code (check approve --request --action secrets --target <org>/<NAME>): an agent cannot mint one. Names only are ever printed.
3
3
  // -- MODULES -- //
4
4
  import { findRegistration } from './registry.js';
5
5
  import { forgetSecrets, importSecrets, listSecrets } from './secrets.js';
@@ -47,7 +47,7 @@ export async function secretsCommand(args, ctx) {
47
47
  if (!file || !keys)
48
48
  throw new Error('Say --from <file> and --keys A,B.');
49
49
  if (!approval)
50
- throw new Error(`Secrets are imported with the owner's approval code. The owner runs, in a terminal: agent-flow-board check approve --action secrets --target ${org}/${name}, then you pass --approval <code>.`);
50
+ throw new Error(`Secrets are imported with the owner's approval code. Ask the owner on the board: agent-flow-board check approve --request --action secrets --target ${org}/${name}, then you pass --approval <code>.`);
51
51
  const done = importSecrets({
52
52
  org,
53
53
  name,
@@ -0,0 +1,104 @@
1
+ // THE MACHINE'S SIGN IN KEY: A P-256 KEY PAIR WHOSE PUBLIC HALF IS PUBLISHED TO THE BOARD, SO THE OWNER CAN TYPE A TEST ACCOUNT'S PASSWORD ON THE BOARD WITHOUT ANY AGENT SEEING IT
2
+ // The private half lives in the same sealed store as the saved sign ins (AES-GCM under the key in the bridge home) and is opened only in this process. A password is decrypted in memory,
3
+ // goes to the page through Playwright `fill` and is never an argument, an environment variable, a log line or an audit line. The format mirrors src/lib/shared/signinCrypto.ts.
4
+ // Honest limit: a process that runs as the same OS user can read the key files, like every other local secret here.
5
+ // -- DEP-MODULES -- //
6
+ import { createHash, webcrypto } from 'node:crypto';
7
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { appendAudit } from './audit.js';
11
+ import { checksHome, ensureDir } from './paths.js';
12
+ import { seal, unseal } from './store.js';
13
+ // -- CONSTANTS -- //
14
+ export const SIGNIN_ALG = 'ECDH-P256+HKDF-SHA256+AES-256-GCM';
15
+ const subtle = webcrypto.subtle;
16
+ const enc = new TextEncoder();
17
+ const keyFile = () => join(checksHome(), 'signin-key.bin');
18
+ const idFile = () => join(checksHome(), 'signin-key.id');
19
+ const AAD = 'signin-private-key';
20
+ // -- FUNCTIONS -- //
21
+ const b64u = (bytes) => Buffer.from(bytes).toString('base64url');
22
+ const unb64u = (text) => new Uint8Array(Buffer.from(text, 'base64url'));
23
+ /** The text that binds a ciphertext to where it may be opened. Must equal signinAad in src/lib/shared/signinCrypto.ts. */
24
+ export const signinAad = (orgId, runnerId, registration, keyId) => `afb-signin-v1|${orgId}|${runnerId}|${registration.toLowerCase()}|${keyId}`;
25
+ /** Twelve hex characters in groups of four, the same rule as the board and the browser. */
26
+ export function fingerprintOf(publicKey) {
27
+ const hex = createHash('sha256')
28
+ .update(Buffer.from(publicKey, 'base64url'))
29
+ .digest('hex')
30
+ .slice(0, 12);
31
+ return hex.match(/.{4}/g).join('-');
32
+ }
33
+ /** Makes the key pair once and keeps the private half sealed. Returns the id and the public half. */
34
+ export async function ensureSigninKey() {
35
+ ensureDir(checksHome());
36
+ if (existsSync(keyFile()) && existsSync(idFile())) {
37
+ const pair = readPair();
38
+ if (pair)
39
+ return pair;
40
+ }
41
+ const pair = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
42
+ 'deriveBits'
43
+ ]);
44
+ const pkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', pair.privateKey));
45
+ const pub = new Uint8Array(await subtle.exportKey('raw', pair.publicKey));
46
+ const keyId = b64u(webcrypto.getRandomValues(new Uint8Array(12)));
47
+ writeFileSync(keyFile(), seal(AAD, pkcs8), { mode: 0o600 });
48
+ writeFileSync(idFile(), JSON.stringify({ keyId, publicKey: b64u(pub) }), { mode: 0o600 });
49
+ pkcs8.fill(0);
50
+ appendAudit('signin key made', { keyId });
51
+ return { keyId, publicKey: b64u(pub) };
52
+ }
53
+ function readPair() {
54
+ try {
55
+ const v = JSON.parse(readFileSync(idFile(), 'utf8'));
56
+ return typeof v.keyId === 'string' && typeof v.publicKey === 'string'
57
+ ? { keyId: v.keyId, publicKey: v.publicKey }
58
+ : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /** Publishes the public key. The board accepts it only on this machine's own runner token; a changed key is shown there with its time. */
65
+ export async function publishSigninKey(board) {
66
+ const { keyId, publicKey } = await ensureSigninKey();
67
+ const done = await board.publishSigninKey({ keyId, publicKey });
68
+ return done;
69
+ }
70
+ /** The fingerprint of this machine's key, for `check status`, or null when none was made yet. */
71
+ export function localFingerprint() {
72
+ const pair = existsSync(idFile()) ? readPair() : null;
73
+ return pair ? fingerprintOf(pair.publicKey) : null;
74
+ }
75
+ /** Opens one envelope in memory. A ciphertext for another org, runner, registration or key fails here, because the associated data differs. */
76
+ export async function openSignin(envelope, where) {
77
+ const me = readPair();
78
+ if (!me || me.keyId !== envelope.keyId || envelope.alg !== SIGNIN_ALG)
79
+ throw new Error('That sign in was made for another key of this machine.');
80
+ const pkcs8 = new Uint8Array(unseal(AAD, new Uint8Array(readFileSync(keyFile()))));
81
+ let account;
82
+ try {
83
+ const priv = await subtle.importKey('pkcs8', pkcs8, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
84
+ const pack = JSON.parse(envelope.ciphertext);
85
+ const peer = await subtle.importKey('raw', unb64u(pack.epk), { name: 'ECDH', namedCurve: 'P-256' }, false, []);
86
+ const bits = await subtle.deriveBits({ name: 'ECDH', public: peer }, priv, 256);
87
+ const aad = enc.encode(signinAad(where.orgId, where.runnerId, envelope.registration, envelope.keyId));
88
+ const base = await subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
89
+ const key = await subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: aad }, base, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
90
+ const plain = await subtle.decrypt({ name: 'AES-GCM', iv: unb64u(pack.iv), additionalData: aad }, key, unb64u(pack.ct));
91
+ const v = JSON.parse(new TextDecoder().decode(plain));
92
+ if (typeof v.e !== 'string' || typeof v.p !== 'string')
93
+ throw new Error('shape');
94
+ account = { email: v.e, password: v.p };
95
+ }
96
+ catch {
97
+ // Never says why: nothing about the contents leaves this function
98
+ throw new Error('That sign in could not be opened on this machine.');
99
+ }
100
+ finally {
101
+ pkcs8.fill(0);
102
+ }
103
+ return account;
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-flow-board/bridge",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Local bridge to an agent-flow-board server: forwards MCP calls over stdio, runs the coding tool hooks, writes the editor status and runs browser checks on this machine. It holds no board tool code.",
6
6
  "license": "MIT",