@impeccable/reviews 0.1.0 → 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/tools.js CHANGED
@@ -7,16 +7,20 @@ 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 } 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
- import { focusCheckup, focusState } from './focus.js';
12
+ import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
13
  import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
14
- import { previewHostName, provePreview, readHostedPreviews } from './hosted.js';
14
+ import { netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
15
+ import { cfRunner, pagesProject, readCloudflarePreviews } from './cloudflare.js';
16
+ import { passCloudflareAccess } from './cloudflare-access.js';
15
17
  import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
16
- import { connectVercelProject, vercelStatus, vercelTeams, waitForVercelAccess } from './vercel.js';
18
+ import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
19
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelStatus, vercelTeams, waitForVercelAccess } from './vercel.js';
17
20
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
18
21
  import { fitsTask, stagesOf } from './stages.js';
19
22
  import { redact } from './log.js';
23
+ import { gitEnvironment } from './sandbox.js';
20
24
  import { openBrowser } from './login.js';
21
25
  import { TASK_STATUSES } from './sync.js';
22
26
  import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
@@ -196,12 +200,20 @@ const NETLIFY_PREVIEWS = {
196
200
  pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
197
201
  },
198
202
  };
203
+ const CLOUDFLARE_PREVIEWS = {
204
+ type: 'object',
205
+ additionalProperties: false,
206
+ properties: {
207
+ pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
208
+ branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the build of its head only, and waits up to five minutes for it to finish.' },
209
+ },
210
+ };
199
211
  const PROVE_PREVIEW = {
200
212
  type: 'object',
201
213
  additionalProperties: false,
202
214
  required: ['url', 'expect'],
203
215
  properties: {
204
- url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews or impeccable_netlify_previews listed, or a page on it.' },
216
+ url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed, or a page on it.' },
205
217
  expect: { type: 'string', minLength: 1, description: 'Words the page shows that a file of this checkout holds, such as the app\'s name in its title.' },
206
218
  },
207
219
  };
@@ -222,7 +234,7 @@ const ASK = {
222
234
  options: {
223
235
  type: 'array',
224
236
  description: 'single and multi: the choices, at least two.',
225
- items: { type: 'object', additionalProperties: false, required: ['label', 'value'], properties: { label: { type: 'string', minLength: 1 }, value: { type: 'string', minLength: 1 } } },
237
+ items: { type: 'object', additionalProperties: false, required: ['label', 'value'], properties: { label: { type: 'string', minLength: 1 }, value: { type: 'string', minLength: 1 }, selected: { type: 'boolean', description: 'multi only: picked when the question opens; the person can unpick it.' } } },
226
238
  },
227
239
  prefill: { type: 'string', minLength: 1, description: 'text, not with sensitive, and account: what the (username) field holds when the dialog opens, such as the seed\'s username. The person can change it.' },
228
240
  confirmLabel: { type: 'string', minLength: 1, description: 'confirm only: the way on, such as "Share test accounts".' },
@@ -252,6 +264,8 @@ function askError(q) {
252
264
  return q.options ? 'input.options is for single and multi only' : null;
253
265
  if (values.length < 2)
254
266
  return `input.options needs at least two choices for ${q.kind}`;
267
+ if (q.kind === 'single' && q.options.some((o) => o.selected !== undefined))
268
+ return 'input.options[].selected is for multi only';
255
269
  return new Set(values).size === values.length ? null : 'input.options has a value twice';
256
270
  }
257
271
  /** Why `url` is not pull request `number` of owner/repo on GitHub; null when it is. */
@@ -396,7 +410,7 @@ async function stopPreviewChild(child) {
396
410
  catch { /* already stopped */ }
397
411
  }
398
412
  /** Local reproduction only: no credentials, external database, response body or process output leaves this helper. */
399
- export async function verifyLocalPreview(input, root, signal) {
413
+ export async function verifyLocalPreview(input, root, signal, sandbox) {
400
414
  const probes = [];
401
415
  const fail = (stage, reason) => ({ verified: false, stage, reason, probes });
402
416
  if (process.platform === 'win32')
@@ -493,8 +507,11 @@ export async function verifyLocalPreview(input, root, signal) {
493
507
  scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
494
508
  const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
495
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 };
496
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') };
497
- 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));
498
515
  if (!listing.ok)
499
516
  return fail(stage, 'Could not list checkout files.');
500
517
  const files = [...new Set(listing.output.split('\0').filter(Boolean))];
@@ -534,7 +551,7 @@ export async function verifyLocalPreview(input, root, signal) {
534
551
  await copyFile(source, target);
535
552
  await chmod(target, info.mode & 0o777);
536
553
  if (file.split('/').at(-1) === 'package.json') {
537
- 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));
538
555
  const scripts = (value) => Object.entries(JSON.parse(value).scripts ?? {}).sort(([a], [b]) => a.localeCompare(b));
539
556
  scriptsChanged ||= !baseline.ok || JSON.stringify(scripts(baseline.output)) !== JSON.stringify(scripts(await readFile(source, 'utf8')));
540
557
  }
