@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.
@@ -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
- exec('npm', ['pack', `${name}@${target}`, '--pack-destination', work]);
349
- const tarball = readdirSync(work).find((entry) => entry.endsWith('.tgz'));
350
- if (!tarball) throw new Error('npm pack wrote no tarball');
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', join(work, tarball)]);
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
+ }
@@ -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';