@agent-flow-board/bridge 0.2.0 → 0.3.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 +16 -0
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +3 -2
- package/dist/checks/bind.js +3 -2
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +137 -32
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/command.js +75 -88
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connections.js +4 -3
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +50 -1
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +28 -4
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +24 -0
- package/dist/checks/preview.js +418 -191
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +1 -1
- package/dist/checks/recipe.js +19 -5
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +243 -53
- package/dist/checks/sandbox.js +32 -10
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +119 -30
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +7 -1
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +190 -63
- package/dist/checks/wire.js +87 -0
- package/package.json +1 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SAVED SIGN-INS: ONE SEALED FILE PER REGISTRATION AND SITE
|
|
2
|
+
// A saved sign-in is the browser state (cookies and local storage) of a person signed in to one site. It is sealed with the bridge's key, bound
|
|
3
|
+
// to the registration and the site address (the AAD), and lives under that registration's own state folder. It is used only for the site it
|
|
4
|
+
// was saved for, and it never reaches a check except through the child's stdin.
|
|
5
|
+
// -- DEP-MODULES -- //
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
// -- MODULES -- //
|
|
10
|
+
import { ensureDir, ORG_SLUG, stateDirFor } from './paths.js';
|
|
11
|
+
import { renameRetry } from './fsretry.js';
|
|
12
|
+
import { seal, unseal } from './store.js';
|
|
13
|
+
// -- CONSTANTS -- //
|
|
14
|
+
const NAME = /^[A-Za-z0-9_-]{1,32}$/;
|
|
15
|
+
const FILE = /^[0-9a-f]{16}\.enc$/;
|
|
16
|
+
const enc = new TextEncoder();
|
|
17
|
+
const dec = new TextDecoder();
|
|
18
|
+
// -- FUNCTIONS -- //
|
|
19
|
+
function check(org, name) {
|
|
20
|
+
if (!ORG_SLUG.test(org))
|
|
21
|
+
throw new Error('The organisation is not a slug.');
|
|
22
|
+
if (!NAME.test(name))
|
|
23
|
+
throw new Error('The registration name is not a plain name.');
|
|
24
|
+
}
|
|
25
|
+
const profilesDir = (org, name) => {
|
|
26
|
+
check(org, name);
|
|
27
|
+
return join(stateDirFor(org, name), 'profiles');
|
|
28
|
+
};
|
|
29
|
+
/** The file name for a site address: 16 hex characters of its hash. */
|
|
30
|
+
export const fileNameFor = (bindKey) => `${createHash('sha256').update(bindKey).digest('hex').slice(0, 16)}.enc`;
|
|
31
|
+
const aadFor = (org, name, bindKey) => `profile:${org}/${name}:${bindKey}`;
|
|
32
|
+
/** Saves (replaces) the sign-in of one registration and site. Returns the file name, not the path. */
|
|
33
|
+
export function saveProfile(org, name, bindKey, state) {
|
|
34
|
+
if (!bindKey)
|
|
35
|
+
throw new Error('A saved sign-in needs the address of the site.');
|
|
36
|
+
const dir = ensureDir(profilesDir(org, name));
|
|
37
|
+
const file = fileNameFor(bindKey);
|
|
38
|
+
const profile = { bindKey, savedAt: new Date().toISOString(), state };
|
|
39
|
+
const blob = seal(aadFor(org, name, bindKey), enc.encode(JSON.stringify(profile)));
|
|
40
|
+
const tmp = join(dir, `${file}.${process.pid}.tmp`);
|
|
41
|
+
writeFileSync(tmp, blob, { mode: 0o600 });
|
|
42
|
+
renameRetry(tmp, join(dir, file));
|
|
43
|
+
// The address is not a secret: a plain index lets a listing name the site without opening the sealed file
|
|
44
|
+
writeFileSync(indexPath(org, name, file), JSON.stringify({ bindKey }), { mode: 0o600 });
|
|
45
|
+
return file;
|
|
46
|
+
}
|
|
47
|
+
/** The saved sign-in for exactly this registration and site, or null (none, damaged, or saved for another address). */
|
|
48
|
+
export function loadProfile(org, name, bindKey) {
|
|
49
|
+
return loadProfileChecked(org, name, bindKey).profile;
|
|
50
|
+
}
|
|
51
|
+
/** The same, and says when a file is there but cannot be opened (damaged, or sealed for another address), so a run does not go signed out without saying so. */
|
|
52
|
+
export function loadProfileChecked(org, name, bindKey) {
|
|
53
|
+
const path = join(profilesDir(org, name), fileNameFor(bindKey));
|
|
54
|
+
if (!existsSync(path))
|
|
55
|
+
return { profile: null, damaged: false };
|
|
56
|
+
try {
|
|
57
|
+
const raw = unseal(aadFor(org, name, bindKey), readFileSync(path));
|
|
58
|
+
const p = JSON.parse(dec.decode(raw));
|
|
59
|
+
return p && p.bindKey === bindKey
|
|
60
|
+
? { profile: p, damaged: false }
|
|
61
|
+
: { profile: null, damaged: true };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { profile: null, damaged: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** A plain sentence when every timed cookie has run out, so a run can say so before it fails on a sign-in page. */
|
|
68
|
+
export function expiryNote(profile) {
|
|
69
|
+
const cookies = profile?.state?.cookies ?? [];
|
|
70
|
+
const now = Date.now() / 1000;
|
|
71
|
+
const timed = cookies.filter((c) => typeof c.expires === 'number' && c.expires > 0);
|
|
72
|
+
return timed.length && timed.every((c) => c.expires < now)
|
|
73
|
+
? 'The saved sign-in has expired.'
|
|
74
|
+
: null;
|
|
75
|
+
}
|
|
76
|
+
/** Which sites have a saved sign-in: file, address and date, never a cookie or a value. The address is read from the plain index beside each file. */
|
|
77
|
+
export function listProfiles(org, name) {
|
|
78
|
+
const dir = profilesDir(org, name);
|
|
79
|
+
if (!existsSync(dir))
|
|
80
|
+
return [];
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const file of readdirSync(dir).filter((f) => FILE.test(f))) {
|
|
83
|
+
let bindKey = '(unknown)';
|
|
84
|
+
try {
|
|
85
|
+
bindKey = String(JSON.parse(readFileSync(indexPath(org, name, file), 'utf8'))
|
|
86
|
+
.bindKey ?? bindKey);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* No index: the file is still listed */
|
|
90
|
+
}
|
|
91
|
+
out.push({ file, bindKey, savedAt: statSync(join(dir, file)).mtime.toISOString() });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
const indexPath = (org, name, file) => join(profilesDir(org, name), file + '.site');
|
|
96
|
+
/** Removes one saved sign-in by its exact file name. True when a file went. */
|
|
97
|
+
export function removeProfile(org, name, file) {
|
|
98
|
+
if (!FILE.test(file))
|
|
99
|
+
throw new Error('That is not a saved sign-in file name.');
|
|
100
|
+
const dir = profilesDir(org, name);
|
|
101
|
+
let removed = false;
|
|
102
|
+
for (const f of [file, `${file}.site`]) {
|
|
103
|
+
const p = join(dir, f);
|
|
104
|
+
if (existsSync(p)) {
|
|
105
|
+
rmSync(p, { force: true });
|
|
106
|
+
removed ||= f === file;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return removed;
|
|
110
|
+
}
|
package/dist/checks/provision.js
CHANGED
|
@@ -18,7 +18,7 @@ const IMAGE = 'postgres:16-alpine';
|
|
|
18
18
|
const DOCKER_COMMANDS = new Set(['version', 'run', 'rm', 'exec', 'inspect', 'logs']);
|
|
19
19
|
// -- FUNCTIONS -- //
|
|
20
20
|
/** Runs the real docker program. Never throws: a program that cannot start is a status of -1. */
|
|
21
|
-
const realDocker = (args) => new Promise((resolve) => {
|
|
21
|
+
export const realDocker = (args) => new Promise((resolve) => {
|
|
22
22
|
execFile('docker', args, { windowsHide: true, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
23
23
|
const code = err
|
|
24
24
|
? typeof err.code === 'number'
|
package/dist/checks/recipe.js
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// The repo is the one thing an agent edits, so the recipe is never trusted as it stands: it is pinned (bind.ts) and it is checked here
|
|
3
3
|
// against constants of this package. A recipe cannot name another program, a port a check never uses, an env key outside the list, or a value
|
|
4
4
|
// that is anything but a placeholder or a short plain word. It can only ask for less than the guardrails allow.
|
|
5
|
-
// Sealed secrets ({secret:NAME}) are
|
|
5
|
+
// Sealed secrets ({secret:NAME}) are allowed only on Supabase-style and mail settings (sealedNames.ts), a mail setting takes only the sealed value of its own name, and a
|
|
6
|
+
// service key never goes to a PUBLIC_, VITE_ or NEXT_PUBLIC_ name. The values are filled in by secrets.ts when the preview starts; this file only checks the shape.
|
|
6
7
|
// -- DEP-MODULES -- //
|
|
7
8
|
import { createHash } from 'node:crypto';
|
|
8
9
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
9
10
|
import { join } from 'node:path';
|
|
10
11
|
// -- MODULES -- //
|
|
11
12
|
import { FORBIDDEN_PORTS, checkRepoFolder, commandProblem } from './guardrails.js';
|
|
13
|
+
import { MAIL_ENV_KEY, SECRET_ENV_KEY, placeholderProblems } from './sealedNames.js';
|
|
12
14
|
// -- CONSTANTS -- //
|
|
13
15
|
export const RECIPE_FILE = '.afb-checks.json';
|
|
14
16
|
/** The only keys of a recipe. */
|
|
@@ -22,7 +24,8 @@ export const RECIPE_KEYS = [
|
|
|
22
24
|
'runtimeRole',
|
|
23
25
|
'testUser',
|
|
24
26
|
'env',
|
|
25
|
-
'appPaths'
|
|
27
|
+
'appPaths',
|
|
28
|
+
'warm'
|
|
26
29
|
];
|
|
27
30
|
/** Env keys a recipe may name: the app's own settings and the database urls, never PATH, NODE_OPTIONS or anything that loads code. */
|
|
28
31
|
const ENV_KEY = /^(AFB_[A-Z0-9_]{1,50}|BETTER_AUTH_SECRET|DATABASE_URL|DATABASE_OWNER_URL|EMAIL_PROVIDER|EMAIL_FROM|ORIGIN)$/;
|
|
@@ -130,6 +133,13 @@ export function validateRecipe(recipe, scripts) {
|
|
|
130
133
|
if (!ok)
|
|
131
134
|
out.push('"testUser" must be { "email": ..., "password": ... } with a plain email and a password of 8 or more plain characters. It is a preview-only account, public by design.');
|
|
132
135
|
}
|
|
136
|
+
// Warm previews are not wired in this version (the benchmark rule waived them), so the key is refused instead of being accepted and ignored
|
|
137
|
+
if (recipe.warm !== undefined) {
|
|
138
|
+
if (typeof recipe.warm !== 'boolean')
|
|
139
|
+
out.push('"warm" must be true or false.');
|
|
140
|
+
else if (recipe.warm)
|
|
141
|
+
out.push('"warm": warm previews are not enabled in this version.');
|
|
142
|
+
}
|
|
133
143
|
if (recipe.appPaths !== undefined &&
|
|
134
144
|
!(Array.isArray(recipe.appPaths) &&
|
|
135
145
|
recipe.appPaths.every((p) => typeof p === 'string' && APP_PATH.test(p) && !p.includes('..'))))
|
|
@@ -139,10 +149,14 @@ export function validateRecipe(recipe, scripts) {
|
|
|
139
149
|
out.push('"env" must be an object of setting names and values.');
|
|
140
150
|
else
|
|
141
151
|
for (const [k, v] of Object.entries(recipe.env)) {
|
|
142
|
-
if (
|
|
143
|
-
out.push(`The recipe gives ${k} a sealed secret. Sealed secrets are not supported in this version of the check commands.`);
|
|
144
|
-
else if (!ENV_KEY.test(k) && !PUBLIC_ENV_KEY.test(k))
|
|
152
|
+
if (!ENV_KEY.test(k) && !PUBLIC_ENV_KEY.test(k) && !SECRET_ENV_KEY.test(k))
|
|
145
153
|
out.push(`The recipe sets ${k}, which is not an env key a recipe may set.`);
|
|
154
|
+
// A sealed value only on a Supabase-style or mail setting, and only a key of that kind: never a database, NODE_OPTIONS or a path
|
|
155
|
+
else if (typeof v === 'string' && v.startsWith('{secret:'))
|
|
156
|
+
out.push(...placeholderProblems(k, v));
|
|
157
|
+
// A mail setting is never written in the recipe: its value comes from the sealed store, behind the mail guard
|
|
158
|
+
else if (MAIL_ENV_KEY.test(k))
|
|
159
|
+
out.push(`The recipe gives ${k}, a mail setting, a value that is not its own sealed value ({secret:${k}}).`);
|
|
146
160
|
else if (typeof v !== 'string' || !(PLACEHOLDER.test(v) || PLAIN.test(v)))
|
|
147
161
|
out.push(`The recipe gives ${k} a value that is not a placeholder ({baseUrl}, {port}, {random}, {databaseUrl}, {databaseOwnerUrl}) or a short plain word.`);
|
|
148
162
|
else if (/^\d+$/.test(v) && FORBIDDEN_PORTS.has(Number(v)))
|