@impeccable/reviews 0.1.1 → 0.1.2

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/dist/report.js ADDED
@@ -0,0 +1,68 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ /** How long the command waits for its reports at the end: never long enough for anyone to notice. */
4
+ const SEND_MS = 2000;
5
+ /** Off with --no-telemetry or DO_NOT_TRACK=1: nothing is sent, not even the launch. */
6
+ export function reportingOff(env, flag) {
7
+ return flag || /^(1|true|yes)$/i.test(env.DO_NOT_TRACK?.trim() ?? '');
8
+ }
9
+ function version() {
10
+ try {
11
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
+ }
13
+ catch {
14
+ return 'unknown';
15
+ }
16
+ }
17
+ /**
18
+ * The command's reporter. Before sign-in it holds its steps, since only a
19
+ * token names the person they belong to; signing in sends them with it, at the
20
+ * time each happened. A command that ends without signing in sends them with
21
+ * no token, under its launch id alone, and the Worker keeps them unnamed.
22
+ * Nothing it sends can fail the command or hold it past two seconds.
23
+ */
24
+ export function createReporter(opts) {
25
+ const send = opts.fetchImpl ?? fetch;
26
+ const launchId = opts.launchId ?? randomUUID();
27
+ const about = { launch_id: launchId, version: version(), node_major: Number(process.versions.node.split('.')[0]), os: process.platform };
28
+ let held = [];
29
+ let auth;
30
+ const sending = new Set();
31
+ const post = (events, token, url = `${opts.host}/app/cli/events`) => {
32
+ if (!events.length)
33
+ return;
34
+ const sent = send(url, {
35
+ method: 'POST',
36
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
37
+ body: JSON.stringify({ ...about, events }),
38
+ signal: AbortSignal.timeout(SEND_MS),
39
+ }).catch(() => undefined);
40
+ sending.add(sent);
41
+ void sent.finally(() => sending.delete(sent));
42
+ };
43
+ return {
44
+ step(step) {
45
+ if (opts.off)
46
+ return;
47
+ const stamped = { ...step, at: new Date().toISOString() };
48
+ if (auth)
49
+ post([stamped], auth.token, auth.url);
50
+ else
51
+ held.push(stamped);
52
+ },
53
+ signedIn(token, cliApi) {
54
+ if (opts.off)
55
+ return;
56
+ auth = { token, url: `${cliApi.replace(/\/$/, '')}/events` };
57
+ post(held, token, auth.url);
58
+ held = [];
59
+ },
60
+ async done() {
61
+ if (opts.off)
62
+ return;
63
+ post(held);
64
+ held = [];
65
+ await Promise.race([Promise.allSettled([...sending]), new Promise((resolve) => setTimeout(resolve, SEND_MS).unref())]);
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,280 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { homedir, tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
8
+ import { createLocalBashOperations } from '@earendil-works/pi-coding-agent';
9
+ import { UserError } from './log.js';
10
+ /** Its git folders, as git names them; readCheckout has already refused a checkout whose own .git holds a commondir. */
11
+ export function gitDirsOf(root) {
12
+ const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
13
+ return { root: realpathSync(root), gitDir: realpathSync(git('rev-parse', '--absolute-git-dir')), commonDir: realpathSync(resolve(root, git('rev-parse', '--git-common-dir'))) };
14
+ }
15
+ let pinned;
16
+ let own;
17
+ /**
18
+ * A folder of the command's own, outside the checkout and everything a sandboxed command writes. What runs from here
19
+ * as the person and fetches a package (npx for cf, a host CLI's install) reads no .npmrc and no node_modules of the
20
+ * repository's choosing.
21
+ */
22
+ export function ownFolder() {
23
+ if (!own) {
24
+ const folder = (own = mkdtempSync(join(tmpdir(), 'impeccable-reviews-own-')));
25
+ process.on('exit', () => rmSync(folder, { recursive: true, force: true }));
26
+ }
27
+ return own;
28
+ }
29
+ /**
30
+ * The environment of every git that runs outside the sandbox, the command's own and the person's commands (gh and the
31
+ * host CLIs run git too): no hooks and no fsmonitor, whose programs a sandboxed command could have written, and, once
32
+ * the sandbox started, this checkout's git folders named rather than found, so a .git or a commondir a sandboxed
33
+ * command made is never what git reads.
34
+ */
35
+ export function gitEnvironment(env = process.env) {
36
+ const settings = [env.GIT_CONFIG_PARAMETERS, "'core.hooksPath=/dev/null' 'core.fsmonitor=false'"].filter(Boolean).join(' ');
37
+ return { ...env, GIT_CONFIG_PARAMETERS: settings, ...(pinned ? { GIT_DIR: pinned.gitDir, GIT_COMMON_DIR: pinned.commonDir, GIT_WORK_TREE: pinned.root } : {}) };
38
+ }
39
+ /** Credential folders and files, from the home folder, that no command in the sandbox reads: SSH's, GPG's and the cloud CLIs' keys, and git's, gh's and Docker's sign-ins. */
40
+ const CREDENTIALS = ['.ssh', '.gnupg', '.aws', '.azure', '.config/gcloud', '.kube', '.docker', '.netrc', '.git-credentials', '.config/gh'];
41
+ /** The package managers' caches and stores, which an install writes. */
42
+ const CACHES = ['.npm', '.pnpm-store', '.local/share/pnpm', '.cache/pnpm', '.cache/yarn', '.yarn/berry', '.bun/install/cache', '.cache/node/corepack', '.cache/prisma'];
43
+ /** macOS keeps the keychains, and some of those caches, under Library. */
44
+ const MACOS = { credentials: ['Library/Keychains'], caches: ['Library/pnpm', 'Library/Caches/pnpm', 'Library/Caches/Yarn'] };
45
+ /**
46
+ * What a command in the sandbox reads and writes: it reads what the person can but the credential folders, and writes
47
+ * in the checkout, its git folders (a worktree's are outside it), the run's scratch folder and the package caches, but
48
+ * never where git reads settings or programs from: config, hooks, info, a commondir, the submodules' and the other
49
+ * worktrees' git folders, and a .git below the checkout's top. Bubblewrap takes no pattern in a write rule, and puts a
50
+ * file in place of a denied path that is not there, which git would read as an empty commondir: on Linux only what is
51
+ * there is denied, and the git that runs outside is pinned to this checkout's folders instead.
52
+ */
53
+ export function sandboxPolicy(o) {
54
+ const macos = o.platform === 'darwin';
55
+ const { root, gitDir, commonDir } = o.git;
56
+ const inHome = (paths) => paths.map((path) => join(o.home, path));
57
+ const denyWrite = [
58
+ ...['config', 'config.worktree', 'hooks', 'info', 'modules'].map((name) => join(commonDir, name)),
59
+ ...['commondir', 'config.worktree'].map((name) => join(gitDir, name)),
60
+ ...o.worktrees.flatMap((name) => ['commondir', 'config.worktree'].map((file) => join(commonDir, 'worktrees', name, file))),
61
+ // A worktree's or a submodule's .git is a file that names its git folder.
62
+ ...(gitDir === join(root, '.git') ? [] : [join(root, '.git')]),
63
+ ...(macos ? [join(root, '*', '**', '.git')] : []),
64
+ // Inside npm's cache, which installs write: what npx installed, which the command's own tools run outside the sandbox.
65
+ join(o.home, '.npm', '_npx'),
66
+ ];
67
+ const exists = o.exists ?? existsSync;
68
+ return {
69
+ denyRead: inHome([...CREDENTIALS, ...(macos ? MACOS.credentials : [])]),
70
+ allowWrite: [...new Set([root, gitDir, commonDir, o.scratch, ...inHome([...CACHES, ...(macos ? MACOS.caches : [])])])],
71
+ denyWrite: [...new Set(macos ? denyWrite : denyWrite.filter((path) => exists(path)))],
72
+ };
73
+ }
74
+ /** GitHub and the hosts it serves files from, for a dependency on GitHub. */
75
+ const GITHUB = ['github.com', '*.github.com', '*.githubusercontent.com'];
76
+ /** The public registries a kind of manifest or lockfile installs from. */
77
+ const REGISTRIES = [
78
+ [/(?:^|\/)(?:package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|bun\.lockb?)$/, ['registry.npmjs.org']],
79
+ [/(?:^|\/)(?:yarn\.lock|\.yarnrc\.yml)$/, ['registry.npmjs.org', 'registry.yarnpkg.com', 'repo.yarnpkg.com']],
80
+ [/(?:^|\/)(?:requirements[\w.-]*\.txt|pyproject\.toml|Pipfile|Pipfile\.lock|poetry\.lock|uv\.lock)$/, ['pypi.org', 'files.pythonhosted.org']],
81
+ ];
82
+ /** What a dependency downloads while the app installs or builds: Google's fonts for next/font, Prisma's engines. */
83
+ const DOWNLOADS = {
84
+ next: ['fonts.googleapis.com', 'fonts.gstatic.com'],
85
+ prisma: ['binaries.prisma.sh'],
86
+ '@prisma/client': ['binaries.prisma.sh'],
87
+ };
88
+ /** A host's API, once the person said yes to setting that host up. */
89
+ const HOST_APIS = { vercel: ['vercel.com', '*.vercel.com'], netlify: ['netlify.com', '*.netlify.com'] };
90
+ /** The hosts of the registries an npm config names, in its registry=, @scope:registry= and //host/:_authToken= lines; never a value's secret. */
91
+ export function npmrcHosts(text) {
92
+ const hosts = [];
93
+ for (const line of text.split(/\r?\n/)) {
94
+ const registry = /^\s*(?:@[\w.-]+:)?registry\s*=\s*(\S+)/i.exec(line)?.[1];
95
+ const auth = /^\s*\/\/([^/\s:]+(?::\d+)?)\//.exec(line)?.[1];
96
+ if (auth)
97
+ hosts.push(auth.toLowerCase());
98
+ else if (registry && URL.canParse(registry))
99
+ hosts.push(new URL(registry).host);
100
+ }
101
+ return hosts;
102
+ }
103
+ /** The names of a package.json's dependencies of every kind; none when it cannot be read. */
104
+ function dependenciesOf(json) {
105
+ try {
106
+ const manifest = JSON.parse(json ?? '');
107
+ return ['dependencies', 'devDependencies', 'optionalDependencies'].flatMap((field) => Object.keys(manifest[field] ?? {}));
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ /**
114
+ * The hosts a command in the sandbox reaches: --host, GitHub, the public registries the checkout's manifests and
115
+ * lockfiles install from, what its dependencies download, the registries of the person's own npm config, and a host's
116
+ * API once they said yes to setting it up. A registry the checkout's own lockfile names is its text, not the person's:
117
+ * it is reached when it is a public one, or theirs.
118
+ */
119
+ export function allowedDomains(s) {
120
+ const host = new URL(s.host);
121
+ const names = [host.port ? `${host.hostname}:${host.port}` : host.hostname, ...GITHUB];
122
+ for (const [kind, registries] of REGISTRIES)
123
+ if (s.files.some((file) => kind.test(file)))
124
+ names.push(...registries);
125
+ // The checkout's own manifests: node_modules holds other people's, and can hold thousands.
126
+ for (const manifest of s.files.filter((file) => /(?:^|\/)package\.json$/.test(file) && !/(?:^|\/)node_modules\//.test(file)))
127
+ names.push(...dependenciesOf(s.read(manifest)).flatMap((name) => DOWNLOADS[name] ?? []));
128
+ names.push(...npmrcHosts(s.npmrc ?? ''));
129
+ for (const agreed of s.hosts)
130
+ names.push(...(HOST_APIS[agreed] ?? []));
131
+ return [...new Set(names)];
132
+ }
133
+ /**
134
+ * What a command in the sandbox also gets: Node's fetch through the sandbox's proxy; no browser downloads, which land in
135
+ * caches it does not write, from hosts it does not reach; and no git hooks installed by husky, which writes the git config
136
+ * the sandbox keeps.
137
+ */
138
+ const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0' };
139
+ /** The sandbox's own command line, which the local preview check runs each command through, in a sandbox of its own. */
140
+ const SRT = createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime/dist/cli.js');
141
+ /** Runs `true` in the sandbox: undefined when it did, else the first line of why not. */
142
+ async function selfTest() {
143
+ const wrapped = await SandboxManager.wrapWithSandbox('true');
144
+ return new Promise((done) => {
145
+ let said = '';
146
+ const child = spawn(existsSync('/bin/bash') ? '/bin/bash' : 'bash', ['-c', wrapped], { stdio: ['ignore', 'ignore', 'pipe'] });
147
+ child.stderr.on('data', (chunk) => (said += chunk.toString()));
148
+ child.once('error', (err) => done(err.message));
149
+ child.once('close', (code) => done(code === 0 ? undefined : said.trim().split('\n')[0] || `it exited with ${code}`));
150
+ });
151
+ }
152
+ /** The files the checkout tracks and the new ones it does not ignore, by path from its top. */
153
+ function checkoutFiles(root) {
154
+ try {
155
+ return execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024, env: gitEnvironment() }).split('\0').filter(Boolean);
156
+ }
157
+ catch {
158
+ return [];
159
+ }
160
+ }
161
+ const readText = (path) => {
162
+ try {
163
+ return readFileSync(path, 'utf8');
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ };
169
+ /**
170
+ * Starts the sandbox for this run, before anything runs: on macOS a sandbox that will not start stops the command,
171
+ * and elsewhere its commands run unsandboxed, which `warning` says once, on the first screen. From here on git
172
+ * outside the sandbox is pinned to this checkout's folders.
173
+ */
174
+ export async function startSandbox(o) {
175
+ const platform = process.platform;
176
+ const home = o.home ?? homedir();
177
+ const git = gitDirsOf(o.root);
178
+ pinned = git;
179
+ // Bubblewrap denies only what is there, so the folder npx keeps its installs in is made before any command runs: a command in the sandbox never gets to make it its own.
180
+ try {
181
+ mkdirSync(join(home, '.npm', '_npx'), { recursive: true });
182
+ }
183
+ catch {
184
+ // A home that takes no folder takes none from a sandboxed command either.
185
+ }
186
+ const worktrees = (() => {
187
+ try {
188
+ return readdirSync(join(git.commonDir, 'worktrees'));
189
+ }
190
+ catch {
191
+ return [];
192
+ }
193
+ })();
194
+ const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees });
195
+ const files = checkoutFiles(git.root);
196
+ const sources = { host: o.host, files, read: (path) => readText(join(git.root, path)), npmrc: readText(join(home, '.npmrc')) };
197
+ let domains = allowedDomains({ ...sources, hosts: o.hosts() });
198
+ const config = { network: { allowedDomains: domains, deniedDomains: [] }, filesystem: policy };
199
+ // Temp files land in the scratch folder: the sandbox's runtime names the folder its commands get as TMPDIR from this variable.
200
+ const temp = join(o.scratch, 'tmp');
201
+ mkdirSync(temp, { recursive: true });
202
+ process.env.CLAUDE_CODE_TMPDIR = temp;
203
+ // The local preview check's settings sit where no sandboxed command writes.
204
+ const settings = ownFolder();
205
+ let off;
206
+ if (platform !== 'darwin' && platform !== 'linux')
207
+ off = `no sandbox for ${platform}`;
208
+ else if (!SandboxManager.isSupportedPlatform())
209
+ off = 'no sandbox for this system';
210
+ else {
211
+ const missing = SandboxManager.checkDependencies().errors;
212
+ if (missing.length)
213
+ off = missing.join(', ');
214
+ }
215
+ if (!off) {
216
+ try {
217
+ // A host off the list is refused by the sandbox's proxy; the debug log names it, which says what a failed install or build wanted.
218
+ await SandboxManager.initialize(config, async ({ host, port }) => {
219
+ o.log.write('sandbox refused a host', `${host}:${port}`);
220
+ return false;
221
+ });
222
+ off = await selfTest();
223
+ }
224
+ catch (err) {
225
+ off = (err instanceof Error ? err.message : String(err)).split('\n')[0];
226
+ }
227
+ if (off)
228
+ await SandboxManager.reset().catch(() => { });
229
+ }
230
+ if (off && platform === 'darwin') {
231
+ throw new UserError(`Impeccable runs the setup's commands in macOS's sandbox, and it did not start here (${off}). Run impeccable-reviews in a terminal of your own, outside any other sandbox.`, { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
232
+ }
233
+ o.log.write(off ? `sandbox off: ${off}` : 'sandbox on', { domains, ...policy });
234
+ const local = createLocalBashOperations();
235
+ let running = 0;
236
+ const shell = {
237
+ exec: async (command, cwd, options) => {
238
+ const place = o.place(command);
239
+ if (place === 'install')
240
+ return local.exec(command, ownFolder(), options);
241
+ if (place === 'person')
242
+ return local.exec(command, cwd, { ...options, env: gitEnvironment(options.env) });
243
+ if (off)
244
+ return local.exec(command, cwd, options);
245
+ // A host's API joins the list once the person said yes to setting that host up.
246
+ const next = allowedDomains({ ...sources, hosts: o.hosts() });
247
+ if (next.join() !== domains.join()) {
248
+ domains = next;
249
+ SandboxManager.updateConfig({ ...config, network: { ...config.network, allowedDomains: domains } });
250
+ o.log.write('sandbox hosts', domains);
251
+ }
252
+ const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, undefined, options.signal);
253
+ running++;
254
+ try {
255
+ return await local.exec(wrapped, cwd, { ...options, env: { ...(options.env ?? process.env), ...INSIDE } });
256
+ }
257
+ finally {
258
+ // On Linux this takes away the files bubblewrap put in place of absent denied paths: only once no command still runs on them.
259
+ if (--running === 0)
260
+ SandboxManager.cleanupAfterCommand();
261
+ }
262
+ },
263
+ };
264
+ return {
265
+ warning: off && `${off[0].toUpperCase()}${off.slice(1)}, so the setup's commands run without a sandbox, with all your access.`,
266
+ shell,
267
+ preview(folder) {
268
+ if (off || platform === 'linux')
269
+ return null;
270
+ const file = join(settings, `preview-${randomBytes(6).toString('hex')}.json`);
271
+ const own = {
272
+ network: { allowedDomains: domains, deniedDomains: [], allowLocalBinding: true },
273
+ filesystem: { denyRead: policy.denyRead, allowWrite: [realpathSync(folder)], denyWrite: [] },
274
+ };
275
+ writeFileSync(file, JSON.stringify(own), { mode: 0o600 });
276
+ // The command's TMPDIR comes from CLAUDE_CODE_TMPDIR; the sandbox's own sockets stay in this process's temp folder, whose path fits a socket's.
277
+ return (command, env) => ({ file: process.execPath, args: [SRT, '--settings', file, '-c', command], env: { ...env, ...INSIDE, CLAUDE_CODE_TMPDIR: env.TMPDIR ?? join(folder, 'tmp'), TMPDIR: tmpdir() } });
278
+ },
279
+ };
280
+ }
package/dist/tools.js CHANGED
@@ -7,12 +7,12 @@ import { request } from 'node:http';
7
7
  import { createServer } from 'node:net';
8
8
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
9
  import { defineTool } from '@earendil-works/pi-coding-agent';
10
- import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
10
+ import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
13
  import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
14
14
  import { netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
15
- import { pagesProject, readCloudflarePreviews } from './cloudflare.js';
15
+ import { cfRunner, pagesProject, readCloudflarePreviews } from './cloudflare.js';
16
16
  import { passCloudflareAccess } from './cloudflare-access.js';
17
17
  import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
18
18
  import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
@@ -20,6 +20,7 @@ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, ve
20
20
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
21
21
  import { fitsTask, stagesOf } from './stages.js';
22
22
  import { redact } from './log.js';
23
+ import { gitEnvironment } from './sandbox.js';
23
24
  import { openBrowser } from './login.js';
24
25
  import { TASK_STATUSES } from './sync.js';
25
26
  import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
@@ -409,7 +410,7 @@ async function stopPreviewChild(child) {
409
410
  catch { /* already stopped */ }
410
411
  }
411
412
  /** Local reproduction only: no credentials, external database, response body or process output leaves this helper. */
412
- export async function verifyLocalPreview(input, root, signal) {
413
+ export async function verifyLocalPreview(input, root, signal, sandbox) {
413
414
  const probes = [];
414
415
  const fail = (stage, reason) => ({ verified: false, stage, reason, probes });
415
416
  if (process.platform === 'win32')
@@ -506,8 +507,11 @@ export async function verifyLocalPreview(input, root, signal) {
506
507
  scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
507
508
  const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
508
509
  await Promise.all([snapshot, home, temp].map((p) => mkdir(p, { mode: 0o700 })));
510
+ // The app's own commands run in a sandbox of their own, writing only in this folder; the check's git and Docker run outside it.
511
+ const inSandbox = sandbox?.preview(scratch);
512
+ const confined = (command, processEnv) => inSandbox?.(command, processEnv) ?? { file: '/bin/sh', args: ['-c', command], env: processEnv };
509
513
  env = { ...env, ...supplied, ...(serverInput ? { PORT: String(serverInput.port) } : {}), HOME: home, TMPDIR: temp, TMP: temp, TEMP: temp, XDG_CONFIG_HOME: join(home, '.config'), npm_config_cache: join(scratch, 'npm'), npm_config_userconfig: join(home, '.npmrc'), npm_config_globalconfig: join(scratch, 'global-npmrc'), PIP_CONFIG_FILE: '/dev/null', PIP_NO_INPUT: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_REQUIRE_VIRTUALENV: 'true', BUNDLE_PATH: join(scratch, 'bundle'), BUNDLE_APP_CONFIG: join(scratch, 'bundle-config') };
510
- const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true);
514
+ const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true, 120_000, signal ?? null, gitEnvironment(env));
511
515
  if (!listing.ok)
512
516
  return fail(stage, 'Could not list checkout files.');
513
517
  const files = [...new Set(listing.output.split('\0').filter(Boolean))];
@@ -547,7 +551,7 @@ export async function verifyLocalPreview(input, root, signal) {
547
551
  await copyFile(source, target);
548
552
  await chmod(target, info.mode & 0o777);
549
553
  if (file.split('/').at(-1) === 'package.json') {
550
- const baseline = await run('git', ['show', `HEAD:${file}`], root, true);
554
+ const baseline = await run('git', ['show', `HEAD:${file}`], root, true, 120_000, signal ?? null, gitEnvironment(env));
551
555
  const scripts = (value) => Object.entries(JSON.parse(value).scripts ?? {}).sort(([a], [b]) => a.localeCompare(b));
552
556
  scriptsChanged ||= !baseline.ok || JSON.stringify(scripts(baseline.output)) !== JSON.stringify(scripts(await readFile(source, 'utf8')));
553
557
  }
@@ -629,8 +633,9 @@ export async function verifyLocalPreview(input, root, signal) {
629
633
  }
630
634
  if (!executable)
631
635
  return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
632
- // Precreate even for explicit bootstrap steps so every command in a compound step uses it.
633
- if (!(await run(executable, ['-m', 'venv', '.venv'], directory)).ok)
636
+ // Precreate even for explicit bootstrap steps so every command in a compound step uses it. `-m` imports from the folder, the checkout's code, so it runs in the sandbox too.
637
+ const venv = confined(`'${executable.replace(/'/g, `'\\''`)}' -m venv .venv`, env);
638
+ if (!(await run(venv.file, venv.args, directory, false, 120_000, signal ?? null, venv.env)).ok)
634
639
  return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
635
640
  }
636
641
  const environmentFor = (directory) => {
@@ -666,7 +671,8 @@ export async function verifyLocalPreview(input, root, signal) {
666
671
  }
667
672
  for (const [index, step] of input.steps.entries()) {
668
673
  stage = step.phase;
669
- if (!(await run('/bin/sh', ['-c', step.command], directories[index], false, 120_000, signal ?? null, environmentFor(directories[index]))).ok)
674
+ const command = confined(step.command, environmentFor(directories[index]));
675
+ if (!(await run(command.file, command.args, directories[index], false, 120_000, signal ?? null, command.env)).ok)
670
676
  return fail(stage, signal?.aborted ? 'Verification was cancelled.' : 'The local step failed or exceeded two minutes; its output was not retained.');
671
677
  }
672
678
  if (staticInput) {
@@ -682,7 +688,8 @@ export async function verifyLocalPreview(input, root, signal) {
682
688
  const free = await new Promise((done) => { const check = createServer(); check.once('error', () => done(false)); check.listen(serverInput.port, '127.0.0.1', () => check.close(() => done(true))); });
683
689
  if (!free)
684
690
  return fail(stage, 'The preview port is already in use; an existing service was not probed.');
685
- server = spawn('/bin/sh', ['-c', serverInput.start.command], { cwd: directories.at(-1), env: environmentFor(directories.at(-1)), detached: true, stdio: 'ignore' });
691
+ const start = confined(serverInput.start.command, environmentFor(directories.at(-1)));
692
+ server = spawn(start.file, start.args, { cwd: directories.at(-1), env: start.env, detached: true, stdio: 'ignore' });
686
693
  let exited = false;
687
694
  server.once('error', () => { exited = true; });
688
695
  server.once('exit', () => { exited = true; });
@@ -746,7 +753,7 @@ const execFileAsync = promisify(execFile);
746
753
  */
747
754
  export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
748
755
  try {
749
- await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000 });
756
+ await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000, env: gitEnvironment() });
750
757
  return true;
751
758
  }
752
759
  catch (err) {
@@ -981,11 +988,11 @@ export function workflowGitEnvironment() {
981
988
  'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CURL_VERBOSE', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG',
982
989
  'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM', 'GIT_PREFIX', 'GIT_ASKPASS', 'SSH_ASKPASS'].includes(key))
983
990
  delete env[key];
984
- return Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
991
+ return gitEnvironment(Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
985
992
  GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', GIT_SSH_VARIANT: 'ssh',
986
993
  GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
987
994
  // Removing the variables alone still permits globally configured trace2 targets.
988
- GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' });
995
+ GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' }));
989
996
  }
990
997
  const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
991
998
  export function workflowDefaultBranch(value) {
@@ -1413,16 +1420,16 @@ export function createTools(ctx) {
1413
1420
  let provenHost;
1414
1421
  let savedHost;
1415
1422
  /**
1416
- * A host's build, waited for on the screen as the task in progress with its timer: `read` again every so often until
1417
- * `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
1423
+ * A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
1424
+ * every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
1418
1425
  */
1419
- async function awaitBuild(status, read, built, signal) {
1426
+ async function awaitBuild(host, status, read, built, signal) {
1420
1427
  let evidence = await read();
1421
1428
  if (built(evidence))
1422
1429
  return evidence;
1423
1430
  const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
1424
1431
  said(status);
1425
- reportWait({ since: Date.now(), limitMs: wait.totalMs });
1432
+ reportWait({ since: Date.now(), host });
1426
1433
  try {
1427
1434
  for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
1428
1435
  await sleep(wait.everyMs, undefined, { signal });
@@ -1528,6 +1535,12 @@ export function createTools(ctx) {
1528
1535
  const hostSignIns = new Map();
1529
1536
  /** The teams a host's sign-in offered, each by name and slug: the choices a `<host>-team` question shows, whatever the model lists, so the pick the fence takes is a slug. */
1530
1537
  const teamChoices = new Map();
1538
+ /** A host's setup ended before anything was set up on the host: nothing more is asked for it, and the fence lets its CLI and its hold on pushes go. */
1539
+ const endSetup = (host) => {
1540
+ hostSignIns.set(host, 'skipped');
1541
+ teamChoices.delete(host);
1542
+ endHostSetup(ctx.log, host);
1543
+ };
1531
1544
  const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1532
1545
  const hostCli = (host, pi, signal) => {
1533
1546
  const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
@@ -1577,7 +1590,7 @@ export function createTools(ctx) {
1577
1590
  if (!hostSignIns.has(host)) {
1578
1591
  const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
1579
1592
  if (answer.skipped) {
1580
- hostSignIns.set(host, 'skipped');
1593
+ endSetup(host);
1581
1594
  return { skipped: true };
1582
1595
  }
1583
1596
  hostSignIns.set(host, 'confirmed');
@@ -1599,11 +1612,11 @@ export function createTools(ctx) {
1599
1612
  if (status.cli === 'signed-in')
1600
1613
  return signedIn();
1601
1614
  if (attempt === 2) {
1602
- hostSignIns.set(host, 'skipped');
1615
+ endSetup(host);
1603
1616
  return { skipped: true, reason: `The ${HOST_NAMES[host]} sign-in did not complete.` };
1604
1617
  }
1605
1618
  if ((await ask(HOST_SCREENS[`${host}-signin-retry`])).skipped) {
1606
- hostSignIns.set(host, 'skipped');
1619
+ endSetup(host);
1607
1620
  return { skipped: true };
1608
1621
  }
1609
1622
  }
@@ -1742,7 +1755,7 @@ export function createTools(ctx) {
1742
1755
  return [
1743
1756
  tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. Only the push itself proves Git may push.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
1744
1757
  tool('impeccable_sync_workflow', 'Update the preview workflow', 'Write the state\'s workflow to .github/workflows/impeccable-preview.yml byte for byte and remove the retired workflow the state names, after checking access as impeccable_workflow_access does. Call it once the settings are saved, and again after committing: stagedMatches and committedMatches true (and retiredRemoval staged and committed) prove the commit holds the exact bytes; null means nothing was compared. It refuses, changing nothing, when the file has the person\'s local edits. It never branches, stages, commits or pushes.', NO_INPUT, (_p, pi, signal) => syncWorkflowTool(pi, signal)),
1745
- tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal))),
1758
+ tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal, ctx.sandbox))),
1746
1759
  tool('impeccable_preview_run', 'Read the preview attempt', 'On a rerun, once impeccable_test_pr recorded the setup pull request: read its preview workflow\'s runs. Answers each attempt with its failed stage and a fixed reason (never log text), the three-attempt budget (used, remaining, historyKnown), and adds the attempts to the task list. Reading is free.', PREVIEW_RUN, async (p, pi, signal) => {
1747
1760
  if (!retry.pr)
1748
1761
  return 'Refused: record the setup pull request with impeccable_test_pr first.';
@@ -1884,8 +1897,12 @@ export function createTools(ctx) {
1884
1897
  if (error)
1885
1898
  return `Invalid input: ${error}.`;
1886
1899
  const answer = await askPerson(q);
1887
- if (answer.skipped)
1900
+ if (answer.skipped) {
1901
+ // Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
1902
+ if (host?.[1] === 'vercel')
1903
+ endSetup('vercel');
1888
1904
  return JSON.stringify({ skipped: true });
1905
+ }
1889
1906
  if (q.kind === 'multi')
1890
1907
  return JSON.stringify({ values: answer.values ?? [] });
1891
1908
  if (q.kind === 'confirm') {
@@ -1986,7 +2003,7 @@ export function createTools(ctx) {
1986
2003
  const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
1987
2004
  // A head's build is still building until it has a final status.
1988
2005
  const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
1989
- const evidence = branch !== undefined ? await awaitBuild(`Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
2006
+ const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
1990
2007
  listedPreviews.clear();
1991
2008
  vercelMade.clear();
1992
2009
  publicProduction.clear();
@@ -2019,24 +2036,24 @@ export function createTools(ctx) {
2019
2036
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
2020
2037
  const states = ['success', 'ready', 'failure', 'error'];
2021
2038
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
2022
- const evidence = pull !== undefined ? await awaitBuild(`Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
2039
+ const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
2023
2040
  netlifyListed.clear();
2024
2041
  for (const p of evidence.previews)
2025
2042
  if (p.state === 'success' || p.state === 'ready')
2026
2043
  netlifyListed.add(new URL(p.url).origin);
2027
2044
  return JSON.stringify(evidence);
2028
2045
  }),
2029
- tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or wrangler on this machine when it is signed in (read only: it never signs in). cli says whether wrangler is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or wrangler. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2046
+ tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2030
2047
  const pull = p.pull;
2031
2048
  const branch = p.branch;
2032
2049
  if (pull !== undefined && branch !== undefined)
2033
2050
  return 'Invalid input: give pull or branch, not both.';
2034
2051
  const read = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch });
2035
- // Cloudflare posts its check run once the build ended: until then the head lists nothing, or wrangler's row says it is building.
2036
- // GitHub's record unread ends the wait; wrangler's alone does not, since the check run still says when the build ends.
2052
+ // Cloudflare posts its check run once the build ended: until then the head lists nothing, or cf's row says it is building.
2053
+ // GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
2037
2054
  const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
2038
2055
  const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
2039
- const evidence = subject ? await awaitBuild(`Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
2056
+ const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
2040
2057
  cloudflareListed.clear();
2041
2058
  for (const preview of evidence.previews) {
2042
2059
  if (preview.state !== 'success')
@@ -2142,15 +2159,14 @@ export function createTools(ctx) {
2142
2159
  const at = gated;
2143
2160
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
2144
2161
  let pattern;
2145
- const prove = async (password) => {
2146
- const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password });
2162
+ const prove = async (password, form = false) => {
2163
+ const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form });
2147
2164
  if (proof.proven && proof.pattern)
2148
2165
  pattern = proof.pattern;
2149
2166
  return proof;
2150
2167
  };
2151
- // A password in hand already: another deploy preview of the same site opens with it.
2152
- if (passwordSaved && sitePassword && (await prove(sitePassword)).proven) {
2153
- passwordSites.add(site);
2168
+ // A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
2169
+ if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
2154
2170
  provenPattern = pattern ?? provenPattern;
2155
2171
  return JSON.stringify(passwordSaved);
2156
2172
  }
@@ -2197,7 +2213,7 @@ export function createTools(ctx) {
2197
2213
  }
2198
2214
  return JSON.stringify(passwordOutcome);
2199
2215
  }),
2200
- tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token and one taking the Cloudflare API token that creating it takes, then the token named "Impeccable reviews" created and allowed in the policy of the application in front of the preview; on a skip, or where that cannot be done, one screen taking a pasted client id and secret. It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out), skipped (nothing was saved; reason says what stopped it), refused (the typed token did not open the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2216
+ tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret. It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (the typed token did not open the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2201
2217
  if (!accessGated)
2202
2218
  return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
2203
2219
  if (accessOutcome?.status === 'skipped')
@@ -2228,6 +2244,10 @@ export function createTools(ctx) {
2228
2244
  answer = await passCloudflareAccess({
2229
2245
  url: at.url,
2230
2246
  consent: async () => !(await ask(HOST_SCREENS['cloudflare-access'])).skipped,
2247
+ cf: cfRunner({}, ctx.hostRunner),
2248
+ login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
2249
+ retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
2250
+ status: said,
2231
2251
  apiToken: async () => {
2232
2252
  const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
2233
2253
  return typed.skipped ? undefined : typed.value;
@@ -2250,7 +2270,7 @@ export function createTools(ctx) {
2250
2270
  accessProjects.clear();
2251
2271
  accessToken = value;
2252
2272
  accessStored = told.status === 'saved';
2253
- accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}) };
2273
+ accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
2254
2274
  const project = pagesProject(new URL(at.url).origin);
2255
2275
  if (project)
2256
2276
  accessProjects.add(project);