@@ -616,8 +633,9 @@ export async function verifyLocalPreview(input, root, signal) {
616
633
  }
617
634
  if (!executable)
618
635
  return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
619
- // Precreate even for explicit bootstrap steps so every command in a compound step uses it.
620
- 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)
621
639
  return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
622
640
  }
623
641
  const environmentFor = (directory) => {
@@ -653,7 +671,8 @@ export async function verifyLocalPreview(input, root, signal) {
653
671
  }
654
672
  for (const [index, step] of input.steps.entries()) {
655
673
  stage = step.phase;
656
- 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)
657
676
  return fail(stage, signal?.aborted ? 'Verification was cancelled.' : 'The local step failed or exceeded two minutes; its output was not retained.');
658
677
  }
659
678
  if (staticInput) {
@@ -669,7 +688,8 @@ export async function verifyLocalPreview(input, root, signal) {
669
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))); });
670
689
  if (!free)
671
690
  return fail(stage, 'The preview port is already in use; an existing service was not probed.');
672
- 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' });
673
693
  let exited = false;
674
694
  server.once('error', () => { exited = true; });
675
695
  server.once('exit', () => { exited = true; });
@@ -733,7 +753,7 @@ const execFileAsync = promisify(execFile);
733
753
  */
734
754
  export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
735
755
  try {
736
- 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() });
737
757
  return true;
738
758
  }
739
759
  catch (err) {
@@ -968,11 +988,11 @@ export function workflowGitEnvironment() {
968
988
  'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CURL_VERBOSE', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG',
969
989
  'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM', 'GIT_PREFIX', 'GIT_ASKPASS', 'SSH_ASKPASS'].includes(key))
970
990
  delete env[key];
971
- 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',
972
992
  GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', GIT_SSH_VARIANT: 'ssh',
973
993
  GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
974
994
  // Removing the variables alone still permits globally configured trace2 targets.
975
- GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' });
995
+ GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' }));
976
996
  }
977
997
  const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
