@impeccable/reviews 0.1.1 → 0.1.3
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/README.md +25 -7
- package/dist/agent.js +2 -2
- package/dist/bin.js +38 -0
- package/dist/cloudflare-access.js +157 -49
- package/dist/cloudflare.js +93 -51
- package/dist/fence.js +107 -39
- package/dist/host-questions.js +4 -3
- package/dist/hosted.js +29 -8
- package/dist/log.js +6 -1
- package/dist/login.js +8 -6
- package/dist/main.js +76 -23
- package/dist/netlify-password.js +2 -1
- package/dist/netlify.js +4 -1
- package/dist/playbook/cases/cloudflare-previews.md +4 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/monorepo.md +4 -3
- package/dist/playbook/cases/netlify-previews.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +4 -4
- package/dist/playbook/cases/preview-protection.md +3 -3
- package/dist/playbook/cases/pull-requests.md +2 -2
- package/dist/playbook/cases/server-previews.md +17 -0
- package/dist/playbook/cases/sign-in.md +0 -1
- package/dist/playbook/cases/static-site.md +1 -1
- package/dist/playbook/cases/vercel-previews.md +1 -1
- package/dist/playbook/cases/vercel-setup.md +3 -3
- package/dist/playbook.js +3 -2
- package/dist/preflight.js +52 -12
- package/dist/report.js +68 -0
- package/dist/sandbox.js +280 -0
- package/dist/tools.js +58 -35
- package/dist/ui/look.js +13 -5
- package/dist/ui/run-state.js +9 -9
- package/dist/ui/screens.js +15 -16
- package/package.json +2 -1
- package/dist/playbook/cases/ci-tunnel.md +0 -21
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Sets a repository up for Impeccable for PRs from your terminal. Run it in a checkout of the repository: it signs you in through the browser, then a setup agent reads the checkout, runs what it needs, saves the settings through Impeccable's setup API, asks you only what it cannot find out, opens a test drive pull request for Impeccable to review (and a setup pull request to merge, when the repository needs one) and writes a setup report. A web page mirrors the run as it goes and follows the review.
|
|
4
4
|
|
|
5
|
-
You need Node 22.19 or newer, git, and the `gh` command signed in to GitHub as someone who can push branches to the repository (the agent opens the pull request as you).
|
|
5
|
+
You need Node 22.19 or newer, git, and the `gh` command signed in to GitHub as someone who can push branches to the repository (the agent opens the pull request as you). On Linux, `bubblewrap`, `socat` and `ripgrep` let the agent's commands run in a sandbox.
|
|
6
6
|
|
|
7
7
|
## Run it
|
|
8
8
|
|
|
@@ -23,6 +23,7 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
|
|
|
23
23
|
|
|
24
24
|
| Flag | Default | What it does |
|
|
25
25
|
| --- | --- | --- |
|
|
26
|
+
| `--install-dir <path>` | the folder the command is run in | The folder to set up, a checkout of the repository or a folder inside one, so the command runs from anywhere: `npx @impeccable/reviews --install-dir ~/code/your-repo`. A relative path is taken from the folder the command is run in; `IMPECCABLE_REVIEWS_INSTALL_DIR` does the same when the option is absent. |
|
|
26
27
|
| `--host <origin>` | `https://impeccable.reviews` | Impeccable's address. Plain `http` only for localhost. |
|
|
27
28
|
| `--model <id>` | the model the service hands out (Sonnet 5.5 today) | The model the setup agent runs on, through Impeccable's gateway. |
|
|
28
29
|
| `--effort <level>` | `high` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`. |
|
|
@@ -31,12 +32,13 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
|
|
|
31
32
|
| `--transcript <path>` | the temp folder | Writes the transcript there instead: a folder, or a `.md` file with the `.jsonl` beside it. |
|
|
32
33
|
| `--scan-report` | off | Writes what the fence's content checks found (`scan-report-<run>.json`: each match's check, where it was, the tool and what it did, never the text, with counts per check) beside the transcript, and prints where it is. |
|
|
33
34
|
| `--frames <dir>` | off | Writes each screen into the folder as it changes (`NNN-<state>.ansi` with its styling, `NNN-<state>.txt`, and `index.json`), for the eval. |
|
|
35
|
+
| `--no-telemetry` | off | Reports none of the command's steps to Impeccable (see What it reports). `DO_NOT_TRACK=1` does the same. |
|
|
34
36
|
|
|
35
37
|
## What happens
|
|
36
38
|
|
|
37
|
-
1. **Sign in.** The consent page
|
|
39
|
+
1. **Sign in.** The consent page names this repository, read from the checkout's `origin` remote; you approve, and it answers a small server on the first free port of 8239, 8238, 8240, 8237, 8236 and 8235, with PKCE and a state check, then sends the browser on to the run's web page. On a remote shell, press P and paste the address the browser ends on.
|
|
38
40
|
2. **A web front end?** Impeccable reviews web front ends, mobile web included. A checkout with none (a React Native, Expo or Flutter app without a web target, or a service with no page, template, stylesheet or web framework) ends here: the terminal and the web page say "Impeccable reviews web front ends, and this repository does not have one. Nothing was set up.", and it exits 2. When the checkout cannot tell, Impeccable's own scan decides. A checkout with a web front end that the scan skipped stops the same way, with the setup page, where the person chooses Add anyway before running it again.
|
|
39
|
-
3. **Set up.** On a run with its own capped model token (model calls go through Impeccable's gateway, so this machine holds no model key), the agent reads the checkout, runs the build, saves settings through the setup API, and asks you in a dialog only what it cannot find out. It never asks you to run a command: what it cannot run goes into the report, with the command. When Vercel, Netlify or Cloudflare Pages already deploys previews of your pull requests, it opens the newest one (Netlify's it finds on GitHub, and through the Netlify CLI when you are signed in to it on this machine, read only; Cloudflare's in the check run and the comment Cloudflare's GitHub app posts, and through
|
|
41
|
+
3. **Set up.** On a run with its own capped model token (model calls go through Impeccable's gateway, so this machine holds no model key), the agent reads the checkout, runs the build, saves settings through the setup API, and asks you in a dialog only what it cannot find out. It never asks you to run a command: what it cannot run goes into the report, with the command. When Vercel, Netlify or Cloudflare Pages already deploys previews of your pull requests, it opens the newest one (Netlify's it finds on GitHub, and through the Netlify CLI when you are signed in to it on this machine, read only; Cloudflare's in the check run and the comment Cloudflare's GitHub app posts, and through Cloudflare's CLI, cf, when you are signed in to it here, read only), and once that page is your app it takes the host's previews as they are, with no question and no setup pull request. When the repository is set up for Vercel or Netlify but that host has never previewed it, it asks you for one thing, to sign in to the host, and only when its CLI on this machine is signed out: one screen, Sign in or Skip, the login in your browser on Enter, and a retry when it did not complete (two failed retries count as a skip). Then it does the rest itself. On Vercel it creates the project with `vercel link` in the team the CLI's current scope names (asking which, only when you have several and none is current), connects the repository, puts Vercel's login in front of every address of the new project before pushing anything (Vercel makes a new project's first deployment production, whatever branch it builds, and would serve the test drive at the project's own address otherwise; the report says how to open production once your default branch is live), pushes the test drive and waits up to five minutes for Vercel's build of it (the task in progress reads "waiting for Vercel's preview · 1:05", counting up), proves that preview, takes Vercel's previews as they are and only then opens the test drive's pull request, so its review renders; a new project starts with Vercel Authentication on, which the run gets past with a bypass secret on your yes. On Netlify it creates the site in your Netlify team (asking which, only when you have several), connects the repository through Netlify's site settings and Netlify's GitHub app, so pull requests get deploy previews, pushes the test drive and opens it as a draft, which Impeccable leaves unreviewed, waits for its deploy preview, proves it, takes Netlify's previews as they are and marks the draft ready. When the host's GitHub app cannot reach the repository, one more screen opens the app's page on GitHub for you to give it access, and the run waits. On skip it builds Impeccable's own previews as usual.
|
|
40
42
|
4. **Two pull requests,** opened as you with `gh`, even when something is left for a person, which goes into the report, both into the default branch and in this order:
|
|
41
43
|
- the test drive, safe to close (`impeccable/test-drive`, "Impeccable test drive (safe to close)"), always: a few real style or layout changes on a page the preview renders, on top of the setup commit when there is one, so it carries the workflow, whose job builds the preview on the pull request while Impeccable's review waits for it. Its pull request opens only once its preview is proven: the host's build of the pushed branch, or the local proof of Impeccable's own preview. No preview, no test drive: the run then says why in the report and what you can do, and still opens the setup pull request when the repository needs one;
|
|
42
44
|
- the setup pull request, to merge (`impeccable/setup`, "Set up Impeccable previews"), only when the repository needs a change to keep previews working: the preview workflow, any fix the build needed.
|
|
@@ -46,17 +48,33 @@ The sign-in's setup token lasts an hour. A run that goes longer asks you to sign
|
|
|
46
48
|
|
|
47
49
|
## What the agent may run
|
|
48
50
|
|
|
49
|
-
The
|
|
51
|
+
The agent's commands run in an operating-system sandbox, macOS's seatbelt or Linux's bubblewrap, through Anthropic's sandbox runtime (`@anthropic-ai/sandbox-runtime`), which holds even when the repository talks the agent into something:
|
|
52
|
+
|
|
53
|
+
- **Reads**: what your account reads, but your credential folders: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.netrc`, `~/.git-credentials`, `~/.config/gh`, and your keychains on macOS.
|
|
54
|
+
- **Writes**: the checkout and its git folder, but never where git takes settings or programs from (its config, hooks, `info`, a `commondir`, the submodules' and the other worktrees' git folders, a `.git` below the checkout's top); the run's scratch folder; and the package managers' caches (npm, pnpm, Yarn, Bun, Corepack, Prisma's engines), but not what `npx` installed in npm's. An install skips the browsers Cypress, Puppeteer and Playwright download, and husky's git hooks.
|
|
55
|
+
- **Network**: only the hosts this setup needs: `--host`; GitHub (`github.com`, `*.github.com`, `*.githubusercontent.com`); the public registries your manifests and lockfiles install from (npm's, Yarn's, PyPI); the registries your own `~/.npmrc` names, never one only the repository names; Google's fonts for a Next.js app and Prisma's engines, when the app uses them; and Vercel's or Netlify's API once you said yes to setting that host up. The debug log names each host it refused.
|
|
56
|
+
|
|
57
|
+
What acts as you runs outside the sandbox, as a command of its own: `git commit`, `git push`, `gh`, `vercel`, `netlify` and the install of a host's CLI need your sign-ins, keys and keychain, which the sandbox never opens, and run none of the repository's code. Git's hooks are off for them, and for every git the command runs itself, and git is pointed at this checkout's own git folders. A command that joins one of them to another program is refused, and the agent runs them apart; `cd` and git's other commands may come along. What the command fetches there itself, a host's CLI or Cloudflare's `cf` through `npx`, it fetches from a folder of its own, so the repository's `.npmrc` and `node_modules` choose nothing of what runs as you. On macOS the local preview check runs the app's commands in a sandbox of its own: they write only in its throwaway copy, read and reach what the agent's commands do, and serve the app on this machine, where the check requests it. On Linux bubblewrap gives each command a network of its own, which the check could not request, so there the check runs them as before, in its copy with a clean environment.
|
|
58
|
+
|
|
59
|
+
On macOS the command stops when the sandbox does not start, as inside another tool's sandbox. On Linux it needs `bubblewrap`, `socat` and `ripgrep`: without them, and on other systems, the first screen says once that the commands run without a sandbox, with all your access, as they did before. On Linux bubblewrap takes no pattern in a write rule, so a command there can make a `.git` below the checkout's top; git outside the sandbox never reads it during the run, being pointed at the checkout's own folders.
|
|
60
|
+
|
|
61
|
+
The fence stays the layer inside the sandbox: it keeps the setup to its own steps and out of `.env` files, and the agent's commands get your environment without the tokens, keys and passwords in it. It also reads what goes past it: text shaped like a secret (a cloud or GitHub key, a private key, a token) in a file the agent writes, a setting, a commit, a pull request or a message to you is refused, a command that names a credential file (`~/.ssh`, `.aws/credentials`, `.netrc`, `.env`) is refused, and what the agent reads gets a line saying it is data when it carries text shaped like instructions; the debug log and `--scan-report` list each match.
|
|
50
62
|
|
|
51
63
|
Without asking: `git status|diff|log|show|add|commit|checkout -b|push -u origin <branch>`, `gh pr create|view` and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
|
|
52
64
|
|
|
53
65
|
`vercel whoami` and `vercel --version`. Once you are signed in to Vercel for the setup: `vercel link --yes`, `vercel project add|ls`, `vercel git connect` and `vercel deploy` for a preview, on the project named after the folder and in the one team the sign-in settled on (the CLI's current scope, your only team, or the one you pick when it asks): no other `--scope`, `--team` or `--project`, and a global install of the Vercel CLI itself when it is missing (`npm install -g vercel`, or your package manager's global add). The sign-in (`vercel login`) is the run's own tool, never the agent's command. Never a production deploy, a project's removal, its environment variables or protection settings (`vercel project protection`, `api` and `curl` print a project's bypass secrets, so the agent's shell never runs them; the lock before a new project's first deployment and the bypass secret are the run's own tools), or a token on the command line.
|
|
54
66
|
|
|
55
|
-
`netlify --version` and `netlify status`. Once you are signed in to Netlify for the setup: `netlify link
|
|
67
|
+
`netlify --version` and `netlify status`. Once you are signed in to Netlify for the setup: `netlify link` and `netlify sites:create`, in your own current Netlify team, or with `--account-slug` for the one team you picked when the run asked, and a global install of the Netlify CLI itself when it is missing (`npm install -g netlify-cli`, or your package manager's global add). The sign-in (`netlify login`), the repository's connection (the site's settings, through Netlify's API) and the site's password (`netlify api` prints a site's settings, the password among them) are the run's own tools, never the agent's commands; `netlify init` never runs. Never a deploy (`netlify deploy --build` builds the repository with your Netlify sign-in, and the two never run together), a site's removal, its environment variables, or a token on the command line.
|
|
56
68
|
|
|
57
69
|
Scratch output, such as the log of a server the agent starts, goes to a folder in the OS temp directory, never into the checkout; a `>` redirect into that run's folder is the one write outside the checkout that runs, and the agent reads what it wrote there back (the path with its symlinks resolved).
|
|
58
70
|
|
|
59
|
-
Refused, never asked, with the reason going back to the agent, which finds another way or leaves the step to you in the report: anything else (`node -e` too), a read outside the checkout (symlinks followed), a read of a `.env` file, a recursive `grep` without `--exclude=.env*`, a push to the default branch, a global install, publishing, a script whose name says it publishes, releases or deploys, a setting such as `PATH`, `EDITOR` or `NODE_OPTIONS` that changes what runs, a `cd` that may not run (after `&&` or `||`, or in a pipe) or goes into a folder that is not there, git patterns that can match a `.env` file, a `git add` of every change or of a folder without `':(exclude,glob)**/.env*'`, a commit message or template read from a `.env` file or from outside the checkout and the scratch folder, package scripts and the tools on the list once the agent has changed a `package.json` or a file they load (a `*.config.*` or `.*rc` file, `tsconfig*.json`, anything under `node_modules`…; after `vite.config.*`, whose preview hosts the setup may correct, the local preview check still runs), and, past 400 tool calls in a run, any but the progress update and the report. `rm -rf`, force pushes, `git reset --hard`, `sudo`, database commands that throw data away (`prisma migrate reset`, `db push --force-reset`, `sequelize db:drop`…), other writes outside the checkout and writes into `.git
|
|
71
|
+
Refused, never asked, with the reason going back to the agent, which finds another way or leaves the step to you in the report: anything else (`node -e` too), a read outside the checkout (symlinks followed), a read of a `.env` file, a recursive `grep` without `--exclude=.env*`, a push to the default branch, a global install, publishing, a script whose name says it publishes, releases or deploys, a setting such as `PATH`, `EDITOR` or `NODE_OPTIONS` that changes what runs, a `cd` that may not run (after `&&` or `||`, or in a pipe) or goes into a folder that is not there, a command that joins `git commit` or `push`, `gh`, `vercel`, `netlify` or a host CLI's install to another program, git patterns that can match a `.env` file, a `git add` of every change or of a folder without `':(exclude,glob)**/.env*'`, a commit message or template read from a `.env` file or from outside the checkout and the scratch folder, package scripts and the tools on the list once the agent has changed a `package.json` or a file they load (a `*.config.*` or `.*rc` file, `tsconfig*.json`, anything under `node_modules`…; after `vite.config.*`, whose preview hosts the setup may correct, the local preview check still runs), and, past 400 tool calls in a run, any but the progress update and the report. `rm -rf`, force pushes, `git reset --hard`, `sudo`, database commands that throw data away (`prisma migrate reset`, `db push --force-reset`, `sequelize db:drop`…), other writes outside the checkout and writes into a `.git`, the checkout's or one below its top, never run.
|
|
72
|
+
|
|
73
|
+
## What it reports
|
|
74
|
+
|
|
75
|
+
So that a setup that stops in the terminal shows where it stopped, the command tells Impeccable about the steps only it sees: that it started, a check that stopped it (Node too old, no git, not a checkout, no `origin` remote, a remote not on GitHub, the sandbox not starting), a sign-in that did not finish (cancelled, timed out, every callback port taken, a different repository approved), and being stopped (Ctrl+C, a signal, a crash, the last update to the web page lost). A stopped run's last update also says why it stopped.
|
|
76
|
+
|
|
77
|
+
Each report carries the step, how it ended, when, the command's version, Node's major version, your operating system and an id made fresh for the run. Never your code, file names, what you were asked or answered, or any token's value. It never names you: once you sign in, the reports go with the sign-in token, and Impeccable puts them on the account that approved, so steps from before sign-in wait and go with it. A run that never signs in sends its steps with the run's id alone. A report that cannot be sent is dropped and never holds the command up for more than two seconds. `--no-telemetry` or `DO_NOT_TRACK=1` turns all of it off.
|
|
60
78
|
|
|
61
79
|
## Tokens
|
|
62
80
|
|
|
@@ -68,4 +86,4 @@ Vercel's bypass secret, which lets reviews open previews behind Vercel's login,
|
|
|
68
86
|
|
|
69
87
|
Netlify's site password (Password Protection on Pro and Enterprise plans; "Project visibility: Password" on a credit-based Pro plan) is handled the same way. When a Netlify deploy preview asks for it, the command reads the site's settings through the Netlify CLI, signed in as you. For a password Netlify gives back, it asks you once, on one screen, whether to store the site password for Impeccable, and on your yes reads it from the site as you. When Netlify does not give the password back, the CLI is signed out or the site is in none of your teams, a screen takes the password you type, hidden (the run never sets one itself: the Netlify CLI takes a new password only on its command line, where any process could read it). It opens the preview with the password first, as basic credentials in the `Authorization` header (Netlify's password page asks for a password alone, so the credentials carry no username), and saves it, with the setting that sends that header, only once the page shows your app; a password that opens the preview on Netlify's own form but not in the header is reported, not saved, since reviews can send only a header. A site behind Netlify's team login (a private site, or team login protection) has no way in for tools: the run reports it and takes no method. The agent never holds the password: it goes from the command's own process to Netlify and to Impeccable's setting, and the debug log and the transcript remove it.
|
|
70
88
|
|
|
71
|
-
A Cloudflare Access service token, which lets reviews open Pages previews behind Access, is handled the same way. When Access stands in front of a preview, the command asks you once, on one screen, whether to create a service token for Impeccable
|
|
89
|
+
A Cloudflare Access service token, which lets reviews open Pages previews behind Access, is handled the same way. When Access stands in front of a preview, the command asks you once, on one screen, whether to create a service token for Impeccable. On your yes it creates the token as you through Cloudflare's CLI, cf (run through npx), named "Impeccable reviews", and allows it in a Service Auth policy of the Access application in front of the preview (a reusable policy added to the application's list). When cf here is not signed in with Access, your browser opens Cloudflare's sign-in first, for five scopes (`account:read`, `user:read`, `pages:read`, `access.write`, `access-service-token.write`), with a retry when it did not complete. Only where that sign-in cannot create the token (it grants no Access, cf does not run here, or the application runs on policies of its own, which cf has no command for) does the command ask for a Cloudflare API token with two permissions (Access: Apps and Policies, Edit; Access: Service Tokens, Edit), used for that run only and stored nowhere. On a skip, or where that cannot be done, a screen takes a client id and secret you paste, the secret hidden. It opens the preview with the token in the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers first and saves it, with the setting that sends them, only once the page shows your app; a token it made that opens nothing is deleted, with its policy. The agent never holds either, and the debug log and the transcript remove both. You revoke it in Zero Trust, Access controls, Service credentials, Service Tokens.
|
package/dist/agent.js
CHANGED
|
@@ -110,8 +110,8 @@ export async function runAgent(opts) {
|
|
|
110
110
|
model,
|
|
111
111
|
thinkingLevel: opts.effort,
|
|
112
112
|
tools: [...BUILT_IN_TOOLS, ...opts.tools.map((t) => t.name)],
|
|
113
|
-
// pi's own bash, whose commands run without the person's credentials in their environment.
|
|
114
|
-
customTools: [defineTool(createBashToolDefinition(cwd, { spawnHook: (spawn) => ({ ...spawn, env: shellEnvironment(spawn.env) }) })), ...opts.tools],
|
|
113
|
+
// pi's own bash, whose commands run in the sandbox (or as the person, outside it) without the person's credentials in their environment.
|
|
114
|
+
customTools: [defineTool(createBashToolDefinition(cwd, { operations: opts.bash, spawnHook: (spawn) => ({ ...spawn, env: shellEnvironment(spawn.env) }) })), ...opts.tools],
|
|
115
115
|
});
|
|
116
116
|
return { ...created, services, diagnostics: services.diagnostics };
|
|
117
117
|
};
|
package/dist/bin.js
CHANGED
|
@@ -4,8 +4,46 @@
|
|
|
4
4
|
const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
|
|
5
5
|
if (major < 22 || (major === 22 && minor < 19)) {
|
|
6
6
|
console.error(`impeccable-reviews needs Node 22.19 or newer, and this is Node ${process.versions.node}. Install a newer Node from https://nodejs.org and run it again.`);
|
|
7
|
+
await reportOldNode(major);
|
|
7
8
|
process.exit(1);
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* The one step reported from here (report.ts reports the rest): Node was too
|
|
12
|
+
* old to run the command. Unnamed, with nothing but the launch, two seconds at
|
|
13
|
+
* most, and never with --no-telemetry or DO_NOT_TRACK=1. Written for the old
|
|
14
|
+
* Node it runs on: no imports, and nothing at all where fetch is missing.
|
|
15
|
+
*/
|
|
16
|
+
async function reportOldNode(nodeMajor) {
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
if (args.includes('--no-telemetry') || /^(1|true|yes)$/i.test(process.env.DO_NOT_TRACK?.trim() ?? ''))
|
|
19
|
+
return;
|
|
20
|
+
if (typeof fetch !== 'function' || typeof globalThis.crypto?.randomUUID !== 'function')
|
|
21
|
+
return;
|
|
22
|
+
const named = args.find((a) => a.startsWith('--host='))?.slice('--host='.length) ?? (args.includes('--host') ? args[args.indexOf('--host') + 1] : undefined);
|
|
23
|
+
let host;
|
|
24
|
+
try {
|
|
25
|
+
host = new URL(named ?? 'https://impeccable.reviews');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// The same rule as --host itself: plain http only to this machine.
|
|
31
|
+
if (!(host.protocol === 'https:' || (host.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(host.hostname))))
|
|
32
|
+
return;
|
|
33
|
+
let version = 'unknown';
|
|
34
|
+
try {
|
|
35
|
+
const { readFileSync } = await import('node:fs');
|
|
36
|
+
version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// The report goes without it.
|
|
40
|
+
}
|
|
41
|
+
const body = { launch_id: globalThis.crypto.randomUUID(), version, node_major: nodeMajor, os: process.platform, events: [{ event: 'setup_command_check_failed', outcome: 'node_too_old', at: new Date().toISOString() }] };
|
|
42
|
+
await Promise.race([
|
|
43
|
+
fetch(`${host.origin}/app/cli/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(() => undefined),
|
|
44
|
+
new Promise((resolve) => setTimeout(resolve, 2000).unref()),
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
9
47
|
const { main } = await import('./main.js');
|
|
10
48
|
await main(process.argv.slice(2));
|
|
11
49
|
export {};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { cfFailure, cfStatus } from './cloudflare.js';
|
|
2
3
|
/**
|
|
3
4
|
* Getting past Cloudflare Access the way Cloudflare documents it for tools: a service token, a client id and a client
|
|
4
5
|
* secret sent in two headers, which a Service Auth policy of the Access application in front of the previews allows.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* The run makes both as the person through cf, Cloudflare's CLI, signed in with the scopes below, and signs cf in, in
|
|
7
|
+
* the browser, where it lacks them. Where that sign-in cannot make them (it grants no Access, cf does not run here, or
|
|
8
|
+
* the application runs on policies of its own, which cf has no command to add to), the person gives the run a Cloudflare
|
|
9
|
+
* API token with two permissions instead, used in the command's own process for this run and kept nowhere. The person
|
|
7
10
|
* consents once, on the tool's screen, before anything is made; a token opens the locked address before anything saves
|
|
8
11
|
* it; neither it nor the API token goes back to the model.
|
|
9
12
|
*/
|
|
@@ -13,13 +16,117 @@ export const ACCESS_SECRET_HEADER = 'CF-Access-Client-Secret';
|
|
|
13
16
|
/** The name the token and its policy carry in Zero Trust, so the person knows whose they are and can revoke them. */
|
|
14
17
|
export const TOKEN_NAME = 'Impeccable reviews';
|
|
15
18
|
const API = 'https://api.cloudflare.com/client/v4';
|
|
19
|
+
/**
|
|
20
|
+
* The OAuth scopes the run's Cloudflare sign-in asks for. Each is named for the API token permission it grants: Access:
|
|
21
|
+
* Apps and Policies Write and Access: Service Tokens Write, which the token and its policy take; Pages Read, for cf's
|
|
22
|
+
* list of the preview deployments; and the account and user reads cf finds the accounts with (/accounts, /memberships).
|
|
23
|
+
*/
|
|
24
|
+
export const CF_SCOPES = ['account:read', 'user:read', 'pages:read', 'access.write', 'access-service-token.write'];
|
|
25
|
+
/** Of those, what creating the token and its policy takes: a sign-in without them cannot. */
|
|
26
|
+
const ACCESS_SCOPES = ['account:read', 'access.write', 'access-service-token.write'];
|
|
16
27
|
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
17
28
|
const list = (value) => (Array.isArray(value) ? value.map(record).filter((item) => item !== undefined) : []);
|
|
18
29
|
/** An id Cloudflare gave, safe in a path: an account's 32 hex digits, or a UUID. */
|
|
19
30
|
const uid = (value) => typeof value === 'string' && /^[0-9a-f-]{8,64}$/i.test(value);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
/** Why a call failed, in fixed words: the permissions for a 401 or a 403, else the status. Cloudflare's own message never goes on. */
|
|
32
|
+
const refusal = (way, reply, none) => (reply.ok ? none
|
|
33
|
+
: reply.status === 401 || reply.status === 403 ? `${way.who[0].toUpperCase()}${way.who.slice(1)} cannot do this: it needs Access: Apps and Policies → Edit and Access: Service Tokens → Edit, on the account that holds the Access application.`
|
|
34
|
+
: reply.status === null ? "Cloudflare's API did not answer." : `Cloudflare's API answered ${reply.status}.`);
|
|
35
|
+
/** Cloudflare's API with the API token the person gave, for this run alone. */
|
|
36
|
+
function withApiToken(key, deps) {
|
|
37
|
+
return {
|
|
38
|
+
who: 'the Cloudflare API token',
|
|
39
|
+
cf: false,
|
|
40
|
+
call: async (method, path, body, undoing = false) => {
|
|
41
|
+
try {
|
|
42
|
+
const res = await (deps.fetch ?? fetch)(`${API}${path}`, {
|
|
43
|
+
method,
|
|
44
|
+
headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
45
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
46
|
+
signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(deps.signal && !undoing ? [deps.signal] : [])]),
|
|
47
|
+
});
|
|
48
|
+
const data = record(await res.json().catch(() => undefined));
|
|
49
|
+
return res.ok && data?.success === true ? { ok: true, result: data.result } : { ok: false, status: res.status };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { ok: false, status: null };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The cf command for each call in an account's Access; a policy of an application's own has none. A delete asks first unless forced, and the run deletes only what it made. */
|
|
58
|
+
const CF_COMMANDS = [
|
|
59
|
+
[/^GET apps$/, () => ['applications', 'list']],
|
|
60
|
+
[/^GET policies$/, () => ['policies', 'list']],
|
|
61
|
+
[/^POST service_tokens$/, () => ['service-tokens', 'create']],
|
|
62
|
+
[/^POST policies$/, () => ['policies', 'create']],
|
|
63
|
+
[/^PUT apps\/([\w-]+)$/, (id) => ['applications', 'update', id]],
|
|
64
|
+
[/^DELETE service_tokens\/([\w-]+)$/, (id) => ['service-tokens', 'delete', id, '--force']],
|
|
65
|
+
[/^DELETE policies\/([\w-]+)$/, (id) => ['policies', 'delete', id, '--force']],
|
|
66
|
+
];
|
|
67
|
+
/** Cloudflare's API through cf signed in as the person: each call as the cf command that makes it, in the account its path names, which is one of the sign-in's. */
|
|
68
|
+
function throughCf(cf, accounts, signal) {
|
|
69
|
+
return {
|
|
70
|
+
who: 'the Cloudflare sign-in',
|
|
71
|
+
cf: true,
|
|
72
|
+
call: async (method, path, body, undoing = false) => {
|
|
73
|
+
const [route = '', query] = path.split('?');
|
|
74
|
+
const paging = new URLSearchParams(query);
|
|
75
|
+
if (method === 'GET' && route === '/accounts')
|
|
76
|
+
return { ok: true, result: paging.get('page') === '1' ? accounts.map((id) => ({ id })) : [] };
|
|
77
|
+
const at = /^\/accounts\/([0-9a-f]{32})\/access\/(.+)$/.exec(route);
|
|
78
|
+
const call = `${method} ${at?.[2]}`;
|
|
79
|
+
const command = CF_COMMANDS.find(([pattern]) => pattern.test(call));
|
|
80
|
+
if (!at || !command)
|
|
81
|
+
return { ok: false, status: null };
|
|
82
|
+
const args = ['zero-trust', 'access', ...command[1](command[0].exec(call)[1] ?? ''),
|
|
83
|
+
...(paging.has('page') ? ['--page', paging.get('page'), '--per-page', paging.get('per_page')] : []),
|
|
84
|
+
...(body === undefined ? [] : ['--body', JSON.stringify(body)])];
|
|
85
|
+
try {
|
|
86
|
+
// cf prints the result as JSON, and a delete nothing.
|
|
87
|
+
const printed = await cf(args, { account: at[1], ...(undoing ? {} : { signal }) });
|
|
88
|
+
const start = printed.search(/[[{]/);
|
|
89
|
+
return { ok: true, result: start < 0 ? null : JSON.parse(printed.slice(start)) };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return { ok: false, status: cfFailure(err) };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* cf signed in with the scopes, as it is or after Cloudflare's sign-in in the browser: one that did not complete offers
|
|
99
|
+
* a retry, and two failed retries end it. The new sign-in asks for the scopes it held as well, so whatever else the
|
|
100
|
+
* person uses cf for goes on working.
|
|
101
|
+
*/
|
|
102
|
+
async function reach(deps) {
|
|
103
|
+
const granted = (s) => s.cli === 'signed-in' && (!s.scopes || ACCESS_SCOPES.every((scope) => s.scopes.includes(scope)));
|
|
104
|
+
const through = (s) => ({ way: throughCf(deps.cf, s.accounts, deps.signal) });
|
|
105
|
+
let status = await cfStatus(deps.cf, deps.signal);
|
|
106
|
+
if (granted(status))
|
|
107
|
+
return through(status);
|
|
108
|
+
if (status.cli === 'missing' || status.cli === 'unknown')
|
|
109
|
+
return { fallback: "Cloudflare's CLI did not run here." };
|
|
110
|
+
for (let attempt = 0;; attempt++) {
|
|
111
|
+
deps.status?.("Opening the browser for Cloudflare's login");
|
|
112
|
+
const scopes = [...new Set([...(status.scopes ?? []).filter((scope) => scope !== 'offline_access'), ...CF_SCOPES])];
|
|
113
|
+
// The callback on this machine, not the device flow, which asks the person to match a code cf prints where they never see it.
|
|
114
|
+
const done = await deps.login(['auth', 'login', '--force', '--no-device', '--scopes', ...scopes], { signal: deps.signal }).then(() => true, () => false);
|
|
115
|
+
deps.signal?.throwIfAborted();
|
|
116
|
+
const now = await cfStatus(deps.cf, deps.signal);
|
|
117
|
+
if (granted(now))
|
|
118
|
+
return through(now);
|
|
119
|
+
// Signed in anew, and still without Access: Cloudflare gave this sign-in none, which an API token can have.
|
|
120
|
+
if (done && now.cli === 'signed-in')
|
|
121
|
+
return { fallback: 'The Cloudflare sign-in gave the run no access to Cloudflare Access.' };
|
|
122
|
+
if (now.cli === 'signed-in')
|
|
123
|
+
status = now;
|
|
124
|
+
if (attempt === 2)
|
|
125
|
+
return { stopped: 'The Cloudflare sign-in did not complete.' };
|
|
126
|
+
if (!(await deps.retry()))
|
|
127
|
+
return { stopped: 'The person skipped the Cloudflare sign-in.' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
23
130
|
/** Where an application sends Access's login: its domain, its self-hosted domains and its public destinations, each a host with an optional path. */
|
|
24
131
|
function destinations(app) {
|
|
25
132
|
const uris = list(app.destinations).filter((d) => d.type === undefined || d.type === 'public').map((d) => d.uri);
|
|
@@ -40,28 +147,19 @@ function settingsOf(app) {
|
|
|
40
147
|
/** What undoing answers when Cloudflare refused a step of it: the person removes the rest. */
|
|
41
148
|
const LEFT_BEHIND = ` Some of what the run made could not be taken back: delete the service token and the policy named "${TOKEN_NAME}" in Zero Trust.`;
|
|
42
149
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* application on reusable policies (every one the dashboard makes now) gets a reusable policy added to
|
|
46
|
-
* policies of its own gets one of those, since the two never mix on one application.
|
|
47
|
-
*
|
|
150
|
+
* As the person, the way they reach Cloudflare's API: the one self-hosted Access application whose destination covers
|
|
151
|
+
* the address, in the accounts they reach, then the service token, then a Service Auth policy that allows it on that
|
|
152
|
+
* application. An application on reusable policies (every one the dashboard makes now) gets a reusable policy added to
|
|
153
|
+
* its list; one on policies of its own gets one of those, since the two never mix on one application. Through cf, that
|
|
154
|
+
* application, or a refusal of permissions once what was made is undone, answers a `fallback` for the API token. What
|
|
155
|
+
* this made is undone, last first, when a later step fails, or on `undo`, even once the run is stopped.
|
|
48
156
|
*/
|
|
49
|
-
async function create(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
56
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
57
|
-
signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(deps.signal && !undoing ? [deps.signal] : [])]),
|
|
58
|
-
});
|
|
59
|
-
const data = record(await res.json().catch(() => undefined));
|
|
60
|
-
return res.ok && data?.success === true ? { ok: true, result: data.result } : { ok: false, status: res.status };
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return { ok: false, status: null };
|
|
64
|
-
}
|
|
157
|
+
async function create(way, deps) {
|
|
158
|
+
const api = way.call;
|
|
159
|
+
/** Why it stopped; through cf a 401 or a 403 is the API token's to try, unless something the run made was left behind. */
|
|
160
|
+
const stop = (reply, none, left = '') => {
|
|
161
|
+
const why = `${refusal(way, reply, none)}${left}`;
|
|
162
|
+
return way.cf && !left && !reply.ok && (reply.status === 401 || reply.status === 403) ? { fallback: why } : { reason: why };
|
|
65
163
|
};
|
|
66
164
|
/** Every page of a list, as many as it takes: a page shorter than a full one is the last. */
|
|
67
165
|
const every = async (path, per) => {
|
|
@@ -80,7 +178,7 @@ async function create(key, deps) {
|
|
|
80
178
|
const url = new URL(deps.url);
|
|
81
179
|
const accounts = await every('/accounts', 50);
|
|
82
180
|
if (!accounts.ok)
|
|
83
|
-
return
|
|
181
|
+
return stop(accounts, '');
|
|
84
182
|
// Every account's applications, so two that cover the address, in one account or two, are never settled by the order they came in.
|
|
85
183
|
const found = [];
|
|
86
184
|
let unread;
|
|
@@ -98,7 +196,7 @@ async function create(key, deps) {
|
|
|
98
196
|
if (found.length > 1)
|
|
99
197
|
return { reason: `${found.length} Access applications cover ${url.host}, so which one's policy should allow the token is not clear.` };
|
|
100
198
|
if (!found.length)
|
|
101
|
-
return
|
|
199
|
+
return unread ? stop(unread, '') : { reason: `No Access application ${way.who} reaches covers ${url.host}.` };
|
|
102
200
|
const { account, app } = found[0];
|
|
103
201
|
const base = `/accounts/${account}/access`;
|
|
104
202
|
const links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
|
|
@@ -106,10 +204,12 @@ async function create(key, deps) {
|
|
|
106
204
|
if (links.length) {
|
|
107
205
|
const policies = await every(`${base}/policies`, 1000);
|
|
108
206
|
if (!policies.ok)
|
|
109
|
-
return
|
|
207
|
+
return stop(policies, '');
|
|
110
208
|
const ids = new Set(list(policies.result).map((p) => p.id));
|
|
111
209
|
reusable = links.every((link) => ids.has(link.id));
|
|
112
210
|
}
|
|
211
|
+
if (!reusable && way.cf)
|
|
212
|
+
return { fallback: 'The Access application runs on policies of its own, which cf has no command to add to.' };
|
|
113
213
|
const undo = [];
|
|
114
214
|
const rollback = async () => {
|
|
115
215
|
let clean = true;
|
|
@@ -117,7 +217,7 @@ async function create(key, deps) {
|
|
|
117
217
|
clean = (await step()).ok && clean;
|
|
118
218
|
return clean;
|
|
119
219
|
};
|
|
120
|
-
const fail = async (reply, none) => (
|
|
220
|
+
const fail = async (reply, none) => stop(reply, none, (await rollback()) ? '' : LEFT_BEHIND);
|
|
121
221
|
const made = await api('POST', `${base}/service_tokens`, { name: TOKEN_NAME });
|
|
122
222
|
const token = record(made.ok ? made.result : undefined);
|
|
123
223
|
// The secret is shown this once: the log and the transcript learn it before anything else happens.
|
|
@@ -161,9 +261,9 @@ async function admitted(token, deps) {
|
|
|
161
261
|
}
|
|
162
262
|
/**
|
|
163
263
|
* Gets a service token in place for the address Cloudflare Access stands in front of, and saves only one that opens it.
|
|
164
|
-
* On the person's yes
|
|
165
|
-
*
|
|
166
|
-
* one they made.
|
|
264
|
+
* On the person's yes the run creates it and allows it in the application's policy, through cf signed in as them, or
|
|
265
|
+
* with an API token they give where that sign-in cannot; on a skip, or when that cannot be done or the token does not
|
|
266
|
+
* open the address (and what was made is undone), the person may paste one they made.
|
|
167
267
|
*/
|
|
168
268
|
export async function passCloudflareAccess(deps) {
|
|
169
269
|
const shut = (proof) => proof.reason ?? 'The address did not open with the service token.';
|
|
@@ -177,25 +277,33 @@ export async function passCloudflareAccess(deps) {
|
|
|
177
277
|
if (!(await deps.consent()))
|
|
178
278
|
stopped = 'The person skipped creating a service token.';
|
|
179
279
|
else {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
187
|
-
|
|
280
|
+
const reached = await reach(deps);
|
|
281
|
+
let made = 'way' in reached ? await create(reached.way, deps) : 'stopped' in reached ? { reason: reached.stopped } : reached;
|
|
282
|
+
let apiToken = false;
|
|
283
|
+
if ('fallback' in made) {
|
|
284
|
+
// Where cf's sign-in cannot make the token, an API token the person makes for the run can.
|
|
285
|
+
const key = (await deps.apiToken())?.trim();
|
|
286
|
+
if (!key)
|
|
287
|
+
made = { reason: `${made.fallback} The person skipped the Cloudflare API token that creating the service token then takes.` };
|
|
188
288
|
else {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
throw err;
|
|
193
|
-
});
|
|
194
|
-
if (proof.proven)
|
|
195
|
-
return keep(made.token, 'created', { application: made.application, ...(made.expires ? { expires: made.expires } : {}) });
|
|
196
|
-
stopped = `${shut(proof)}${(await made.undo()) ? '' : LEFT_BEHIND}`;
|
|
289
|
+
deps.remember(key);
|
|
290
|
+
apiToken = true;
|
|
291
|
+
made = await create(withApiToken(key, deps), deps);
|
|
197
292
|
}
|
|
198
293
|
}
|
|
294
|
+
if (!('token' in made))
|
|
295
|
+
stopped = 'reason' in made ? made.reason : made.fallback;
|
|
296
|
+
else {
|
|
297
|
+
const token = made;
|
|
298
|
+
// A token that opens nothing is no use to anyone, nor is one a stopped run leaves behind: it and its policy go.
|
|
299
|
+
const proof = await admitted(token.token, deps).catch(async (err) => {
|
|
300
|
+
await token.undo();
|
|
301
|
+
throw err;
|
|
302
|
+
});
|
|
303
|
+
if (proof.proven)
|
|
304
|
+
return keep(token.token, 'created', { application: token.application, ...(token.expires ? { expires: token.expires } : {}), ...(apiToken ? { apiToken: true } : {}) });
|
|
305
|
+
stopped = `${shut(proof)}${(await token.undo()) ? '' : LEFT_BEHIND}`;
|
|
306
|
+
}
|
|
199
307
|
}
|
|
200
308
|
// A stopped run asks nothing more.
|
|
201
309
|
deps.signal?.throwIfAborted();
|