@impeccable/reviews 0.1.3 → 0.1.5

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,7 +7,7 @@ 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, endHostSetup, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
10
+ import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, 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';
@@ -16,7 +16,7 @@ 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';
19
- import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelStatus, vercelTeams, waitForVercelAccess } from './vercel.js';
19
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
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';
@@ -240,9 +240,20 @@ const ASK = {
240
240
  confirmLabel: { type: 'string', minLength: 1, description: 'confirm only: the way on, such as "Share test accounts".' },
241
241
  skipLabel: { type: 'string', minLength: 1, description: 'confirm only: the other way, such as "Skip for now".' },
242
242
  skippable: { type: 'boolean', description: 'Every question offers Skip, whatever this says.' },
243
- sensitive: { type: 'boolean', description: 'text only: a password. The person types it hidden, and the answer\'s value is a reference ("secret:…") that impeccable_write turns back into what they typed.' },
243
+ sensitive: { type: 'boolean', description: 'text only: a password. The person types it hidden, and the answer\'s value is a reference ("secret:…") that impeccable_write turns back into what they typed. A text question whose title, prompt or hint names a token, a password, a secret, a key or a credential is asked this way whatever this says.' },
244
244
  },
245
245
  };
246
+ /**
247
+ * What the person reads of a question names a credential: its answer is typed hidden, however the model asked (#154).
248
+ * Names as code spells them (GITHUB_TOKEN, accessToken) are also read split into words; a design token is not one (#238).
249
+ */
250
+ const CREDENTIAL = /\b(?:(?<!design[ -])tokens?|passwords?|passphrases?|secrets?|(?:api[\s_-]?)?keys?|credentials?)\b/i;
251
+ const namesCredential = (q) => {
252
+ const text = [q.title, q.prompt, q.hint].filter(Boolean).join('\n');
253
+ return CREDENTIAL.test(text) || CREDENTIAL.test(text.replace(/_|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, ' '));
254
+ };
255
+ /** Under the field of every text question typed in the open, where the model reads the answer. */
256
+ const NEVER_A_CREDENTIAL = 'Never enter a password, a token or a key here.';
246
257
  const NETLIFY_CONNECT = {
247
258
  type: 'object',
248
259
  additionalProperties: false,
@@ -1419,26 +1430,37 @@ export function createTools(ctx) {
1419
1430
  /** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
1420
1431
  let provenHost;
1421
1432
  let savedHost;
1433
+ /** Whether the repository's last saved method is a host's previews, and whether any write named an app: a monorepo's apps each go their own way, and only a run whose host builds them all opens no workflow and no setup pull request. */
1434
+ let hostMethod = false;
1435
+ let perApp = false;
1422
1436
  /**
1423
1437
  * A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
1424
1438
  * 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.
1425
1439
  */
1426
- async function awaitBuild(host, status, read, built, signal) {
1427
- let evidence = await read();
1428
- if (built(evidence))
1429
- return evidence;
1440
+ async function awaitBuild(host, status, read, built, page, signal) {
1441
+ const first = await read();
1442
+ if (built(first))
1443
+ return first;
1430
1444
  const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
1431
- said(status);
1432
- reportWait({ since: Date.now(), host });
1433
- try {
1434
- for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
1435
- await sleep(wait.everyMs, undefined, { signal });
1445
+ const waited = async (evidence) => {
1446
+ said(status);
1447
+ reportWait({ since: Date.now(), host });
1448
+ try {
1449
+ for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
1450
+ await sleep(wait.everyMs, undefined, { signal });
1451
+ }
1436
1452
  }
1437
- }
1438
- finally {
1439
- reportWait(null);
1440
- }
1441
- return evidence;
1453
+ finally {
1454
+ reportWait(null);
1455
+ }
1456
+ return evidence;
1457
+ };
1458
+ // No build in the time: the host's page where a build is turned on, and the wait again on the person's yes.
1459
+ return onHostPage(`${host.toLowerCase()}-build`, page, (e) => !built(e), await waited(first), async () => waited(await read()), {
1460
+ skipped: `The person stopped waiting for ${host}'s build.`,
1461
+ still: `${host} built no preview in the time.`,
1462
+ stopped: `The run stopped while ${host}'s build was waited for.`,
1463
+ }, signal);
1442
1464
  }
1443
1465
  /** What a wait for a pull request's build is for: the test drive's preview, or another pull request's. */
1444
1466
  const subjectOf = async (pull) => {
@@ -1458,7 +1480,7 @@ export function createTools(ctx) {
1458
1480
  /** 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
1481
  let sitePassword;
1460
1482
  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. */
1483
+ /** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, the team login kept after the retries stays, and a site made public stays public. Another site of the repository is read on its own. */
1462
1484
  const passwordStands = new Map();
1463
1485
  /** 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
1486
  const passwordConsented = new Set();
@@ -1477,10 +1499,11 @@ export function createTools(ctx) {
1477
1499
  const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
1478
1500
  const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
1479
1501
  const workflowReady = (access) => access.authenticated === true
1480
- && access.originMatches === true && access.defaultBranch !== null && !access.needsWorkflowScope;
1502
+ && access.originMatches === true && access.defaultBranch !== null && !access.needsWorkflowScope
1503
+ && access.push !== 'refused' && access.push !== 'signed-out';
1481
1504
  async function workflowPreparation(pi, signal) {
1482
1505
  signal?.throwIfAborted();
1483
- const access = await (ctx.workflowAccessReader ?? readWorkflowAccess)(ctx.owner, ctx.repo, pi.cwd, { signal });
1506
+ const access = { ...await (ctx.workflowAccessReader ?? readWorkflowAccess)(ctx.owner, ctx.repo, pi.cwd, { signal }), push: ctx.pushAccess ?? 'unknown' };
1484
1507
  signal?.throwIfAborted();
1485
1508
  const base = workflowReady(access)
1486
1509
  ? await (ctx.workflowBaseRefresher ?? refreshWorkflowBase)(ctx.owner, ctx.repo, access.defaultBranch, pi.cwd, { signal })
@@ -1542,6 +1565,9 @@ export function createTools(ctx) {
1542
1565
  endHostSetup(ctx.log, host);
1543
1566
  };
1544
1567
  const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1568
+ /** The Vercel project and the Netlify site this run set up, by name, for the pages of theirs a screen opens. */
1569
+ let vercelProject;
1570
+ let netlifySite;
1545
1571
  const hostCli = (host, pi, signal) => {
1546
1572
  const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
1547
1573
  const cli = runner();
@@ -1622,25 +1648,37 @@ export function createTools(ctx) {
1622
1648
  }
1623
1649
  }
1624
1650
  /**
1625
- * The one-time access a host's GitHub app needs when it cannot reach the repository: a screen that opens the app's page
1626
- * on GitHub, a wait of up to five minutes for the host to reach it (`reaches`, tried again every so often), a retry
1627
- * screen, and after two failed retries the connection left to the person.
1651
+ * What the person does on a host's page, asked in the run: the screen `screen` (then `<screen>-retry`) opens the page,
1652
+ * `again` waits up to five minutes for it to be done, trying every so often, and after two failed retries the step is
1653
+ * left undone with `words.still`. `pending` says it is not done yet; the run carries on either way.
1628
1654
  */
1629
- async function hostAccess(host, result, reaches, signal) {
1630
- const name = HOST_NAMES[host];
1631
- for (let retries = 0; result.access && !signal?.aborted; retries++) {
1632
- if ((await ask(HOST_SCREENS[retries ? `${host}-access-retry` : `${host}-access`])).skipped)
1633
- return { ...result, reason: `The person skipped giving ${name}'s GitHub app access to the repository.` };
1634
- (ctx.openBrowser ?? openBrowser)(result.access);
1635
- said(`Waiting for ${name} to reach the repository`);
1636
- result = await reaches();
1655
+ async function onHostPage(screen, page, pending, result, again, words, signal) {
1656
+ for (let retries = 0; pending(result) && !signal?.aborted; retries++) {
1657
+ if ((await ask(HOST_SCREENS[retries ? `${screen}-retry` : screen])).skipped)
1658
+ return { ...result, reason: words.skipped };
1659
+ const at = page(result);
1660
+ if (at)
1661
+ (ctx.openBrowser ?? openBrowser)(at);
1662
+ if (words.waiting)
1663
+ said(words.waiting);
1664
+ result = await again(result);
1637
1665
  if (signal?.aborted)
1638
- return { ...result, reason: `The run stopped while ${name} was waited for.` };
1639
- if (result.access && retries === 2)
1640
- return { ...result, reason: `${name}'s GitHub app still cannot reach the repository.` };
1666
+ return { ...result, reason: words.stopped };
1667
+ if (pending(result) && retries === 2)
1668
+ return { ...result, reason: words.still };
1641
1669
  }
1642
1670
  return result;
1643
1671
  }
1672
+ /** The one-time access a host's GitHub app needs when it cannot reach the repository, given on the app's page on GitHub while `reaches` tries again. */
1673
+ function hostAccess(host, result, reaches, signal) {
1674
+ const name = HOST_NAMES[host];
1675
+ return onHostPage(`${host}-access`, (r) => r.access, (r) => Boolean(r.access), result, reaches, {
1676
+ waiting: `Waiting for ${name} to reach the repository`,
1677
+ skipped: `The person skipped giving ${name}'s GitHub app access to the repository.`,
1678
+ still: `${name}'s GitHub app still cannot reach the repository.`,
1679
+ stopped: `The run stopped while ${name} was waited for.`,
1680
+ }, signal);
1681
+ }
1644
1682
  /** The connection of the linked Netlify site with this repository, through Netlify's site settings and its GitHub app, with the app's access asked when it cannot read the repository. */
1645
1683
  async function netlifyConnect(pi, settings, signal) {
1646
1684
  // The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI.
@@ -1650,6 +1688,7 @@ export function createTools(ctx) {
1650
1688
  const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
1651
1689
  const clone = ctx.hostWait?.clone;
1652
1690
  const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, settings, signal, ...(clone ? { wait: clone } : {}) });
1691
+ netlifySite = result.site?.name ?? netlifySite;
1653
1692
  // A build every 30 s until one reads the repository: the person is giving the app access meanwhile.
1654
1693
  return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
1655
1694
  ? { connected: true, site: result.site, how: 'github-app' } : result), signal);
@@ -1673,7 +1712,18 @@ export function createTools(ctx) {
1673
1712
  settleVercelFirstDeployment(ctx.log, { projectId: link?.projectId ?? null, how: 'connection' });
1674
1713
  return result;
1675
1714
  }
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.' };
1715
+ let 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.' };
1716
+ vercelProject = first.project ?? vercelProject;
1717
+ // Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
1718
+ if (link && first.production === 'none' && !first.locked) {
1719
+ const page = vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), vercelProject);
1720
+ first = await onHostPage('vercel-lock', () => page, (f) => f.production !== 'exists' && !f.locked, first, () => waitForVercelLock(vercel, link, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 10_000 }, signal }), {
1721
+ waiting: "Waiting for Vercel's login on the project",
1722
+ skipped: 'The person skipped locking the Vercel project.',
1723
+ still: "Vercel's login is still not in front of the project's addresses.",
1724
+ stopped: 'The run stopped while the lock was waited for.',
1725
+ }, signal);
1726
+ }
1677
1727
  if (link && (first.production === 'exists' || first.locked === true))