978
998
  export function workflowDefaultBranch(value) {
@@ -1388,25 +1408,28 @@ export function createTools(ctx) {
1388
1408
  }
1389
1409
  /** What the person typed into sensitive questions, by the reference the model holds instead. */
1390
1410
  const typed = new Map();
1391
- /** The origins of the successful Preview deployments of pull requests GitHub listed, and of the built deploy previews Netlify's evidence listed, which a proof may open; whether one answered with this app; and the address pattern a Netlify proof answered. */
1411
+ /** The origins of the successful Preview deployments of pull requests GitHub listed, and of the built previews Netlify's and Cloudflare's evidence listed, which a proof may open; whether one answered with this app; and the address pattern a Netlify or Cloudflare proof answered. */
1392
1412
  const listedPreviews = new Set();
1393
1413
  const netlifyListed = new Set();
1414
+ /** The origins of Production deployments Vercel made of a pushed branch that no locked project of this folder's made: served at a project's own address without a login, so no proof opens them. */
1415
+ const publicProduction = new Set();
1416
+ const cloudflareListed = new Set();
1394
1417
  let hostedPreviewProven = false;
1395
1418
  let provenPattern;
1396
1419
  /** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
1397
1420
  let provenHost;
1398
1421
  let savedHost;
1399
1422
  /**
1400
- * A host's build, waited for on the screen as the task in progress with its timer: `read` again every so often until
1401
- * `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.
1402
1425
  */
1403
- async function awaitBuild(status, read, built, signal) {
1426
+ async function awaitBuild(host, status, read, built, signal) {
1404
1427
  let evidence = await read();
1405
1428
  if (built(evidence))
1406
1429
  return evidence;
1407
1430
  const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
1408
1431
  said(status);
1409
- reportWait({ since: Date.now(), limitMs: wait.totalMs });
1432
+ reportWait({ since: Date.now(), host });
1410
1433
  try {
1411
1434
  for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
1412
1435
  await sleep(wait.everyMs, undefined, { signal });
@@ -1424,7 +1447,7 @@ export function createTools(ctx) {
1424
1447
  };
1425
1448
  /** Of the origins listed, those of deployments Vercel's own bot made: the only addresses a bypass secret is ever sent to, since a deployment anyone with write access can record names any address. */
1426
1449
  const vercelMade = new Set();
1427
- /** The address impeccable_prove_preview met Vercel's login at, and the words it looked for there. */
1450
+ /** The address impeccable_prove_preview met a host's lock at, the words it looked for there, and whose lock it was. */
1428
1451
  let gated;
1429
1452
  /** The bypass secret saved in this run, for the proof of a Vercel address to send; never for the model. It is one project's, so it goes only to the origins impeccable_vercel_bypass proved it on, never to another project's deployment of this repository. */
1430
1453
  let bypassSecret;
@@ -1432,6 +1455,24 @@ export function createTools(ctx) {
1432
1455
  /** What impeccable_vercel_bypass answered, less the secret: a skip is asked once, so that answer stands; the saved one is what an address the secret opens later gets. */
1433
1456
  let bypassOutcome;
1434
1457
  let bypassSaved;
1458
+ /** The Netlify site password saved in this run, for the proof of that site's addresses to send; never for the model. It is one site's, so it goes only to that site's deploy previews. */
1459
+ let sitePassword;
1460
+ const passwordSites = new Set();
1461
+ /** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, and the team login stays. Another site of the repository is read on its own. */
1462
+ const passwordStands = new Map();
1463
+ /** The sites whose password the person said yes to storing: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
1464
+ const passwordConsented = new Set();
1465
+ /** The saved answer, what an address the password opens later gets. */
1466
+ let passwordSaved;
1467
+ /** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
1468
+ let accessGated;
1469
+ /** The service token that opened a preview in this run, what the tool said of it, the Pages projects it opened and whether the store holds it: once it does, the proofs of those projects' previews send it; never the model. */
1470
+ let accessToken;
1471
+ let accessMade = {};
1472
+ const accessProjects = new Set();
1473
+ let accessStored = false;
1474
+ /** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
1475
+ let accessOutcome;
1435
1476
  // Keep ownership of files for this one run; never recreate it per call.
1436
1477
  const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
1437
1478
  const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
@@ -1494,6 +1535,12 @@ export function createTools(ctx) {
1494
1535
  const hostSignIns = new Map();
1495
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. */
1496
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
+ };
1497
1544
  const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1498
1545
  const hostCli = (host, pi, signal) => {
1499
1546
  const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
@@ -1543,7 +1590,7 @@ export function createTools(ctx) {
1543
1590
  if (!hostSignIns.has(host)) {
1544
1591
  const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
1545
1592
  if (answer.skipped) {
1546
- hostSignIns.set(host, 'skipped');
1593
+ endSetup(host);
1547
1594
  return { skipped: true };
1548
1595
  }
1549
1596
  hostSignIns.set(host, 'confirmed');
@@ -1565,11 +1612,11 @@ export function createTools(ctx) {
1565
1612
  if (status.cli === 'signed-in')
1566
1613
  return signedIn();
1567
1614
  if (attempt === 2) {
1568
- hostSignIns.set(host, 'skipped');
1615
+ endSetup(host);
1569
1616
  return { skipped: true, reason: `The ${HOST_NAMES[host]} sign-in did not complete.` };
1570
1617
  }
1571
1618
  if ((await ask(HOST_SCREENS[`${host}-signin-retry`])).skipped) {
1572
- hostSignIns.set(host, 'skipped');
1619
+ endSetup(host);
1573
1620
  return { skipped: true };
1574
1621
  }
1575
1622
  }
@@ -1607,14 +1654,29 @@ export function createTools(ctx) {
1607
1654
  return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
1608
1655
  ? { connected: true, site: result.site, how: 'github-app' } : result), signal);
1609
1656
  }
1610
- /** The connection of the linked Vercel project with this repository, `vercel git connect`, with the app's access asked when Vercel's GitHub app cannot reach the repository. */
1657
+ /**
1658
+ * The connection of the linked Vercel project with this repository, `vercel git connect`, with the app's access asked
1659
+ * when Vercel's GitHub app cannot reach the repository; then, connected, the project locked behind Vercel's login at
1660
+ * every address while it has no production deployment, before anything is pushed: Vercel makes a new project's first
1661
+ * deployment Production whatever branch it builds, and serves it at the project's own address.
1662
+ */
1611
1663
  async function vercelConnect(pi, signal) {
1612
1664
  if (!hostSetupAgreed(ctx.log, 'vercel'))
1613
1665
  return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
1614
1666
  const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
1615
- const result = await connectVercelProject(vercel);
1616
1667
  // Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
1617
- return hostAccess('vercel', result, () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
1668
+ const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
1669
+ // The fence holds pushes and deploys from the sign-in until the linked project's first deployment is settled: Vercel's app cannot reach the repository (a push builds nothing), production is deployed already, or the project is locked at every address.
1670
+ const link = await readLink(pi.cwd);
1671
+ if (!result.connected) {
1672
+ if (result.access !== undefined)
1673
+ settleVercelFirstDeployment(ctx.log, { projectId: link?.projectId ?? null, how: 'connection' });
1674
+ return result;
1675
+ }
1676
+ const first = link ? await prepareFirstDeployment(vercel, link) : { production: 'unknown', locked: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
1677
+ if (link && (first.production === 'exists' || first.locked === true))
1678
+ settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
1679
+ return { ...result, ...first };
1618
1680
  }
1619
1681
  async function syncWorkflowTool(pi, signal) {
1620
1682
  try {
@@ -1658,15 +1720,42 @@ export function createTools(ctx) {
1658
1720
  const res = await requestJson(url, { ...init, token: await ctx.auth.token(pi) }, ctx.log);
1659
1721
  return res.status === 401 ? requestJson(url, { ...init, token: await ctx.auth.renew(pi) }, ctx.log) : res;
1660
1722
  };
1661
- /** A read of the setup API, trimmed to this setup before the model sees it. */
1723
+ /**
1724
+ * Saves a host's key for reviews: the key as Impeccable's credential for the lock, then the setting that sends it in
1725
+ * `header`, both through the setup API. `stored` says the first write took; `failed` what stopped it, else null.
1726
+ */
1727
+ const saveLock = async (pi, what, header, value) => {
1728
+ const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
1729
+ if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value } })).ok)
1730
+ return { stored: false, failed: `The setup API did not store the ${what}.` };
1731
+ if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header } })).ok)
1732
+ return { stored: true, failed: `The setup API stored the ${what}, but not the setting that sends it: it is turned on under Deployment protection on the setup page.` };
1733
+ return { stored: true, failed: null };
1734
+ };
1735
+ /** The instructions the writes are checked against: the first state may have come before the scan proposed the apps, so a later read that shows them brings the apps write in. */
1736
+ let instructions = ctx.instructions;
1737
+ /** Whether the run signed in again for apps the scan proposed after the sign-in. */
1738
+ let renewedForApps = false;
1739
+ /**
1740
+ * A read of the state or the checkup, trimmed to this setup before the model sees it. The setup token covers the apps
1741
+ * there were at sign-in, so an app the scan proposed later reads `not-selected`: the run signs in again, for a token
1742
+ * that covers every app there is now, and reads again. Once it has, a card still left out goes into the report.
1743
+ */
1662
1744
  const get = async (pi, path, focus) => {
1663
- const res = await call(pi, `${ctx.setupApi}${path}`);
1745
+ let res = await call(pi, `${ctx.setupApi}${path}`);
1746
+ if (res.ok && !renewedForApps && appsLeftOut(res.data, ctx.repo)) {
1747
+ await ctx.auth.renew(pi, 'apps');
1748
+ renewedForApps = true;
1749
+ res = await call(pi, `${ctx.setupApi}${path}`);
1750
+ }
1751
+ if (res.ok && ctx.allInstructions !== undefined && severalApps(res.data))
1752
+ instructions = focusInstructions(ctx.allInstructions, { apps: true });
1664
1753
  return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
1665
1754
  };
