@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.
- package/README.md +26 -0
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +274 -0
- package/dist/checks/askBoard.js +143 -0
- package/dist/checks/audit.js +3 -2
- package/dist/checks/bind.js +3 -2
- package/dist/checks/board.js +30 -0
- 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 +126 -87
- package/dist/checks/commands.js +315 -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 +655 -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 +307 -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 +454 -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/signinKey.js +104 -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/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,320 @@
|
|
|
1
|
+
// THE PRIVATE COPY OF A REPO FOR ONE RUN, SO A RUN BUILDS AND SERVES FROM ITS OWN FOLDER AND NEVER FROM THE ONE A PERSON'S PREVIEW SERVES.
|
|
2
|
+
// A git repo with a commit is copied as a local clone (`git clone --local --no-checkout`: nothing is fetched from anywhere else and nothing is
|
|
3
|
+
// written into the repo's own .git, objects are hard linked), with the origin removed and HEAD checked out detached; then only the working-tree
|
|
4
|
+
// changes (tracked and untracked, not ignored) are copied over it, so a run tests what is on disk now. A git repo with no commit is copied from the
|
|
5
|
+
// files git lists; a folder that is not a git repo is copied with caps on files and size. Env files, keys, links and programs named like the
|
|
6
|
+
// package managers are never copied. If HEAD moves while the copy is made, the copy is made once more.
|
|
7
|
+
// -- DEP-MODULES -- //
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'node:fs';
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
11
|
+
// -- MODULES -- //
|
|
12
|
+
import { ensureDir, under } from './paths.js';
|
|
13
|
+
// -- CONSTANTS -- //
|
|
14
|
+
/** A file bigger than this is not copied (a build output, a dump, a video). */
|
|
15
|
+
export const MAX_COPY_BYTES = 5 * 1024 * 1024;
|
|
16
|
+
/** A folder that is not a git repo is copied whole, so it may not be a whole disk. */
|
|
17
|
+
export const MAX_PLAIN_FILES = 20_000;
|
|
18
|
+
export const MAX_PLAIN_BYTES = 400 * 1024 * 1024;
|
|
19
|
+
/** Source and document extensions: a file named secrets.ts or credentials.json is code or text a project owns, not a credentials dump, so it is copied. */
|
|
20
|
+
const SOURCE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|svelte|json|md)$/i;
|
|
21
|
+
/**
|
|
22
|
+
* Files that are never copied, by name: secrets, keys, and a program named like the ones a shell would look up. The credentials rule only applies to a file
|
|
23
|
+
* without a source extension, and the program rule only to a real program (a script file such as lib/git.js is source). A path git tracks is never skipped
|
|
24
|
+
* by these (it is already in the commit); env files are the exception, they have their own rule.
|
|
25
|
+
*/
|
|
26
|
+
const NEVER = [
|
|
27
|
+
{ re: /(^|\/)\.env(\.[^/]*)?$/i, why: 'an env file' },
|
|
28
|
+
{ re: /\.(pem|key|p12|pfx|jks|keystore)$/i, why: 'a key file' },
|
|
29
|
+
{ re: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i, why: 'an ssh key' },
|
|
30
|
+
{ re: /(^|\/)(credentials?|secrets?)(\.[^/]*)?$/i, why: 'a credentials file' },
|
|
31
|
+
{
|
|
32
|
+
re: /(^|\/)(yarn|npm|npx|pnpm|node|git)\.(cmd|bat|exe|com|ps1|vbs)$/i,
|
|
33
|
+
why: 'a program named like a tool it would be run as'
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
/** An example env file holds no secrets and is copied. */
|
|
37
|
+
const ENV_EXAMPLE = /\.(example|sample|template)$/i;
|
|
38
|
+
/** The reason a path is never copied, or null. Exported for the tests. */
|
|
39
|
+
export function neverCopy(rel, tracked) {
|
|
40
|
+
for (const n of NEVER) {
|
|
41
|
+
if (!n.re.test(rel))
|
|
42
|
+
continue;
|
|
43
|
+
if (n.why === 'an env file') {
|
|
44
|
+
if (ENV_EXAMPLE.test(rel))
|
|
45
|
+
continue;
|
|
46
|
+
return n.why;
|
|
47
|
+
}
|
|
48
|
+
if (tracked)
|
|
49
|
+
continue;
|
|
50
|
+
if (n.why === 'a credentials file' && SOURCE_EXT.test(rel))
|
|
51
|
+
continue;
|
|
52
|
+
return n.why;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const SKIP_DIR = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
57
|
+
/** Env files a build tool loads on its own in a production build. A committed one is taken out of the copy as well. */
|
|
58
|
+
const TRACKED_ENV = /(^|\/)\.env(\.local|\.production|\.production\.local)?$/i;
|
|
59
|
+
// -- FUNCTIONS -- //
|
|
60
|
+
/** Runs git with an argument list and no shell. Resolves with the exit status; never throws. */
|
|
61
|
+
export function git(cwd, args) {
|
|
62
|
+
return new Promise((resolveResult) => {
|
|
63
|
+
execFile('git', args, {
|
|
64
|
+
cwd,
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
encoding: 'buffer',
|
|
67
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
68
|
+
timeout: 10 * 60_000
|
|
69
|
+
}, (err, stdout, stderr) => {
|
|
70
|
+
const code = err
|
|
71
|
+
? typeof err.code === 'number'
|
|
72
|
+
? err.code
|
|
73
|
+
: -1
|
|
74
|
+
: 0;
|
|
75
|
+
resolveResult({
|
|
76
|
+
status: code,
|
|
77
|
+
stdout: stdout,
|
|
78
|
+
stderr: String(stderr ?? '')
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const must = (r, what) => {
|
|
84
|
+
if (r.status !== 0)
|
|
85
|
+
throw new Error(`${what} failed: ${(r.stderr || r.stdout.toString('utf8')).trim().slice(-300)}`);
|
|
86
|
+
return r.stdout;
|
|
87
|
+
};
|
|
88
|
+
/** The commit of HEAD, or null when the folder has no commit (or is not a git repo). */
|
|
89
|
+
export async function headCommit(repo) {
|
|
90
|
+
const r = await git(repo, ['rev-parse', '--verify', 'HEAD']);
|
|
91
|
+
return r.status === 0 ? r.stdout.toString('utf8').trim() || null : null;
|
|
92
|
+
}
|
|
93
|
+
/** The top folder of the git repo `folder` is in, or null when it is in none. */
|
|
94
|
+
export async function gitRoot(folder) {
|
|
95
|
+
const r = await git(folder, ['rev-parse', '--show-toplevel']);
|
|
96
|
+
if (r.status !== 0)
|
|
97
|
+
return null;
|
|
98
|
+
const out = r.stdout.toString('utf8').trim();
|
|
99
|
+
return out ? resolve(out) : null;
|
|
100
|
+
}
|
|
101
|
+
/** The tracked and the untracked-not-ignored files of a repo, as paths relative to it. Null when the folder is not a git repo. */
|
|
102
|
+
export async function listFiles(repo) {
|
|
103
|
+
const r = await git(repo, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
|
|
104
|
+
if (r.status !== 0)
|
|
105
|
+
return null;
|
|
106
|
+
return [...new Set(r.stdout.toString('utf8').split('\0').filter(Boolean))];
|
|
107
|
+
}
|
|
108
|
+
/** The changes of a working tree as { deleted, changed } paths relative to the git root, from git itself (NUL separated, so any file name works). */
|
|
109
|
+
export async function workingTreeChanges(root) {
|
|
110
|
+
const out = must(await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']), 'git status').toString('utf8');
|
|
111
|
+
const parts = out.split('\0');
|
|
112
|
+
if (parts.at(-1) === '')
|
|
113
|
+
parts.pop();
|
|
114
|
+
const deleted = [];
|
|
115
|
+
const changed = [];
|
|
116
|
+
for (let i = 0; i < parts.length; i++) {
|
|
117
|
+
const entry = parts[i];
|
|
118
|
+
if (entry.length < 4)
|
|
119
|
+
continue;
|
|
120
|
+
const x = entry[0];
|
|
121
|
+
const y = entry[1];
|
|
122
|
+
const file = entry.slice(3);
|
|
123
|
+
if (x === 'R' || x === 'C') {
|
|
124
|
+
// A rename lists the new name, then the old one
|
|
125
|
+
const from = parts[++i];
|
|
126
|
+
if (x === 'R')
|
|
127
|
+
deleted.push(from);
|
|
128
|
+
changed.push(file);
|
|
129
|
+
}
|
|
130
|
+
else if (x === 'D' || y === 'D')
|
|
131
|
+
deleted.push(file);
|
|
132
|
+
else
|
|
133
|
+
changed.push(file);
|
|
134
|
+
}
|
|
135
|
+
return { deleted, changed };
|
|
136
|
+
}
|
|
137
|
+
/** The real path of a folder (so 8.3 names and case do not matter when two paths are compared), or its resolved path. */
|
|
138
|
+
const realOf = (folder) => {
|
|
139
|
+
try {
|
|
140
|
+
return realpathSync.native(folder);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return resolve(folder);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const safeRel = (rel) => !!rel && !isAbsolute(rel) && !rel.split(/[\\/]/).includes('..') && !rel.includes('\0');
|
|
147
|
+
/**
|
|
148
|
+
* Copies one file of the repo into the copy. Returns true when it was copied. A link is refused by name (a copy that followed it could carry files
|
|
149
|
+
* from outside the repo into the build); the other reasons to leave a file out are listed in `skipped`.
|
|
150
|
+
*/
|
|
151
|
+
function copyOne(ctx, rel) {
|
|
152
|
+
if (!safeRel(rel))
|
|
153
|
+
throw new Error(`The file "${rel.slice(0, 80)}" is not a file the tool copies.`);
|
|
154
|
+
const parts = rel.split('/');
|
|
155
|
+
if (parts[0] === '.git')
|
|
156
|
+
throw new Error(`The file "${rel.slice(0, 80)}" is not a file the tool copies.`);
|
|
157
|
+
if (SKIP_DIR.test(rel))
|
|
158
|
+
return false;
|
|
159
|
+
// Every folder above the file must be a real folder
|
|
160
|
+
for (let i = 1; i < parts.length; i++) {
|
|
161
|
+
const folder = parts.slice(0, i).join('/');
|
|
162
|
+
let st;
|
|
163
|
+
try {
|
|
164
|
+
st = lstatSync(join(ctx.src, folder));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
if (st.isSymbolicLink())
|
|
170
|
+
throw new Error(`"${folder.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
|
|
171
|
+
}
|
|
172
|
+
const from = join(ctx.src, ...parts);
|
|
173
|
+
let st;
|
|
174
|
+
try {
|
|
175
|
+
st = lstatSync(from);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false; // Listed by git but deleted in the working tree
|
|
179
|
+
}
|
|
180
|
+
if (st.isSymbolicLink())
|
|
181
|
+
throw new Error(`"${rel.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
|
|
182
|
+
if (!st.isFile())
|
|
183
|
+
return false;
|
|
184
|
+
const never = neverCopy(rel, ctx.tracked.has(rel));
|
|
185
|
+
if (never) {
|
|
186
|
+
ctx.skipped.push({ path: rel, why: never });
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (st.size > MAX_COPY_BYTES) {
|
|
190
|
+
ctx.skipped.push({ path: rel, why: 'larger than 5 MB' });
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const to = under(ctx.dest, ...parts);
|
|
194
|
+
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
|
195
|
+
copyFileSync(from, to);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
/** Every file of a folder that is not a git repo, as relative paths, with the limits that keep it from being a whole disk. */
|
|
199
|
+
function plainFiles(repo, limits = {}) {
|
|
200
|
+
const maxFiles = limits.files ?? MAX_PLAIN_FILES;
|
|
201
|
+
const maxBytes = limits.bytes ?? MAX_PLAIN_BYTES;
|
|
202
|
+
const out = [];
|
|
203
|
+
let bytes = 0;
|
|
204
|
+
const walk = (rel) => {
|
|
205
|
+
for (const e of readdirSync(join(repo, rel), { withFileTypes: true })) {
|
|
206
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
207
|
+
if (SKIP_DIR.test(`${r}/`))
|
|
208
|
+
continue;
|
|
209
|
+
if (e.isSymbolicLink())
|
|
210
|
+
throw new Error(`"${r.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
|
|
211
|
+
if (e.isDirectory())
|
|
212
|
+
walk(r);
|
|
213
|
+
else if (e.isFile()) {
|
|
214
|
+
out.push(r);
|
|
215
|
+
bytes += lstatSync(join(repo, r)).size;
|
|
216
|
+
if (out.length > maxFiles || bytes > maxBytes)
|
|
217
|
+
throw new Error(`${repo} is not a git repo and holds more than ${maxFiles} files or ${Math.round(maxBytes / 1024 / 1024)} MB, which is too much to copy for a run. Make it a git repo, or point the project at a smaller folder.`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
walk('');
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/** The tracked links of a checked-out clone, by name: a link in the commit would reach outside the copy. */
|
|
225
|
+
async function trackedLinks(dir) {
|
|
226
|
+
const out = must(await git(dir, ['ls-files', '-s', '-z']), 'git ls-files').toString('utf8');
|
|
227
|
+
const links = [];
|
|
228
|
+
for (const entry of out.split('\0').filter(Boolean)) {
|
|
229
|
+
const tab = entry.indexOf('\t');
|
|
230
|
+
if (tab > 0 && entry.startsWith('120000 '))
|
|
231
|
+
links.push(entry.slice(tab + 1));
|
|
232
|
+
}
|
|
233
|
+
return links;
|
|
234
|
+
}
|
|
235
|
+
async function cloneCopy(root, dest, head, ctx) {
|
|
236
|
+
ensureDir(dirname(dest));
|
|
237
|
+
must(await git(dirname(dest), ['clone', '--local', '--no-checkout', '--quiet', root, dest]), 'git clone');
|
|
238
|
+
await git(dest, ['remote', 'remove', 'origin']);
|
|
239
|
+
must(await git(dest, ['checkout', '--quiet', '--force', '--detach', head]), 'git checkout');
|
|
240
|
+
const links = await trackedLinks(dest);
|
|
241
|
+
if (links.length)
|
|
242
|
+
throw new Error(`"${links[0].slice(0, 80)}" is a link in the commit. A check does not copy links; remove it from git.`);
|
|
243
|
+
const tracked = must(await git(dest, ['ls-files', '-z']), 'git ls-files').toString('utf8');
|
|
244
|
+
for (const f of tracked.split('\0').filter(Boolean)) {
|
|
245
|
+
ctx.tracked.add(f);
|
|
246
|
+
if (TRACKED_ENV.test(f))
|
|
247
|
+
rmSync(under(dest, f), { force: true });
|
|
248
|
+
}
|
|
249
|
+
const { deleted, changed } = await workingTreeChanges(root);
|
|
250
|
+
for (const rel of deleted) {
|
|
251
|
+
if (!safeRel(rel) || SKIP_DIR.test(rel))
|
|
252
|
+
continue;
|
|
253
|
+
try {
|
|
254
|
+
rmSync(under(dest, rel), { force: true });
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
ctx.skipped.push({ path: rel, why: 'could not remove it' });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
let copied = 0;
|
|
261
|
+
for (const rel of changed)
|
|
262
|
+
if (copyOne(ctx, rel))
|
|
263
|
+
copied++;
|
|
264
|
+
return copied;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Makes one run's private copy of `repo` in `dest` (which must not exist yet). The copy is made from the git root that holds `repo`;
|
|
268
|
+
* `repoInCopy` says where `repo` is inside it. A link anywhere in what is copied is refused by name.
|
|
269
|
+
*/
|
|
270
|
+
export async function makePrivateCopy(repo, dest, opts = {}) {
|
|
271
|
+
const log = opts.log ?? (() => { });
|
|
272
|
+
const root = (await gitRoot(repo)) ?? resolve(repo);
|
|
273
|
+
for (let attempt = 1;; attempt++) {
|
|
274
|
+
const ctx = { src: root, dest, skipped: [], tracked: new Set() };
|
|
275
|
+
const head = await headCommit(root);
|
|
276
|
+
let files = 0;
|
|
277
|
+
let mode;
|
|
278
|
+
if (head) {
|
|
279
|
+
mode = 'clone';
|
|
280
|
+
files = await cloneCopy(root, dest, head, ctx);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
284
|
+
const listed = await listFiles(root);
|
|
285
|
+
mode = listed ? 'listed' : 'plain';
|
|
286
|
+
if (listed) {
|
|
287
|
+
const cached = await git(root, ['ls-files', '--cached', '-z']);
|
|
288
|
+
if (cached.status === 0)
|
|
289
|
+
for (const f of cached.stdout.toString('utf8').split('\0').filter(Boolean))
|
|
290
|
+
ctx.tracked.add(f);
|
|
291
|
+
}
|
|
292
|
+
for (const rel of listed ?? plainFiles(root, opts.limits))
|
|
293
|
+
if (copyOne(ctx, rel))
|
|
294
|
+
files++;
|
|
295
|
+
}
|
|
296
|
+
await opts.afterCopy?.(attempt);
|
|
297
|
+
// Another commit made in the repo while it was being copied would leave a tree that is neither commit: it is copied again, once
|
|
298
|
+
if (head && attempt === 1 && (await headCommit(root)) !== head) {
|
|
299
|
+
log('The repo moved to another commit while it was being copied; copying again.');
|
|
300
|
+
rmSync(dest, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (ctx.skipped.length) {
|
|
304
|
+
log(`${ctx.skipped.length} file(s) were not copied (env files, keys, files over 5 MB).`);
|
|
305
|
+
// Names only, never content
|
|
306
|
+
for (const k of ctx.skipped)
|
|
307
|
+
log(` not copied: ${k.path.slice(0, 120)} (${k.why})`);
|
|
308
|
+
}
|
|
309
|
+
const rel = relative(realOf(root), realOf(repo));
|
|
310
|
+
return {
|
|
311
|
+
files,
|
|
312
|
+
skipped: ctx.skipped.length,
|
|
313
|
+
skippedList: ctx.skipped,
|
|
314
|
+
head,
|
|
315
|
+
sourceRoot: root,
|
|
316
|
+
repoInCopy: rel && !rel.startsWith('..') ? join(dest, rel) : dest,
|
|
317
|
+
mode
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|