1678
1728
  settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
1679
1729
  return { ...result, ...first };
@@ -1683,7 +1733,7 @@ export function createTools(ctx) {
1683
1733
  const access = await workflowPreparation(pi, signal);
1684
1734
  if (!workflowReady(access))
1685
1735
  return JSON.stringify(workflowRefusal(access.needsWorkflowScope ? 'The active GitHub CLI account is missing workflow scope for HTTPS. Recheck access after updating it.'
1686
- : 'Workflow access is not ready. Recheck authentication, origin and the default branch.'));
1736
+ : 'Workflow access is not ready. Recheck authentication, push access, origin and the default branch.'));
1687
1737
  if (access.base?.status !== 'refreshed' || access.base.branch !== access.defaultBranch || !access.base.sha)
1688
1738
  return JSON.stringify(workflowRefusal('The current default branch could not be refreshed. Nothing was synchronized.'));
1689
1739
  signal?.throwIfAborted();
@@ -1753,7 +1803,7 @@ export function createTools(ctx) {
1753
1803
  return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
1754
1804
  };
1755
1805
  return [
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)),
1806
+ 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. push is whether git may push to origin, as a dry run at the start found it.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
1757
1807
  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)),
1758
1808
  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 already on Impeccable\'s tunnel 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))),
1759
1809
  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) => {
@@ -1862,6 +1912,10 @@ export function createTools(ctx) {
1862
1912
  retry.requireHistory = true;
1863
1913
  if (object(p.body)?.intendedMethod === 'preview-deploy')
1864
1914
  savedHost = provenHost;
1915
+ if (saved?.appKey !== undefined)
1916
+ perApp = true;
1917
+ if (saved && 'intendedMethod' in saved)
1918
+ hostMethod = saved.intendedMethod === 'preview-deploy';
1865
1919
  for (const key of Object.keys(object(p.body) ?? {}))
1866
1920
  if (PREVIEW_FIELDS.has(key) && keys?.[key] === 'stored')
1867
1921
  previews.changes.add(key);
@@ -1896,9 +1950,17 @@ export function createTools(ctx) {
1896
1950
  // What the person reads in a fixed question's choice is what is recorded: an option's value is its label, whatever value the model gave it; a host's teams are the ones its sign-in offered, each read by its name and recorded by its slug.
1897
1951
  const options = host ? teamChoices.get(host[1]) : asked.options?.map(({ label }) => ({ label, value: label }));
1898
1952
  const q = { ...(fixed ? { id: asked.id, ...fixed, options } : asked), skippable: true };
1899
- const error = askError(q);
1953
+ // A question that names a credential is typed hidden, so the model gets a reference and never the credential.
1954
+ const hidden = q.kind === 'text' && !q.sensitive && namesCredential(q);
1955
+ if (hidden)
1956
+ q.sensitive = true;
1957
+ const error = hidden && q.prefill !== undefined ? 'input.prefill is not for a text question that names a token, a password, a secret, a key or a credential, which is typed hidden' : askError(q);
1900
1958
  if (error)
1901
1959
  return `Invalid input: ${error}.`;
1960
+ if (hidden)
1961
+ ctx.log.write('ask hidden (names a credential)', q.id);
1962
+ if (q.kind === 'text' && !q.sensitive)
1963
+ q.caution = NEVER_A_CREDENTIAL;
1902
1964
  const answer = await askPerson(q);
1903
1965
  if (answer.skipped) {
1904
1966
  // Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
@@ -1921,12 +1983,16 @@ export function createTools(ctx) {
1921
1983
  typed.set(reference, (q.kind === 'account' ? answer.password : answer.value) ?? '');
1922
1984
  return JSON.stringify(q.kind === 'account' ? { username: answer.username ?? '', password: reference } : { value: reference });
1923
1985
  }),
1924
- tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, and a status line about another task than the one in progress.', PROGRESS, async (p) => {
1986
+ tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, a status line about another task than the one in progress, and a Preview workflow or Setup pull request task once a host\'s previews are the method.', PROGRESS, async (p) => {
1925
1987
  const status = p.status.trim();
1926
1988
  const tasks = withAttempts(p.tasks);
1927
- const voice = statusVoiceError(status) ?? stageError(status, p.tasks.filter((t) => !t.id.startsWith('preview-attempt-')));
1989
+ const own = p.tasks.filter((t) => !t.id.startsWith('preview-attempt-'));
1990
+ const voice = statusVoiceError(status) ?? stageError(status, own);
1928
1991
  if (voice)
1929
1992
  return voice;
1993
+ const unneeded = hostMethod && !perApp && !ctx.sync.payload.setup_pr ? own.filter((t) => /\bsetup pull request\b/i.test(t.title) || stagesOf(t.title).includes('Preview workflow')) : [];
1994
+ if (unneeded.length)
1995
+ return `Refused: ${savedHost ?? 'the host'} builds this run's previews, so the repository needs no workflow and no setup pull request. Leave ${unneeded.map((t) => `"${t.title}"`).join(' and ')} off the list: a task the run never does is not shown, not even as skipped. Nothing was saved: call impeccable_progress again.`;
1930
1996
  ctx.sync.update({ phase: 'running', status, tasks });
1931
1997
  reportStatus(status);
1932
1998
  reportTasks(tasks);
@@ -1979,16 +2045,17 @@ export function createTools(ctx) {
1979
2045
  if (!left.includes('Watch the review on the test drive'))
1980
2046
  left.push('Watch the review on the test drive');
1981
2047
  }
1982
- const { phase, status, question, report_markdown, test_pr } = ctx.sync.payload;
2048
+ const { phase, status, question, report_markdown, test_pr, left: leftBefore } = ctx.sync.payload;
1983
2049
  // A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one.
1984
2050
  const covered = test_pr && recordedBranches?.setup
1985
2051
  ? await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, recordedBranches.drive) : null;
1986
2052
  const drive = covered === false ? undefined : test_pr;
1987
- ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive });
2053
+ // The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
2054
+ ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, left: left.slice(0, 20) });
1988
2055
  const res = await ctx.sync.flush();