1666
1755
  return [
1667
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)),
1668
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)),
1669
- 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))),
1670
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) => {
1671
1760
  if (!retry.pr)
1672
1761
  return 'Refused: record the setup pull request with impeccable_test_pr first.';
@@ -1735,20 +1824,20 @@ export function createTools(ctx) {
1735
1824
  reportTasks(tasks);
1736
1825
  return JSON.stringify({ ...observation, attempts: attempts(), budget: budget() });
1737
1826
  }),
1738
- tool('impeccable_state', 'Setup state', 'Read the setup state again, trimmed to this setup: each repository\'s scan, preview and sign-in items, and the setup page\'s other cards in notPartOfThisSetup.', NO_INPUT, (_p, pi) => get(pi, '/state', focusState)),
1827
+ tool('impeccable_state', 'Setup state', 'Read the setup state again, trimmed to this setup: each repository\'s scan, preview and sign-in items, its apps item where the scan proposed several apps, and the setup page\'s other cards in notPartOfThisSetup.', NO_INPUT, (_p, pi) => get(pi, '/state', focusState)),
1739
1828
  tool('impeccable_checkup', 'Checkup', 'Run the checkup for this setup\'s items: it reads GitHub again and answers status (complete, waiting or incomplete), next (what to do, in order), forPerson (what only the person can do) and waitingFor (what needs time).', NO_INPUT, (_p, pi) => get(pi, '/checkup', focusCheckup)),
