diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 8d07487..020c28e 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -62,7 +62,8 @@ The pages get a build step, even though nothing is compiled. The build is what r 2. Install `@patchstack/connect` as a runtime dependency with the project's package manager. If it is missing, `setup` adds it itself. 3. Run `npx @patchstack/connect setup`. In a hosted builder, scope `PATCHSTACK_ENVIRONMENT=sandbox` to that command — see "Automated setup". 4. Finish any `✘` line under `To do` in the report at the end of `setup`. "Automated setup" names each one. Do not start, restart or stop the person's app, and do not touch its data, to check the install: `setup` never runs the app, and the protection check needs an app the person already has running. -5. End by passing on the `──── Summary ────` that `setup` prints: what Patchstack found, the link, what is left to do and the next step, with a reminder to deploy when they are ready. See "The message you end on". +5. Run `npx @patchstack/connect doctor`. Fix every `✗` it reports, as its `Fix:` line says, and run it again until none is left. A `✗` marked "for the app owner" (connecting their Patchstack account, publishing the app) is not yours to fix: the summary already hands it to the person. +6. End by passing on the `──── Summary ────` that `setup` prints: what Patchstack found, the link, what is left to do and the next step, with a reminder to deploy when they are ready. See "The message you end on". **In a workspace repo** — a root `package.json` that declares `workspaces`, with the app in a package such as `apps/web` (Hostinger Horizons generates this shape) — run every command from the repo root. The site is recorded in the root's `.patchstackrc.json`, and `setup` wires the guard and the widget into the app package from there. Do not run `setup` inside the app package, and do not create a `.patchstackrc.json` there. When `setup`, `scan`, `widget` or `protect` is run inside a package of a repo whose root records the site, it runs at the root instead and prints `Running at the project root (…)`. @@ -85,6 +86,7 @@ Every command at a glance — what it does, whether it reads your source, what i | `guide` | Print this project's setup status: what is left to do and the four steps, with tailored commands. `--full` adds this reference. | Reads local protection wiring for verification | Nothing | Nothing | | `status` | Re-print the site UUID + dashboard URL and check whether the site still exists (active / removed / could not verify) and whether it is connected to an account. | No | The connected note in `.patchstackrc.json`, when it changed | Site-existence and connection checks | | `next` | Say where the project stands and the one thing to do now: connect the account (with the link), or go live (with the same steps `go-live` prints, or asking where the app is hosted when the project does not say). Answers the "done" the summary ends on. | Reads the project files `guide` and `go-live` read | The connected note in `.patchstackrc.json`, when it changed | The site UUID, to the public claim page that says whether it is connected (skipped once `.patchstackrc.json` records that it is) | +| `doctor [--url ]` | Check that everything Patchstack needs works, and print each problem with its fix: the install, the site, the account, the guard's wiring, its rules file, the read-only rules credential, the map link, the build steps, and whether the published app fetches its rules. A problem only the app's owner can fix (connecting the account, publishing) is marked as theirs. Exits 1 when it finds a problem. | Reads the project files `guide` and `protect --check` read | Nothing | The site UUID, to the public claim page that says whether it is connected (skipped once `.patchstackrc.json` records that it is); the read-only rules credential from the guard's rules file, and the site key when this machine holds one, each once to `monitor/pulse/token`, to check Patchstack accepts them; one GET with that token to `monitor/pulse/sync-status/`, which answers only when the published app (read-only credential) and a key-holding copy last fetched rules, and how many (doctor never fetches rules itself, which would be recorded as a fetch); with `--url`, two GET requests to that address: its page, and the same page with a test attack in a `file` parameter, which another firewall in front of the app may also flag | | `vulns` | Show what Patchstack found in this project's packages: vulnerabilities by severity and outdated packages, as counts, with the link that shows the details. Package names are shown in the dashboard once the site is connected. | No | Nothing | The site UUID, to the public claim page that returns the counts | | `go-live [--host ]` | Show how to add the project's API key to its host and deploy, in three steps. The host is read from project files (`vercel.json`, `netlify.toml`, `wrangler.toml`, `fly.toml`, `render.yaml`, `railway.json`, `amplify.yml`, `Procfile`, `.replit`, a Lovable project's packages) or named with `--host`. The commands it prints read the key from `.patchstackrc.local.json` as they run; it never prints the key. | Reads those project files only | Nothing | Nothing | | `widget [--remove]` | Add the Patchstack Connector widget tag to the root page (in a workspace repo, the app package's root page), or take the tag Connect added out with `--remove` (a tag added by hand is left alone). Saves the choice as `"widget"` in `.patchstackrc.json`, which `scan` and `mark-build` follow; `setup --widget` saves the same choice. | Reads the root page to find where the tag goes | `.patchstackrc.json`; the widget ``, { status: page }); + }) as unknown as typeof fetch; + return { fetchImpl, calls, auth }; +} + +const claimed = async (): Promise => ({ state: 'claimed', summary: null }); +async function doctor(cwd: string, options: { url?: string; claimPreview?: () => Promise; net?: ReturnType; config?: Partial } = {}) { + const net = options.net ?? network(); + const state = await collectGuideState(cwd); + return runDoctorChecks(cwd, state, config(options.config), { url: options.url ?? null, fetchImpl: net.fetchImpl, claimPreview: options.claimPreview ?? claimed, settleMs: 0 }); +} +const problems = (findings: DoctorFinding[]) => findings.filter((f) => f.status === 'problem').map((f) => f.text); + +describe('doctor', () => { + it('finds no problem in a project set up the way setup leaves it', async () => { + const findings = await doctor(project()); + expect(problems(findings)).toEqual([]); + expect(findings.map((f) => f.text)).toContain('The read-only rules credential works: the published app can fetch rule updates and report attacks'); + expect(renderDoctor(findings).join('\n')).toContain('No problems found.'); + }); + + it('says what to run when the project is not on Patchstack', async () => { + const cwd = project(); + rmSync(join(cwd, '.patchstackrc.json')); + expect(await doctor(cwd, { config: { siteUuid: null } as never })).toEqual([ + { status: 'problem', text: 'This project is not on Patchstack yet', fix: ['Run: npx @patchstack/connect setup'] }, + ]); + }); + + it('flags a site with no owner as report-only, with the link to connect it', async () => { + const cwd = project(); + writeFileSync(join(cwd, '.patchstackrc.json'), JSON.stringify({ siteUuid: UUID, widget: false })); + const findings = await doctor(cwd, { claimPreview: async () => ({ state: 'unclaimed', summary: null }) }); + const finding = findings.find((f) => f.text.startsWith('Not connected')); + expect(finding?.status).toBe('problem'); + expect(finding?.fix?.[0]).toContain(`claim?site=${UUID}`); + expect(findings.filter((f) => /connected/i.test(f.text))).toHaveLength(1); + }); + + it('flags a rules file without the read-only credential, and one Patchstack refuses', async () => { + expect(problems(await doctor(project({ build_id: 'b'.repeat(64) })))).toEqual([ + "The guard's rules file has no read-only rules credential, so the published app cannot fetch rule updates or report attacks without the site key", + ]); + expect(problems(await doctor(project(), { net: network({ token: 401 }) }))).toEqual([ + 'Patchstack refused the read-only rules credential (the site key was replaced, or the site was removed)', + ]); + const offline = await doctor(project(), { net: network({ token: 'down' }) }); + expect(problems(offline)).toEqual([]); + expect(offline.find((f) => f.status === 'unknown')?.text).toBe('Could not reach Patchstack to check the read-only rules credential'); + }); + + it('flags a map that is not linked to the guard', async () => { + expect(problems(await doctor(project({ rules_reader: READER })))).toEqual([ + "The app's map is not linked to the guard, so rules written for this app's own inputs can only report, not block", + ]); + }); + + it('flags a guard that is not wired', async () => { + const cwd = project(); + writeFileSync(join(cwd, 'server.js'), "import http from 'node:http';\nhttp.createServer((req, res) => res.end('ok')).listen(3000);\n"); + expect(problems(await doctor(cwd))).toEqual(['Blocking attacks needs a server']); + }); + + describe('with --url', () => { + it('passes a live app that carries the widget and blocks the test attack', async () => { + const cwd = project(); + writeFileSync(join(cwd, '.patchstackrc.json'), JSON.stringify({ siteUuid: UUID, widget: true, claimed: true })); + const findings = await doctor(cwd, { url: 'https://app.example' }); + expect(findings.filter((f) => f.text.startsWith('The live app')).map((f) => [f.status, f.text])).toEqual([ + ['ok', 'The live app carries the Patchstack widget for this site'], + ['ok', "The live app's guard blocked a test attack"], + ]); + }); + + it('flags a live app that lets the test attack through', async () => { + const findings = await doctor(project(), { url: 'https://app.example', net: network({ attack: 200 }) }); + expect(problems(findings)).toEqual(['The live app let a test attack through (it answered 200 to ?file=../../../../etc/passwd)']); + }); + + it('sends no test attack to a page that does not load', async () => { + const net = network({ page: 404 }); + const findings = await doctor(project(), { url: 'https://app.example', net }); + expect(findings.find((f) => f.text.startsWith('The live app'))?.status).toBe('unknown'); + expect(net.calls.some((url) => url.includes('file='))).toBe(false); + }); + }); + + describe('whether the published app fetches its rules', () => { + const syncText = (findings: DoctorFinding[]) => findings.find((f) => /fetched|never fetched|rules syncing/.test(f.text)); + + it('passes an app that fetched them recently, reading the record with the token and never fetching rules itself', async () => { + const net = network(); + const finding = syncText(await doctor(project(), { net })); + expect(finding).toMatchObject({ status: 'ok', text: 'The published app fetched its rules from Patchstack 3 minutes ago (17 rules)' }); + expect(net.auth).toEqual(['Bearer reader-token']); + expect(net.calls.some((url) => url.includes('/rules/'))).toBe(false); + }); + + it('treats an old fetch as a question until doctor has visited the app, then as a problem', async () => { + const stale = { reader: { at: minutesAgo(300), rule_count: 17 } }; + expect(syncText(await doctor(project(), { net: network({ sync: stale }) }))?.status).toBe('unknown'); + const visited = syncText(await doctor(project(), { url: 'https://app.example', net: network({ sync: stale }) })); + expect(visited).toMatchObject({ status: 'problem', text: 'The published app last fetched its rules from Patchstack 5 hours ago (17 rules), and did not fetch them when doctor just visited it' }); + }); + + it('flags an app that has never fetched them once doctor has visited it', async () => { + const finding = syncText(await doctor(project(), { url: 'https://app.example', net: network({ sync: { reader: null } }) })); + expect(finding).toMatchObject({ status: 'problem', text: 'The published app has never fetched its rules from Patchstack, even when doctor just visited it' }); + }); + + it('does not call a key-holding copy the published app', async () => { + const finding = syncText(await doctor(project(), { net: network({ sync: { reader: null, site_key: { at: minutesAgo(10), rule_count: 17 } } }) })); + expect(finding?.status).toBe('unknown'); + expect(finding?.text).toContain('Only a copy holding the site key has fetched rules (10 minutes ago)'); + }); + + it('says so when Patchstack does not report syncing yet', async () => { + expect(syncText(await doctor(project(), { net: network({ sync: 404 }) }))).toMatchObject({ status: 'unknown', text: 'Patchstack does not report rules syncing for this site yet' }); + }); + }); + + it('ends by telling whoever runs it to fix each problem and run it again', () => { + const lines = renderDoctor([{ status: 'problem', text: 'x', fix: ['Run: a', 'Then: b'] }], { url: 'https://app.example' }).join('\n'); + expect(lines).toContain(' ✗ x\n Fix: Run: a\n Then: b'); + expect(lines).toContain('1 problem to fix in the project. Fix each ✗ above as its Fix line says, then run npx @patchstack/connect doctor again until none is left.'); + }); + + it('keeps the owner\'s steps apart from what the project needs, so an assistant does not wait on them', () => { + const lines = renderDoctor([{ status: 'problem', text: 'Not connected', fix: ['Open the link'], owner: true }]).join('\n'); + expect(lines).toContain('✗ Not connected (for the app owner)'); + expect(lines).not.toContain('to fix in the project'); + expect(lines).toContain('1 step for the app owner'); + }); +});