1989
2056
  if (res && res.status >= 400 && res.status < 500) {
1990
2057
  // A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
1991
- ctx.sync.update({ phase, status, question, report_markdown, test_pr });
2058
+ ctx.sync.update({ phase, status, question, report_markdown, test_pr, left: leftBefore });
1992
2059
  return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
1993
2060
  }
1994
2061
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
@@ -1997,7 +2064,7 @@ export function createTools(ctx) {
1997
2064
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
1998
2065
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
1999
2066
  }),
2000
- 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) => {
2067
+ 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, and again on the person\'s yes when the time ran out. 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) => {
2001
2068
  const branch = p.branch;
2002
2069
  const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
2003
2070
  // 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
@@ -2006,7 +2073,7 @@ export function createTools(ctx) {
2006
2073
  const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
2007
2074
  // A head's build is still building until it has a final status.
2008
2075
  const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
2009
- 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();
2076
+ 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, () => vercelProjectPage('settings/git', chosenHostTeam(ctx.log, 'vercel'), vercelProject), signal) : await read();
2010
2077
  listedPreviews.clear();
2011
2078
  vercelMade.clear();
2012
2079
  publicProduction.clear();
@@ -2033,20 +2100,25 @@ export function createTools(ctx) {
2033
2100
  }
2034
2101
  return JSON.stringify(evidence);
2035
2102
  }),
