@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 +19 -9
- package/dist/checks/approve.js +76 -15
- package/dist/checks/askBoard.js +143 -0
- package/dist/checks/board.js +30 -0
- package/dist/checks/command.js +58 -6
- package/dist/checks/commands.js +98 -2
- package/dist/checks/doctor.js +7 -1
- package/dist/checks/importlegacy.js +3 -3
- package/dist/checks/login.js +135 -31
- package/dist/checks/registry.js +5 -5
- package/dist/checks/roots.js +2 -2
- package/dist/checks/secrets.js +7 -5
- package/dist/checks/secretscmd.js +2 -2
- package/dist/checks/signinKey.js +104 -0
- package/package.json +1 -1
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
|
|
38
|
-
|
|
|
39
|
-
| `agent-flow-board check project <NAME> --org <slug> --repo <dir> [--app-dir web]`
|
|
40
|
-
| `agent-flow-board check approve --action <roots\|register\|secrets\|adopt-env\|pictures>`
|
|
41
|
-
| `agent-flow-board check
|
|
42
|
-
| `agent-flow-board check
|
|
43
|
-
| `agent-flow-board check
|
|
44
|
-
| `agent-flow-board check
|
|
45
|
-
| `agent-flow-board check
|
|
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.
|
package/dist/checks/approve.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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', {
|
|
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
|
|
257
|
+
let found;
|
|
201
258
|
withLockSync('approvals', () => {
|
|
202
259
|
const all = readEntries();
|
|
203
260
|
const live = all.filter((e) => e.exp > now);
|
|
204
|
-
|
|
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 (
|
|
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', {
|
|
212
|
-
|
|
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
|
+
}
|
package/dist/checks/board.js
CHANGED
|
@@ -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
|
}
|
package/dist/checks/command.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
package/dist/checks/commands.js
CHANGED
|
@@ -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, () => {
|
package/dist/checks/doctor.js
CHANGED
|
@@ -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)
|
package/dist/checks/login.js
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
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 {
|
|
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
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
/**
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
say(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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 (
|
|
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
|
-
|
|
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))) {
|
package/dist/checks/registry.js
CHANGED
|
@@ -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},
|
|
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},
|
|
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 ?? ''))
|
package/dist/checks/roots.js
CHANGED
|
@@ -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}",
|
|
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}",
|
|
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;
|
package/dist/checks/secrets.js
CHANGED
|
@@ -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 {
|
|
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}\`
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
"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",
|