@arjunkhera/atlas 0.4.53 → 0.4.55
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/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/door/cli.mjs +65 -57
- package/door/lib/approve.mjs +115 -5
- package/door/lib/gate-audit.mjs +1 -0
- package/door/lib/gate-check.mjs +42 -2
- package/door/lib/gate.mjs +30 -2
- package/door/lib/install.mjs +50 -26
- package/door/lib/refresh-judge.mjs +132 -0
- package/door/lib/refresh.mjs +320 -0
- package/door/lib/signed-act.mjs +9 -1
- package/door/lib/window.mjs +271 -0
- package/owner-only.yaml +1 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +15 -7
- package/work/mcp.mjs +33 -4
- package/work/start.mjs +63 -0
package/door/lib/install.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Code to add the catalog and install the plugin. It writes no settings file.
|
|
10
10
|
import { execFileSync } from 'node:child_process';
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
15
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -332,6 +332,51 @@ export function notesLines({ gh, repo, from, to }) {
|
|
|
332
332
|
return out;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// Every entry under root is a plain file or a folder. A link would make the
|
|
336
|
+
// file walk read what it points at (security review of item ade7d96a).
|
|
337
|
+
export function onlyPlainEntries(root) {
|
|
338
|
+
const walk = (dir) => {
|
|
339
|
+
for (const entry of readdirSync(dir)) {
|
|
340
|
+
const stat = lstatSync(join(dir, entry));
|
|
341
|
+
if (stat.isDirectory()) { if (!walk(join(dir, entry))) return false; } else if (!stat.isFile()) return false;
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
};
|
|
345
|
+
return walk(root);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Pack one release from npm into `work` and prove it: the integrity that npm
|
|
349
|
+
// records, a release run record of this exact tarball on a master commit that
|
|
350
|
+
// was once the tip, and every file equal to that commit with the version
|
|
351
|
+
// stamped. The upgrade and the cloud refresh share it (item ade7d96a). It
|
|
352
|
+
// returns the lines of a refusal, or the files, the tarball path and the run.
|
|
353
|
+
// The package folder holds no file until the proof passed: the caller copies
|
|
354
|
+
// `unpacked` only after it.
|
|
355
|
+
export function provenRelease({ name, version, repo, gh, exec, work, line = () => {} }) {
|
|
356
|
+
const integrity = exec('npm', ['view', `${name}@${version}`, 'dist.integrity']).trim();
|
|
357
|
+
exec('npm', ['pack', `${name}@${version}`, '--pack-destination', work]);
|
|
358
|
+
const tarballName = readdirSync(work).find((entry) => entry.endsWith('.tgz'));
|
|
359
|
+
if (!tarballName) throw new Error('npm pack wrote no tarball');
|
|
360
|
+
const tarball = join(work, tarballName);
|
|
361
|
+
const bytes = readFileSync(tarball);
|
|
362
|
+
if (sha512Integrity(bytes) !== integrity) return { code: 'integrity', refused: ['Refused: the tarball does not match the integrity npm records.'] };
|
|
363
|
+
const { run, commit, problem, code } = findRelease({ gh, repo, version, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
364
|
+
if (problem) return { code, refused: [`Refused: ${problem}. It may have been published from a branch.`] };
|
|
365
|
+
line(` tag v${version}, recorded by release run ${run.id}, on master commit ${commit.slice(0, 12)}`);
|
|
366
|
+
const unpacked = join(work, 'unpacked');
|
|
367
|
+
mkdirSync(unpacked);
|
|
368
|
+
exec('tar', ['-xzf', tarball, '-C', unpacked]);
|
|
369
|
+
if (!onlyPlainEntries(unpacked)) return { code: 'special-entry', refused: ['Refused: the tarball holds a link or another entry that is not a plain file.'] };
|
|
370
|
+
const files = filesUnder(join(unpacked, 'package'));
|
|
371
|
+
const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${commit}?recursive=1`]).tree;
|
|
372
|
+
let stamped;
|
|
373
|
+
try { stamped = stampedBlobs({ gh, repo, commit, version }); } catch (error) { return { code: 'stamp', refused: [`Refused: the version files of that commit cannot take the stamp: ${error.message}`] }; }
|
|
374
|
+
const problems = matchTree({ tree, files, stamped });
|
|
375
|
+
if (problems.length) return { code: 'files', refused: ['Refused: the tarball is not the commit that run built.', ...problems.slice(0, 20).map((problem) => ` ${problem}`)] };
|
|
376
|
+
line(` every one of ${files.size} files matches that commit`);
|
|
377
|
+
return { files, tarball, unpacked: join(unpacked, 'package'), run, commit };
|
|
378
|
+
}
|
|
379
|
+
|
|
335
380
|
export async function upgrade({ packageRoot, options = {}, line = console.log, env = process.env, exec = defaultExec, ask = defaultAsk }) {
|
|
336
381
|
const { name, version: installed } = packageJson(packageRoot);
|
|
337
382
|
const repo = repoOf(packageRoot);
|
|
@@ -339,34 +384,13 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
|
|
|
339
384
|
const target = options.version && options.version !== true ? String(options.version) : exec('npm', ['view', name, 'version']).trim();
|
|
340
385
|
if (!EXACT_VERSION.test(target)) throw new Error(`npm answered "${target}", which is not a version`);
|
|
341
386
|
if (target === installed) { line(`Atlas ${installed} is the newest release. Nothing to do.`); return 0; }
|
|
342
|
-
const integrity = exec('npm', ['view', `${name}@${target}`, 'dist.integrity']).trim();
|
|
343
|
-
|
|
344
387
|
line(`Atlas ${installed} → ${target}`);
|
|
345
388
|
|
|
346
389
|
const work = mkdtempSync(join(tmpdir(), 'atlas-upgrade-'));
|
|
347
390
|
try {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const bytes = readFileSync(join(work, tarball));
|
|
352
|
-
if (sha512Integrity(bytes) !== integrity) { line('Refused: the tarball does not match the integrity npm records.'); return 1; }
|
|
353
|
-
const { run, commit, problem } = findRelease({ gh, repo, version: target, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
354
|
-
if (problem) { line(`Refused: ${problem}. It may have been published from a branch.`); return 1; }
|
|
355
|
-
line(` tag v${target}, recorded by release run ${run.id}, on master commit ${commit.slice(0, 12)}`);
|
|
356
|
-
const unpacked = join(work, 'unpacked');
|
|
357
|
-
mkdirSync(unpacked);
|
|
358
|
-
exec('tar', ['-xzf', join(work, tarball), '-C', unpacked]);
|
|
359
|
-
const files = filesUnder(join(unpacked, 'package'));
|
|
360
|
-
const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${commit}?recursive=1`]).tree;
|
|
361
|
-
let stamped;
|
|
362
|
-
try { stamped = stampedBlobs({ gh, repo, commit, version: target }); } catch (error) { line(`Refused: the version files of that commit cannot take the stamp: ${error.message}`); return 1; }
|
|
363
|
-
const problems = matchTree({ tree, files, stamped });
|
|
364
|
-
if (problems.length) {
|
|
365
|
-
line('Refused: the tarball is not the commit that run built.');
|
|
366
|
-
for (const problem of problems.slice(0, 20)) line(` ${problem}`);
|
|
367
|
-
return 1;
|
|
368
|
-
}
|
|
369
|
-
line(` every one of ${files.size} files matches that commit`);
|
|
391
|
+
const proven = provenRelease({ name, version: target, repo, gh, exec, work, line });
|
|
392
|
+
if (proven.refused) { for (const text of proven.refused) line(text); return 1; }
|
|
393
|
+
const { files, tarball } = proven;
|
|
370
394
|
line('');
|
|
371
395
|
for (const text of notesLines({ gh, repo, from: installed, to: target })) line(text);
|
|
372
396
|
|
|
@@ -376,7 +400,7 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
|
|
|
376
400
|
line('');
|
|
377
401
|
if (!options.yes && !(await ask(`Install Atlas ${target}?`))) { line('Nothing was changed.'); return 1; }
|
|
378
402
|
|
|
379
|
-
exec('npm', ['install', '-g', '--ignore-scripts',
|
|
403
|
+
exec('npm', ['install', '-g', '--ignore-scripts', tarball]);
|
|
380
404
|
writeFileSync(catalogFile(env), `${JSON.stringify(catalogFor({ name, version: target }), null, 2)}\n`);
|
|
381
405
|
exec('claude', ['plugin', 'marketplace', 'update', CATALOG_NAME]);
|
|
382
406
|
exec('claude', ['plugin', 'update', PLUGIN_ID]);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The loaded copy judges the merges before a refresh moves (item ade7d96a;
|
|
2
|
+
// the owner picked "1: The loaded copy judges" on 31 October 2026, decision
|
|
3
|
+
// 6869a363). The push audit workflow runs the code of the commit it judges,
|
|
4
|
+
// so a push can change what it says. Here the loaded Atlas, which this
|
|
5
|
+
// session already trusts, judges each first-parent commit of master after
|
|
6
|
+
// the loaded release, up to the new release commit. Each commit passes three
|
|
7
|
+
// checks, each in a process or call of its own, all at the same time:
|
|
8
|
+
// 1. `atlas gate audit --commit`: a pull request merged it after its
|
|
9
|
+
// required checks were green, and each gate run came from the base copy.
|
|
10
|
+
// 2. `atlas gate hold --commit <sha> --from <its parent>`: the gate judges
|
|
11
|
+
// the merge again and must say success, or the owner signed an act for
|
|
12
|
+
// it. An owner or a red answer holds (security re-review of #292, S1).
|
|
13
|
+
// 3. The pull request that merged it targeted master.
|
|
14
|
+
//
|
|
15
|
+
// refresh.mjs starts this file as its own process group before the release
|
|
16
|
+
// proof, so the two run at the same time. The arguments are one JSON object:
|
|
17
|
+
// { repo, fromCommit, toCommit, work, deadline }; the clone goes in
|
|
18
|
+
// work/clone. At the deadline (epoch ms) the judge ends its whole process
|
|
19
|
+
// group. It prints one JSON line:
|
|
20
|
+
// { code: null } when each merge is green, or { code, commits }.
|
|
21
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
22
|
+
import { mkdirSync, realpathSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
// More merges than this since the loaded release are too many to judge in
|
|
27
|
+
// the start time. The refresh keeps the copy and says so.
|
|
28
|
+
export const MAX_MERGES = 8;
|
|
29
|
+
const CALL_MS = 10_000;
|
|
30
|
+
const HOLD_EXIT = 3;
|
|
31
|
+
const FETCH_MS = 30_000;
|
|
32
|
+
// The folder of the package in the repo, and how far back the start may move.
|
|
33
|
+
export const PACKAGE_DIR = 'packages/atlas';
|
|
34
|
+
const MAX_LOOK_BACK = 200;
|
|
35
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
36
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
37
|
+
const bin = fileURLToPath(new URL('../../bin/atlas', import.meta.url));
|
|
38
|
+
|
|
39
|
+
const git = (clone, args, timeout = CALL_MS) => execFileSync('git', ['-C', clone, ...args], { encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
40
|
+
const has = (clone, sha) => { try { git(clone, ['cat-file', '-e', `${sha}^{commit}`]); return true; } catch { return false; } };
|
|
41
|
+
|
|
42
|
+
// The commits to judge, oldest first, from a full clone of master: a gate
|
|
43
|
+
// judgement needs the merge base of each pull request, so the clone is never
|
|
44
|
+
// shallow.
|
|
45
|
+
export function mergesSince({ repo, fromCommit, toCommit, clone, remote = `https://github.com/${repo}.git` }) {
|
|
46
|
+
mkdirSync(clone, { recursive: true });
|
|
47
|
+
git(clone, ['init', '-q']);
|
|
48
|
+
git(clone, ['-c', 'credential.helper=', '-c', 'credential.helper=!gh auth git-credential', 'fetch', '-q', '--no-tags', remote, '+refs/heads/master:refs/remotes/origin/master'], FETCH_MS);
|
|
49
|
+
if (!has(clone, fromCommit) || !has(clone, toCommit)) return { code: 'audit-silent' };
|
|
50
|
+
// The loaded release must sit on the first-parent line of the new one.
|
|
51
|
+
const line = git(clone, ['rev-list', '--first-parent', '--reverse', toCommit]).split('\n').filter(Boolean);
|
|
52
|
+
let at = line.indexOf(fromCommit);
|
|
53
|
+
if (at < 0) return { code: 'audit-silent' };
|
|
54
|
+
// A tag can move to a later commit with the same package files, so the
|
|
55
|
+
// range starts where those files first appeared on the line: each commit
|
|
56
|
+
// after that is judged (security re-review of #292, S1).
|
|
57
|
+
const treeOf = (sha) => { try { return git(clone, ['rev-parse', '--verify', '-q', `${sha}:${PACKAGE_DIR}`]); } catch { return null; } };
|
|
58
|
+
const loaded = treeOf(fromCommit);
|
|
59
|
+
if (!loaded) return { code: 'audit-silent' };
|
|
60
|
+
let steps = 0;
|
|
61
|
+
while (at > 0 && treeOf(line[at - 1]) === loaded) {
|
|
62
|
+
at -= 1;
|
|
63
|
+
steps += 1;
|
|
64
|
+
if (steps > MAX_LOOK_BACK) return { code: 'audit-silent' };
|
|
65
|
+
}
|
|
66
|
+
const commits = line.slice(at + 1);
|
|
67
|
+
if (!commits.length || commits.at(-1) !== toCommit) return { code: 'audit-silent' };
|
|
68
|
+
if (commits.length > MAX_MERGES) return { code: 'audit-many' };
|
|
69
|
+
return { commits: commits.map((sha) => ({ sha, parent: git(clone, ['rev-parse', '--verify', `${sha}^1`]) })) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One program run; its exit code, or 2 when it could not run.
|
|
73
|
+
function exitOf(run, args, env) {
|
|
74
|
+
return new Promise((done) => {
|
|
75
|
+
const child = run(process.execPath, [bin, ...args], { env, stdio: 'ignore' });
|
|
76
|
+
child.on('error', () => done(2));
|
|
77
|
+
child.on('exit', (code) => done(code ?? 2));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The base branch of the pull request that merged `sha`, or null.
|
|
82
|
+
export function mergedInto({ repo, sha, env }) {
|
|
83
|
+
return new Promise((done) => {
|
|
84
|
+
execFile('gh', ['api', '--method', 'GET', `repos/${repo}/commits/${sha}/pulls`], { encoding: 'utf8', timeout: CALL_MS, env, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
|
|
85
|
+
if (error) return done(null);
|
|
86
|
+
try {
|
|
87
|
+
const pull = (JSON.parse(stdout) ?? []).find((each) => each?.merged_at && each.merge_commit_sha === sha);
|
|
88
|
+
done(typeof pull?.base?.ref === 'string' ? pull.base.ref : null);
|
|
89
|
+
} catch { done(null); }
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Each commit at the same time. Its state is green, red or unread.
|
|
95
|
+
export function judgeAll({ repo, clone, commits, env, run = spawn, base = mergedInto }) {
|
|
96
|
+
return Promise.all(commits.map(async ({ sha, parent }) => {
|
|
97
|
+
const [audit, hold, into] = await Promise.all([
|
|
98
|
+
exitOf(run, ['gate', 'audit', '--repo', repo, '--commit', sha, '--root', clone], env),
|
|
99
|
+
exitOf(run, ['gate', 'hold', '--repo', repo, '--commit', sha, '--from', parent, '--root', clone], env),
|
|
100
|
+
base({ repo, sha, env }),
|
|
101
|
+
]);
|
|
102
|
+
if (audit === 1 || hold === HOLD_EXIT || (into !== null && into !== 'master')) return { sha, state: 'red' };
|
|
103
|
+
if (audit !== 0 || hold !== 0 || into === null) return { sha, state: 'unread' };
|
|
104
|
+
return { sha, state: 'green' };
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function judgeMerges({ repo, fromCommit, toCommit, clone, env = process.env, run, base, remote }) {
|
|
109
|
+
if (!REPO.test(String(repo)) || !SHA.test(String(fromCommit)) || !SHA.test(String(toCommit))) return { code: 'audit-silent' };
|
|
110
|
+
let found;
|
|
111
|
+
try { found = mergesSince({ repo, fromCommit, toCommit, clone, ...(remote ? { remote } : {}) }); } catch { return { code: 'audit-silent' }; }
|
|
112
|
+
if (found.code) return found;
|
|
113
|
+
const results = await judgeAll({ repo, clone, commits: found.commits, env, ...(run ? { run } : {}), ...(base ? { base } : {}) });
|
|
114
|
+
// A red audit, a held merge, or a merge into another branch is red. Any
|
|
115
|
+
// other failure means a check could not judge, never that it is green.
|
|
116
|
+
const red = results.filter((one) => one.state === 'red').map((one) => one.sha);
|
|
117
|
+
if (red.length) return { code: 'audit-judged-red', commits: red };
|
|
118
|
+
if (results.some((one) => one.state !== 'green')) return { code: 'audit-silent' };
|
|
119
|
+
return { code: null, commits: found.commits.map((one) => one.sha) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
123
|
+
let answer;
|
|
124
|
+
try {
|
|
125
|
+
const args = JSON.parse(process.argv[2] ?? '{}');
|
|
126
|
+
// The judge's own deadline: it ends itself and each check it started.
|
|
127
|
+
const left = Number(args.deadline) - Date.now();
|
|
128
|
+
setTimeout(() => { try { process.kill(-process.pid, 'SIGKILL'); } catch { process.exit(2); } }, Number.isFinite(left) ? Math.max(left, 0) : 0).unref();
|
|
129
|
+
answer = await judgeMerges({ repo: args.repo, fromCommit: args.fromCommit, toCommit: args.toCommit, clone: join(String(args.work), 'clone') });
|
|
130
|
+
} catch { answer = { code: 'audit-silent' }; }
|
|
131
|
+
process.stdout.write(`${JSON.stringify(answer)}\n`);
|
|
132
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// The cloud refresh (item ade7d96a; the owner picked "C: Atlas refreshes
|
|
2
|
+
// itself" and "1: Wait for a green push audit" on 31 October 2026, decisions
|
|
3
|
+
// b929c81d and 9e9615f1). The cloud saves the result of its setup script as a
|
|
4
|
+
// checkpoint, and a new session starts from that checkpoint, so a new session
|
|
5
|
+
// can load an old Atlas. At the start of each cloud session, work/start.mjs
|
|
6
|
+
// runs this file as its own process, with the short child environment: never
|
|
7
|
+
// the Engram key. It moves the loaded plugin folder to the newest release on
|
|
8
|
+
// npm, and only to a release that the release proof and the push audit
|
|
9
|
+
// accept. On any failure it keeps the folder it has.
|
|
10
|
+
//
|
|
11
|
+
// Design: docs/design-docs/cloud-self-refresh/.
|
|
12
|
+
//
|
|
13
|
+
// The rules:
|
|
14
|
+
// 1. Only in a Claude cloud session, only for a copy under
|
|
15
|
+
// ~/.claude/plugins/cache/, and never with ATLAS_REFRESH=off.
|
|
16
|
+
// 2. Only forward: npm must name a newer version than the loaded one.
|
|
17
|
+
// 3. The release proof is the upgrade's: provenRelease in install.mjs.
|
|
18
|
+
// 4. The push audit approves the release: its run on the release commit, and
|
|
19
|
+
// each of its runs on a master commit after the loaded release, ended in
|
|
20
|
+
// success, and no gate-audit or gate-files issue is open.
|
|
21
|
+
// 4a. The loaded copy judges too (the owner's pick of 31 October, decision
|
|
22
|
+
// 6869a363): refresh-judge.mjs runs this copy's own `atlas gate audit` on
|
|
23
|
+
// each of those commits, at the same time as the proof. The workflow runs
|
|
24
|
+
// the code it judges, so its green alone is not enough.
|
|
25
|
+
// 5. The folder is written from the checked bytes, and a tarball with a link
|
|
26
|
+
// or another special entry is refused.
|
|
27
|
+
// 6. The swap is two renames on one disk. A failed second rename puts the old
|
|
28
|
+
// folder back.
|
|
29
|
+
// 7. The record holds fixed codes, versions, run ids, commits and issue
|
|
30
|
+
// numbers only: no text from npm or GitHub reaches the lead.
|
|
31
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
32
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, cpSync, openSync, closeSync } from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import { dirname, join, sep } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { newer, onlyPlainEntries, provenRelease, proveCopy, repoOf, tagCommit } from './install.mjs';
|
|
37
|
+
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
38
|
+
|
|
39
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
|
|
40
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
41
|
+
// The refresh before the swap may take this long. Each program call has its
|
|
42
|
+
// own limit too, so one call that hangs cannot hold the session.
|
|
43
|
+
export const BUDGET_MS = 20_000;
|
|
44
|
+
// With MCP_TIMEOUT set in the cloud environment, work/start.mjs gives the
|
|
45
|
+
// refresh a longer budget in ATLAS_REFRESH_BUDGET_MS. It is never shorter
|
|
46
|
+
// than BUDGET_MS and never longer than MAX_BUDGET_MS.
|
|
47
|
+
export const MAX_BUDGET_MS = 100_000;
|
|
48
|
+
export function budgetOf(env) {
|
|
49
|
+
const asked = Number(env.ATLAS_REFRESH_BUDGET_MS);
|
|
50
|
+
return Number.isInteger(asked) ? Math.min(Math.max(asked, BUDGET_MS), MAX_BUDGET_MS) : BUDGET_MS;
|
|
51
|
+
}
|
|
52
|
+
export const CALL_MS = 10_000;
|
|
53
|
+
// Claude Code writes this marker into a plugin copy that is in use.
|
|
54
|
+
const IN_USE = '.in_use';
|
|
55
|
+
// A lock older than this is left from a process that died.
|
|
56
|
+
const STALE_LOCK_MS = 5 * 60_000;
|
|
57
|
+
export const AUDIT_WORKFLOW = 'atlas-gate-audit.yml';
|
|
58
|
+
export const AUDIT_LABELS = Object.freeze(['job:gate-audit', 'job:gate-files']);
|
|
59
|
+
|
|
60
|
+
// Each reason the refresh can give, in fixed words. The lead says the words;
|
|
61
|
+
// the record holds only the code and typed values.
|
|
62
|
+
export const REASONS = Object.freeze({
|
|
63
|
+
'not-cloud': 'not a cloud session',
|
|
64
|
+
off: 'ATLAS_REFRESH=off',
|
|
65
|
+
'not-in-cache': 'the loaded copy is not in ~/.claude/plugins/cache/',
|
|
66
|
+
'no-package': 'the loaded package.json could not be read',
|
|
67
|
+
locked: 'another refresh runs',
|
|
68
|
+
'npm-silent': 'npm did not give the newest version',
|
|
69
|
+
'npm-odd': 'npm did not answer with a version',
|
|
70
|
+
integrity: 'the tarball does not match the integrity npm records',
|
|
71
|
+
'no-tag': 'no tag names the release commit',
|
|
72
|
+
'not-on-master': 'the release commit is not on master',
|
|
73
|
+
'never-tip': 'the release commit was never the tip of master',
|
|
74
|
+
'no-record': 'no master release run recorded this tarball',
|
|
75
|
+
stamp: 'the version files of the release commit cannot take the stamp',
|
|
76
|
+
files: 'the tarball is not the commit that its release run built',
|
|
77
|
+
'proof-stopped': 'the release proof stopped: GitHub or npm did not answer',
|
|
78
|
+
'audit-pending': 'the push audit of the release has not finished',
|
|
79
|
+
'audit-red': 'a push audit run since the loaded release did not end in success',
|
|
80
|
+
'audit-issue': 'an audit issue is open',
|
|
81
|
+
'audit-silent': 'GitHub did not give every push audit run or issue',
|
|
82
|
+
'audit-judged-red': 'the loaded Atlas judged a merge since the loaded release as red',
|
|
83
|
+
'audit-many': 'too many merges came after the loaded release to judge at the start',
|
|
84
|
+
'no-from-tag': 'no tag names the commit of the loaded release, so the audit cannot find the range',
|
|
85
|
+
'from-unproven': 'the loaded copy is not proven to be the release that its tag names, so the range of new merges is not known',
|
|
86
|
+
'special-entry': 'the tarball holds a link or another entry that is not a plain file',
|
|
87
|
+
timeout: 'the refresh used up its start time (MCP_TIMEOUT gives it more)',
|
|
88
|
+
'swap-failed': 'the swap failed, and the old folder is back',
|
|
89
|
+
stopped: 'the refresh stopped',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const JUDGE_FILE = fileURLToPath(new URL('./refresh-judge.mjs', import.meta.url));
|
|
93
|
+
|
|
94
|
+
// Start the loaded copy's judge of the merges as its own process group. The
|
|
95
|
+
// refresh runs only in a cloud session, so the judge and each audit it starts
|
|
96
|
+
// keep the cloud proxy that their `gh` needs (child-env.mjs). It
|
|
97
|
+
// gives { wait(deadline), stop() }: wait blocks until the judge answers or
|
|
98
|
+
// the deadline passes, and stop ends the judge and each audit it started.
|
|
99
|
+
export function startJudge({ repo, fromCommit, toCommit, work, env, deadline, now = Date.now }) {
|
|
100
|
+
const out = join(work, 'judge.json');
|
|
101
|
+
const fd = openSync(out, 'w');
|
|
102
|
+
let child;
|
|
103
|
+
try { child = spawn(process.execPath, [JUDGE_FILE, JSON.stringify({ repo, fromCommit, toCommit, work, deadline })], { env: { ...childEnvironment(env), CLAUDE_CODE_REMOTE: 'true' }, stdio: ['ignore', fd, 'ignore'], detached: true }); } finally { closeSync(fd); }
|
|
104
|
+
child.unref();
|
|
105
|
+
const stop = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* it ended */ } };
|
|
106
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
107
|
+
const wait = (deadline) => {
|
|
108
|
+
for (;;) {
|
|
109
|
+
const text = readFileSync(out, 'utf8');
|
|
110
|
+
if (text.endsWith('\n')) { try { return JSON.parse(text); } catch { return { code: 'audit-silent' }; } }
|
|
111
|
+
if (now() >= deadline) { stop(); return { code: 'timeout' }; }
|
|
112
|
+
Atomics.wait(cell, 0, 0, 100);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return { wait, stop };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const refreshFile = (env) => join(env.HOME, '.atlas', 'refresh.json');
|
|
119
|
+
|
|
120
|
+
function childExec(env) {
|
|
121
|
+
return (command, args) => execFileSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: CALL_MS, stdio: ['pipe', 'pipe', 'pipe'], env: childEnvironment(env) });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Is packageRoot the installed copy that Claude Code loads, and not a working
|
|
125
|
+
// tree, a test copy or a dev copy? Real paths on both sides.
|
|
126
|
+
export function inPluginCache(packageRoot, env) {
|
|
127
|
+
if (typeof env.HOME !== 'string' || !env.HOME) return false;
|
|
128
|
+
try {
|
|
129
|
+
const cache = realpathSync(join(env.HOME, '.claude', 'plugins', 'cache'));
|
|
130
|
+
return realpathSync(packageRoot).startsWith(cache + sep);
|
|
131
|
+
} catch { return false; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The push audit approves the release (the owner's answer to the S1). It
|
|
135
|
+
// returns null, or a refusal with its code and typed values.
|
|
136
|
+
export function auditApproves({ gh, repo, fromCommit, toCommit }) {
|
|
137
|
+
// Only runs made since the loaded release: one page holds them, and a
|
|
138
|
+
// count above the page means a run was not read, so the refresh refuses.
|
|
139
|
+
const since = gh(['api', '--method', 'GET', `repos/${repo}/commits/${fromCommit}`]).commit?.committer?.date;
|
|
140
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(String(since))) return { code: 'audit-silent', runs: [] };
|
|
141
|
+
const page = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/${AUDIT_WORKFLOW}/runs?branch=master&event=push&per_page=100&created=${encodeURIComponent(`>=${since.slice(0, 10)}`)}`]);
|
|
142
|
+
const runs = page.workflow_runs ?? [];
|
|
143
|
+
if (!Number.isInteger(page.total_count) || page.total_count > runs.length) return { code: 'audit-silent', runs: [] };
|
|
144
|
+
const ours = runs.filter((run) => run.event === 'push' && run.head_branch === 'master' && run.path === `.github/workflows/${AUDIT_WORKFLOW}`);
|
|
145
|
+
const judge = (list) => {
|
|
146
|
+
const open = list.filter((run) => run.status !== 'completed');
|
|
147
|
+
if (open.length) return { code: 'audit-pending', runs: open.map((run) => run.id) };
|
|
148
|
+
const red = list.filter((run) => run.conclusion !== 'success');
|
|
149
|
+
if (red.length) return { code: 'audit-red', runs: red.map((run) => run.id) };
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
const target = ours.filter((run) => run.head_sha === toCommit);
|
|
153
|
+
if (!target.length) return { code: 'audit-pending', runs: [] };
|
|
154
|
+
const targetVerdict = judge(target);
|
|
155
|
+
if (targetVerdict) return targetVerdict;
|
|
156
|
+
// Each push after the loaded release has its own audit run. The compare
|
|
157
|
+
// lists at most 250 commits; a longer range is refused, never guessed.
|
|
158
|
+
const compare = gh(['api', '--method', 'GET', `repos/${repo}/compare/${fromCommit}...${toCommit}`]);
|
|
159
|
+
const listed = compare.commits ?? [];
|
|
160
|
+
if (!Number.isInteger(compare.total_commits) || compare.total_commits > listed.length) return { code: 'audit-silent', runs: [] };
|
|
161
|
+
const between = new Set(listed.map((commit) => commit.sha));
|
|
162
|
+
const verdict = judge(ours.filter((run) => between.has(run.head_sha)));
|
|
163
|
+
if (verdict) return verdict;
|
|
164
|
+
const issues = [];
|
|
165
|
+
for (const label of AUDIT_LABELS) {
|
|
166
|
+
for (const issue of gh(['api', '--method', 'GET', `repos/${repo}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=20`]) ?? []) {
|
|
167
|
+
if (Number.isInteger(issue.number) && !issues.includes(issue.number)) issues.push(issue.number);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return issues.length ? { code: 'audit-issue', issues } : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Write the checked bytes into `next`, with the file modes of the unpacked
|
|
174
|
+
// tarball, so an executable stays executable.
|
|
175
|
+
export function writeFolder({ files, unpacked, next }) {
|
|
176
|
+
rmSync(next, { recursive: true, force: true });
|
|
177
|
+
for (const [path, bytes] of files) {
|
|
178
|
+
const target = join(next, path);
|
|
179
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
180
|
+
writeFileSync(target, bytes);
|
|
181
|
+
chmodSync(target, statSync(join(unpacked, path)).mode & 0o755);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Put the folder `next` where `packageRoot` is. Two renames on one disk; when
|
|
186
|
+
// the second fails, the first is undone.
|
|
187
|
+
export function swapFolder({ packageRoot, next, rename = renameSync }) {
|
|
188
|
+
const old = `${packageRoot}.old`;
|
|
189
|
+
rmSync(old, { recursive: true, force: true });
|
|
190
|
+
rename(packageRoot, old);
|
|
191
|
+
try { rename(next, packageRoot); } catch (error) {
|
|
192
|
+
rename(old, packageRoot);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
rmSync(old, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function takeLock(lock, now) {
|
|
199
|
+
try { mkdirSync(lock); return true; } catch {
|
|
200
|
+
// A stale lock moves aside under a name of this process first, so two
|
|
201
|
+
// processes cannot both clear it and both take it.
|
|
202
|
+
try {
|
|
203
|
+
if (now() - statSync(lock).mtimeMs > STALE_LOCK_MS) {
|
|
204
|
+
const aside = `${lock}.stale-${process.pid}`;
|
|
205
|
+
renameSync(lock, aside);
|
|
206
|
+
rmSync(aside, { recursive: true, force: true });
|
|
207
|
+
mkdirSync(lock);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
} catch { /* another process has it */ }
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// One refresh. It returns a record: state moved, current, kept or skipped,
|
|
216
|
+
// with from, to, a code and typed values. It never throws.
|
|
217
|
+
export function refresh({ packageRoot, env = process.env, exec = childExec(env), now = Date.now, rename = renameSync, judge = startJudge, proveFrom = proveCopy }) {
|
|
218
|
+
const started = now();
|
|
219
|
+
const budget = budgetOf(env);
|
|
220
|
+
let from = null;
|
|
221
|
+
let to = null;
|
|
222
|
+
const done = (state, extra = {}) => ({ state, from, to, ...extra, seconds: Math.round((now() - started) / 100) / 10, at: new Date(now()).toISOString() });
|
|
223
|
+
const kept = (code, extra = {}) => done('kept', { code, ...extra });
|
|
224
|
+
if (env.CLAUDE_CODE_REMOTE !== 'true') return done('skipped', { code: 'not-cloud' });
|
|
225
|
+
if (env.ATLAS_REFRESH === 'off') return done('skipped', { code: 'off' });
|
|
226
|
+
if (!inPluginCache(packageRoot, env)) return done('skipped', { code: 'not-in-cache' });
|
|
227
|
+
let name;
|
|
228
|
+
try {
|
|
229
|
+
({ name, version: from } = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')));
|
|
230
|
+
if (!EXACT_VERSION.test(from)) throw new Error('no version');
|
|
231
|
+
} catch { from = null; return kept('no-package'); }
|
|
232
|
+
const lock = `${packageRoot}.refresh-lock`;
|
|
233
|
+
if (!takeLock(lock, now)) return kept('locked');
|
|
234
|
+
const work = mkdtempSync(join(tmpdir(), 'atlas-refresh-'));
|
|
235
|
+
const next = `${packageRoot}.next`;
|
|
236
|
+
let judged = null;
|
|
237
|
+
try {
|
|
238
|
+
let newest;
|
|
239
|
+
try { newest = exec('npm', ['view', name, 'version']).trim(); } catch { return kept('npm-silent'); }
|
|
240
|
+
if (!EXACT_VERSION.test(newest)) return kept('npm-odd');
|
|
241
|
+
if (!newer(newest, from)) return done('current', { to: from });
|
|
242
|
+
to = newest;
|
|
243
|
+
const repo = repoOf(packageRoot);
|
|
244
|
+
const gh = (args) => JSON.parse(exec('gh', args));
|
|
245
|
+
// The range to judge: the commits of the two tags. The loaded copy
|
|
246
|
+
// starts to judge it now, while the proofs run. Anyone who can push can
|
|
247
|
+
// move a tag, so the start must be the commit that the loaded copy's own
|
|
248
|
+
// proof finds (security re-review of #292, S1).
|
|
249
|
+
let fromCommit;
|
|
250
|
+
let toCommit;
|
|
251
|
+
try { fromCommit = tagCommit(gh, repo, from); toCommit = tagCommit(gh, repo, to); } catch { return kept('audit-silent'); }
|
|
252
|
+
if (!SHA.test(fromCommit ?? '')) return kept('no-from-tag');
|
|
253
|
+
if (SHA.test(toCommit ?? '')) judged = judge({ repo, fromCommit, toCommit, work, env, deadline: started + budget, now });
|
|
254
|
+
let loaded;
|
|
255
|
+
try { loaded = proveFrom({ packageRoot, exec }); } catch { loaded = null; }
|
|
256
|
+
if (loaded?.state !== 'match' || loaded.commit !== fromCommit) return kept('from-unproven');
|
|
257
|
+
// Any master commit can make a release record with its own release.yml,
|
|
258
|
+
// and the tag can move to it. npm never takes a version twice, so the
|
|
259
|
+
// proven run must have started before npm published the loaded version
|
|
260
|
+
// (third security review of #292, S1).
|
|
261
|
+
try {
|
|
262
|
+
const published = JSON.parse(exec('npm', ['view', `${name}@${from}`, 'time', '--json']))?.[from];
|
|
263
|
+
const run = gh(['api', '--method', 'GET', `repos/${repo}/actions/runs/${Number(loaded.run)}`]);
|
|
264
|
+
const ran = Date.parse(String(run?.created_at));
|
|
265
|
+
if (!Number.isInteger(loaded.run) || run?.head_sha !== fromCommit || !(ran < Date.parse(String(published)))) return kept('from-unproven');
|
|
266
|
+
} catch { return kept('from-unproven'); }
|
|
267
|
+
let proven;
|
|
268
|
+
try { proven = provenRelease({ name, version: to, repo, gh, exec, work }); } catch { return kept('proof-stopped'); }
|
|
269
|
+
if (proven.refused) return kept(REASONS[proven.code] ? proven.code : 'files');
|
|
270
|
+
if (!judged || proven.commit !== toCommit) return kept('audit-silent', { commit: proven.commit });
|
|
271
|
+
let refusal;
|
|
272
|
+
try { refusal = auditApproves({ gh, repo, fromCommit, toCommit: proven.commit }); } catch { return kept('audit-silent', { commit: proven.commit }); }
|
|
273
|
+
if (refusal) return kept(refusal.code, { commit: proven.commit, ...(refusal.runs ? { runs: refusal.runs.filter(Number.isInteger).slice(0, 10) } : {}), ...(refusal.issues ? { issues: refusal.issues.slice(0, 10) } : {}) });
|
|
274
|
+
const verdict = judged.wait(started + budget);
|
|
275
|
+
if (verdict?.code !== null) return kept(REASONS[verdict?.code] ? verdict.code : 'audit-silent', { commit: proven.commit, ...(Array.isArray(verdict?.commits) ? { commits: verdict.commits.filter((one) => SHA.test(String(one))).map((one) => one.slice(0, 12)).slice(0, 10) } : {}) });
|
|
276
|
+
// provenRelease refused a link already; this is the second look, at the
|
|
277
|
+
// folder that the swap copies from.
|
|
278
|
+
if (!onlyPlainEntries(proven.unpacked)) return kept('special-entry', { commit: proven.commit });
|
|
279
|
+
if (now() - started > budget) return kept('timeout', { commit: proven.commit });
|
|
280
|
+
writeFolder({ files: proven.files, unpacked: proven.unpacked, next });
|
|
281
|
+
if (existsSync(join(packageRoot, IN_USE))) cpSync(join(packageRoot, IN_USE), join(next, IN_USE), { recursive: true });
|
|
282
|
+
try { swapFolder({ packageRoot, next, rename }); } catch { return kept('swap-failed', { commit: proven.commit }); }
|
|
283
|
+
// The plugin moved. The `atlas` command moves to the same checked file.
|
|
284
|
+
let command = 'moved';
|
|
285
|
+
try { exec('npm', ['install', '-g', '--ignore-scripts', proven.tarball]); } catch { command = 'behind'; }
|
|
286
|
+
return done('moved', { run: proven.run.id, commit: proven.commit, files: proven.files.size, command });
|
|
287
|
+
} catch {
|
|
288
|
+
return kept('stopped');
|
|
289
|
+
} finally {
|
|
290
|
+
judged?.stop();
|
|
291
|
+
// The lock goes first, so a folder that will not go away never leaves the
|
|
292
|
+
// lock behind. The refresh never throws.
|
|
293
|
+
try { rmSync(lock, { recursive: true, force: true }); } catch { /* a stale lock clears in 5 min */ }
|
|
294
|
+
for (const folder of [next, work]) { try { rmSync(folder, { recursive: true, force: true, maxRetries: 3 }); } catch { /* a temp folder */ } }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The words of a record, for the lead and the log.
|
|
299
|
+
export function describe(record) {
|
|
300
|
+
if (!record || typeof record !== 'object') return 'no refresh record';
|
|
301
|
+
const reason = REASONS[record.code] ?? '';
|
|
302
|
+
if (record.state === 'moved') return `Atlas moved from ${record.from} to ${record.to} at the start of this session.`;
|
|
303
|
+
if (record.state === 'current') return `Atlas ${record.from} is the newest release.`;
|
|
304
|
+
if (record.state === 'kept') return `The refresh kept Atlas ${record.from ?? '(unknown)'}${record.to ? ` and did not move to ${record.to}` : ''}: ${reason}.`;
|
|
305
|
+
return `No refresh: ${reason}.`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function writeRecord(env, record) {
|
|
309
|
+
if (typeof env.HOME !== 'string' || !env.HOME) return;
|
|
310
|
+
try { mkdirSync(dirname(refreshFile(env)), { recursive: true }); writeFileSync(refreshFile(env), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); } catch { /* the record is advice */ }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Run as a program by work/start.mjs: one refresh of the package this file is
|
|
314
|
+
// in, one JSON line on stdout.
|
|
315
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
316
|
+
const packageRoot = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, '');
|
|
317
|
+
const record = refresh({ packageRoot, env: process.env });
|
|
318
|
+
writeRecord(process.env, record);
|
|
319
|
+
process.stdout.write(`${JSON.stringify(record)}\n`);
|
|
320
|
+
}
|
package/door/lib/signed-act.mjs
CHANGED
|
@@ -60,6 +60,8 @@ const HASH = '[0-9a-f]{64}';
|
|
|
60
60
|
const NUMBER = '[1-9][0-9]{0,8}';
|
|
61
61
|
// A branch name: plain characters only, so a line never holds a space.
|
|
62
62
|
const BRANCH = '[A-Za-z0-9._/-]{1,100}';
|
|
63
|
+
// A time in the form that payloadText writes: UTC, with milliseconds.
|
|
64
|
+
const TIME = '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z';
|
|
63
65
|
export const BRANCH_FORM = new RegExp(`^${BRANCH}$`);
|
|
64
66
|
|
|
65
67
|
// Each kind of act, its fields, and the exact form of its line. The order of
|
|
@@ -93,6 +95,12 @@ export const ACTS = Object.freeze({
|
|
|
93
95
|
// audit code, made with `atlas approve audit-code` after the owner read
|
|
94
96
|
// that diff (item f36c6b0f). It names the merge commit and the fingerprint
|
|
95
97
|
// of its audit-code change, so the touch covers only the diff he read.
|
|
98
|
+
// A signing window (item 4af66381; design docs/design-docs/signing-window/).
|
|
99
|
+
// It names the repo, the branch, the tip that the owner's morning list
|
|
100
|
+
// ended at (the seen commit), and the end time. The touch is the start, so
|
|
101
|
+
// the end needs no act; window.mjs holds the 12-hour rule and the
|
|
102
|
+
// allow-list.
|
|
103
|
+
window: { fields: ['repo', 'branch', 'seen', 'ends'], form: `(${REPO}) (${BRANCH}) (${SHA}) (${TIME})` },
|
|
96
104
|
'accept-code': { fields: ['repo', 'pr', 'head', 'merge', 'run', 'diff', 'words', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) (${SHA}) ([1-9][0-9]{0,15}) (${HASH}) (${HASH}) ([^\\s].{0,99})` },
|
|
97
105
|
});
|
|
98
106
|
|
|
@@ -122,7 +130,7 @@ export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
|
|
|
122
130
|
export const AUDIT_CODE = Object.freeze([
|
|
123
131
|
AUDIT_WORKFLOW, '.github/workflows/atlas-gate.yml', '.github/workflows/atlas-jobs.yml', '.github/workflows/release.yml',
|
|
124
132
|
'packages/atlas/bin/atlas', 'packages/atlas/door/cli.mjs', 'packages/atlas/owner-only.yaml', 'packages/atlas/door/jobs/gate-audit.yaml',
|
|
125
|
-
...['gate.mjs', 'gate-audit.mjs', 'gate-check.mjs', 'owner-only.mjs', 'jobs.mjs', 'issues.mjs', 'signed-act.mjs'].map((name) => `packages/atlas/door/lib/${name}`),
|
|
133
|
+
...['gate.mjs', 'gate-audit.mjs', 'gate-check.mjs', 'owner-only.mjs', 'jobs.mjs', 'issues.mjs', 'signed-act.mjs', 'window.mjs'].map((name) => `packages/atlas/door/lib/${name}`),
|
|
126
134
|
'packages/atlas/shape/check.mjs', 'packages/atlas/work/lib/child-env.mjs',
|
|
127
135
|
]);
|
|
128
136
|
export const AUDIT_CODE_TEST = 'audit-code';
|