2036
- tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
2103
+ tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
2037
2104
  const pull = p.pull;
2038
2105
  const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull });
2039
2106
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
2040
2107
  const states = ['success', 'ready', 'failure', 'error'];
2041
2108
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
2042
- const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
2109
+ const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, (e) => {
2110
+ // The waited pull request's own site; else the one site known, so another app's site never opens.
2111
+ const known = new Set([...e.sites, ...(netlifySite ? [netlifySite] : [])]);
2112
+ const site = e.previews.find((preview) => preview.pullRequest === pull)?.site ?? (known.size === 1 ? [...known][0] : undefined);
2113
+ return site ? `https://app.netlify.com/projects/${encodeURIComponent(site)}/configuration/deploys#deploy-previews` : 'https://app.netlify.com/';
2114
+ }, signal) : await read();
2043
2115
  netlifyListed.clear();
2044
2116
  for (const p of evidence.previews)
2045
2117
  if (p.state === 'success' || p.state === 'ready')
2046
2118
  netlifyListed.add(new URL(p.url).origin);
2047
2119
  return JSON.stringify(evidence);
2048
2120
  }),
2049
- 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) => {
2121
+ 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, and again on the person's yes when the time ran out. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2050
2122
  const pull = p.pull;
2051
2123
  const branch = p.branch;
2052
2124
  if (pull !== undefined && branch !== undefined)
@@ -2056,7 +2128,7 @@ export function createTools(ctx) {
2056
2128
  // GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
2057
2129
  const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
2058
2130
  const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
2059
- const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
2131
+ const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal) : await read();
2060
2132
  cloudflareListed.clear();
2061
2133
  for (const preview of evidence.previews) {
2062
2134
  if (preview.state !== 'success')
@@ -2089,7 +2161,7 @@ export function createTools(ctx) {
2089
2161
  if (proof.proven && proof.pattern)
2090
2162
  provenPattern = proof.pattern;
2091
2163
  if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
2092
- gated = { url, expect: p.expect, gate: proof.gate };
2164
+ gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}) };
2093
2165
  if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
