@agent-flow-board/bridge 0.2.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.
Files changed (54) hide show
  1. package/README.md +26 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +274 -0
  4. package/dist/checks/askBoard.js +143 -0
  5. package/dist/checks/audit.js +3 -2
  6. package/dist/checks/bind.js +3 -2
  7. package/dist/checks/board.js +30 -0
  8. package/dist/checks/broker.js +439 -0
  9. package/dist/checks/browser.js +137 -32
  10. package/dist/checks/browserpool.js +221 -0
  11. package/dist/checks/cache.js +710 -0
  12. package/dist/checks/command.js +126 -87
  13. package/dist/checks/commands.js +315 -0
  14. package/dist/checks/connections.js +4 -3
  15. package/dist/checks/copy.js +320 -0
  16. package/dist/checks/dbpool.js +710 -0
  17. package/dist/checks/doctor.js +655 -0
  18. package/dist/checks/envPolicy.js +50 -1
  19. package/dist/checks/envrules.js +322 -0
  20. package/dist/checks/evidence.js +114 -0
  21. package/dist/checks/fsretry.js +29 -0
  22. package/dist/checks/importlegacy.js +300 -0
  23. package/dist/checks/install.js +150 -0
  24. package/dist/checks/live.js +76 -0
  25. package/dist/checks/lock.js +28 -4
  26. package/dist/checks/login.js +307 -0
  27. package/dist/checks/mailguard-hook.cjs +216 -0
  28. package/dist/checks/mailguard.js +98 -0
  29. package/dist/checks/paths.js +24 -0
  30. package/dist/checks/preview.js +418 -191
  31. package/dist/checks/profiles.js +110 -0
  32. package/dist/checks/provision.js +1 -1
  33. package/dist/checks/recipe.js +19 -5
  34. package/dist/checks/registry.js +548 -0
  35. package/dist/checks/repoenv.js +162 -0
  36. package/dist/checks/roots.js +144 -0
  37. package/dist/checks/routing.js +68 -0
  38. package/dist/checks/run.js +243 -53
  39. package/dist/checks/sandbox.js +32 -10
  40. package/dist/checks/scheduler.js +383 -0
  41. package/dist/checks/scope.js +119 -30
  42. package/dist/checks/sealedNames.js +39 -0
  43. package/dist/checks/secrets.js +454 -0
  44. package/dist/checks/secretscmd.js +80 -0
  45. package/dist/checks/service.js +589 -0
  46. package/dist/checks/signin.js +7 -1
  47. package/dist/checks/signinKey.js +104 -0
  48. package/dist/checks/singleflight.js +307 -0
  49. package/dist/checks/store.js +92 -0
  50. package/dist/checks/surface.js +124 -0
  51. package/dist/checks/warm.js +342 -0
  52. package/dist/checks/watch.js +190 -63
  53. package/dist/checks/wire.js +87 -0
  54. package/package.json +1 -1
