@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
package/README.md
CHANGED
|
@@ -32,8 +32,24 @@ Needs Node 20 or later. Run it with no arguments to see its usage; the list shou
|
|
|
32
32
|
| `agent-flow-board check watch [--parallel N] [--once]` | Takes runs started from the board's Run button, for folders where `check run` was used before. |
|
|
33
33
|
| `agent-flow-board check status`, `check doctor --fix`, `check clear`, `check setup` | Reads and repairs the state, removes old runs, installs the browser. All take `--json`. |
|
|
34
34
|
|
|
35
|
+
More check commands:
|
|
36
|
+
|
|
37
|
+
| Command | What it does |
|
|
38
|
+
| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| `agent-flow-board check project <NAME> --org <slug> --repo <dir> [--app-dir web]` | Registers a repo folder (and an optional app folder) for a board project, or a live site with `--url`. A repo must be inside a folder the owner approved. |
|
|
40
|
+
| `agent-flow-board check approve --action <roots\|register\|secrets\|adopt-env\|pictures>` | The owner, on a terminal, gets a single-use code (10 minutes). Agents cannot make one. |
|
|
41
|
+
| `agent-flow-board check roots add\|list\|remove` | The owner-approved parent folders a registration may live in (at most 5). |
|
|
42
|
+
| `agent-flow-board check secrets import\|list\|forget` | Seals test Supabase and mail values per registration (owner's code needed). Names only are ever printed. |
|
|
43
|
+
| `agent-flow-board check login <NAME> --org <slug> --seeded` | Saves a sign in for a registration from `AFB_CHECKS_<NAME>_EMAIL` and `_PASSWORD`. |
|
|
44
|
+
| `agent-flow-board check service install\|status\|uninstall` | Windows: starts the worker when you sign in. |
|
|
45
|
+
| `agent-flow-board check import-legacy --from <afb-checks folder> --org <slug>` | Read only: re-registers the old tool's projects and copies its check scripts. |
|
|
46
|
+
|
|
47
|
+
Runs share the machine: a slot set, one browser per host set, one database container with a clone per run, and kept dependencies and builds.
|
|
48
|
+
|
|
35
49
|
Check scripts and run state live in `~/.agent-flow-board/checks/`, never in a repo. Playwright is not installed with the bridge: the first run installs it there.
|
|
36
50
|
|
|
51
|
+
The sandboxed check can write its own run folder only. What the tool must be able to trust lives outside it: the live and end markers of a run (`checks/markers/`, checked against the names the tool derives for that run before any sweep acts on them) and the install, build, preview and mail-guard logs (`checks/logs/<run id>/`, which can hold values the app printed). Pictures and `result.json` are read from the run folder only when they are plain files (never a link), small enough, and the right kind of file. Changing the folder, the app folder, the site or the hosts of a registration that keeps an adopted `.env` or saved state needs the owner's `register` code (for a live site the code names `NAME@host`), and adopting a `.env` for another folder needs its own `adopt-env` code. A run started by hand stops after 40 minutes (`AFB_CHECKS_RUN_LIMIT_MS` changes it). Warm previews are not enabled in this version: `"warm": true` in a recipe is refused.
|
|
52
|
+
|
|
37
53
|
The easy way to set everything up is the **Editor status** tab on the board's **Connect a tool** page: copy its prompt into Claude Code and the agent does the steps, asking you once before it writes anything.
|
|
38
54
|
|
|
39
55
|
## What it writes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// THE APP FOLDER OF A PROJECT. Some repos keep the app in a subfolder (the git root has no package.json, the app and its lockfile live in web/).
|
|
2
|
+
// A registration may name that folder with `appDir`; the private copy is still made from the git root, but install, build and start run in the
|
|
3
|
+
// app folder, and the files that decide what runs (package.json, lockfile, recipe) are read from there. Without appDir the app folder is the repo root.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
6
|
+
import { isAbsolute, join } from 'node:path';
|
|
7
|
+
// -- MODULES -- //
|
|
8
|
+
import { isInside } from './paths.js';
|
|
9
|
+
// -- CONSTANTS -- //
|
|
10
|
+
const SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,60}$/;
|
|
11
|
+
const BARRED = new Set(['.git', 'node_modules']);
|
|
12
|
+
/** Names Windows keeps for devices, with or without an extension. */
|
|
13
|
+
const DEVICE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
|
14
|
+
// -- FUNCTIONS -- //
|
|
15
|
+
/** The cosmetic forms (web\, ./web, web/) become one value: web. Anything else is returned as it is, for appDirText to refuse. */
|
|
16
|
+
export function normalizeAppDir(raw) {
|
|
17
|
+
let s = String(raw ?? '')
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/\\/g, '/');
|
|
20
|
+
while (s.startsWith('./'))
|
|
21
|
+
s = s.slice(2);
|
|
22
|
+
s = s.replace(/\/+$/, '');
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
/** One plain sentence when `appDir` is not a plain relative folder name (or a few of them), else null. Looks at the text only. */
|
|
26
|
+
export function appDirText(appDir) {
|
|
27
|
+
if (typeof appDir !== 'string' || !appDir)
|
|
28
|
+
return 'appDir must be a folder name inside the repo, for example web.';
|
|
29
|
+
if (normalizeAppDir(appDir) !== appDir)
|
|
30
|
+
return `appDir "${appDir}" must be written as plain folder names with forward slashes and no trailing slash, for example web.`;
|
|
31
|
+
if (isAbsolute(appDir) || /^[A-Za-z]:/.test(appDir) || appDir.startsWith('/'))
|
|
32
|
+
return 'appDir must be inside the repo, not an absolute path.';
|
|
33
|
+
const segments = appDir.split('/');
|
|
34
|
+
if (segments.length > 4)
|
|
35
|
+
return 'appDir may name at most 4 nested folders.';
|
|
36
|
+
for (const s of segments) {
|
|
37
|
+
if (s === '.' || s === '..')
|
|
38
|
+
return 'appDir may not contain . or .. .';
|
|
39
|
+
if (!SEGMENT.test(s))
|
|
40
|
+
return `appDir has a folder name (${s.slice(0, 40)}) with characters a plain folder name does not use.`;
|
|
41
|
+
if (BARRED.has(s.toLowerCase()))
|
|
42
|
+
return `appDir may not go through ${s}.`;
|
|
43
|
+
// Windows drops a trailing dot, so web. would be read as web while a git path web./src matches nothing
|
|
44
|
+
if (s.endsWith('.') || DEVICE.test(s))
|
|
45
|
+
return `appDir has a folder name (${s.slice(0, 40)}) that ends with a dot or is a device name.`;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Walks appDir folder by folder from `base`, without following a link: a link, a junction, a file in its place or a folder that leaves `base` is a
|
|
51
|
+
* problem. Used on the live repo when a registration is made and again on the private copy of every run.
|
|
52
|
+
*/
|
|
53
|
+
export function walkAppDir(base, appDir, what = 'the repo') {
|
|
54
|
+
const text = appDirText(appDir);
|
|
55
|
+
if (text)
|
|
56
|
+
return { problem: text };
|
|
57
|
+
let cur = base;
|
|
58
|
+
for (const seg of appDir.split('/')) {
|
|
59
|
+
cur = join(cur, seg);
|
|
60
|
+
let st;
|
|
61
|
+
try {
|
|
62
|
+
st = lstatSync(cur);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return {
|
|
66
|
+
problem: `The app folder ${appDir} is not in ${what}${what === 'the repo' ? '' : ' (is it committed?)'}.`
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (st.isSymbolicLink())
|
|
70
|
+
return {
|
|
71
|
+
problem: `The app folder ${appDir} goes through a link (${seg}), which an app folder may not do.`
|
|
72
|
+
};
|
|
73
|
+
if (!st.isDirectory())
|
|
74
|
+
return { problem: `${appDir} is not a folder in ${what}.` };
|
|
75
|
+
}
|
|
76
|
+
if (!isInside(base, cur))
|
|
77
|
+
return { problem: `The app folder ${appDir} leaves ${what}.` };
|
|
78
|
+
return { dir: cur };
|
|
79
|
+
}
|
|
80
|
+
/** The app folder of a config in the live repo: repo/appDir, or the repo itself. The text is not checked here. */
|
|
81
|
+
export function appRoot(cfg) {
|
|
82
|
+
return cfg.appDir ? join(cfg.repo, ...cfg.appDir.split('/')) : cfg.repo;
|
|
83
|
+
}
|
|
84
|
+
/** The app folder inside a run's private copy (`root` is the copy of the folder that holds the app folder), or an error that stops the run. */
|
|
85
|
+
export function sandboxAppRoot(root, appDir) {
|
|
86
|
+
if (!appDir)
|
|
87
|
+
return root;
|
|
88
|
+
const r = walkAppDir(root, appDir, 'the private copy of the repo');
|
|
89
|
+
if (r.problem !== undefined)
|
|
90
|
+
throw new Error(r.problem);
|
|
91
|
+
if (!existsSync(join(r.dir, 'package.json')))
|
|
92
|
+
throw new Error(`The app folder ${appDir} has no package.json in the private copy of the repo (a submodule, or not committed?).`);
|
|
93
|
+
return r.dir;
|
|
94
|
+
}
|
|
95
|
+
/** A problem with an app folder of a live repo: the text, the walk and the package.json. Null when it is fine. */
|
|
96
|
+
export function appDirProblem(repo, appDir) {
|
|
97
|
+
const r = walkAppDir(repo, appDir);
|
|
98
|
+
if (r.problem !== undefined)
|
|
99
|
+
return r.problem;
|
|
100
|
+
if (!existsSync(join(r.dir, 'package.json')))
|
|
101
|
+
return `The app folder ${appDir} has no package.json.`;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/** The pathspecs of the app files relative to the git root: appPaths are relative to the app folder, so they are prefixed with it. */
|
|
105
|
+
export const gitAppPaths = (appPaths, appDir) => appDir ? appPaths.map((p) => (p === '.' ? appDir : `${appDir}/${p}`)) : appPaths;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// THE OWNER APPROVAL CHANNEL. An agent can ask for things the owner must decide (register a live site, adopt a repo .env, widen the roots, upload pictures of a
|
|
2
|
+
// signed-in run, import secrets). The owner runs `check approve --action <x> --target <name>` on an interactive terminal and gets a single-use code that is valid for
|
|
3
|
+
// 10 minutes for that one action and target. The code is stored hashed (HMAC with a key in the bridge home), so the file never holds a usable code. An agent cannot
|
|
4
|
+
// make a code: minting needs a terminal. This is tamper-evident, not tamper-proof: a process that runs as the same user can read the key.
|
|
5
|
+
// -- DEP-MODULES -- //
|
|
6
|
+
import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { existsSync, linkSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
// -- MODULES -- //
|
|
10
|
+
import { appendAudit } from './audit.js';
|
|
11
|
+
import { withLockSync } from './lock.js';
|
|
12
|
+
import { approvalsFile, checksHome, ensureDir } from './paths.js';
|
|
13
|
+
import { renameRetry } from './fsretry.js';
|
|
14
|
+
// -- CONSTANTS -- //
|
|
15
|
+
export const APPROVAL_ACTIONS = [
|
|
16
|
+
'register',
|
|
17
|
+
'secrets',
|
|
18
|
+
'adopt-env',
|
|
19
|
+
'roots',
|
|
20
|
+
'pictures'
|
|
21
|
+
];
|
|
22
|
+
/** A code is valid for ten minutes. */
|
|
23
|
+
export const APPROVAL_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
const MAX_CODES = 20;
|
|
25
|
+
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
26
|
+
const CODE_LENGTH = 12;
|
|
27
|
+
// -- FUNCTIONS -- //
|
|
28
|
+
/** A refusal the owner or the agent can read: one plain sentence. The command handlers turn it into exit code 1. */
|
|
29
|
+
export class Refusal extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'Refusal';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** A mistake in how a command was typed (exit code 2). */
|
|
36
|
+
export class UsageError extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'UsageError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export const isApprovalAction = (v) => APPROVAL_ACTIONS.includes(v);
|
|
43
|
+
/** Writes a temporary file in the same folder and renames it over the real one, so a reader never sees half a file. */
|
|
44
|
+
export function writeFileAtomic(file, text) {
|
|
45
|
+
ensureDir(dirname(file));
|
|
46
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
47
|
+
try {
|
|
48
|
+
writeFileSync(temp, text, { mode: 0o600 });
|
|
49
|
+
renameRetry(temp, file);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
rmSync(temp, { force: true });
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const keyFile = () => join(checksHome(), 'approval.key');
|
|
57
|
+
/** The HMAC key of the approvals, the sealed roots and the registration files. Created once, whole, with mode 0600 (linked into place so nobody reads half of it). */
|
|
58
|
+
export function approvalKey() {
|
|
59
|
+
ensureDir(checksHome());
|
|
60
|
+
const file = keyFile();
|
|
61
|
+
if (!existsSync(file)) {
|
|
62
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
63
|
+
writeFileSync(tmp, randomBytes(32).toString('hex'), { mode: 0o600, flag: 'wx' });
|
|
64
|
+
try {
|
|
65
|
+
linkSync(tmp, file);
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e.code !== 'EEXIST')
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rmSync(tmp, { force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const key = Buffer.from(readFileSync(file, 'utf8').trim(), 'hex');
|
|
76
|
+
if (key.length !== 32)
|
|
77
|
+
throw new Error('The approval key file is damaged. Remove it and approve again.');
|
|
78
|
+
return key;
|
|
79
|
+
}
|
|
80
|
+
/** HMAC-SHA256 hex of a text under the approval key, with a purpose word so one seal can never stand for another. */
|
|
81
|
+
export function seal(purpose, text) {
|
|
82
|
+
return createHmac('sha256', approvalKey()).update(`${purpose}\0${text}`).digest('hex');
|
|
83
|
+
}
|
|
84
|
+
/** True when two hex strings are equal, compared in constant time. */
|
|
85
|
+
export function sameMac(a, b) {
|
|
86
|
+
return a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The target as it is compared: a name is lower cased, a folder (the `roots` action) is its real path when it exists, else its resolved path, lower cased on Windows.
|
|
90
|
+
* The owner and the agent may therefore write the same target in different spellings.
|
|
91
|
+
*/
|
|
92
|
+
export function normaliseTarget(action, target) {
|
|
93
|
+
const text = String(target ?? '').trim();
|
|
94
|
+
if (!text || text.length > 400)
|
|
95
|
+
throw new Refusal('An approval needs a target (a name or a folder).');
|
|
96
|
+
if (action !== 'roots')
|
|
97
|
+
return text.toLowerCase();
|
|
98
|
+
let full = resolve(text);
|
|
99
|
+
try {
|
|
100
|
+
full = realpathSync.native(full);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* A folder that is gone is compared by the path as written */
|
|
104
|
+
}
|
|
105
|
+
return process.platform === 'win32' ? full.toLowerCase() : full;
|
|
106
|
+
}
|
|
107
|
+
function normaliseCode(code) {
|
|
108
|
+
const text = String(code ?? '')
|
|
109
|
+
.toUpperCase()
|
|
110
|
+
.replace(/[\s-]/g, '');
|
|
111
|
+
return new RegExp(`^[${ALPHABET}]{${CODE_LENGTH}}$`).test(text) ? text : null;
|
|
112
|
+
}
|
|
113
|
+
const macOf = (action, target, code) => seal('approval', `${action}\0${target}\0${code}`);
|
|
114
|
+
function readEntries() {
|
|
115
|
+
try {
|
|
116
|
+
const v = JSON.parse(readFileSync(approvalsFile(), 'utf8'));
|
|
117
|
+
const list = v && typeof v === 'object' ? v.codes : null;
|
|
118
|
+
if (!Array.isArray(list))
|
|
119
|
+
return [];
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const e of list) {
|
|
122
|
+
if (!e || typeof e !== 'object')
|
|
123
|
+
continue;
|
|
124
|
+
const { h, action, target, exp } = e;
|
|
125
|
+
if (typeof h === 'string' &&
|
|
126
|
+
typeof action === 'string' &&
|
|
127
|
+
isApprovalAction(action) &&
|
|
128
|
+
typeof target === 'string' &&
|
|
129
|
+
typeof exp === 'number')
|
|
130
|
+
out.push({ h, action, target, exp });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const writeEntries = (codes) => writeFileAtomic(approvalsFile(), JSON.stringify({ version: 1, codes }, null, 2) + '\n');
|
|
139
|
+
/**
|
|
140
|
+
* Makes a single-use code for one action and target. Needs an interactive terminal: `isTTY` is passed in by the caller (the command passes the real flag, a test
|
|
141
|
+
* passes true or false), and without it nothing is made. An older code for the same action and target is replaced.
|
|
142
|
+
*/
|
|
143
|
+
export function mintApproval(action, target, opts) {
|
|
144
|
+
if (!opts.isTTY)
|
|
145
|
+
throw new Refusal('An approval code is made by the owner on an interactive terminal. This is not one, so no code was made.');
|
|
146
|
+
if (!isApprovalAction(action))
|
|
147
|
+
throw new Refusal(`"${action.slice(0, 30)}" is not an action (${APPROVAL_ACTIONS.join(', ')}).`);
|
|
148
|
+
const now = opts.now ?? Date.now();
|
|
149
|
+
const norm = normaliseTarget(action, target);
|
|
150
|
+
let code = '';
|
|
151
|
+
for (let i = 0; i < CODE_LENGTH; i++)
|
|
152
|
+
code += ALPHABET[randomInt(ALPHABET.length)];
|
|
153
|
+
const exp = now + APPROVAL_TTL_MS;
|
|
154
|
+
withLockSync('approvals', () => {
|
|
155
|
+
const kept = readEntries().filter((e) => e.exp > now && !(e.action === action && e.target === norm));
|
|
156
|
+
kept.push({ h: macOf(action, norm, code), action, target: norm, exp });
|
|
157
|
+
writeEntries(kept.slice(-MAX_CODES));
|
|
158
|
+
});
|
|
159
|
+
appendAudit('approval minted', { action, target: norm, minutes: APPROVAL_TTL_MS / 60000 });
|
|
160
|
+
return {
|
|
161
|
+
code: `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`,
|
|
162
|
+
expiresAt: new Date(exp).toISOString()
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* True when the code is valid for this action and target right now, without using it. A command with several codes checks all of them with this first and uses them
|
|
167
|
+
* (consumeApproval) last, so a wrong code never burns the right ones.
|
|
168
|
+
*/
|
|
169
|
+
export function peekApproval(action, target, code, now = Date.now()) {
|
|
170
|
+
if (!isApprovalAction(action))
|
|
171
|
+
return false;
|
|
172
|
+
const plain = normaliseCode(code);
|
|
173
|
+
if (!plain)
|
|
174
|
+
return false;
|
|
175
|
+
let norm;
|
|
176
|
+
try {
|
|
177
|
+
norm = normaliseTarget(action, target);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const want = macOf(action, norm, plain);
|
|
183
|
+
return readEntries().some((e) => e.exp > now && e.action === action && sameMac(e.h, want));
|
|
184
|
+
}
|
|
185
|
+
/** True once for a valid code of this action and target, and false for a wrong action, a wrong target, an expired, a used or an unknown code. */
|
|
186
|
+
export function consumeApproval(action, target, code, now = Date.now()) {
|
|
187
|
+
if (!isApprovalAction(action))
|
|
188
|
+
return false;
|
|
189
|
+
const plain = normaliseCode(code);
|
|
190
|
+
if (!plain)
|
|
191
|
+
return false;
|
|
192
|
+
let norm;
|
|
193
|
+
try {
|
|
194
|
+
norm = normaliseTarget(action, target);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const want = macOf(action, norm, plain);
|
|
200
|
+
let hit = false;
|
|
201
|
+
withLockSync('approvals', () => {
|
|
202
|
+
const all = readEntries();
|
|
203
|
+
const live = all.filter((e) => e.exp > now);
|
|
204
|
+
const found = live.find((e) => e.action === action && sameMac(e.h, want));
|
|
205
|
+
hit = found !== undefined;
|
|
206
|
+
const rest = found ? live.filter((e) => e !== found) : live;
|
|
207
|
+
if (hit || rest.length !== all.length)
|
|
208
|
+
writeEntries(rest);
|
|
209
|
+
});
|
|
210
|
+
if (hit)
|
|
211
|
+
appendAudit('approved with code', { action, target: norm });
|
|
212
|
+
return hit;
|
|
213
|
+
}
|
package/dist/checks/audit.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// path or a command text.
|
|
4
4
|
// -- DEP-MODULES -- //
|
|
5
5
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
-
import { appendFileSync, existsSync, linkSync, readFileSync,
|
|
6
|
+
import { appendFileSync, existsSync, linkSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
// -- MODULES -- //
|
|
9
9
|
import { withLockSync } from './lock.js';
|
|
10
10
|
import { auditFile, checksHome, ensureDir } from './paths.js';
|
|
11
|
+
import { renameRetry } from './fsretry.js';
|
|
11
12
|
// -- CONSTANTS -- //
|
|
12
13
|
const MAX_ACTION = 40;
|
|
13
14
|
const MAX_VALUE = 200;
|
|
@@ -81,7 +82,7 @@ export function appendAudit(action, detail = {}) {
|
|
|
81
82
|
const head = { lines: lines.length + 1, head: mac };
|
|
82
83
|
const tmp = `${headFile()}.${process.pid}.tmp`;
|
|
83
84
|
writeFileSync(tmp, JSON.stringify(head), { mode: 0o600 });
|
|
84
|
-
|
|
85
|
+
renameRetry(tmp, headFile());
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
88
|
function readHead() {
|
package/dist/checks/bind.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// When the pin changes (a new script, a changed recipe, a new lockfile) the watcher stops and tells the person to run `check run` in that
|
|
3
3
|
// folder, which looks at the new setup and pins it again. Nothing in the repo can re-pin by itself.
|
|
4
4
|
// -- DEP-MODULES -- //
|
|
5
|
-
import { existsSync, readFileSync, realpathSync,
|
|
5
|
+
import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
// -- MODULES -- //
|
|
8
8
|
import { bindingsFile, ensureDir, ORG_SLUG } from './paths.js';
|
|
9
|
+
import { renameRetry } from './fsretry.js';
|
|
9
10
|
import { checkRepoFolder } from './guardrails.js';
|
|
10
11
|
import { pinFiles } from './recipe.js';
|
|
11
12
|
// -- CONSTANTS -- //
|
|
@@ -44,7 +45,7 @@ function writeAll(bindings) {
|
|
|
44
45
|
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
45
46
|
try {
|
|
46
47
|
writeFileSync(temp, JSON.stringify({ version: 1, bindings }, null, 2) + '\n', { mode: 0o600 });
|
|
47
|
-
|
|
48
|
+
renameRetry(temp, file);
|
|
48
49
|
}
|
|
49
50
|
catch (e) {
|
|
50
51
|
rmSync(temp, { force: true });
|