2094
2166
  accessGated = { url, expect: p.expect };
2095
2167
  return JSON.stringify(proof);
@@ -2103,7 +2175,7 @@ export function createTools(ctx) {
2103
2175
  return 'Refused: publish is a folder relative to the checkout, such as "dist".';
2104
2176
  return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
2105
2177
  }),
2106
- 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))),
2178
+ 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, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings 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))),
2107
2179
  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) => {
2108
2180
  if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
2109
2181
  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.';
@@ -2123,6 +2195,8 @@ export function createTools(ctx) {
2123
2195
  link: () => readLink(pi.cwd),
2124
2196
  consent: async () => !(await ask(HOST_SCREENS['vercel-bypass'])).skipped,
2125
2197
  paste: async () => {
2198
+ // The locked preview may be another project's than the one this run connected, so the person picks the project.
2199
+ (ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection'));
2126
2200
  const answer = await ask(HOST_SCREENS['vercel-bypass-key']);
2127
2201
  return answer.skipped ? undefined : answer.value?.trim();
2128
2202
  },
@@ -2152,13 +2226,10 @@ export function createTools(ctx) {
2152
2226
  }
2153
2227
  return JSON.stringify(bypassOutcome);
2154
2228
  }),
2155
- 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) => {
2229
+ 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. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. 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), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), 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) => {
2156
2230
  const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
2157
2231
  if (!gated || !site)
2158
2232
  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.';
2159
- const stands = passwordStands.get(site);
2160
- if (stands)
2161
- return JSON.stringify(stands);
2162
2233
  const at = gated;
2163
2234
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
2164
2235
  let pattern;
@@ -2168,6 +2239,17 @@ export function createTools(ctx) {
2168
2239
  pattern = proof.pattern;
2169
2240
  return proof;
2170
2241
  };
2242
+ const stands = passwordStands.get(site);
2243
+ if (stands?.status === 'opened') {
2244
+ // A site made public stands while the address opens with nothing sent; behind the login again, it is read again.
2245
+ if ((await prove(undefined)).proven) {
2246
+ provenPattern = pattern ?? provenPattern;
2247
+ return JSON.stringify(stands);
2248
+ }
2249
+ passwordStands.delete(site);
2250
+ }
2251
+ else if (stands)
2252
+ return JSON.stringify(stands);
2171
2253
  // A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
2172
2254
  if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
2173
2255
  provenPattern = pattern ?? provenPattern;
@@ -2183,6 +2265,21 @@ export function createTools(ctx) {
2183
2265
  return passwordConsented.add(site), true;
2184
2266
  return false;
2185
2267
  },
2268
+ makePublic: async () => !(await ask(HOST_SCREENS['netlify-visibility'])).skipped,
2269
+ ...(at.teamLogin ? { teamLogin: true } : {}),
2270
+ // The yes to making the site public stands for the sign-in it needs: no second question before Netlify's login opens in the browser.
2271
+ signIn: async () => {
2272
+ if (!hostSignIns.has('netlify'))
2273
+ hostSignIns.set('netlify', 'confirmed');
2274
+ return (await hostSignIn('netlify', pi, signal)).signedIn === true;
2275
+ },
2276
+ retry: async (page, first) => {
2277
+ if (first)
2278
+ (ctx.openBrowser ?? openBrowser)(page);
2279
+ // The browser may not have opened (BROWSER=none, no opener): the address is on the screen as well.
2280
+ const screen = HOST_SCREENS['netlify-visibility-retry'];
2281
+ return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
2282
+ },
2186
2283
  paste: async () => {
2187
2284
  const answer = await ask(HOST_SCREENS['netlify-password-key']);
2188
2285
  return answer.skipped ? undefined : answer.value?.trim();
@@ -2195,9 +2292,15 @@ export function createTools(ctx) {
2195
2292
  },
2196
2293
  remember: noteSensitiveAnswer,
2197
2294
  });