1740
1829
  tool('impeccable_write', 'Write a setting', 'Write a setting through the setup API, at an endpoint the instructions list and with the method they give.', WRITE, async (p, pi, signal) => {
1741
- const target = resolveWrite(ctx.instructions, ctx.setupApi, ctx.repo, p.path);
1830
+ const target = resolveWrite(instructions, ctx.setupApi, ctx.repo, p.path);
1742
1831
  if ('error' in target)
1743
1832
  return `Refused: ${target.error}`;
1744
1833
  if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
1745
- return 'Refused: a Vercel bypass secret is saved by impeccable_vercel_bypass, which opens the preview with it first, and never through this tool. Nothing was saved.';
1834
+ return "Refused: a host's key is saved by impeccable_vercel_bypass (a Vercel bypass secret), impeccable_netlify_password (a Netlify site password) or impeccable_cloudflare_access (a Cloudflare Access service token), which opens the preview with it first, and never through this tool. Nothing was saved.";
1746
1835
  if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
1747
1836
  return "Refused: prove a preview address with impeccable_prove_preview first: the host's previews become the method only once one answered with this app. Nothing was saved.";
1748
1837
  }
1749
1838
  const saved = object(p.body);
1750
1839
  if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
1751
- return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify preview. Nothing was saved.';
1840
+ return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify or Cloudflare Pages preview. Nothing was saved.';
1752
1841
  }
1753
1842
  const testAccounts = target.url.endsWith('/test-accounts');
1754
1843
  let body = p.body;
@@ -1808,8 +1897,12 @@ export function createTools(ctx) {
1808
1897
  if (error)
1809
1898
  return `Invalid input: ${error}.`;
1810
1899
  const answer = await askPerson(q);
1811
- 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');
1812
1904
  return JSON.stringify({ skipped: true });
1905
+ }
1813
1906
  if (q.kind === 'multi')
1814
1907
  return JSON.stringify({ values: answer.values ?? [] });
1815
1908
  if (q.kind === 'confirm') {
@@ -1901,24 +1994,39 @@ export function createTools(ctx) {
1901
1994
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
1902
1995
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
1903
1996
  }),
