@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,150 @@
|
|
|
1
|
+
// THE INSTALL OF A PRIVATE COPY: WHICH COMMAND, WHETHER IT MAY BE CACHED, AND HOW A PACKAGE MANAGER IS STARTED ON WINDOWS.
|
|
2
|
+
// The commands are constants: nothing in the repo can change them. They install exactly what the lock file says and, where the package manager
|
|
3
|
+
// allows it, with the package scripts switched off (a postinstall script would run a program the agent wrote).
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
9
|
+
// -- CONSTANTS -- //
|
|
10
|
+
/** The programs that are .cmd shims on Windows. */
|
|
11
|
+
const SHIMMED = /^(yarn|npm|pnpm|npx)$/;
|
|
12
|
+
/** The files an install depends on. */
|
|
13
|
+
const LOCK_INPUTS = [
|
|
14
|
+
'yarn.lock',
|
|
15
|
+
'package-lock.json',
|
|
16
|
+
'pnpm-lock.yaml',
|
|
17
|
+
'pnpm-workspace.yaml',
|
|
18
|
+
'.pnpmfile.cjs',
|
|
19
|
+
'.yarnrc.yml'
|
|
20
|
+
];
|
|
21
|
+
// -- FUNCTIONS -- //
|
|
22
|
+
const has = (dir, file) => existsSync(join(dir, file));
|
|
23
|
+
/** Which package manager the lock file in `dir` names, or null when there is none (a frozen install needs one) or no package.json. */
|
|
24
|
+
export function packageManagerOf(dir) {
|
|
25
|
+
if (!has(dir, 'package.json'))
|
|
26
|
+
return null;
|
|
27
|
+
if (has(dir, 'yarn.lock'))
|
|
28
|
+
return 'yarn';
|
|
29
|
+
if (has(dir, 'package-lock.json'))
|
|
30
|
+
return 'npm';
|
|
31
|
+
if (has(dir, 'pnpm-lock.yaml'))
|
|
32
|
+
return 'pnpm';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The frozen install command for `dir`, or null when it has no lock file. Yarn with a .yarnrc.yml is Yarn 4, whose own `--immutable` is the frozen
|
|
37
|
+
* form; the others get --ignore-scripts.
|
|
38
|
+
*/
|
|
39
|
+
export function installCommand(dir) {
|
|
40
|
+
switch (packageManagerOf(dir)) {
|
|
41
|
+
case 'yarn':
|
|
42
|
+
return has(dir, '.yarnrc.yml')
|
|
43
|
+
? ['yarn', 'install', '--immutable']
|
|
44
|
+
: ['yarn', 'install', '--frozen-lockfile', '--ignore-scripts'];
|
|
45
|
+
case 'npm':
|
|
46
|
+
return ['npm', 'ci', '--ignore-scripts'];
|
|
47
|
+
case 'pnpm':
|
|
48
|
+
return ['pnpm', 'install', '--frozen-lockfile', '--ignore-scripts'];
|
|
49
|
+
default:
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* True when the finished install of `dir` may be kept and copied into another folder. A pnpm install never is: pnpm writes the absolute path of its
|
|
55
|
+
* store into node_modules/.modules.yaml, and its links point into that store, so a copy in another folder is refused by the next pnpm run.
|
|
56
|
+
*/
|
|
57
|
+
export function installCacheable(dir) {
|
|
58
|
+
const manager = packageManagerOf(dir);
|
|
59
|
+
if (!manager || manager === 'pnpm')
|
|
60
|
+
return false;
|
|
61
|
+
// A node_modules that pnpm made is never cached whatever lock file sits beside it
|
|
62
|
+
if (has(dir, join('node_modules', '.modules.yaml')) || has(dir, '.modules.yaml'))
|
|
63
|
+
return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
/** A hash of the files an install depends on: what the installed dependencies depend on. */
|
|
67
|
+
export function lockHash(dir) {
|
|
68
|
+
const h = createHash('sha1');
|
|
69
|
+
for (const f of LOCK_INPUTS)
|
|
70
|
+
if (has(dir, f))
|
|
71
|
+
h.update(f).update(readFileSync(join(dir, f)));
|
|
72
|
+
return h.digest('hex');
|
|
73
|
+
}
|
|
74
|
+
/** The first file on PATH named `name` with one of the executable extensions, or null. Case-insensitive on Windows. */
|
|
75
|
+
export function findOnPath(name, pathEnv, exts, exists = existsSync) {
|
|
76
|
+
for (const dir of pathEnv.split(delimiter).filter(Boolean))
|
|
77
|
+
for (const ext of exts) {
|
|
78
|
+
const file = join(dir, name + ext);
|
|
79
|
+
if (exists(file))
|
|
80
|
+
return file;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* What to start for a command. On Windows yarn, npm and pnpm are usually .cmd shims, which a plain spawn cannot find and Node refuses to start
|
|
86
|
+
* directly (EINVAL): they go through `cmd.exe /d /s /c name.cmd`, unless a real .exe of that name is first on PATH. The arguments came from a command
|
|
87
|
+
* that was already checked (words, flags and a port, no shell characters), so cmd.exe has nothing to interpret. Elsewhere the argv is unchanged.
|
|
88
|
+
*/
|
|
89
|
+
export function programArgv(argv, opts = {}) {
|
|
90
|
+
const [file, ...args] = argv;
|
|
91
|
+
if (!file || (opts.platform ?? process.platform) !== 'win32' || !SHIMMED.test(file))
|
|
92
|
+
return argv;
|
|
93
|
+
const found = findOnPath(file, opts.pathEnv ?? '', ['.exe', '.cmd', '.bat'], opts.exists);
|
|
94
|
+
if (found && found.toLowerCase().endsWith('.exe'))
|
|
95
|
+
return [found, ...args];
|
|
96
|
+
return ['cmd.exe', '/d', '/s', '/c', `${file}.cmd`, ...args];
|
|
97
|
+
}
|
|
98
|
+
/** The PATH of an environment object, whatever its case (Windows calls it Path). */
|
|
99
|
+
export function pathOf(env) {
|
|
100
|
+
for (const k of Object.keys(env))
|
|
101
|
+
if (k.toLowerCase() === 'path')
|
|
102
|
+
return env[k] ?? '';
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The folder an install runs in: `appCwd` when it holds a lock file, else the nearest folder above it, up to `stopAt` (the root of the private copy), that does.
|
|
107
|
+
* A monorepo keeps one lock file at the top and the apps below it. Null when there is none, and the install is skipped.
|
|
108
|
+
*/
|
|
109
|
+
export function installDirFor(appCwd, stopAt) {
|
|
110
|
+
const top = resolve(stopAt);
|
|
111
|
+
let dir = resolve(appCwd);
|
|
112
|
+
for (let i = 0; i < 32; i++) {
|
|
113
|
+
if (packageManagerOf(dir))
|
|
114
|
+
return dir;
|
|
115
|
+
if (dir === top)
|
|
116
|
+
return null;
|
|
117
|
+
const up = dirname(dir);
|
|
118
|
+
if (up === dir)
|
|
119
|
+
return null;
|
|
120
|
+
// Never above the copy
|
|
121
|
+
const rel = relative(top, up);
|
|
122
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
123
|
+
return null;
|
|
124
|
+
dir = up;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
/** How a version is asked for. A test replaces it. */
|
|
129
|
+
export const versionIo = {
|
|
130
|
+
run: (argv, opts) => new Promise((resolveRun) => {
|
|
131
|
+
const [file, ...args] = programArgv(argv, { pathEnv: pathOf(opts.env) });
|
|
132
|
+
if (!file)
|
|
133
|
+
return resolveRun('');
|
|
134
|
+
execFile(file, args, { cwd: opts.cwd, env: opts.env, windowsHide: true, timeout: 30_000 }, (err, stdout) => resolveRun(err ? '' : (String(stdout).trim().split(/\r?\n/)[0] ?? '')));
|
|
135
|
+
})
|
|
136
|
+
};
|
|
137
|
+
const versions = new Map();
|
|
138
|
+
/** The version of a package manager as `<pm> --version` prints it, asked once per process. An empty string when it cannot be asked (the cache key then differs from every real version). */
|
|
139
|
+
export async function packageManagerVersion(pm, cwd, env = process.env) {
|
|
140
|
+
const known = versions.get(pm);
|
|
141
|
+
if (known !== undefined)
|
|
142
|
+
return known;
|
|
143
|
+
const version = (await versionIo.run([pm, '--version'], { cwd, env })).slice(0, 40);
|
|
144
|
+
// A failed ask is not remembered: the next run asks again
|
|
145
|
+
if (version)
|
|
146
|
+
versions.set(pm, version);
|
|
147
|
+
return version;
|
|
148
|
+
}
|
|
149
|
+
/** Forgets the versions asked so far (tests). */
|
|
150
|
+
export const forgetPackageManagerVersions = () => versions.clear();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// LIVE-SITE REGISTRATIONS: A REGISTRATION WITH A URL AND NO REPO. NOTHING IS BUILT OR STARTED; THE RUN IS UNVERIFIED AND NEVER WRITES.
|
|
2
|
+
// This file only judges the settings (url and extraHosts) and hands the guard its options. Writes are never allowed on a live site: a check
|
|
3
|
+
// there drives pages and reads, and the guard stops every POST, PUT, PATCH and DELETE.
|
|
4
|
+
// -- MODULES -- //
|
|
5
|
+
import { FORBIDDEN_PORTS } from './guardrails.js';
|
|
6
|
+
// -- CONSTANTS -- //
|
|
7
|
+
const MAX_EXTRA_HOSTS = 16;
|
|
8
|
+
/** A plain DNS name: dot separated labels of letters, digits and dashes. No wildcard, port, path or underscore. */
|
|
9
|
+
const HOST_NAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
10
|
+
const LOCAL_HOSTS = new Set([
|
|
11
|
+
'localhost',
|
|
12
|
+
'127.0.0.1',
|
|
13
|
+
'[::1]',
|
|
14
|
+
'::1',
|
|
15
|
+
'0.0.0.0',
|
|
16
|
+
'host.docker.internal'
|
|
17
|
+
]);
|
|
18
|
+
// -- FUNCTIONS -- //
|
|
19
|
+
const isLoopbackName = (h) => LOCAL_HOSTS.has(h) || h.endsWith('.localhost') || /^127\./.test(h) || h === '[::]';
|
|
20
|
+
/** True for a dotted IPv4 address (also the short and number forms a browser accepts) or anything holding a colon (IPv6). */
|
|
21
|
+
const isIpLiteral = (h) => h.includes(':') || h.startsWith('[') || /^[0-9.]+$/.test(h) || /^0x[0-9a-f]+$/i.test(h);
|
|
22
|
+
/** One plain sentence per problem with the live-site settings. An empty list means they are acceptable. */
|
|
23
|
+
export function liveSiteProblems(cfg) {
|
|
24
|
+
const problems = [];
|
|
25
|
+
if (cfg.url !== undefined) {
|
|
26
|
+
let u = null;
|
|
27
|
+
try {
|
|
28
|
+
u = new URL(String(cfg.url));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
problems.push('The site address is not a web address.');
|
|
32
|
+
}
|
|
33
|
+
if (u) {
|
|
34
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
35
|
+
problems.push('The site address must start with http:// or https://.');
|
|
36
|
+
else {
|
|
37
|
+
if (u.username || u.password)
|
|
38
|
+
problems.push('The site address may not hold a user name or password.');
|
|
39
|
+
const host = u.hostname.toLowerCase();
|
|
40
|
+
if (isLoopbackName(host)) {
|
|
41
|
+
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
|
42
|
+
if (FORBIDDEN_PORTS.has(port))
|
|
43
|
+
problems.push(`The site address uses port ${port}, which a check never uses.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const extra = cfg.extraHosts ?? [];
|
|
49
|
+
if (!Array.isArray(extra))
|
|
50
|
+
return [...problems, 'The extra hosts must be a list of host names.'];
|
|
51
|
+
if (extra.length > MAX_EXTRA_HOSTS)
|
|
52
|
+
problems.push(`At most ${MAX_EXTRA_HOSTS} extra hosts are allowed.`);
|
|
53
|
+
for (const raw of extra) {
|
|
54
|
+
const h = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
55
|
+
const shown = typeof raw === 'string' ? raw.slice(0, 60) : String(typeof raw);
|
|
56
|
+
if (!h || (!HOST_NAME.test(h) && !isIpLiteral(h)))
|
|
57
|
+
problems.push(`The extra host "${shown}" is not an exact host name (no wildcard, port, path or address).`);
|
|
58
|
+
else if (isIpLiteral(h))
|
|
59
|
+
problems.push(`The extra host "${shown}" is an IP address. Only host names are allowed.`);
|
|
60
|
+
else if (isLoopbackName(h))
|
|
61
|
+
problems.push(`The extra host "${shown}" is this machine, which is never an extra host.`);
|
|
62
|
+
}
|
|
63
|
+
return problems;
|
|
64
|
+
}
|
|
65
|
+
/** What the child's guard is given for a live site: writes never allowed, and only the exact extra hosts reachable besides the site. */
|
|
66
|
+
export function liveSiteGuardOptions(cfg) {
|
|
67
|
+
const bad = liveSiteProblems(cfg);
|
|
68
|
+
if (bad.length)
|
|
69
|
+
throw new Error(bad[0]);
|
|
70
|
+
return {
|
|
71
|
+
allowWrites: false,
|
|
72
|
+
extraHosts: [...new Set((cfg.extraHosts ?? []).map((h) => h.trim().toLowerCase()))]
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** True when the settings describe a live site (a url and no repo to build). */
|
|
76
|
+
export const isLiveSite = (cfg) => typeof cfg.url === 'string' && cfg.url !== '';
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// FILE LOCKS FOR THE CHECK COMMANDS: A SHORT EXCLUSIVE LOCK (THE AUDIT CHAIN) AND THE ONE-WORKER-PER-MACHINE LOCK OF `check watch`.
|
|
2
|
+
// Two lessons are built in. A lock file that is created empty and written afterwards looks stale to a second process, so the file is written whole into a
|
|
3
|
+
// temporary file and linked into place in one step. A stale lock that is unlinked and then created again lets two contenders in, so a stale lock is RENAMED
|
|
4
|
+
// aside, what was moved is compared with the stale one that was seen, and a fresh lock that was moved by mistake is put back.
|
|
5
|
+
// -- DEP-MODULES -- //
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
// -- MODULES -- //
|
|
10
|
+
import { renameRetry } from './fsretry.js';
|
|
11
|
+
import { assertName, ensureDir, locksDir } from './paths.js';
|
|
12
|
+
// -- CONSTANTS -- //
|
|
13
|
+
/** A lock whose content cannot be read is only treated as stale after this long (it is created whole, so this means a damaged file). */
|
|
14
|
+
const UNREADABLE_GRACE_MS = 2_000;
|
|
15
|
+
/** For a lock that only a dead process may lose. */
|
|
16
|
+
export const NEVER_STALE_MS = Number.MAX_SAFE_INTEGER;
|
|
17
|
+
/**
|
|
18
|
+
* The worker lock is lost by a dead process, and also by a heartbeat that stopped: a crashed worker whose process id was handed to another program would otherwise lock
|
|
19
|
+
* out `check watch` for ever. The worker touches its lock every few seconds (watch.ts), so this is long enough to survive a busy machine.
|
|
20
|
+
*/
|
|
21
|
+
export const WORKER_STALE_MS = 3 * 60_000;
|
|
22
|
+
// -- FUNCTIONS -- //
|
|
23
|
+
/** True when a process with this id exists (it may belong to another user, which still counts as alive). */
|
|
24
|
+
export function pidAlive(pid) {
|
|
25
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
return e.code === 'EPERM';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const lockPath = (name) => join(locksDir(), `${assertName(name, 'lock name')}.lock`);
|
|
36
|
+
function parseBody(text) {
|
|
37
|
+
try {
|
|
38
|
+
const v = JSON.parse(text);
|
|
39
|
+
if (!v || typeof v !== 'object')
|
|
40
|
+
return null;
|
|
41
|
+
const b = v;
|
|
42
|
+
if (typeof b.pid !== 'number' || typeof b.at !== 'string' || typeof b.token !== 'string')
|
|
43
|
+
return null;
|
|
44
|
+
return { pid: b.pid, at: b.at, token: b.token };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function readText(file) {
|
|
51
|
+
try {
|
|
52
|
+
return readFileSync(file, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function isStale(text, file, staleMs) {
|
|
59
|
+
const body = text === null ? null : parseBody(text);
|
|
60
|
+
if (body)
|
|
61
|
+
return !pidAlive(body.pid) || Date.now() - Date.parse(body.at) > staleMs;
|
|
62
|
+
// Unreadable or damaged: given a moment, then stale
|
|
63
|
+
try {
|
|
64
|
+
return Date.now() - statSync(file).mtimeMs > UNREADABLE_GRACE_MS;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Creates the lock file with its whole content at once. False when somebody holds it. */
|
|
71
|
+
function tryCreate(file, text) {
|
|
72
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
73
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
74
|
+
try {
|
|
75
|
+
linkSync(tmp, file);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (e.code === 'EEXIST')
|
|
80
|
+
return false;
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
rmSync(tmp, { force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Moves the lock file aside under a name of its own. Returns what was moved, or null when somebody else moved it first. When what was moved is not
|
|
89
|
+
* `expected` (somebody made a new lock between the look and the move), it is linked back.
|
|
90
|
+
*/
|
|
91
|
+
function moveAside(file, expected, keep) {
|
|
92
|
+
const aside = `${file}.aside.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
93
|
+
try {
|
|
94
|
+
renameSync(file, aside);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const moved = readText(aside);
|
|
100
|
+
if (moved !== null && moved !== expected && keep(moved)) {
|
|
101
|
+
try {
|
|
102
|
+
linkSync(aside, file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* A newer lock is already there: the one that was moved is dropped */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
rmSync(aside, { force: true });
|
|
109
|
+
return moved ?? '';
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Takes the lock `name` without waiting. Returns null when a live holder has it. A lock whose process is gone, or that is older than `staleMs`,
|
|
113
|
+
* is taken over.
|
|
114
|
+
*/
|
|
115
|
+
export function acquireLock(name, { staleMs }) {
|
|
116
|
+
const file = lockPath(name);
|
|
117
|
+
ensureDir(locksDir());
|
|
118
|
+
const token = randomBytes(8).toString('hex');
|
|
119
|
+
const mine = JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }) +
|
|
120
|
+
'\n';
|
|
121
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
122
|
+
if (tryCreate(file, mine))
|
|
123
|
+
return makeLock(file, token);
|
|
124
|
+
const held = readText(file);
|
|
125
|
+
if (held === null && !statExists(file))
|
|
126
|
+
continue; // Released between the two looks
|
|
127
|
+
if (!isStale(held, file, staleMs))
|
|
128
|
+
return null;
|
|
129
|
+
// Stale: moved aside in one step, so of two contenders only one moves it. A fresh lock that was moved by mistake goes back
|
|
130
|
+
moveAside(file, held, (moved) => !isStale(moved, file, staleMs));
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const statExists = (file) => {
|
|
135
|
+
try {
|
|
136
|
+
statSync(file);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
function makeLock(file, token) {
|
|
144
|
+
let released = false;
|
|
145
|
+
return {
|
|
146
|
+
file,
|
|
147
|
+
touch() {
|
|
148
|
+
if (released)
|
|
149
|
+
return;
|
|
150
|
+
const text = readText(file);
|
|
151
|
+
const body = text === null ? null : parseBody(text);
|
|
152
|
+
if (!body || body.token !== token)
|
|
153
|
+
return;
|
|
154
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
155
|
+
try {
|
|
156
|
+
const next = { ...body, at: new Date().toISOString() };
|
|
157
|
+
writeFileSync(tmp, JSON.stringify(next) + '\n', { mode: 0o600 });
|
|
158
|
+
renameRetry(tmp, file);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// The next touch tries again
|
|
162
|
+
rmSync(tmp, { force: true });
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
release() {
|
|
166
|
+
if (released)
|
|
167
|
+
return;
|
|
168
|
+
released = true;
|
|
169
|
+
// Moved aside first, so a lock that was taken over from us in the meantime is never removed by mistake
|
|
170
|
+
const text = readText(file);
|
|
171
|
+
if (text === null || parseBody(text)?.token !== token)
|
|
172
|
+
return;
|
|
173
|
+
moveAside(file, text, () => true);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
178
|
+
/** Runs `fn` while holding the lock, waiting up to `waitMs` for another holder. Throws one plain sentence when it could not get the lock. */
|
|
179
|
+
export function withLockSync(name, fn, { staleMs = 15_000, waitMs = 20_000 } = {}) {
|
|
180
|
+
const until = Date.now() + waitMs;
|
|
181
|
+
let lock = acquireLock(name, { staleMs });
|
|
182
|
+
while (!lock) {
|
|
183
|
+
if (Date.now() > until)
|
|
184
|
+
throw new Error(`Another process kept the "${name}" lock for more than ${Math.round(waitMs / 1000)} seconds.`);
|
|
185
|
+
nap(15);
|
|
186
|
+
lock = acquireLock(name, { staleMs });
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return fn();
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
lock.release();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** The process id that holds a lock, or null when nobody does. For the plain sentence of a refused second worker. */
|
|
196
|
+
export function lockHolderPid(name) {
|
|
197
|
+
const text = readText(lockPath(name));
|
|
198
|
+
const body = text === null ? null : parseBody(text);
|
|
199
|
+
return body && pidAlive(body.pid) ? body.pid : null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The one-worker-per-machine lock of `check watch`. A dead process loses it, and so does one whose heartbeat stopped for three minutes (a reused process id).
|
|
203
|
+
* The worker calls `touch()` on its lock regularly. Returns null when another worker holds it; the caller says so in one plain line.
|
|
204
|
+
*/
|
|
205
|
+
export function machineLock(name) {
|
|
206
|
+
return acquireLock(`machine-${name}`, { staleMs: WORKER_STALE_MS });
|
|
207
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// `check login <NAME> --org <slug> [--seeded]`: SAVING A SIGN-IN FOR A REGISTRATION'S SITE, AND THE RULES FOR USING ONE
|
|
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: that needs an interactive terminal, and is refused without one.
|
|
4
|
+
// This file is the ONE place in the bridge that reads a terminal, and only in waitForEnter, only after the TTY check.
|
|
5
|
+
// Nothing here starts a preview itself: the caller injects `target`, and the sign in function is injected too, so tests launch no browser.
|
|
6
|
+
// A cookie, a password or a token is never printed.
|
|
7
|
+
// -- DEP-MODULES -- //
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
// -- MODULES -- //
|
|
12
|
+
import { loadPlaywright } from './browser.js';
|
|
13
|
+
import { ORG_SLUG, registrationsDir } from './paths.js';
|
|
14
|
+
import { expiryNote, loadProfileChecked, saveProfile } from './profiles.js';
|
|
15
|
+
import { redact } from './redact.js';
|
|
16
|
+
import { signInWithPassword } from './signin.js';
|
|
17
|
+
// -- CONSTANTS -- //
|
|
18
|
+
const NAME = /^[A-Za-z0-9_-]{1,32}$/;
|
|
19
|
+
// -- FUNCTIONS -- //
|
|
20
|
+
/** The variable names of a registration: AFB_CHECKS_<NAME upper, dash to underscore>_EMAIL and _PASSWORD. */
|
|
21
|
+
export function envNames(name) {
|
|
22
|
+
const n = String(name).toUpperCase().replace(/-/g, '_');
|
|
23
|
+
return { email: `AFB_CHECKS_${n}_EMAIL`, password: `AFB_CHECKS_${n}_PASSWORD` };
|
|
24
|
+
}
|
|
25
|
+
/** The seeded account of a registration from the environment, or null when either variable is missing or empty. */
|
|
26
|
+
export function seededAccount(name, env = process.env) {
|
|
27
|
+
const n = envNames(name);
|
|
28
|
+
const email = env[n.email];
|
|
29
|
+
const password = env[n.password];
|
|
30
|
+
return email && password ? { email, password } : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether a run uses a saved sign-in. Only when the script's header (or the check) names the very registration the run uses: a run that was
|
|
34
|
+
* routed there by a fallback, or a script that names another registration, opens signed out. Names compare without regard to case.
|
|
35
|
+
*/
|
|
36
|
+
export function chooseState(input) {
|
|
37
|
+
const { registration, scriptRegistration, profile } = input;
|
|
38
|
+
if (!profile || !scriptRegistration || !registration)
|
|
39
|
+
return 'none';
|
|
40
|
+
return scriptRegistration.toLowerCase() === registration.toLowerCase() ? 'saved' : 'none';
|
|
41
|
+
}
|
|
42
|
+
/** Pictures of a signed-in run are uploaded only when the owner opted in on the registration. A signed-out run uploads as usual. */
|
|
43
|
+
export function pictureUploadAllowed(registration, signedIn) {
|
|
44
|
+
return !signedIn || registration.uploadSignedInPictures === true;
|
|
45
|
+
}
|
|
46
|
+
/** The saved sign-in a run should open with, with the expiry note, or null. Applies chooseState. */
|
|
47
|
+
export function savedStateFor(input) {
|
|
48
|
+
const { profile, damaged } = loadProfileChecked(input.org, input.registration, input.bindKey);
|
|
49
|
+
// A saved sign-in that cannot be opened says so: the run would otherwise go signed out and fail on a sign-in page with no explanation
|
|
50
|
+
if (damaged)
|
|
51
|
+
return {
|
|
52
|
+
profile: null,
|
|
53
|
+
note: 'The saved sign-in is damaged, make it again (agent-flow-board check login).'
|
|
54
|
+
};
|
|
55
|
+
if (chooseState({ ...input, profile }) !== 'saved' || !profile)
|
|
56
|
+
return null;
|
|
57
|
+
return { profile, note: expiryNote(profile) };
|
|
58
|
+
}
|
|
59
|
+
/** Cookies and origins that belong to the site's hosts, nothing else (no other site's sign-in is saved). */
|
|
60
|
+
export function keepRelated(state, hosts) {
|
|
61
|
+
const s = state;
|
|
62
|
+
const bare = (d) => d.replace(/^\./, '').toLowerCase();
|
|
63
|
+
const related = (d) => hosts.some((h) => {
|
|
64
|
+
const a = bare(d);
|
|
65
|
+
const b = h.toLowerCase();
|
|
66
|
+
return a === b || b.endsWith(`.${a}`) || a.endsWith(`.${b}`);
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
cookies: (s?.cookies ?? []).filter((c) => related(String(c.domain ?? ''))),
|
|
70
|
+
origins: (s?.origins ?? []).filter((o) => {
|
|
71
|
+
try {
|
|
72
|
+
return related(new URL(String(o.origin)).hostname);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** The one terminal read of the bridge: waits for the person to press Enter. Used only by the headed login, after the TTY check. */
|
|
81
|
+
function waitForEnter(question) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
84
|
+
rl.question(question, () => {
|
|
85
|
+
rl.close();
|
|
86
|
+
resolve();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** The headed login: a visible browser the person drives, then Enter. Only cookies and origins of the site are kept. */
|
|
91
|
+
async function headedLogin(target, say) {
|
|
92
|
+
const { chromium } = await loadPlaywright();
|
|
93
|
+
const browser = await chromium.launch({ headless: false });
|
|
94
|
+
try {
|
|
95
|
+
const ctx = await browser.newContext({ baseURL: target.baseUrl, viewport: null });
|
|
96
|
+
const page = await ctx.newPage();
|
|
97
|
+
await page.goto(target.baseUrl);
|
|
98
|
+
say(`A browser window is open on ${target.baseUrl}. Sign in there the way you normally do (a password or Google).`);
|
|
99
|
+
say('When you can see you are signed in, come back here and press Enter. Do not close the window first.');
|
|
100
|
+
await waitForEnter('Press Enter when signed in... ');
|
|
101
|
+
const hosts = [new URL(target.baseUrl).hostname, ...(target.hosts ?? [])];
|
|
102
|
+
return keepRelated(await ctx.storageState(), hosts);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await browser.close().catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function loadRegistrationFile(org, name) {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(readFileSync(join(registrationsDir(), org, `${name}.json`), 'utf8'));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** The default target: only a live site needs no preview. A repo registration needs the caller to start its preview. */
|
|
117
|
+
async function defaultTarget(registration) {
|
|
118
|
+
if (!registration.url)
|
|
119
|
+
throw new Error(`${registration.name} is built from a repo, so its sign-in needs a preview. Start one through the run command.`);
|
|
120
|
+
return {
|
|
121
|
+
baseUrl: registration.url,
|
|
122
|
+
hosts: registration.extraHosts ?? [],
|
|
123
|
+
stop: async () => undefined
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function flagValue(args, flag) {
|
|
127
|
+
const i = args.indexOf(flag);
|
|
128
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
129
|
+
}
|
|
130
|
+
/** `check login <NAME> --org <slug> [--seeded]`. Exit code 0 saved, 1 a problem with what was asked, 2 could not run. */
|
|
131
|
+
export async function loginCommand(args, ctx) {
|
|
132
|
+
const deps = {
|
|
133
|
+
signIn: (baseUrl, account) => signInWithPassword(baseUrl, account),
|
|
134
|
+
headed: headedLogin,
|
|
135
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
136
|
+
env: process.env,
|
|
137
|
+
loadRegistration: loadRegistrationFile,
|
|
138
|
+
target: defaultTarget,
|
|
139
|
+
save: saveProfile,
|
|
140
|
+
...ctx.deps
|
|
141
|
+
};
|
|
142
|
+
const say = (line) => ctx.log(line);
|
|
143
|
+
const flags = new Set(['--org']);
|
|
144
|
+
const positional = args.filter((a, i) => !a.startsWith('--') && !flags.has(args[i - 1] ?? ''));
|
|
145
|
+
const name = positional[0];
|
|
146
|
+
const org = flagValue(args, '--org');
|
|
147
|
+
const seeded = args.includes('--seeded');
|
|
148
|
+
if (!name || !org) {
|
|
149
|
+
say('Usage: check login <NAME> --org <slug> [--seeded]');
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
if (!NAME.test(name) || !ORG_SLUG.test(org)) {
|
|
153
|
+
say('The registration name or the organisation is not a plain name.');
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
// A headed login waits for a person at a screen: without a terminal it would wait for ever, so it stops at once
|
|
157
|
+
if (!seeded && !deps.isTTY) {
|
|
158
|
+
say('Signing in by hand needs a person at a terminal (it opens a browser window and waits), so it is refused here. Use --seeded with AFB_CHECKS_<NAME>_EMAIL and AFB_CHECKS_<NAME>_PASSWORD set, or a registration whose recipe has a test user.');
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
const registration = deps.loadRegistration(org, name);
|
|
162
|
+
if (!registration || registration.org !== org) {
|
|
163
|
+
say(`No registration "${name}" in ${org}.`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
let target = null;
|
|
167
|
+
const secrets = [];
|
|
168
|
+
try {
|
|
169
|
+
target = await deps.target(registration);
|
|
170
|
+
const bindKey = new URL(target.baseUrl).origin;
|
|
171
|
+
let state;
|
|
172
|
+
if (seeded) {
|
|
173
|
+
const account = seededAccount(name, deps.env) ?? target.testUser ?? null;
|
|
174
|
+
if (!account) {
|
|
175
|
+
const n = envNames(name);
|
|
176
|
+
say(`No account to sign in with. Set ${n.email} and ${n.password}, or give the recipe a test user.`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
secrets.push(account.password);
|
|
180
|
+
state = await deps.signIn(target.baseUrl, account);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
state = await deps.headed(target, say);
|
|
184
|
+
}
|
|
185
|
+
const kept = state;
|
|
186
|
+
if (!kept || (!(kept.cookies?.length ?? 0) && !(kept.origins?.length ?? 0))) {
|
|
187
|
+
say('Nothing was saved because the browser holds no sign-in for this address.');
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
deps.save(org, name, bindKey, state);
|
|
191
|
+
say(`Saved the sign-in for ${org}/${name} on ${bindKey}. It stays on this machine, sealed.`);
|
|
192
|
+
if (ctx.json)
|
|
193
|
+
ctx.out(JSON.stringify({ saved: true, registration: `${org}/${name}`, site: bindKey }));
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
say(redact(e instanceof Error ? e.message : String(e), secrets));
|
|
198
|
+
return 2;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await target?.stop().catch(() => undefined);
|
|
202
|
+
}
|
|
203
|
+
}
|