2198
- const passwordOutcome = { ...told, ...(told.status === 'saved' ? { proven: true } : {}) };
2199
- if (told.status === 'skipped' || told.status === 'login')
2295
+ const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
2296
+ if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
2200
2297
  passwordStands.set(site, passwordOutcome);
2298
+ if (told.status === 'opened') {
2299
+ // The site is public now: its previews open with nothing sent.
2300
+ hostedPreviewProven = true;
2301
+ provenHost = 'Netlify';
2302
+ provenPattern = pattern ?? provenPattern;
2303
+ }
2201
2304
  if (value) {
2202
2305
  // 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.
2203
2306
  sitePassword = value;
@@ -1,7 +1,7 @@
1
1
  import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
- import { redact, SECRET_FIELD } from './log.js';
4
+ import { scrub, SECRET_FIELD } from './log.js';
5
5
  const LIMIT = 2000;
6
6
  /** `location` is a .md file (the .jsonl goes beside it) or a folder; the OS temp folder without one. */
7
7
  export function transcriptPaths(runId, location) {
@@ -23,18 +23,27 @@ const fenced = (text, lang = '') => {
23
23
  return `${fence}${lang}\n${text}\n${fence}`;
24
24
  };
25
25
  const count = (n) => (n ?? 0).toLocaleString('en-US');
26
- /** A copy of an event with every string cleaned, a credential field's value and everything belonging to a sensitive tool call reading [redacted]. */
27
- function mask(value, calls, clean) {
26
+ /** A copy of an event with every string and field name cleaned, each string beside the name of its field, a credential field's value and everything belonging to a sensitive tool call reading [redacted]. */
27
+ function mask(value, calls, clean, field) {
28
28
  if (typeof value === 'string')
29
- return clean(value);
29
+ return clean(value, field);
30
30
  if (Array.isArray(value))
31
- return value.map((v) => mask(v, calls, clean));
31
+ return value.map((v) => mask(v, calls, clean, field));
32
32
  if (!value || typeof value !== 'object')
33
33
  return value;
34
34
  const obj = value;
35
35
  const hidden = typeof obj.toolCallId === 'string' && calls.has(obj.toolCallId);
36
36
  const secret = (k, v) => (hidden && ['content', 'result', 'details'].includes(k)) || (typeof v === 'string' && SECRET_FIELD.test(k));
37
- return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, secret(k, v) ? '[redacted]' : mask(v, calls, clean)]));
37
+ const names = new Set();
38
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => {
39
+ // Two names that clean to the same one stay two fields: the second is numbered.
40
+ const cleaned = clean(k);
41
+ let name = cleaned;
42
+ for (let n = 2; names.has(name); n++)
43
+ name = `${cleaned} ${n}`;
44
+ names.add(name);
45
+ return [name, secret(k, v) ? '[redacted]' : mask(v, calls, clean, k)];
46
+ }));
38
47
  }
