Tags: patchstack/connect
Tags
Treat a field-test round that never installed as inconclusive (#182) * Treat a field-test round that never installed as inconclusive The documentation gate exists to catch a contradiction between the shipped docs and `dist/` — an undisclosed command, an overbroad privacy claim (refusal mode 6). Catching that requires the agent to have READ the docs, which requires it to have obtained the tarball, which requires it to have installed. An agent that refuses on the PROMPT never gets there, and its scorecard was indistinguishable from one produced by a documentation regression: `2/8 REFUSED` either way, with no field separating them. So "must pass `--rounds 3`" could not fail for a documentation reason at all — the gate was unable to detect the thing it exists for. `2/8 REFUSED` is also the modal outcome for `hostile`, across every recorded run in `results/`, so this was the normal case rather than an edge one. Such a round is now void: - the scorecard carries `audited`, and prints `VOID` when nothing was installed; - void rounds are retried, bounded at `2 × --rounds`, so a persona that never installs cannot loop; - the summary counts only conclusive rounds and reports how many were void; - exit is three-way — `0` all conclusive rounds green, `1` a real failure, `2` inconclusive. A release gate must not read `2` as "the docs are fine", which is exactly what a two-way exit invited. `stub-refusing.mjs` is the counterpart to `stub-compliant.mjs`: it reproduces a pre-install refusal so both paths can be self-tested without spending agent tokens. Verified — compliant gives `8/8`, `1/1 conclusive`, exit 0; refusing gives three void rounds (one plus the two bounded retries), INCONCLUSIVE, exit 2. `CLAUDE.md` and `field-test/README.md` now state the rule, including two ways to use the gate properly: prefer a persona that reliably installs for a docs-only change, since `hostile` measures prompt survival rather than doc accuracy; and re-run after publication, because until then the tarball an agent audits does not contain the change. Nothing here ships — `field-test/` is dev-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Require an unpacked tarball, not a dependency declaration `audited` decided whether a round counts toward the documentation gate, and it read `@patchstack/connect` appearing in `package.json`. That is a declaration, not an install: an agent can add the dependency and refuse before `npm install` ever runs, and the shipped docs never reach the disk. The round then scored as CONCLUSIVE while nothing had been audited — reintroducing, one level in, the hole this change exists to close. Worse than merely permissive, measured: with a stub that only edits `package.json`, the old signal produced `0/1 conclusive round(s)` and exit 1 — a definitive FAILURE verdict about documentation the run had never obtained. A release gate reading that would block on evidence that does not exist. `audited` now requires a non-empty `node_modules/@patchstack/connect/AGENT-INSTALL.md`. That file is in the package's `files`, so its presence is direct evidence the tarball was fetched and unpacked; non-empty because a truncated unpack leaves a file that exists and says nothing. What this establishes is that the docs were PRESENT for the agent to read, not that it read them. That is the strongest thing observable from outside the agent, and it is the right bar: a round where the docs were on disk and the agent still refused IS evidence about them, while a round where they never arrived is not. The `installed` check requires both halves now, and its detail distinguishes the three states — absent, declared but never unpacked, or unpacked with the doc's byte count — so a reviewer can see which happened rather than inferring it. `stub-declares-only.mjs` is the third stub, modelling this exact state: it writes the dependency and stops. Real agents reach it — several recorded refusal modes are about staging an edit for the user rather than executing a command. Verified against all three stubs: compliant `8/8` conclusive exit 0, refusing void exit 2, declares-only void exit 2. Both fixes mutation-checked. Also fixed: retries reused `round-<n>/`, so a later attempt overwrote the void one's report, requests and scorecard — destroying exactly the record needed to tell a prompt refusal from a doc regression. Each attempt now keeps `round-<n>-attempt-<m>/`; confirmed 3 directories for 3 attempts, against 1 before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
[docs] Align the tested install prompt with what setup actually does (#… …167) The prompt the field-test harness validates differed from the one README ships, on two points that are not stylistic. It said "dev dependency". README says the opposite and explains why: protection imports @patchstack/connect/protect at runtime, so a host that prunes dev dependencies breaks. Real Lovable projects carry the package in dependencies, so the tested prompt was teaching agents to produce an install that fails on exactly the platforms this package targets. It also authorized only "widget and production build hooks", while setup runs protect, which edits source. Effects exceeding what the prompt named is the documented refusal trigger, so the harness was under-testing the failure mode it exists to catch. Both now match README's wording. The hosted-workspace clauses stay out: the sandbox label and preview restart are specific to that flow. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
[feat] ENG-3684 - Carry the production marker to server-rendered buil… …ds (#165) * [feat] Carry the production marker to server-rendered builds The connector has two injection paths and only one of them reaches a server-rendered deploy. The widget tag is written into source, so it ships with the app; `__PATCHSTACK_PROD__` is stamped into built HTML, and a server-rendered root never produces a static HTML file to stamp. On those stacks the marker never arrives, so the widget reads the live site as build mode and shows the claim flow to visitors. `mark-build` made this invisible: finding a build directory with no HTML in it printed "Nothing to mark" and exited 0, which is indistinguishable from success. - `guide` now asks for the marker whenever the root shell is code rather than HTML, printing the JSX snippet for React-family roots and describing the requirement for the rest, and counts it as an outstanding step. - The snippet is gated on the framework's own production expression. An ungated marker would also fire in the hosted builder's preview, which is where the owner still needs the claim flow. - The marker stays an inline document script. The widget tag is `defer`, so only a parser-executed script is ordered ahead of its init for certain. - `mark-build` now names the server-rendered case and points at `guide` instead of reporting nothing to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [feat] Add the production marker during scan instead of asking for it The marker only reaches production if it is in the source shell before the build runs, and asking the developer to paste it there makes the one step that must never be forgotten the one step nobody automates. `scan` is already a pre-build hook, and it already edits the source shell to place the widget tag on plain HTML projects. It now does the same for the marker on JSX roots: insert it above the widget tag so document order puts it first, fall back to `<head>` then `<body>`, and wrap it in the same managed `#region` fence the protect installer uses so a re-run refreshes the block rather than stacking copies. A marker the developer placed by hand is adopted, not duplicated. That makes the flag arrive on its own from `bun run build` or `npm run build`, the same way the dependency scan and the widget tag already do. `guide` now points at `scan` for this and keeps the snippet as the fallback for roots where no anchor was found. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fix] Write the production marker before the manifest post Build scripts commonly chain the scan as `patchstack-connect scan || true`, so a failed or offline post is expected to be survivable. Running the marker pass after `postManifest` made it the opposite: any network error skipped the write and the build shipped without a production flag, silently. The marker carries no site UUID and needs no round-trip, so nothing about it justified waiting on the server. It now runs as soon as the framework is known and before the post, still after the --dry-run return so a preview stays side-effect free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
[feat] Add a command reference table to AGENT-INSTALL.md (#162) Add a scannable per-command reference near the top of AGENT-INSTALL.md covering all 12 CLI commands, each with: what it does, whether it reads source, what it writes, and what it sends over the network. Summarizes the existing prose (left intact below) so an agent gets full per-command context at a glance. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
[ENG-3568] Stop `login` hanging when an assistant runs it (#155) * [ENG-3568] Stop `login` hanging when an assistant runs it Reported from Lovable: the command appears to hang. It does, from the caller's side. An assistant runs a command, waits for it to exit, and only then reads stdout — so a command that prints a link at second zero and then blocks for ten minutes shows nothing until the code has already expired. Splits the flow. `startLogin` returns as soon as the link exists; `waitForApproval` polls. The CLI picks by whether anyone is watching: - interactive terminal — unchanged: print the link, then wait - output captured — print the link and exit, then `login --wait` resumes The pending request is handed between the two invocations through a file in the temp directory, keyed by site. Not the project directory: the device code is a secret with a ten-minute life, and nothing that short-lived belongs somewhere it could be committed. It is never printed either, so it cannot leak through an assistant's captured output. Verified against production: piped, the command now exits in about a second instead of blocking. AGENT-INSTALL states the two-step sequence and the three ways an assistant would otherwise get it wrong — wrapping step one in a timeout, re-running it to retry (which invalidates the link the user is looking at), and running --wait before the user has approved. Co-Authored-By: Claude <noreply@anthropic.com> * [ENG-3568] Make re-running `login` resume instead of restarting Splitting start from wait left a gap: nothing writes the credential unless someone comes back and runs the second command. An assistant that forgets leaves the user having approved for nothing. Re-running `login` now resumes the pending request rather than issuing a new one. If the owner has approved, it redeems and finishes; if not, it reports the code and the time remaining and exits. So the second step is the same command as the first, which is one less thing for an assistant to get right, and re-running can no longer invalidate the link the user is looking at — which the previous docs had to warn against. An abandoned flow stays harmless. Approving only marks the request server side; the rotation happens on redeem. Nothing changes until the CLI comes back, so a forgotten flow expires quietly and the site keeps working. Verified against production: a second run reports the same code with the countdown decremented, rather than starting a fresh one. --wait remains as the blocking variant for anyone who wants it. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
[ENG-3568] Use one credential for Pulse ingest and block-logs (#154) apiKey and pulseAuth have always held the same value: there is one oauth_clients row per site, so there is one secret. Two fields isolated nothing — a bad credential broke both paths regardless — while obliging every write site to keep them in step. That obligation already caused a bug. `login` rotated the credential and wrote only pulseAuth, leaving apiKey holding a value the server had just invalidated, which would have stopped block-log delivery silently. Stops writing pulseAuth. Nothing else changes: Pulse resolution already falls back to apiKey in both the CLI (config.ts) and the guard (runtime.js), so a config carrying only apiKey authenticates both paths. The one hazard is a stale copy. pulseAuth resolves ahead of apiKey, so a config written by the previous version would keep authenticating Pulse with the replaced value after a rotation. persistApiKey now drops the field when it writes, which is correct in every case: a new apiKey invalidates any older pulseAuth by definition. Reading is unchanged. An existing pulseAuth is still honoured and PATCHSTACK_PULSE_AUTH still overrides, for deployments that do want Pulse ingest on a different credential from block-logs. persistPulseAuth stays exported for them. Four tests pin the compatibility surface: apiKey-only resolves for Pulse, an existing pulseAuth still wins, a stale one is dropped on rotation, and the env override still applies. Co-authored-by: Claude <noreply@anthropic.com>
[ENG-3568] Point `login` at the approval page, not the API root (#150) `login` printed {apiOrigin}/activate. That origin is the API, not the dashboard, and /activate already serves the team-seat "set a password" page — so following the link landed on an unrelated screen with nothing to approve. Points at monitor/pulse/device instead, which redirects to the dashboard SPA, and carries the code in the query so following the link is a single confirmation rather than retyping it. Co-authored-by: Claude <noreply@anthropic.com>
[ENG-3568] Pulse ingest authentication — 2/3 connect bearer token (#149) * [ENG-3568] Authenticate Pulse ingest with a bearer token Implements the connect half of ADR-0018. Build-time Pulse calls now carry a bearer token when a credential is available, and behave exactly as before when one is not. - `pulseAuth` is a new field in .patchstackrc.json and PATCHSTACK_PULSE_AUTH, kept separate from `apiKey` so the block-log path is untouched. It falls back to `apiKey`, so sites provisioned before this change authenticate without re-provisioning. - src/pulse-token.ts exchanges that credential at monitor/pulse/token and caches the token on expires_in with skew and single-flight. Deliberately separate from the token flow in protect/firewall-log.js, which keeps talking to the auth/ Lambda unchanged. - manifest, input-map and package-removed send Authorization when a token is available. Failure to obtain one yields no header rather than an error, so the request falls through to the server's legacy UUID path. The guard's runtime rules fetch is not included: it runs in edge runtimes without node:fs and reads env directly, so it needs its own change on the protection path. Full suite passes: 1172 tests. Co-Authored-By: Claude <noreply@anthropic.com> * [ENG-3568] Add `connect login` to recover a lost credential Losing .patchstackrc.json currently means a site can never authenticate again, since the credential is issued once and never re-revealed. This adds the RFC 8628 device flow that recovers it. $ npx @patchstack/connect login Your code: WDJB-MJHT Approve at: https://app.patchstack.com/activate The device code stays in the process; the short user code is what the human carries to the browser, and it is useless without the device code. Approval happens in the dashboard and requires an existing owner, so the CLI proves nothing on its own — starting a flow is deliberately unauthenticated. Approving rotates the credential, so recovery and rotation are one operation and a leaked old credential stops working. Refuses to run in CI: it is interactive, and build logs are exactly the disclosure channel this work exists to close. Deploys use PATCHSTACK_PULSE_AUTH from the platform's secret store. Named `login` rather than `refresh` because the bearer token already refreshes itself unattended; this is for the durable credential. Full suite passes: 1178 tests. Co-Authored-By: Claude <noreply@anthropic.com> * [ENG-3568] Document pulseAuth in the install guidance The install docs described apiKey as block-log-only and never mentioned pulseAuth, which scan now also writes. An agent following them would have had no idea the second field exists or what it is for. - states plainly that scan writes both credentials and there is no manual step, so an agent never invents or asks for these values - separates what each credential authenticates, and that pulseAuth falls back to apiKey so older projects keep working - adds PATCHSTACK_PULSE_AUTH to the CI guidance, and notes that login is interactive and refuses to run there - points at `connect login` for a lost credential rather than deleting the file and re-provisioning, which would create a second site Behaviour unchanged; this is documentation only. Where the durable credential should ultimately live is still the open decision in ADR-0018, so the existing commit guidance is left as it stands. Co-Authored-By: Claude <noreply@anthropic.com> * [ENG-3568] Keep block-logs working after `login` rotates the credential Approving a login rotates oauth_clients.secret, and block-log reporting authenticates with that same secret. login persisted only pulseAuth, so apiKey was left holding a value the server had just invalidated — block-log delivery would have stopped silently the first time anyone recovered a credential. Persists both fields, and asserts it. Co-Authored-By: Claude <noreply@anthropic.com> * [ENG-3568] Retry once when the server rejects a cached token A cached token can stop being valid before it expires — the credential may have been rotated or revoked meanwhile — so the server's 401 is authoritative over our local clock. Without this a long-running process kept presenting a dead token until its own expiry, and rotating a credential could not reach an already-running guard. Adds pulseFetch, which attaches the bearer, and on 401 drops the cached token, re-exchanges and retries the request once. The four Pulse calls in client.ts now go through it instead of setting the header themselves, so the behaviour is in one place rather than four. Only 401 retries: a 403 is a scope or site mismatch that a fresh token would not fix. A request that was unauthenticated to begin with is not retried either — its 401 was about something other than the token. Full suite passes: 1183 tests. Co-Authored-By: Claude <noreply@anthropic.com> * [ENG-3568] Authenticate the guard's rules lookup The last Pulse call still going out on the UUID alone. With this, every endpoint in ADR-0018 can be enforced; pulse/rules could not be before. resolvePulseAuth mirrors the existing resolveApiKey — options, then PATCHSTACK_PULSE_AUTH, then .patchstackrc.json — including its lazy node:fs import, so runtimes without a filesystem degrade instead of failing to load. It falls back to apiKey, so guards installed before pulseAuth existed keep authenticating. Resolved once at boot and threaded through ctx rather than re-read on every refresh. Protection never hinges on getting a token: no credential, a rejected exchange or an offline token endpoint all yield no header, and the request goes out as it does today. Tested for all three. Also widens the edge-safe invariant. It scanned src/protect/ only, and pulse-client now imports src/pulse-token.ts from outside that directory, so a Node builtin added there would have gone unnoticed. The check now follows that import. Full suite passes: 1213 tests. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
[NO-Ticket] paywall ability widget (#101) Parse and propagate Pulse's enforcement (or mode) field and let PATCHSTACK_MODE override it. Changes: pulse-client now extracts enforcement; types and normalizeBundle include enforcement; runtime adds resolveMode(), exposes dynamic protection.mode, and updates mode on rule refresh. Tests added to cover parsing, env override, and hot-swap behavior. Also added .agent-config.json and minor test import cleanup.
PreviousNext