@@ -0,0 +1,162 @@
1
+ // THE WHOLE .env OF A REGISTRATION THAT THE OWNER OPTED IN (step 3.4, Registration.adoptEnv, set with an approval code). The registration's preview adopts every variable of the
2
+ // repo's own .env files, so the app runs with the settings it was written for, EXCEPT the deny list (variables that load code or move the machine's own settings) and the
3
+ // run's own settings (PORT, HOST, NODE_ENV). The files are read from the registered repo folder, never from the run's copy (a copy has no .env), and never printed: every
4
+ // message here names variables and files, never values. The caller keeps what is returned in `startEnv` OUT of install and build (adopted values reach the START step only),
5
+ // runs databaseProblems() (envrules.ts) over it, and puts `redactions` into the redaction needles.
6
+ // -- DEP-MODULES -- //
7
+ import { closeSync, fstatSync, lstatSync, openSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { expandRefs } from './envrules.js';
11
+ // -- CONSTANTS -- //
12
+ /** Read in this order, a later file overriding an earlier one (the order a production build of Vite and SvelteKit uses). .env.test is read only when none of these exist in that folder. */
13
+ export const ADOPT_FILES = ['.env', '.env.local', '.env.production', '.env.production.local'];
14
+ const FALLBACK = ['.env.test'];
15
+ /** Variables that name the app's own address: pointed at the run's preview, because the value in .env is the live address and would make every form and redirect fail. */
16
+ const SELF_ADDRESS = /^(ORIGIN|BETTER_AUTH_URL|AUTH_URL|NEXTAUTH_URL|APP_URL|SITE_URL|BASE_URL|(PUBLIC_|VITE_|NEXT_PUBLIC_)(APP|SITE|BASE)_URL)$/;
17
+ /** Never taken from a file: the run's own settings (the run sets EMAIL_PROVIDER to log itself). */
18
+ const RUN_OWN = new Set(['PORT', 'HOST', 'NODE_ENV', 'EMAIL_PROVIDER']);
19
+ /** Never adopted, whatever the file says: names that load code, change where programs and certificates come from, or move the machine's own settings. */
20
+ const DENY_EXACT = new Set([
21
+ 'NODE_OPTIONS',
22
+ 'PATH',
23
+ 'PATHEXT',
24
+ 'COMSPEC',
25
+ 'HOME',
26
+ 'USERPROFILE',
27
+ 'APPDATA',
28
+ 'LOCALAPPDATA',
29
+ 'SYSTEMROOT',
30
+ 'TEMP',
31
+ 'TMP',
32
+ 'HTTP_PROXY',
33
+ 'HTTPS_PROXY',
34
+ 'NO_PROXY',
35
+ 'ALL_PROXY',
36
+ 'NODE_EXTRA_CA_CERTS',
37
+ 'NODE_TLS_REJECT_UNAUTHORIZED',
38
+ // Added beyond the plan's list: the same kind of danger
39
+ 'NODE_PATH',
40
+ 'NODE_REPL_EXTERNAL_MODULE'
41
+ ]);
42
+ /** Prefixes are compared without case (npm_config_*, NPM_CONFIG_*, PNPM_*, YARN_*, LD_*). */
43
+ const DENY_PREFIX = ['NPM_CONFIG_', 'PNPM_', 'YARN_', 'LD_', 'DYLD_', 'AFB_MAIL_FENCE_'];
44
+ const MIN_REDACT = 8;
45
+ const MAX_FILE_BYTES = 1_000_000;
46
+ // -- FUNCTIONS -- //
47
+ /** True when a variable may never be adopted. Compared without case, because a Windows environment has none. */
48
+ export function isDeniedName(name) {
49
+ const upper = name.toUpperCase();
50
+ return DENY_EXACT.has(upper) || DENY_PREFIX.some((p) => upper.startsWith(p));
51
+ }
52
+ /** NAME=value lines. `export ` and one pair of quotes are allowed; a quoted value may run over several lines, and \n in a double-quoted value is a line break. Comments and lines that are not settings are skipped. */
53
+ export function parseRepoEnvText(text) {
54
+ const out = new Map();
55
+ const body = String(text);
56
+ const lines = (body.charCodeAt(0) === 0xfeff ? body.slice(1) : body).split(/\r?\n/);
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const line = (lines[i] ?? '').trim();
59
+ if (!line || line.startsWith('#'))
60
+ continue;
61
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$/.exec(line);
62
+ if (!m || m[1] === undefined)
63
+ continue;
64
+ let v = (m[2] ?? '').trim();
65
+ const q = v[0];
66
+ if (q === '"' || q === "'" || q === '`') {
67
+ let body = v.slice(1);
68
+ let end = body.indexOf(q);
69
+ while (end < 0 && i + 1 < lines.length) {
70
+ body += '\n' + (lines[++i] ?? '');
71
+ end = body.indexOf(q);
72
+ }
73
+ v = end < 0 ? body : body.slice(0, end);
74
+ if (q === '"')
75
+ v = v.replace(/\\n/g, '\n');
76
+ }
77
+ else
78
+ v = v.replace(/\s+#.*$/, '');
79
+ out.set(m[1], v);
80
+ }
81
+ return out;
82
+ }
83
+ /** The text of a plain file, or null. A link, a folder or a file over 1 MB is not read (a link could point at a key outside the repo). */
84
+ function readPlainFile(file) {
85
+ try {
86
+ const st = lstatSync(file);
87
+ if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_FILE_BYTES)
88
+ return null;
89
+ const fd = openSync(file, 'r');
90
+ try {
91
+ const fst = fstatSync(fd);
92
+ if (!fst.isFile() || fst.size > MAX_FILE_BYTES)
93
+ return null;
94
+ return readFileSync(fd, 'utf8');
95
+ }
96
+ finally {
97
+ closeSync(fd);
98
+ }
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /** The folders the env files may sit in, repo root first and the app folder after it (the app folder wins). */
105
+ function foldersOf(repo, appDir) {
106
+ const out = [{ dir: repo, label: '' }];
107
+ if (appDir) {
108
+ const clean = appDir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
109
+ if (!clean || clean.split('/').includes('..') || /^([a-zA-Z]:|\/)/.test(clean))
110
+ throw new Error('The app folder of this registration is not a plain relative folder.');
111
+ out.push({ dir: join(repo, clean), label: `${clean}/` });
112
+ }
113
+ return out;
114
+ }
115
+ /**
116
+ * Every variable of the registration's .env files, with the run's own address filled in. Never throws for a missing file (no file is an empty result).
117
+ * `vars.baseUrl` is the run's preview address, put in every self-address variable.
118
+ */
119
+ export function adoptRepoEnv(cfg, vars) {
120
+ const merged = new Map();
121
+ const files = [];
122
+ for (const { dir, label } of foldersOf(cfg.repo, cfg.appDir)) {
123
+ let found = ADOPT_FILES.filter((f) => readPlainFile(join(dir, f)) !== null);
124
+ if (!found.length)
125
+ found = FALLBACK.filter((f) => readPlainFile(join(dir, f)) !== null);
126
+ for (const f of found) {
127
+ const text = readPlainFile(join(dir, f));
128
+ if (text === null)
129
+ continue;
130
+ for (const [k, v] of parseRepoEnvText(text))
131
+ merged.set(k, v);
132
+ files.push(`${label}${f}`);
133
+ }
134
+ }
135
+ const result = { startEnv: {}, redactions: [], names: [], denied: [], files };
136
+ if (!merged.size)
137
+ return result;
138
+ // ${NAME} inside a value is filled from the same files (what dotenv-expand does), since the preview is given plain values
139
+ const plain = Object.fromEntries(merged);
140
+ for (const [k, v] of merged) {
141
+ if (RUN_OWN.has(k.toUpperCase()))
142
+ continue;
143
+ if (isDeniedName(k)) {
144
+ result.denied.push(k);
145
+ continue;
146
+ }
147
+ result.startEnv[k] =
148
+ SELF_ADDRESS.test(k) && /^https?:\/\//i.test(v) ? vars.baseUrl : expandRefs(v, plain);
149
+ }
150
+ result.names = Object.keys(result.startEnv);
151
+ const needles = new Set();
152
+ for (const v of Object.values(result.startEnv)) {
153
+ if (v.length >= MIN_REDACT)
154
+ needles.add(v);
155
+ if (v.includes('\n'))
156
+ for (const l of v.split('\n'))
157
+ if (l.trim().length >= MIN_REDACT)
158
+ needles.add(l.trim());
159
+ }
160
+ result.redactions = [...needles];
161
+ return result;
162
+ }
@@ -0,0 +1,144 @@
1
+ // THE ROOTS: THE FOLDERS THE OWNER APPROVED AS PARENTS OF REGISTERED REPOS. At most five, sealed with an HMAC so a hand-written list is refused. A root is never a drive,
2
+ // the home folder (or a folder above it), the bridge home or a folder of keys and logins. Adding or removing one needs a single-use approval code (approve.ts).
3
+ // -- DEP-MODULES -- //
4
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { isAbsolute, parse, relative, resolve, sep } from 'node:path';
7
+ // -- MODULES -- //
8
+ import { appendAudit } from './audit.js';
9
+ import { Refusal, consumeApproval, sameMac, seal, writeFileAtomic } from './approve.js';
10
+ import { withLockSync } from './lock.js';
11
+ import { rootsFile } from './paths.js';
12
+ import { configDir } from '../tokenStore.js';
13
+ // -- CONSTANTS -- //
14
+ export const MAX_ROOTS = 5;
15
+ const SECRET_FOLDERS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker'];
16
+ // -- FUNCTIONS -- //
17
+ function realOrResolved(p) {
18
+ try {
19
+ return realpathSync.native(resolve(p));
20
+ }
21
+ catch {
22
+ return resolve(p);
23
+ }
24
+ }
25
+ /** True when `child` is `parent` or below it. Both are real paths already; Windows compares without case (path.relative does). */
26
+ export function insideOrSame(parent, child) {
27
+ const rel = relative(parent, child);
28
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
29
+ }
30
+ /** The real path of a folder that may be a root, or a Refusal with one plain sentence. */
31
+ export function cleanRoot(raw) {
32
+ const text = String(raw ?? '').trim();
33
+ if (!text)
34
+ throw new Refusal('No folder is given.');
35
+ let real;
36
+ try {
37
+ real = realpathSync.native(resolve(text));
38
+ }
39
+ catch {
40
+ throw new Refusal(`The folder ${resolve(text)} does not exist.`);
41
+ }
42
+ if (!statSync(real).isDirectory())
43
+ throw new Refusal(`${real} is not a folder.`);
44
+ if (parse(real).root === real)
45
+ throw new Refusal(`${real} is the top of a drive. Name the folder that holds your projects.`);
46
+ if (insideOrSame(real, realOrResolved(homedir())))
47
+ throw new Refusal(`${real} is your home folder or above it. Name a folder inside it that holds your projects.`);
48
+ const bridge = realOrResolved(configDir());
49
+ if (insideOrSame(real, bridge) || insideOrSame(bridge, real))
50
+ throw new Refusal(`${real} is or holds the bridge home, which holds the sign-in. It cannot be a root.`);
51
+ if (real.split(sep).some((s) => SECRET_FOLDERS.includes(s.toLowerCase())))
52
+ throw new Refusal(`${real} is in a folder that holds keys or logins (.ssh, .gnupg, .aws, .kube or .docker), so it cannot be a root.`);
53
+ return real;
54
+ }
55
+ const sealOf = (roots) => seal('roots', JSON.stringify({ version: 1, roots }));
56
+ /** The roots and, when the file cannot be trusted, one sentence saying why (the list is then empty). */
57
+ export function readRoots() {
58
+ const file = rootsFile();
59
+ if (!existsSync(file))
60
+ return { roots: [], problem: null };
61
+ try {
62
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
63
+ const body = doc && typeof doc === 'object' ? doc.body : null;
64
+ const mac = doc && typeof doc === 'object' ? doc.mac : null;
65
+ const roots = body && typeof body === 'object' ? body.roots : null;
66
+ if (typeof mac !== 'string' ||
67
+ !Array.isArray(roots) ||
68
+ !roots.every((r) => typeof r === 'string'))
69
+ return {
70
+ roots: [],
71
+ problem: 'The roots file is not one this tool wrote, so no root counts.'
72
+ };
73
+ const list = roots;
74
+ if (!sameMac(sealOf(list), mac))
75
+ return {
76
+ roots: [],
77
+ problem: 'The roots file was not written by "check roots" on this machine, so no root counts.'
78
+ };
79
+ return { roots: list, problem: null };
80
+ }
81
+ catch {
82
+ return { roots: [], problem: 'The roots file cannot be read, so no root counts.' };
83
+ }
84
+ }
85
+ export const listRoots = () => readRoots().roots;
86
+ function writeRoots(roots) {
87
+ writeFileAtomic(rootsFile(), JSON.stringify({ body: { version: 1, roots }, mac: sealOf(roots) }, null, 2) + '\n');
88
+ }
89
+ /**
90
+ * The root that holds `repo`, compared by real path so a link or a `..` cannot step out, or null. A root that was fine when it was added but now leads
91
+ * somewhere the rules forbid (a junction put in its place) no longer counts.
92
+ */
93
+ export function rootHolding(repo, roots = listRoots()) {
94
+ let real;
95
+ try {
96
+ real = realpathSync.native(resolve(repo));
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ for (const r of roots) {
102
+ let root;
103
+ try {
104
+ root = cleanRoot(r);
105
+ }
106
+ catch {
107
+ continue;
108
+ }
109
+ if (insideOrSame(root, real))
110
+ return root;
111
+ }
112
+ return null;
113
+ }
114
+ /** Adds a root. Needs an approval code for the action "roots" and this folder. The code is used only when everything else is fine. */
115
+ export function addRoot(dir, approval) {
116
+ const real = cleanRoot(dir);
117
+ return withLockSync('roots', () => {
118
+ const roots = readRoots().roots;
119
+ if (roots.some((r) => insideOrSame(r, real) && insideOrSame(real, r)))
120
+ return real;
121
+ if (roots.length >= MAX_ROOTS)
122
+ throw new Refusal(`There are already ${MAX_ROOTS} roots. Remove one first (check roots remove <folder> --approval <code>).`);
123
+ if (!approval || !consumeApproval('roots', real, approval))
124
+ throw new Refusal(`Adding a root needs a code from the owner: check approve --request --action roots --target "${real}", then --approval <code>.`);
125
+ writeRoots([...roots, real]);
126
+ appendAudit('root added', { folder: real, count: roots.length + 1 });
127
+ return real;
128
+ });
129
+ }
130
+ /** Removes a root. Needs an approval code too, so an agent cannot swap one root for another. */
131
+ export function removeRoot(dir, approval) {
132
+ const real = realOrResolved(dir);
133
+ return withLockSync('roots', () => {
134
+ const roots = readRoots().roots;
135
+ const hit = roots.find((r) => insideOrSame(r, real) && insideOrSame(real, r));
136
+ if (!hit)
137
+ throw new Refusal(`${real} is not a root.`);
138
+ if (!approval || !consumeApproval('roots', real, approval))
139
+ throw new Refusal(`Removing a root needs a code from the owner: check approve --request --action roots --target "${real}", then --approval <code>.`);
140
+ writeRoots(roots.filter((r) => r !== hit));
141
+ appendAudit('root removed', { folder: hit, count: roots.length - 1 });
142
+ return hit;
143
+ });
144
+ }
@@ -0,0 +1,68 @@
1
+ // WHICH REGISTRATION A RUN USES. Never a guess. In order: (1) the one named on the run, (2) the one saved on the check, (3) a "// afb-registration: NAME" line in the first 12 lines of
2
+ // the script, (4) the only registration of the board project. Otherwise a refusal with a code an agent can act on and the list of choices.
3
+ // -- MODULES -- //
4
+ import { REGISTRATION_NAME, listRegistrations } from './registry.js';
5
+ // -- CONSTANTS -- //
6
+ const HEADER_LINES = 12;
7
+ const HEADER = /^\s*\/\/\s*afb-registration:\s*([A-Za-z0-9_-]{1,32})\s*$/;
8
+ // -- FUNCTIONS -- //
9
+ /** A refusal that carries a code and the names the agent can choose from. */
10
+ export class RegistrationError extends Error {
11
+ code;
12
+ registrations;
13
+ constructor(code, message, registrations = []) {
14
+ super(message);
15
+ this.code = code;
16
+ this.registrations = registrations;
17
+ this.name = 'RegistrationError';
18
+ }
19
+ }
20
+ /** The registration a check script names in a "// afb-registration: NAME" line among its first 12 lines, or null. */
21
+ export function registrationHeader(source) {
22
+ for (const line of String(source ?? '')
23
+ .split(/\r?\n/)
24
+ .slice(0, HEADER_LINES)) {
25
+ const m = HEADER.exec(line);
26
+ if (m?.[1])
27
+ return m[1];
28
+ }
29
+ return null;
30
+ }
31
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
32
+ /**
33
+ * The registration for a board project. `named` is the name a run, a check or a script header gave (or null). A name may carry its organisation (org/NAME), and then it
34
+ * must be this one. `all` is every registration of the organisation (the registry by default).
35
+ */
36
+ export function pickRegistration(org, boardProject, named, all = listRegistrations(org)) {
37
+ const ofOrg = all.filter((r) => r.org === org);
38
+ const serving = ofOrg.filter((r) => same(r.boardProject, boardProject));
39
+ const list = serving.map((r) => r.name);
40
+ const choices = list.length
41
+ ? `Registrations of ${boardProject}: ${list.join(', ')}.`
42
+ : `${boardProject} has no registration here.`;
43
+ if (named) {
44
+ const parts = String(named).split('/');
45
+ if (parts.length > 2 || (parts.length === 2 && !same(parts[0] ?? '', org)))
46
+ throw new RegistrationError('REGISTRATION_UNKNOWN', `"${String(named).slice(0, 60)}" is not a registration name of ${org}. Write NAME or ${org}/NAME. ${choices}`, list);
47
+ const bare = parts[parts.length - 1] ?? '';
48
+ if (!REGISTRATION_NAME.test(bare))
49
+ throw new RegistrationError('REGISTRATION_UNKNOWN', `"${bare.slice(0, 40)}" is not a registration name. ${choices}`, list);
50
+ const hit = serving.find((r) => same(r.name, bare));
51
+ if (hit)
52
+ return hit;
53
+ const other = ofOrg.find((r) => same(r.name, bare));
54
+ if (other)
55
+ throw new RegistrationError('REGISTRATION_MISMATCH', `The registration ${other.name} serves the board project ${other.boardProject}, not ${boardProject}. ${choices}`, list);
56
+ throw new RegistrationError('REGISTRATION_UNKNOWN', `This machine has no registration ${bare} in ${org}. ${choices}`, list);
57
+ }
58
+ if (serving.length === 1 && serving[0])
59
+ return serving[0];
60
+ if (!serving.length)
61
+ throw new RegistrationError('REGISTRATION_UNKNOWN', `No registration serves ${boardProject} in ${org}. Register one: agent-flow-board check project <NAME> --org ${org} --repo <folder> --board-project ${boardProject}.`, []);
62
+ throw new RegistrationError('REGISTRATION_REQUIRED', `${boardProject} has ${serving.length} registrations (${list.join(', ')}) and none was named. Name one on the run, save one on the check, or put a "// afb-registration: <name>" line at the top of the check script.`, list);
63
+ }
64
+ /** Routes one run: the name on the run, else the name saved on the check, else the script's header, else the only registration. */
65
+ export function routeRun(opts) {
66
+ const named = opts.run || opts.check || (opts.script ? registrationHeader(opts.script) : null);
67
+ return pickRegistration(opts.org, opts.boardProject, named, opts.all);
68
+ }