39
48
  export function createTranscript(opts) {
40
49
  const paths = transcriptPaths(opts.runId, opts.location);
@@ -56,9 +65,10 @@ export function createTranscript(opts) {
56
65
  };
57
66
  // Not even a token's prefix stays, so a search for pst_ or phr_ finds nothing.
58
67
  // Each secret as typed and as JSON writes it, for a result that is JSON text.
59
- const clean = (text) => redact(text, [...secrets].flatMap((s) => [s, JSON.stringify(s).slice(1, -1)])).replace(/\b(pst|phr)_\[redacted\]/g, '[redacted]');
60
- const append = (path, text) => attempt(() => appendFileSync(path, clean(text), { mode: 0o600 }));
61
- const md = (...parts) => append(paths.markdown, `${parts.join('\n\n')}\n\n`);
68
+ // An event's string is cleaned as the value of its `field`: API_TOKEN over a random value says it is a secret.
69
+ const clean = (text, field) => scrub(text, [...secrets].flatMap((s) => [s, JSON.stringify(s).slice(1, -1)]), field).replace(/\b(pst|phr)_\[redacted\]/g, '[redacted]');
70
+ // Each part is cleaned on its own, so a private key whose end is missing takes no more than the rest of its part.
71
+ const md = (...parts) => attempt(() => appendFileSync(paths.markdown, `${parts.map((part) => clean(part)).join('\n\n')}\n\n`, { mode: 0o600 }));
62
72
  // Each run starts both files afresh, so a path used before holds this run alone.
63
73
  attempt(() => {
64
74
  mkdirSync(dirname(paths.markdown), { recursive: true });
@@ -85,7 +95,8 @@ export function createTranscript(opts) {
85
95
  parts.push(`**Question** \`${String(args.id)}\` (${String(args.kind)}${args.sensitive === true ? ', sensitive' : ''}${args.skippable === true ? ', skippable' : ''})`, String(args.prompt ?? ''), ...(options.length ? [options.join('\n')] : []));
86
96
  }
87
97
  else {
88
- parts.push(`**Tool call** \`${block.name}\``, fenced(cut(JSON.stringify(args, null, 2)), 'json'));
98
+ // Cleaned before it is cut, here as below: a secret the cut falls inside would leave its first part.
99
+ parts.push(`**Tool call** \`${block.name}\``, fenced(cut(clean(JSON.stringify(args, null, 2))), 'json'));
89
100
  }
90
101
  }
91
102
  }
@@ -125,13 +136,13 @@ export function createTranscript(opts) {
125
136
  if (message.role === 'assistant')
126
137
  assistant(message);
127
138
  else if (message.role === 'user')
128
- md(`**Prompt**`, cut(textOf(message.content)).replace(/^/gm, '> '));
139
+ md(`**Prompt**`, cut(clean(textOf(message.content))).replace(/^/gm, '> '));
129
140
  else if (message.role === 'custom')
130
141
  md(`_Context sent to the model (${message.customType ?? 'custom'}, ${textOf(message.content).length} characters)._`);
131
142
  }
132
143
  else if (event.type === 'tool_execution_end') {
133
144
  const answer = questions.has(event.toolCallId);
134
- const text = sensitiveCalls.has(event.toolCallId) ? '[redacted]' : cut(textOf(event.result?.content));
145
+ const text = sensitiveCalls.has(event.toolCallId) ? '[redacted]' : cut(clean(textOf(event.result?.content)));
135
146
  md(`**${answer ? 'Answer' : 'Result'}** \`${event.toolName}\`${event.isError ? ' (error)' : ''}`, fenced(text || '(nothing)'));
136
147
  }
137
148
  },
package/dist/ui/bridge.js CHANGED
@@ -130,7 +130,7 @@ async function askOnStdin(q) {
130
130
  return password ? { skipped: false, username, password } : { skipped: true };
131
131
  }
132
132
  const how = q.kind === 'text' ? (q.skippable ? '(Enter to skip)' : '') : q.kind === 'single' ? `Type a number${q.skippable ? ', or Enter to skip' : ''}:` : `Type the numbers, separated by commas${q.skippable ? ', or Enter to skip' : ''}:`;
133
- out.write(`\n${q.prompt}\n${q.hint ? `${q.hint}\n` : ''}`);
133
+ out.write(`\n${q.prompt}\n${q.hint ? `${q.hint}\n` : ''}${q.caution ? `${q.caution}\n` : ''}`);
134
134
  // A multi choice that opens with picks shows them; on a pipe the numbers typed are the whole answer.
135
135
  const marked = q.kind === 'multi' && (q.options ?? []).some((o) => o.selected);
136
136
  (q.options ?? []).forEach((o, i) => out.write(` ${i + 1}. ${marked ? `[${o.selected ? 'x' : ' '}] ` : ''}${o.label}\n`));