@agent-flow-board/bridge 0.1.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 +29 -1
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +126 -0
- package/dist/checks/bind.js +100 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +275 -0
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +267 -0
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +42 -0
- 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 +212 -0
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -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 +207 -0
- 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 +85 -0
- package/dist/checks/preview.js +577 -0
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +246 -0
- package/dist/checks/redact.js +52 -0
- 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 +443 -0
- package/dist/checks/sandbox.js +208 -0
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +383 -0
- 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 +79 -0
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +228 -0
- package/dist/checks/wire.js +87 -0
- package/dist/cli.js +18 -1
- package/dist/hooks/adapters.js +3 -1
- package/dist/hooks/call.js +6 -1
- package/dist/hooks/modVersion.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// GUARDS: WHAT A PAGE MAY REACH, WHAT IT MAY CHANGE, AND WHAT A CHECK IS HANDED.
|
|
2
|
+
// Layer one is the browser itself: every host except the preview's is unresolvable (see browser.ts), whatever a check does with routes.
|
|
3
|
+
// Layer two counts and stops writes. Layer three hands a check a facade of the page that cannot lead back to the context or the cookies.
|
|
4
|
+
// This file imports nothing at runtime, so the sandboxed child can load it. Playwright is only described here by the few members it calls.
|
|
5
|
+
// -- CONSTANTS -- //
|
|
6
|
+
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
7
|
+
const HIDDEN = new Set([
|
|
8
|
+
'context',
|
|
9
|
+
'request',
|
|
10
|
+
'route',
|
|
11
|
+
'unroute',
|
|
12
|
+
'unrouteAll',
|
|
13
|
+
'routeFromHAR',
|
|
14
|
+
'routeWebSocket',
|
|
15
|
+
'addInitScript',
|
|
16
|
+
'exposeFunction',
|
|
17
|
+
'exposeBinding',
|
|
18
|
+
'video',
|
|
19
|
+
'pause',
|
|
20
|
+
'browser',
|
|
21
|
+
'page',
|
|
22
|
+
'mainFrame',
|
|
23
|
+
'frames',
|
|
24
|
+
'frame',
|
|
25
|
+
'parentFrame',
|
|
26
|
+
'childFrames',
|
|
27
|
+
'opener',
|
|
28
|
+
'storageState',
|
|
29
|
+
'cookies',
|
|
30
|
+
'addCookies',
|
|
31
|
+
'clearCookies',
|
|
32
|
+
'newPage',
|
|
33
|
+
'newContext',
|
|
34
|
+
'newCDPSession'
|
|
35
|
+
]);
|
|
36
|
+
// Only Playwright's own objects are wrapped: plain data (what evaluate returns) is handed over as it is
|
|
37
|
+
const WRAPPED = /^_?(Locator|FrameLocator|Frame|ElementHandle|JSHandle|Response|Request|Keyboard|Mouse|Touchscreen|Dialog|Download|FileChooser|ConsoleMessage|Worker|WebSocket|Page)$/;
|
|
38
|
+
const NODE_MODULES = 'fs|fs/promises|child_process|net|http|https|http2|dgram|dns|tls|worker_threads|vm|os|cluster|module|v8|inspector|repl|readline|stream|zlib|crypto|perf_hooks|async_hooks|wasi|tty|process';
|
|
39
|
+
const UNSAFE = [
|
|
40
|
+
[/\bchild_process\b/, 'child_process'],
|
|
41
|
+
[/node:(net|http|https|http2|dgram|dns|tls|worker_threads|vm)\b/, 'a network or process module'],
|
|
42
|
+
[/\brequire\s*\(/, 'require()'],
|
|
43
|
+
[
|
|
44
|
+
new RegExp(`\\bfrom\\s+['"](?:node:)?(?:${NODE_MODULES})['"]`),
|
|
45
|
+
'a module of Node other than path and url'
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
new RegExp(`\\bimport\\s+['"](?:node:)?(?:${NODE_MODULES})['"]`),
|
|
49
|
+
'a module of Node other than path and url'
|
|
50
|
+
],
|
|
51
|
+
[/\bprocess\.(env|binding|kill|exit|getBuiltinModule)\b/, 'process'],
|
|
52
|
+
[/\bfetch\s*\(/, 'fetch()'],
|
|
53
|
+
[/\bXMLHttpRequest\b/, 'XMLHttpRequest'],
|
|
54
|
+
[/\bWebSocket\b/, 'WebSocket'],
|
|
55
|
+
// A dynamic import is fine only for a file next to the check (a relative name written out in full)
|
|
56
|
+
[/\bimport\s*\(\s*(?!['"]\.{1,2}\/)/, 'a dynamic import of a name that is not a relative file'],
|
|
57
|
+
[/node:fs|\bfrom\s+['"]fs['"]/, 'the file system'],
|
|
58
|
+
[/\bprofiles\b/, 'the profiles folder'],
|
|
59
|
+
[
|
|
60
|
+
/\.(page|context|browser|cookies|storageState|mainFrame|frames|frame|newPage|newContext|route|unroute|routeFromHAR|routeWebSocket|addInitScript|exposeFunction|exposeBinding)\s*\(|\.request\b/,
|
|
61
|
+
'a way back to the browser context'
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
/\bgetBuiltinModule\b|\bglobalThis\b|\bFunction\s*\(|\beval\s*\(|\[\s*['"]constructor['"]\s*\]/,
|
|
65
|
+
'a way around the checks'
|
|
66
|
+
]
|
|
67
|
+
];
|
|
68
|
+
// -- FUNCTIONS -- //
|
|
69
|
+
export const hostOf = (url) => {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(url).hostname;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const sameOrigin = (url, origin) => {
|
|
78
|
+
try {
|
|
79
|
+
return new URL(url).origin === origin;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Stops every write and counts what it stopped. A page that submits a form is answered with nothing so it stays where it is.
|
|
87
|
+
* Writes are only let through when the person said so for this run (allowWrites).
|
|
88
|
+
*/
|
|
89
|
+
export async function guardContext(ctx, { origin, allowWrites = false, extraHosts = [], onBlocked = () => { } }) {
|
|
90
|
+
const counts = { writes: 0, outside: 0 };
|
|
91
|
+
await ctx.route('**/*', async (route) => {
|
|
92
|
+
const req = route.request();
|
|
93
|
+
const url = req.url();
|
|
94
|
+
if (url.startsWith('data:') || url.startsWith('blob:'))
|
|
95
|
+
return route.continue();
|
|
96
|
+
if (!sameOrigin(url, origin) && !extraHosts.includes(hostOf(url))) {
|
|
97
|
+
counts.outside++;
|
|
98
|
+
onBlocked(`blocked a request to ${hostOf(url)}`);
|
|
99
|
+
return route.abort();
|
|
100
|
+
}
|
|
101
|
+
if (!READ_METHODS.has(req.method()) && !allowWrites) {
|
|
102
|
+
counts.writes++;
|
|
103
|
+
onBlocked(`stopped a ${req.method()} to ${new URL(url).pathname}`);
|
|
104
|
+
return req.resourceType() === 'document'
|
|
105
|
+
? route.fulfill({ status: 204, body: '' })
|
|
106
|
+
: route.abort();
|
|
107
|
+
}
|
|
108
|
+
return route.continue();
|
|
109
|
+
});
|
|
110
|
+
return counts;
|
|
111
|
+
}
|
|
112
|
+
const refuse = (prop) => () => {
|
|
113
|
+
throw new Error(`${prop} is not available to a check. Checks drive the page; the tool keeps the session and the network rules.`);
|
|
114
|
+
};
|
|
115
|
+
const isThenable = (v) => typeof v.then === 'function';
|
|
116
|
+
/** Wraps something a page returned (a locator, a response, a promise of one) so it can never lead back to the context. */
|
|
117
|
+
function wrap(value, guard) {
|
|
118
|
+
if (value === null || typeof value !== 'object')
|
|
119
|
+
return value;
|
|
120
|
+
if (isThenable(value))
|
|
121
|
+
return value.then((v) => wrap(v, guard));
|
|
122
|
+
if (Array.isArray(value))
|
|
123
|
+
return value.map((v) => wrap(v, guard));
|
|
124
|
+
const name = value.constructor?.name ?? '';
|
|
125
|
+
if (!WRAPPED.test(name))
|
|
126
|
+
return value;
|
|
127
|
+
return proxy(value, guard);
|
|
128
|
+
}
|
|
129
|
+
const originals = new WeakMap();
|
|
130
|
+
/** The real object behind something a check was handed, for the tool's own use (expect needs the real locator). */
|
|
131
|
+
export const unwrap = (v) => typeof v === 'object' && v !== null ? (originals.get(v) ?? v) : v;
|
|
132
|
+
function proxy(value, guard) {
|
|
133
|
+
const made = new Proxy(value, {
|
|
134
|
+
get(target, prop) {
|
|
135
|
+
if (typeof prop === 'string' && HIDDEN.has(prop))
|
|
136
|
+
return new Proxy(refuse(prop), {
|
|
137
|
+
get: refuse(prop),
|
|
138
|
+
apply: refuse(prop),
|
|
139
|
+
set: refuse(prop)
|
|
140
|
+
});
|
|
141
|
+
// expect() talks to a locator through its internal _expect and nothing else
|
|
142
|
+
if (typeof prop === 'string' && prop.startsWith('_') && prop !== '_expect')
|
|
143
|
+
return undefined;
|
|
144
|
+
const v = Reflect.get(target, prop, target);
|
|
145
|
+
// Symbols and the constructor are handed over as they are, so instanceof checks and expect() still recognise a locator
|
|
146
|
+
if (typeof prop === 'symbol' || prop === 'constructor')
|
|
147
|
+
return v;
|
|
148
|
+
if (typeof v !== 'function')
|
|
149
|
+
return wrap(v, guard);
|
|
150
|
+
return (...args) => {
|
|
151
|
+
if (prop === 'goto')
|
|
152
|
+
guard.assertNavigable(args[0]);
|
|
153
|
+
return wrap(v.apply(target, args), guard);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
originals.set(made, value);
|
|
158
|
+
return made;
|
|
159
|
+
}
|
|
160
|
+
/** What a check receives instead of the real page: no context, no cookies, no request client, no routes, and no way back to them through a frame, a locator or a response. */
|
|
161
|
+
export function pageForCheck(page, { origin, extraHosts = [] } = {}) {
|
|
162
|
+
const guard = {
|
|
163
|
+
assertNavigable(url) {
|
|
164
|
+
const u = String(url ?? '');
|
|
165
|
+
if (u.startsWith('/') && !u.startsWith('//'))
|
|
166
|
+
return;
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
parsed = new URL(u);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
throw new Error(`The check tried to open "${u.slice(0, 60)}", which is not an address of the app under test.`);
|
|
173
|
+
}
|
|
174
|
+
if (!['http:', 'https:'].includes(parsed.protocol) ||
|
|
175
|
+
(origin && parsed.origin !== origin && !extraHosts.includes(parsed.hostname)))
|
|
176
|
+
throw new Error(`The check tried to open ${parsed.protocol}//${parsed.host}, which is not the app under test.`);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
return proxy(page, guard);
|
|
180
|
+
}
|
|
181
|
+
/** A speed bump, not a wall: names what a check does that a check has no business doing. Refused unless the person says --allow-unsafe. */
|
|
182
|
+
export function lintCheck(source) {
|
|
183
|
+
const found = [];
|
|
184
|
+
for (const [re, label] of UNSAFE)
|
|
185
|
+
if (re.test(source) && !found.includes(label))
|
|
186
|
+
found.push(label);
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// THE GUARDRAILS OF THE CHECK COMMANDS. THEY ARE CONSTANTS IN THIS FILE, NEVER DATA: NOTHING IN THE REPO UNDER TEST OR IN A RECIPE CAN WIDEN THEM.
|
|
2
|
+
// WHAT THIS DOES AND DOES NOT PROTECT. The preview runs the code of the repo under test, and the agent writes that code. So no rule here can
|
|
3
|
+
// stop an agent that means harm: it could put anything in the app. The rules stop mistakes and keep real things safe: the database that holds
|
|
4
|
+
// real data, the ports of the servers a person runs, the programs a build is allowed to start, and folders that were never meant to be built.
|
|
5
|
+
// -- DEP-MODULES -- //
|
|
6
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { dirname, join, parse, resolve, sep } from 'node:path';
|
|
9
|
+
// -- MODULES -- //
|
|
10
|
+
import { configDir } from '../tokenStore.js';
|
|
11
|
+
// -- CONSTANTS -- //
|
|
12
|
+
/** Never used by a check: the dev server, the build preview that is served, and the local Docker PostgreSQL that holds real data. */
|
|
13
|
+
export const FORBIDDEN_PORTS = new Set([5180, 5190, 5433]);
|
|
14
|
+
/** The only flags of a build or start command, besides the script name. Anything else could run a file the agent wrote. */
|
|
15
|
+
const ARG_OK = /^(--port|\{port\}|--host|127\.0\.0\.1|--strictPort|--)$/;
|
|
16
|
+
/** The one word a script may be given besides flags: what a database command should do. */
|
|
17
|
+
const SUBCOMMAND_OK = /^migrate$/;
|
|
18
|
+
const WORD = /^[A-Za-z0-9_:.@-]+$/;
|
|
19
|
+
/**
|
|
20
|
+
* What the body of a package.json script may be: a known build or preview program with a plain command and plain flags. A body that runs a
|
|
21
|
+
* file, chains commands or sets variables is refused, because that is where an arbitrary program would hide.
|
|
22
|
+
*/
|
|
23
|
+
const BODY_OK = /^(vite|svelte-kit|next|astro|nuxt|nuxi|remix) [a-z][a-z-]*( --?[A-Za-z][A-Za-z-]*( [A-Za-z0-9_.:{}/-]+)?){0,4}$/;
|
|
24
|
+
/** Folders that hold keys and logins: a build never runs inside or at one of them. */
|
|
25
|
+
const SECRET_FOLDERS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker'];
|
|
26
|
+
// -- FUNCTIONS -- //
|
|
27
|
+
const hasPort = (text, port) => new RegExp(`(^|[^0-9])${port}([^0-9]|$)`).test(text);
|
|
28
|
+
/** Paths compare without case on Windows only. */
|
|
29
|
+
const same = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
30
|
+
const insideOrSame = (parent, child) => {
|
|
31
|
+
const p = process.platform === 'win32' ? parent.toLowerCase() : parent;
|
|
32
|
+
const c = process.platform === 'win32' ? child.toLowerCase() : child;
|
|
33
|
+
return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
|
|
34
|
+
};
|
|
35
|
+
/** One plain sentence when a package.json script is not a plain build or preview command, else null. */
|
|
36
|
+
export function scriptBodyProblem(name, scripts) {
|
|
37
|
+
const body = String(scripts[name] ?? '');
|
|
38
|
+
if (!BODY_OK.test(body))
|
|
39
|
+
return `The script "${name}" runs "${body}", which is not a plain build or preview command (vite, svelte-kit, next, astro, nuxt or remix with plain flags).`;
|
|
40
|
+
// A script body may not name a port a check never uses, nor bind to anything but this machine
|
|
41
|
+
for (const p of FORBIDDEN_PORTS)
|
|
42
|
+
if (hasPort(body, p))
|
|
43
|
+
return `The script "${name}" mentions port ${p}, which a check never uses.`;
|
|
44
|
+
const host = /--host(?:\s+(\S+))?/.exec(body);
|
|
45
|
+
if (host && host[1] !== '127.0.0.1')
|
|
46
|
+
return `The script "${name}" binds the preview to something other than 127.0.0.1.`;
|
|
47
|
+
// A pre or post script runs by itself around it, so it is held to the same rule
|
|
48
|
+
for (const hook of [`pre${name}`, `post${name}`])
|
|
49
|
+
if (Object.prototype.hasOwnProperty.call(scripts, hook) && !BODY_OK.test(String(scripts[hook])))
|
|
50
|
+
return `The script "${hook}" runs by itself around "${name}" and is "${scripts[hook]}", which is not a plain build or preview command.`;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One plain sentence when a build or start command is outside the allowed forms, else null. Allowed: `yarn|npm|pnpm [run] <script>` where the
|
|
55
|
+
* script is named in package.json and its body is a plain build or preview command, with only the flags above. No shell characters, no `..`,
|
|
56
|
+
* no other program, and not `node <file>`: a file is where the agent's own program would run.
|
|
57
|
+
*/
|
|
58
|
+
export function commandProblem(cmd, scripts, what) {
|
|
59
|
+
const text = String(cmd ?? '').trim();
|
|
60
|
+
if (!text)
|
|
61
|
+
return `${what} is empty.`;
|
|
62
|
+
if (/[&|;<>`$()\\\r\n"'*?%^!]/.test(text) || text.includes('..'))
|
|
63
|
+
return `${what} "${text}" has a character a shell would act on.`;
|
|
64
|
+
const t = text.split(/\s+/);
|
|
65
|
+
if (!['yarn', 'npm', 'pnpm'].includes(t[0]))
|
|
66
|
+
return `${what} "${text}" starts with ${t[0]}. Only yarn, npm or pnpm running a package.json script are allowed.`;
|
|
67
|
+
let i = 1;
|
|
68
|
+
if (t[i] === 'run')
|
|
69
|
+
i++;
|
|
70
|
+
const script = t[i];
|
|
71
|
+
if (!script || !WORD.test(script))
|
|
72
|
+
return `${what} "${text}" names no package.json script.`;
|
|
73
|
+
if (!Object.prototype.hasOwnProperty.call(scripts, script))
|
|
74
|
+
return `${what} "${text}" runs "${script}", which is not a script in package.json.`;
|
|
75
|
+
const body = scriptBodyProblem(script, scripts);
|
|
76
|
+
if (body)
|
|
77
|
+
return `${what} "${text}": ${body}`;
|
|
78
|
+
const rest = t.slice(i + 1);
|
|
79
|
+
for (let j = 0; j < rest.length; j++) {
|
|
80
|
+
if (j === 0 && SUBCOMMAND_OK.test(rest[j]))
|
|
81
|
+
continue;
|
|
82
|
+
if (!ARG_OK.test(rest[j]))
|
|
83
|
+
return `${what} "${text}" has the argument ${rest[j]}, which is not one of --port, --host 127.0.0.1 or --strictPort.`;
|
|
84
|
+
if (rest[j] === '--host' && rest[j + 1] !== '127.0.0.1')
|
|
85
|
+
return `${what} "${text}" binds the preview to something other than 127.0.0.1.`;
|
|
86
|
+
if (rest[j] === '--port' && rest[j + 1] !== '{port}')
|
|
87
|
+
return `${what} "${text}" fixes the port. It must be {port}, so the tool picks a free one.`;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/** True when the folder is a git project or sits inside one (a .git folder or file here or in a parent). */
|
|
92
|
+
function insideGit(folder) {
|
|
93
|
+
let dir = folder;
|
|
94
|
+
for (;;) {
|
|
95
|
+
if (existsSync(join(dir, '.git')))
|
|
96
|
+
return true;
|
|
97
|
+
const up = dirname(dir);
|
|
98
|
+
if (up === dir)
|
|
99
|
+
return false;
|
|
100
|
+
dir = up;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The real path of a folder, or its plain resolved path when it cannot be read. */
|
|
104
|
+
function realOrResolved(folder) {
|
|
105
|
+
try {
|
|
106
|
+
return realpathSync.native(folder);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return resolve(folder);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* One plain sentence when the folder may not be built by a check, else null. The rules: it is a folder that exists, a git project (or inside
|
|
114
|
+
* one), not a drive root, the home folder, the bridge home or a folder of keys and logins, it has no link in its path (a symlink or a Windows
|
|
115
|
+
* junction would let the build leave the folder the person named), and it holds a package.json.
|
|
116
|
+
*/
|
|
117
|
+
export function checkRepoFolder(folder) {
|
|
118
|
+
if (typeof folder !== 'string' || !folder.trim())
|
|
119
|
+
return 'No folder is given.';
|
|
120
|
+
const resolved = resolve(folder);
|
|
121
|
+
let stat;
|
|
122
|
+
try {
|
|
123
|
+
stat = statSync(resolved);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return `The folder ${resolved} does not exist.`;
|
|
127
|
+
}
|
|
128
|
+
if (!stat.isDirectory())
|
|
129
|
+
return `${resolved} is not a folder.`;
|
|
130
|
+
if (parse(resolved).root === resolved)
|
|
131
|
+
return `${resolved} is the top of a drive. Pick the project's own folder.`;
|
|
132
|
+
// The real path must be the path as written, component by component: any link on the way is refused
|
|
133
|
+
let real;
|
|
134
|
+
try {
|
|
135
|
+
real = realpathSync.native(resolved);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return `The folder ${resolved} cannot be read.`;
|
|
139
|
+
}
|
|
140
|
+
if (!same(real, resolved))
|
|
141
|
+
return `${resolved} goes through a link (it really is ${real}). Give the real folder path.`;
|
|
142
|
+
if (same(resolved, resolve(homedir())))
|
|
143
|
+
return 'That is the home folder. Pick the project folder inside it.';
|
|
144
|
+
if (insideOrSame(realOrResolved(configDir()), real))
|
|
145
|
+
return 'That folder is the bridge home, which holds the sign-in. A check never builds there.';
|
|
146
|
+
for (const part of resolved.split(sep)) {
|
|
147
|
+
if (SECRET_FOLDERS.includes(part.toLowerCase()))
|
|
148
|
+
return `${resolved} is inside a ${part} folder, which holds keys or logins. A check never builds there.`;
|
|
149
|
+
}
|
|
150
|
+
if (!insideGit(resolved))
|
|
151
|
+
return `${resolved} is not a git project (no .git here or above it).`;
|
|
152
|
+
if (!existsSync(join(resolved, 'package.json')))
|
|
153
|
+
return `${resolved} has no package.json.`;
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// `check import-legacy --from <afb-checks folder> --org <slug> [--dry-run] [--json]`: MOVES THE SETUP OF THE STANDALONE afb-checks TOOL INTO THE BRIDGE, READ ONLY ON THE OLD FOLDER.
|
|
2
|
+
// It reads ONLY <from>/data/projects/<org>/*.json (the project definitions: repo, appDir, boardProject, recipe, url, extraHosts) and the check scripts
|
|
3
|
+
// <from>/checks/<org>/<item>/<key>.mjs. It never lists or reads anything else under the old folder: no profiles, no secrets, no keys, no sign-ins.
|
|
4
|
+
// Each project is registered again through registry.ts with adoption OFF and no pin; a repo registration is made only when its folder is inside an owner-approved
|
|
5
|
+
// root (otherwise it is listed with the exact command the owner needs). Each script is checked with lintCheck and copied unchanged (headers kept) into the scripts
|
|
6
|
+
// folder; a script that fails the lint is not copied, and one that differs from an existing script is a conflict and is left as it is.
|
|
7
|
+
// -- DEP-MODULES -- //
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
10
|
+
// -- MODULES -- //
|
|
11
|
+
import { Refusal, UsageError } from './approve.js';
|
|
12
|
+
import { appendAudit } from './audit.js';
|
|
13
|
+
import { checkRepoFolder } from './guardrails.js';
|
|
14
|
+
import { lintCheck } from './guard.js';
|
|
15
|
+
import { NAME, ORG_SLUG, scriptPath } from './paths.js';
|
|
16
|
+
import { PROJECT_KEY, REGISTRATION_NAME, findRegistration, registerProject } from './registry.js';
|
|
17
|
+
import { rootHolding } from './roots.js';
|
|
18
|
+
// -- CONSTANTS -- //
|
|
19
|
+
/** A script larger than this is not a check. */
|
|
20
|
+
const MAX_SCRIPT_BYTES = 1_000_000;
|
|
21
|
+
const MAX_PROJECT_BYTES = 200_000;
|
|
22
|
+
// -- FUNCTIONS: SMALL HELPERS -- //
|
|
23
|
+
const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
24
|
+
const text = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
25
|
+
/** A plain file (not a link) as bytes, or null. Refuses anything larger than `max`. */
|
|
26
|
+
function readPlain(file, max) {
|
|
27
|
+
try {
|
|
28
|
+
const st = lstatSync(file);
|
|
29
|
+
if (!st.isFile() || st.size > max)
|
|
30
|
+
return null;
|
|
31
|
+
return readFileSync(file);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The names in a folder that pass `ok`, sorted. A folder that is a link is not entered. */
|
|
38
|
+
function listNames(dir, ok) {
|
|
39
|
+
try {
|
|
40
|
+
if (lstatSync(dir).isSymbolicLink())
|
|
41
|
+
return [];
|
|
42
|
+
return readdirSync(dir).filter(ok).sort();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function parseProject(raw) {
|
|
49
|
+
if (!raw)
|
|
50
|
+
return null;
|
|
51
|
+
try {
|
|
52
|
+
const v = JSON.parse(raw.toString('utf8'));
|
|
53
|
+
if (!isRecord(v))
|
|
54
|
+
return null;
|
|
55
|
+
return {
|
|
56
|
+
repo: text(v.repo),
|
|
57
|
+
appDir: text(v.appDir),
|
|
58
|
+
boardProject: text(v.boardProject),
|
|
59
|
+
recipe: v.recipe === true,
|
|
60
|
+
database: v.database,
|
|
61
|
+
url: text(v.url),
|
|
62
|
+
extraHosts: Array.isArray(v.extraHosts)
|
|
63
|
+
? v.extraHosts.filter((h) => typeof h === 'string')
|
|
64
|
+
: undefined,
|
|
65
|
+
build: text(v.build),
|
|
66
|
+
start: text(v.start),
|
|
67
|
+
ready: text(v.ready)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// -- FUNCTIONS: PROJECTS -- //
|
|
75
|
+
function importProject(org, name, old, dryRun) {
|
|
76
|
+
const skip = (reason, next) => ({
|
|
77
|
+
kind: 'skipped',
|
|
78
|
+
skip: { name, reason, ...(next ? { next } : {}) }
|
|
79
|
+
});
|
|
80
|
+
const boardProject = old.boardProject ?? name;
|
|
81
|
+
if (!PROJECT_KEY.test(boardProject))
|
|
82
|
+
return skip(`its board project "${boardProject.slice(0, 20)}" is not a project key`);
|
|
83
|
+
const existing = findRegistration(org, name);
|
|
84
|
+
if (existing)
|
|
85
|
+
return { kind: 'unchanged' };
|
|
86
|
+
// A live site needs the owner's code; it is never made here
|
|
87
|
+
if (old.url) {
|
|
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>`);
|
|
90
|
+
}
|
|
91
|
+
if (!old.repo)
|
|
92
|
+
return skip('the project file names neither a repo folder nor a live site');
|
|
93
|
+
const folder = resolve(old.repo);
|
|
94
|
+
if (!existsSync(folder))
|
|
95
|
+
return skip('its repo folder is not on this machine');
|
|
96
|
+
const root = rootHolding(folder);
|
|
97
|
+
if (!root) {
|
|
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`);
|
|
100
|
+
}
|
|
101
|
+
const input = {
|
|
102
|
+
org,
|
|
103
|
+
name,
|
|
104
|
+
boardProject,
|
|
105
|
+
repo: folder,
|
|
106
|
+
...(old.appDir ? { appDir: old.appDir } : {}),
|
|
107
|
+
recipe: old.recipe === true,
|
|
108
|
+
adoptEnv: false,
|
|
109
|
+
uploadSignedInPictures: false
|
|
110
|
+
};
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
const problem = checkRepoFolder(old.appDir ? join(folder, old.appDir) : folder);
|
|
113
|
+
return problem ? skip(problem) : { kind: 'registered' };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
registerProject(input);
|
|
117
|
+
return { kind: 'registered' };
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (e instanceof Refusal)
|
|
121
|
+
return skip(e.message);
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// -- FUNCTIONS: SCRIPTS -- //
|
|
126
|
+
function importScripts(from, org, dryRun) {
|
|
127
|
+
const out = {
|
|
128
|
+
found: 0,
|
|
129
|
+
copied: 0,
|
|
130
|
+
same: 0,
|
|
131
|
+
conflicts: [],
|
|
132
|
+
rejected: []
|
|
133
|
+
};
|
|
134
|
+
const base = join(from, 'checks', org);
|
|
135
|
+
for (const item of listNames(base, (n) => NAME.test(n))) {
|
|
136
|
+
const itemDir = join(base, item);
|
|
137
|
+
for (const file of listNames(itemDir, (n) => n.endsWith('.mjs'))) {
|
|
138
|
+
const key = file.slice(0, -'.mjs'.length);
|
|
139
|
+
if (!NAME.test(key))
|
|
140
|
+
continue;
|
|
141
|
+
const bytes = readPlain(join(itemDir, file), MAX_SCRIPT_BYTES);
|
|
142
|
+
if (!bytes)
|
|
143
|
+
continue;
|
|
144
|
+
out.found++;
|
|
145
|
+
const reasons = lintCheck(bytes.toString('utf8'));
|
|
146
|
+
if (reasons.length) {
|
|
147
|
+
out.rejected.push({ item, key, reasons });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const target = scriptPath(org, item, key);
|
|
151
|
+
if (existsSync(target)) {
|
|
152
|
+
const have = readPlain(target, MAX_SCRIPT_BYTES);
|
|
153
|
+
if (have && have.equals(bytes))
|
|
154
|
+
out.same++;
|
|
155
|
+
else
|
|
156
|
+
out.conflicts.push(`${item}/${key}`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!dryRun) {
|
|
160
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
161
|
+
// 'wx': never replaces a script that appeared in the meantime
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(target, bytes, { mode: 0o600, flag: 'wx' });
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
if (e.code === 'EEXIST') {
|
|
167
|
+
out.conflicts.push(`${item}/${key}`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
out.copied++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
// -- FUNCTIONS: THE IMPORT -- //
|
|
179
|
+
/** Reads the old folder (project files and scripts only) and brings both into the bridge. Throws a Refusal or UsageError for input it cannot use. */
|
|
180
|
+
export function importLegacy(opts) {
|
|
181
|
+
const dryRun = opts.dryRun === true;
|
|
182
|
+
const org = opts.org;
|
|
183
|
+
if (!ORG_SLUG.test(org))
|
|
184
|
+
throw new UsageError('--org takes an organisation slug.');
|
|
185
|
+
const from = resolve(opts.from);
|
|
186
|
+
try {
|
|
187
|
+
if (!lstatSync(from).isDirectory())
|
|
188
|
+
throw new Error('not a folder');
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new Refusal(`${basename(from)} is not a folder that exists.`);
|
|
192
|
+
}
|
|
193
|
+
const projectsDir = join(from, 'data', 'projects', org);
|
|
194
|
+
const names = listNames(projectsDir, (n) => n.endsWith('.json')).map((n) => n.slice(0, -5));
|
|
195
|
+
const projects = {
|
|
196
|
+
found: 0,
|
|
197
|
+
registered: [],
|
|
198
|
+
unchanged: [],
|
|
199
|
+
skipped: []
|
|
200
|
+
};
|
|
201
|
+
const notes = [];
|
|
202
|
+
for (const name of names) {
|
|
203
|
+
projects.found++;
|
|
204
|
+
if (!REGISTRATION_NAME.test(name)) {
|
|
205
|
+
projects.skipped.push({
|
|
206
|
+
name: name.slice(0, 40),
|
|
207
|
+
reason: 'its name is not a registration name'
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const old = parseProject(readPlain(join(projectsDir, `${name}.json`), MAX_PROJECT_BYTES));
|
|
212
|
+
if (!old) {
|
|
213
|
+
projects.skipped.push({ name, reason: 'its project file cannot be read' });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!old.recipe && (old.build || old.start))
|
|
217
|
+
notes.push(`${name}: the old build and start commands are not copied; the bridge reads the package scripts or a recipe (.afb-checks.json).`);
|
|
218
|
+
const r = importProject(org, name, old, dryRun);
|
|
219
|
+
if (r.kind === 'skipped')
|
|
220
|
+
projects.skipped.push(r.skip);
|
|
221
|
+
else if (r.kind === 'registered')
|
|
222
|
+
projects.registered.push(name);
|
|
223
|
+
else
|
|
224
|
+
projects.unchanged.push(name);
|
|
225
|
+
}
|
|
226
|
+
const scripts = importScripts(from, org, dryRun);
|
|
227
|
+
const todo = [];
|
|
228
|
+
for (const s of projects.skipped)
|
|
229
|
+
if (s.next)
|
|
230
|
+
todo.push(`${s.name}: ${s.next}`);
|
|
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>.`);
|
|
233
|
+
todo.push(`Make the sign-ins again: ${[...projects.registered, ...projects.unchanged].map((n) => `check login ${n} --org ${org}`).join('; ')}.`);
|
|
234
|
+
}
|
|
235
|
+
if (scripts.conflicts.length)
|
|
236
|
+
todo.push('Compare the conflicting scripts with the ones in the bridge scripts folder and keep the right one by hand; nothing was overwritten.');
|
|
237
|
+
todo.push('When the bridge has taken over, stop the old worker and its service (the owner does this).');
|
|
238
|
+
const summary = {
|
|
239
|
+
ok: !projects.skipped.length && !scripts.conflicts.length && !scripts.rejected.length,
|
|
240
|
+
dryRun,
|
|
241
|
+
org,
|
|
242
|
+
projects,
|
|
243
|
+
scripts,
|
|
244
|
+
notes,
|
|
245
|
+
todo
|
|
246
|
+
};
|
|
247
|
+
if (!dryRun)
|
|
248
|
+
appendAudit('import-legacy', {
|
|
249
|
+
org,
|
|
250
|
+
projects: projects.found,
|
|
251
|
+
registered: projects.registered.length,
|
|
252
|
+
skipped: projects.skipped.length,
|
|
253
|
+
scripts: scripts.found,
|
|
254
|
+
copied: scripts.copied,
|
|
255
|
+
conflicts: scripts.conflicts.length,
|
|
256
|
+
rejected: scripts.rejected.length
|
|
257
|
+
});
|
|
258
|
+
return summary;
|
|
259
|
+
}
|
|
260
|
+
/** `check import-legacy --from <folder> --org <slug> [--dry-run] [--json]`. Exit 0 all imported, 1 something was skipped or needs the owner, 2 could not run. */
|
|
261
|
+
export async function importLegacyCommand(args, ctx) {
|
|
262
|
+
const json = ctx.json ?? args.includes('--json');
|
|
263
|
+
const list = args[0] === 'import-legacy' ? args.slice(1) : args;
|
|
264
|
+
const flag = (name) => {
|
|
265
|
+
const i = list.indexOf(name);
|
|
266
|
+
const v = i >= 0 ? list[i + 1] : undefined;
|
|
267
|
+
return v === undefined || v.startsWith('--') ? undefined : v;
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
const from = flag('--from');
|
|
271
|
+
const org = flag('--org');
|
|
272
|
+
if (!from || !org)
|
|
273
|
+
throw new UsageError('Usage: agent-flow-board check import-legacy --from <afb-checks folder> --org <slug> [--dry-run] [--json]');
|
|
274
|
+
const s = importLegacy({ from, org, dryRun: list.includes('--dry-run') });
|
|
275
|
+
if (json)
|
|
276
|
+
ctx.out(JSON.stringify(s));
|
|
277
|
+
else {
|
|
278
|
+
ctx.log(`${s.dryRun ? 'Would import' : 'Imported'} for ${s.org}: ${s.projects.registered.length} of ${s.projects.found} project(s) registered (${s.projects.unchanged.length} already there), ${s.scripts.copied} of ${s.scripts.found} script(s) copied (${s.scripts.same} already the same).`);
|
|
279
|
+
for (const p of s.projects.skipped)
|
|
280
|
+
ctx.log(`Skipped ${p.name}: ${p.reason}.${p.next ? ` Needs: ${p.next}` : ''}`);
|
|
281
|
+
for (const c of s.scripts.conflicts)
|
|
282
|
+
ctx.log(`Conflict (left as it is): ${c}`);
|
|
283
|
+
for (const r of s.scripts.rejected)
|
|
284
|
+
ctx.log(`Not copied ${r.item}/${r.key}: ${r.reasons.join(', ')}`);
|
|
285
|
+
for (const n of s.notes)
|
|
286
|
+
ctx.log(`Note: ${n}`);
|
|
287
|
+
for (const t of s.todo)
|
|
288
|
+
ctx.log(`To do (owner): ${t}`);
|
|
289
|
+
}
|
|
290
|
+
return s.ok ? 0 : 1;
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
294
|
+
if (json)
|
|
295
|
+
ctx.out(JSON.stringify({ ok: false, event: 'error', error: message }));
|
|
296
|
+
else
|
|
297
|
+
ctx.log(message);
|
|
298
|
+
return e instanceof Refusal ? 1 : 2;
|
|
299
|
+
}
|
|
300
|
+
}
|