1904
- tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer (a project\'s first deployment is called Production, whatever branch it builds, and counts). It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, _pi, signal) => {
1997
+ tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
1905
1998
  const branch = p.branch;
1906
1999
  const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
1907
- // With `branch` the evidence holds only the deployments of its head, and Vercel's is its preview whatever environment it is called: the first deployment of a
2000
+ // With `branch` the evidence holds only the deployments of its head, and Vercel's is its build whatever environment it is called: the first deployment of a
1908
2001
  // project is its Production one, even from a branch. Another integration's deployment of the same commit says nothing about it. Without it a preview is a Preview.
1909
- const preview = (d) => (branch !== undefined ? d.vercel : /preview/i.test(d.environment ?? ''));
1910
- // A head's preview is still building until it has a final status.
1911
- const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => preview(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
1912
- 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();
2002
+ const isPreview = (d) => /preview/i.test(d.environment ?? '');
2003
+ const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
2004
+ // A head's build is still building until it has a final status.
2005
+ const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
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();
1913
2007
  listedPreviews.clear();
1914
2008
  vercelMade.clear();
2009
+ publicProduction.clear();
2010
+ /** The hosts of the linked project's deployments while it is locked at every address, read from Vercel once a Production deployment turns up, and once per call. */
2011
+ let locked;
1915
2012
  for (const d of evidence.deployments) {
1916
- if (d.state === 'success' && d.url && preview(d) && (d.pullRequests.length || branch !== undefined)) {
1917
- const { origin } = new URL(d.url);
1918
- listedPreviews.add(origin);
1919
- if (d.vercel)
1920
- vercelMade.add(origin);
2013
+ if (d.state !== 'success' || !d.url || !ofBranch(d) || !(d.pullRequests.length || branch !== undefined))
2014
+ continue;
2015
+ const url = new URL(d.url);
2016
+ // A pushed branch's Production deployment is its preview only as the locked project's own: under Standard Protection a project's own address serves it to anyone, and another project of this repository may have made it.
2017
+ if (branch !== undefined && !isPreview(d)) {
2018
+ locked ??= await (async () => {
2019
+ const link = await readLink(pi.cwd);
2020
+ return link ? lockedDeploymentHosts((ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }), link) : new Set();
2021
+ })();
2022
+ if (!locked.has(url.host)) {
2023
+ publicProduction.add(url.origin);
2024
+ continue;
2025
+ }
1921
2026
  }
2027
+ listedPreviews.add(url.origin);
2028
+ if (d.vercel)
2029
+ vercelMade.add(url.origin);
1922
2030
  }
1923
2031
  return JSON.stringify(evidence);
1924
2032
  }),
@@ -1928,25 +2036,59 @@ export function createTools(ctx) {
1928
2036
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
1929
2037
  const states = ['success', 'ready', 'failure', 'error'];
1930
2038
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
1931
- 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();
1932
2040
  netlifyListed.clear();
1933
2041
  for (const p of evidence.previews)
1934
2042
  if (p.state === 'success' || p.state === 'ready')
1935
2043
  netlifyListed.add(new URL(p.url).origin);
1936
2044
  return JSON.stringify(evidence);
1937
2045
  }),
1938
- tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews or impeccable_netlify_previews listed for a pull request or a pushed branch (a successful Preview deployment, or a deploy preview that is ready), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify it answers the address pattern to save beside it. gate "vercel-protection" or "netlify-protection" means the host\'s protection stands in front of it. Nothing of the page comes back.', PROVE_PREVIEW, 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) => {
2047
+ const pull = p.pull;
2048
+ const branch = p.branch;
2049
+ if (pull !== undefined && branch !== undefined)
2050
+ return 'Invalid input: give pull or branch, not both.';
2051
+ const read = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch });
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.
2054
+ const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
2055
+ const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
2056
+ const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
2057
+ cloudflareListed.clear();
2058
+ for (const preview of evidence.previews) {
2059
+ if (preview.state !== 'success')
2060
+ continue;
2061
+ cloudflareListed.add(new URL(preview.url).origin);
2062
+ if (preview.alias)
2063
+ cloudflareListed.add(new URL(preview.alias).origin);
2064
+ }
2065
+ return JSON.stringify(evidence);
2066
+ }),
2067
+ tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, or a Pages build that succeeded), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify and Cloudflare Pages it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
1939
2068
  const url = p.url;
1940
- if (!URL.canParse(url) || !(listedPreviews.has(new URL(url).origin) || netlifyListed.has(new URL(url).origin)))
1941
- return 'Refused: open the address of a preview that impeccable_hosted_previews or impeccable_netlify_previews listed for a pull request or a pushed branch, or a page on it.';
1942
- const proof = await provePreview(url, p.expect, pi.cwd, { fetch: ctx.previewFetch, signal, ...(bypassSecret && bypassOrigins.has(new URL(url).origin) ? { bypass: bypassSecret } : {}) });
2069
+ if (URL.canParse(url) && publicProduction.has(new URL(url).origin))
2070
+ return "Refused: Vercel called this deployment Production, as it calls a project's first whatever branch it builds, and no project of this folder's that is locked at every address made it, so it is served at a project's own address without a login: it is no preview, and nothing was opened. No test drive, as the vercel-setup case says.";
2071
+ if (!URL.canParse(url) || !(listedPreviews.has(new URL(url).origin) || netlifyListed.has(new URL(url).origin) || cloudflareListed.has(new URL(url).origin)))
2072
+ return 'Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch, or a page on it.';
2073
+ const site = netlifySiteOf(url);
2074
+ // A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
2075
+ const project = pagesProject(new URL(url).origin);
2076
+ const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
2077
+ const proof = await provePreview(url, p.expect, pi.cwd, {
2078
+ fetch: ctx.previewFetch, signal,
2079
+ ...(bypassSecret && bypassOrigins.has(new URL(url).origin) ? { bypass: bypassSecret } : {}),
2080
+ ...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
2081
+ ...(token ? { access: token } : {}),
2082
+ });
1943
2083
  hostedPreviewProven ||= proof.proven;
1944
2084
  if (proof.proven)
1945
2085
  provenHost = previewHostName(url) ?? provenHost;
1946
2086
  if (proof.proven && proof.pattern)
1947
2087
  provenPattern = proof.pattern;
1948
- if (proof.gate === 'vercel-protection')
1949
- gated = { url, expect: p.expect };
2088
+ if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
2089
+ gated = { url, expect: p.expect, gate: proof.gate };
2090
+ if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
2091
+ accessGated = { url, expect: p.expect };
1950
2092
  return JSON.stringify(proof);
1951
2093
  }),
1952
2094
  tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
@@ -1958,9 +2100,9 @@ export function createTools(ctx) {
1958
2100
  return 'Refused: publish is a folder relative to the checkout, such as "dist".';
1959
2101
  return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
1960
2102
  }),
1961
- tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits. connected true means the repository is connected, or was already. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
2103
+ tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet: Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
1962
2104
  tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened 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 secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
1963
- if (!gated || !vercelMade.has(new URL(gated.url).origin))
2105
+ if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
1964
2106
  return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first.';
1965
2107
  if (bypassOutcome?.status === 'skipped')
1966
2108
  return JSON.stringify(bypassOutcome);
@@ -1983,13 +2125,9 @@ export function createTools(ctx) {
1983
2125
  },
1984
2126
  prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
1985
2127
  save: async (secret) => {
1986
- const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
1987
- if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value: secret } })).ok)
1988
- return 'The setup API did not store the secret.';
1989
- stored = true;
1990
- if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: BYPASS_HEADER } })).ok)
1991
- return 'The setup API stored the secret, but not the setting that sends it: it is turned on under Deployment protection on the setup page.';
1992
- return null;
2128
+ const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret);
2129
+ stored = saved.stored;
2130
+ return saved.failed;
1993
2131
  },
1994
2132
  remember: noteSensitiveAnswer,
1995
2133
  });
@@ -2011,5 +2149,141 @@ export function createTools(ctx) {
2011
2149
  }
2012
2150
  return JSON.stringify(bypassOutcome);
2013
2151
  }),
2152
+ tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify; for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when the CLI cannot read the site or Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), login (Netlify\'s team login stands in front, which no tool gets past: protection says so, and reason why), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2153
+ const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
2154
+ if (!gated || !site)
2155
+ return 'Refused: this is for a Netlify deploy preview, listed by impeccable_netlify_previews, which impeccable_prove_preview answered with gate "netlify-protection". Prove one first.';
2156
+ const stands = passwordStands.get(site);
2157
+ if (stands)
2158
+ return JSON.stringify(stands);
2159
+ const at = gated;
2160
+ /** The address pattern the proof with the password answers, saved as the method once the password is. */
2161
+ let pattern;
2162
+ const prove = async (password, form = false) => {
2163
+ const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form });
2164
+ if (proof.proven && proof.pattern)
2165
+ pattern = proof.pattern;
2166
+ return proof;
2167
+ };
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) {
2170
+ provenPattern = pattern ?? provenPattern;
2171
+ return JSON.stringify(passwordSaved);
2172
+ }
2173
+ /** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
2174
+ let stored = false;
2175
+ const { value, ...told } = await passNetlifyLock({
2176
+ run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
2177
+ site,
2178
+ consent: async () => {
2179
+ if (passwordConsented.has(site) || !(await ask(HOST_SCREENS['netlify-password'])).skipped)
2180
+ return passwordConsented.add(site), true;
2181
+ return false;
2182
+ },
2183
+ paste: async () => {
2184
+ const answer = await ask(HOST_SCREENS['netlify-password-key']);
2185
+ return answer.skipped ? undefined : answer.value?.trim();
2186
+ },
2187
+ prove,
2188
+ save: async (password) => {
2189
+ const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password));
2190
+ stored = saved.stored;
2191
+ return saved.failed;
2192
+ },
2193
+ remember: noteSensitiveAnswer,
2194
+ });
2195
+ const passwordOutcome = { ...told, ...(told.status === 'saved' ? { proven: true } : {}) };
2196
+ if (told.status === 'skipped' || told.status === 'login')
2197
+ passwordStands.set(site, passwordOutcome);
2198
+ if (value) {
2199
+ // What is in hand follows the store: a new password is the one saved now, and the site the last one opened no longer stands.
2200
+ sitePassword = value;
2201
+ passwordSites.clear();
2202
+ passwordSites.add(site);
2203
+ hostedPreviewProven = true;
2204
+ provenHost = 'Netlify';
2205
+ provenPattern = pattern ?? provenPattern;
2206
+ passwordSaved = passwordOutcome;
2207
+ }
2208
+ else if (told.status === 'save-failed' && stored) {
2209
+ // The store holds the password this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through.
2210
+ sitePassword = undefined;
2211
+ passwordSites.clear();
2212
+ passwordSaved = undefined;
2213
+ }
2214
+ return JSON.stringify(passwordOutcome);
2215
+ }),
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) => {
2217
+ if (!accessGated)
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.';
2219
+ if (accessOutcome?.status === 'skipped')
2220
+ return JSON.stringify(accessOutcome);
2221
+ const at = accessGated;
2222
+ let proof;
2223
+ const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
2224
+ const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
2225
+ const save = async (token) => {
2226
+ if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret } })).ok)
2227
+ return 'The setup API did not store the service token.';
2228
+ // An app that signs in with Access sends this token already: the setting would make it a second credential, which the setup API refuses.
2229
+ const repos = object((await call(pi, `${ctx.setupApi}/state`)).data)?.repos;
2230
+ const lock = Array.isArray(repos) ? object(object(repos.map(object).find((r) => r?.name === ctx.repo)?.items)?.['preview-protection']) : undefined;
2231
+ if (lock?.authMethod === 'access')
2232
+ return null;
2233
+ if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access' } })).ok)
2234
+ return 'The setup API stored the service token, but not the setting that sends it: it is turned on under Preview protection on the setup page.';
2235
+ return null;
2236
+ };
2237
+ let answer;
2238
+ if (accessToken && (await prove(accessToken)).proven) {
2239
+ // The token in hand opens this address too: stored already, or stored now.
2240
+ const failed = accessStored ? null : await save(accessToken);
2241
+ answer = { status: failed ? 'save-failed' : 'saved', ...accessMade, ...(failed ? { reason: failed } : {}), value: accessToken };
2242
+ }
2243
+ else {
2244
+ answer = await passCloudflareAccess({
2245
+ url: at.url,
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,
2251
+ apiToken: async () => {
2252
+ const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
2253
+ return typed.skipped ? undefined : typed.value;
2254
+ },
2255
+ paste: async () => {
2256
+ const typed = await ask(HOST_SCREENS['cloudflare-access-key']);
2257
+ return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
2258
+ },
2259
+ prove,
2260
+ save,
2261
+ remember: noteSensitiveAnswer,
2262
+ signal,
2263
+ ...(ctx.hostWait?.policy ? { wait: ctx.hostWait.policy } : {}),
2264
+ });
2265
+ }
2266
+ const { value, ...told } = answer;
2267
+ if (value) {
2268
+ // What is in hand follows the store, which holds one token: a new one is the one saved now, and the projects the last one opened no longer stand.
2269
+ if (value !== accessToken)
2270
+ accessProjects.clear();
2271
+ accessToken = value;
2272
+ accessStored = told.status === 'saved';
2273
+ accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
2274
+ const project = pagesProject(new URL(at.url).origin);
2275
+ if (project)
2276
+ accessProjects.add(project);
2277
+ }
2278
+ const pattern = told.status === 'saved' ? proof?.pattern : undefined;
2279
+ accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
2280
+ if (told.status === 'saved') {
2281
+ hostedPreviewProven = true;
2282
+ provenHost = 'Cloudflare Pages';
2283
+ if (pattern)
2284
+ provenPattern = pattern;
2285
+ }
2286
+ return JSON.stringify(accessOutcome);
2287
+ }),
2014
2288
  ];
2015
2289
  }