Skip to content

feat(auth): setup links tie the agent's fetch, login and key to the person's Final step panel - #917

Merged
allisonmahmood merged 4 commits into
mainfrom
feat/setup-links
Oct 24, 2026
Merged

allisonmahmood merged 4 commits into
mainfrom
feat/setup-links

Conversation

@allisonmahmood

@allisonmahmood allisonmahmood commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #916 (then #914, #913). Merge those first; the onboarding stack ships together.

What changed

The Final step panel could only notice the end of each step: any new computer of the person's, then a patch of theirs. Nothing moved for about a minute after the paste, and any old laptop counted.

The panel's line now carries a one-time setup link: Set up Patchy using <instance>/llms.txt?s=<link>.

  • The agent's fetch marks the line picked up, within seconds of the paste.
  • /llms.txt prints patchy login --setup <link>.
  • The first login that line starts binds to it; a second contests it.
  • The mint records which key it made and who confirmed it.

The panel follows those facts. When a setup can't finish, it says why and offers Get a new line. Events carry a separate setup id, so one person's setup reads as a single funnel in PostHog.

Closes #910. Part of #906.

Look at

  • packages/auth/src/SetupLinks.ts and migration 0027.
    • A link is 128 random bits as 26 base32 characters, one live per person for 30 minutes, reused across reloads.
    • It keeps how its login ended: pending, denied, expired, contested, connected, or confirmed by another account. Device logins are deleted when they finish, so the outcome has to live here. A login that mints after its line ran out ends expired.
    • device_logins.setup_link_id is nullable and ON DELETE SET NULL.
  • DeviceLogins. start binds, deny marks denied, a login that runs out marks expired before it is deleted, and the mint records the key and the confirming user in its own transaction. token.minted gains setupId.
  • The front door. A narrow SetupPickup port, which apps/server/src/SetupPickup.ts provides. The SDK gains no Auth dependency. Pickups spend a per-address minute's budget (rate.setupPickup.perMinute) before reaching the database. An unknown, expired, over-budget or failing link serves the plain /llms.txt, so setup never breaks.
  • The CLI.
    • patchy login --setup <link> keeps the link in its pending login.
    • A rerun with the same link resumes. A rerun with a different link (or a link when none was pending) starts afresh and says the earlier code no longer applies.
    • --setup with --complete is refused.
    • ADR-0004 and the README record this.
  • The panel's states, in a real browser. Each moved on by itself on a dev instance: the agent's fetch, then a login started with the link, confirmed in another tab, then its key collected.
Picked up Login waiting Confirmed, key not yet collected
picked up login pending confirming
Connected: this setup's key Two logins used one line
connected contested

How it was verified

  • pnpm exec vitest run across packages/portal, packages/auth, the front door, the CLI's login, packages/api, packages/limits, packages/core, Patches and the server's migration record: 1,121 tests pass. New ones cover:
    • links: one live per person, pickup once, retire;
    • binding, contested, denied, expired (by poll, and by a mint after the line ran out), a contested line's mint, and the owner's mint against another account's;
    • token.minted's setupId;
    • the front door's live, unknown and failing links, and the pickup budget per address;
    • the CLI's same-link, new-link and legacy reruns;
    • the panel following a line through every state, including a login that runs out with nothing polling, plus Get a new line's origin and stale-account refusals.
  • pnpm typecheck and pnpm lint pass. docs/API.md and docs/limits.md are regenerated, and the skill's lock hash is refreshed.
  • Not run locally: pnpm verify and the rollback rehearsal. CI runs both.

Release impact

  • Breaking — requires action from anyone already running this
  • Not breaking — no action required

The API field and the CLI flag are optional and additive. The migration adds a table and a nullable column.

  • What does a revert leave behind? The setup_links table and device_logins.setup_link_id. Old code never reads them.
  • Does the release before it still work with that? Yes. Agents holding the new --setup command would need the matching CLI, but /llms.txt installs the instance's own release first.

Made by Claude Opus 5.5 in Claude Code, running in T3 Code.

@github-actions github-actions Bot added area:server Touches the server area area:ci CI, shared testing infrastructure, development scripts and tooling area:cli Touches the cli area area:docs Touches the docs area area:ui Portal and first-party browser pages, shared rendering and styles size:l 501-1,000 changed lines size:xl Over 1,000 changed lines; review in multiple passes and removed size:l 501-1,000 changed lines labels Oct 23, 2026
@allisonmahmood

Copy link
Copy Markdown
Owner Author

Review (GPT 6.1 Sol, xhigh): six fixed in 23c0af4, one dismissed

  1. Dismissed: "any live machine token overrides the setup link's outcome". The panel exists to answer one question: can this person's agent act as them yet? A live key of their own says yes, however it was made. The cases Sol raises don't give the person a wrong answer:

    • A contested line where they confirm one of the two logins: they now hold a working key, so "connected" is true. The contested warning matters before they confirm, and it shows until then.
    • "Confirmed by another account": that key belongs to someone else. If the person also has a key of their own, they're connected; if not, the panel shows the other-account fallback.
    • A revoked bound key: revoked keys aren't listed. With no other key, the panel falls back to the line; with one, they're connected.

    Deriving "connected" only from the bound token would tell someone holding a working key that setup failed. The bound token still decides which first publication counts (publishedWith([bound])).

  2. Fixed: expiry is recorded and checked. expired is a new outcome, migration 0027 allows it, and both the start's purge and the poll write it before deleting the login. The panel also reads the login's deadline on the app clock, so an unanswered login shows expired with no later poll or start. Tested in PortalPages.test.ts ("A login left unanswered…") and SetupLinks.test.ts.

  3. Fixed: a login that mints after its line ran out ends expired, not connected (SetupLinks.minted). Tested.

  4. Fixed: /llms.txt pickups spend a per-address bucket (rate.setupPickup.perMinute, 30, host scope) before reaching SQL, keyed by the trusted client address. Past it, the agent gets the plain page and setup still works. Tested in apps/server/src/SetupPickup.test.ts, and docs/limits.md is regenerated.

  5. Fixed: a contested line's bound mint still records its key and who confirmed it, and returns the setup id, so token.minted keeps setupId. The outcome stays contested. Tested (contest, confirm the original, poll).

  6. Fixed: patchy login --setup says when it abandons a pending login: in human output and at the front of agentNextSteps. next stays executable. Asserted for a legacy pending login and for a different line.

  7. Fixed: values are 26 lowercase base32 characters (128 random bits). The parser, the migration's CHECK, the glossary and the assertions are updated together.

Checks: typecheck, lint and 94 affected test files (1,121 tests) pass locally.

@allisonmahmood

Copy link
Copy Markdown
Owner Author

Follow-up from #918's security review: a deadlock this PR introduced, fixed in 4a03a91.

The expiry fix above made start's cleanup write the expired outcome to a setup line before deleting its login. The poll and every answer lock the login first, then the line. So cleanup racing a poll of the same expired login could deadlock (Postgres 40P01). Cleanup now locks the expiring logins first, in user_code order, then writes their lines, then deletes.

Red then green: SetupLinks.test.ts › "cleans up an expired bound login without deadlocking an answer to it" holds the login, waits until start is blocked on it, then takes the line.

  • On the old cleanup: Error: deadlock detected … code: '40P01'.
  • On the fix: passes.

@allisonmahmood

Copy link
Copy Markdown
Owner Author

Follow-up from #918's second security round: one more lock-order inversion, fixed in 2455397.

SetupLinks.current prunes lines more than a day past expiry. If an abandoned login still named one, deleting the line made the foreign key's ON DELETE SET NULL lock that login after the line. That is the reverse of cleanup, the poll and every answer, and Sol reproduced 40P01. Pruning now skips a line any device login still names. It goes once that login's own cleanup has run.

Red then green: SetupLinks.test.ts › "prunes a day-old line only once no login names it".

  • On the old pruning: expected [] to have a length of 1.
  • On the fix: passes.

Base automatically changed from feat/final-step-panel to main October 24, 2026 00:55
allisonmahmood and others added 4 commits October 24, 2026 02:55
…erson's Final step panel

The panel could only notice the end of each step: any new computer of
the person's, then a patch of theirs. Its setup line now carries a
one-time setup link (llms.txt?s=...). The agent's fetch marks it picked
up, llms.txt prints patchy login --setup, the first login it starts
binds to it and a second contests it, and the mint records which key it
made and who confirmed it. The panel follows those facts, with Get a new
line when a setup can't finish. Events carry a separate setup id, so a
person's setup reads as one funnel in PostHog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…imit pickups

A setup line's login that runs out unanswered now ends expired, written before the login is
deleted and shown on the panel from the login's deadline even when nothing polls. A mint after
the line ran out ends expired instead of connected, and a contested line's bound mint still
records its key and setup id. /llms.txt pickups spend a per-address minute's budget before
reaching the database. The CLI says when a new setup line abandons a pending login, and links
are 26 base32 characters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…tup lines

start's cleanup wrote expired outcomes to setup lines before deleting their logins, the reverse
of the poll and every answer, which lock the login first. Cleanup racing a poll of the same
expired login could deadlock. It now locks the expiring logins first, in one order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Deleting a day-old line that an abandoned login still named made the foreign key's SET NULL lock
the login after the line, the reverse of every login path, so pruning could deadlock the login's
cleanup or poll. Such a line now waits until the login's own cleanup has run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@allisonmahmood
allisonmahmood merged commit 01977fb into main Oct 24, 2026
14 checks passed
@allisonmahmood
allisonmahmood deleted the feat/setup-links branch October 24, 2026 01:07
allisonmahmood added a commit that referenced this pull request Oct 24, 2026
## What changed

Once the person's agent starts a login with the setup line, the Final
step panel shows that login's confirmation right there, so the person
never opens a second tab. The link the agent prints keeps working
exactly as before.

Closes #911. Part of #906. Stacked on #917.

## Look at

- **The form is the device page's own.** `confirmForm` in
`packages/auth/src/DevicePages.ts` is now shared by the device page and
the panel. In the panel it reads "Name this computer", prefilled from
the login's hint, with **Confirm** and **Not me**, and carries the setup
link as a hidden field.
- **The server decides, not the page.** A form with `setup` posts to the
existing `POST /login/device`, which sends it to
`DeviceLogins.confirmInline` / `denyInline`.
- Confirm rechecks, under the login's lock then the user lock then the
line's row lock, that the line is the signed-in person's, live, not
retired, still `pending` (so not contested) and bound to this code.
Anything else confirms nothing.
- Every outcome goes back Home, where the panel shows where things
stand. A refreshed sign-in replays nothing, and a form rendered for
another account answers nothing.
- It keeps the device route's same-origin check, account binding, rate
limit and replacement warning.
- Confirm locks the line before checking it against a fresh clock, so a
wait for the lock can't outlast the line.
- The panel's fragment can't be framed (`frame-ancestors 'none'`) and
posts only to Patchy. It names the account it was rendered for, and the
script reloads the page rather than swap in another account's form after
an account switch.
- **Not me settles the login by where it actually is**, in one
transaction:
  - **Pending:** denies it.
- **Confirmed, key not collected:** deletes it, so the terminal's next
poll finds nothing and no key is made.
- **Key already made:** says "That computer is already connected" and
points to Your machines. It never says nothing was connected.

The line ends `denied`, and the panel says "Nothing was connected. If
you didn't just paste the line, you're fine: no computer can publish as
you." with **Get a new line**.
- **Polling never eats a half-typed name.** The panel's script leaves
the form alone while it has focus or an edited name.

| In the panel | After Confirm, key not yet collected | Connected | Not
me |
| --- | --- | --- | --- |
| ![inline
confirm](https://github.com/user-attachments/assets/7992a3e1-b99d-4aeb-9429-8bde4af47687)
| ![after
confirm](https://github.com/user-attachments/assets/ecf46efb-542f-4075-9ee1-7583d7e6c5b2)
|
![connected](https://github.com/user-attachments/assets/c3ee36ad-c293-469f-b0a0-9fc79b706440)
| ![not
me](https://github.com/user-attachments/assets/1904b146-b98e-4591-b8bf-398500984538)
|

Where this differs from #911's wording:
- Refusals (a stale account, an expired code, an emptied name) go back
Home instead of rendering inside the panel. The panel then shows the
current state, which is usually the same form or the fallback. The name
field is `required`, so an empty name rarely reaches the server.
- Not me ends the line `denied` instead of retiring it. The effect is
the same: the line can't bind another login, and Get a new line starts
over.
- The "already connected" answer is a small page with a link to Your
machines, not panel copy, because the panel is gone once a key exists.

## How it was verified

- `pnpm exec vitest run` across `packages/auth`, `packages/portal`,
`packages/sdk`, `packages/limits`, `packages/core`, the CLI login and
the server's setup-pickup and migration tests: 491 tests pass. New ones
cover:
- confirming from the panel, and both entry points answering the same
login;
- a stale panel form once its line is contested, or for another account;
- Not me for pending, confirmed-but-uncollected (the CLI's poll then
gets nothing, so no key is made) and already-minted logins;
- the panel rendering the form only for a live, uncontested, bound line.
- `pnpm typecheck` and `pnpm lint` pass.
- Screenshots above are from a dev instance (`pnpm dev up team`): an
agent's login started with the line, answered in the panel.
- GPT 6.1 Sol security review at xhigh, three rounds, **signed off** on
a758add. It found clickjacking, an account-switch swap, two lock-order
deadlocks (fixed in #917), an expiry-during-lock-wait approval, and two
ways to block Not me. All are fixed with tests, red then green where
it's a race; see the review comments.
- The account-switch reload and Not me with an emptied name were checked
in Chromium on a dev instance.

## Release impact

- [ ] **Breaking** — requires action from anyone already running this
- [x] **Not breaking** — no action required

No schema change: an optional `setup` field on the device form, and new
queries over #917's tables.

- **What does a revert leave behind?** Nothing. A panel form open at the
time posts a `setup` field the old route ignores, so it acts as an
ordinary device confirmation.
- **Does the release before it still work with that?** Yes.

Made by Claude Opus 5.5 in Claude Code, running in T3 Code.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
allisonmahmood added a commit that referenced this pull request Oct 24, 2026
## What changed

This PR adds release 18's What's new entry, written from `b23e6ad`. It
covers everything merged since release 17's `through` (`5dd94f3`), and
it sets the `patchy` release to `0.18.0` in
`packages/patchy/package.json` and `CURRENT_RELEASE`. With this release,
onboarding ends in a Final step on Home that follows the person's agent
through setup and lets them confirm it right there (#906).

| Change | PRs |
| --- | --- |
| New: A Final step to connect your AI agent | #916, #917 |
| New: Confirm your computer without leaving Home | #918 |
| Improved: Setting up ends with your first page | #914 |
| Improved: The logo takes you Home | #913 |
| Behind the scenes: the first time someone opens a colleague's tool is
counted, for activation | #915 |

One merged PR gets no line: #905 is release 17's own entry.

## Look at

- **The entry**, at the top of `releases` in
`packages/core/src/whatsNew.ts`. Its labels are the pages' own: Final
step, Later, Not me, Home, Patches.
- **Rollback safety, from the live commit `a049a3c` to `b23e6ad`.**
- No PR in the range is labelled `one-way`, and every PR answers both
Release impact questions with "Yes".
- Migrations 0026 and 0027 are additive: the `setup_prompt_sign_ins` and
`setup_links` tables, and a nullable `device_logins.setup_link_id` with
`ON DELETE SET NULL`. Release 17 never reads them.
  - `rollback-rehearsal` exercised both migrations on the landed heads:
- #916: `Exercised rollback paths passed: a049a3c → 6037d86 → a049a3c →
6037d86, in 234s.`
- #917: `Exercised rollback paths passed: a049a3c → d03a5bf → a049a3c →
d03a5bf, in 217s.`
- #918 and #915 change no migration or handler descriptor, so they had
nothing to rehearse.
- A rollback takes the Final step panel and setup links away. Home goes
back to the old guide, and a CLI at `0.18.0` stays usable against
release 17: `--setup` is only sent when `/llms.txt` prints it, and
release 17's `/llms.txt` doesn't.

## How it was verified

- `pnpm typecheck` and `pnpm lint` pass, and so do `packages/core`,
`packages/api`, `packages/portal` and the skill lock test (245 tests),
including `whatsNew.test.ts`, `schemas.test.ts` and
`WhatsNewPages.test.ts`.
- The live commit was pinned from `/healthz/deep`: `a049a3c`, release
17. The Deploy run updated last succeeded, and none is unfinished.
- Each PR in the range was read, including its Release impact answers.
All six landed with the reviewed trees: each squash left `main`'s tree
identical to its PR's reviewed head.

## Release impact

- [ ] **Breaking** — requires action from anyone already running this
- [x] **Not breaking** — no action required

This PR changes only the changelog text and the `patchy` version. As
with every release, a CLI installed before this deploy is told to
upgrade.

- **What does a revert leave behind?** Nothing. People whose seen marker
reached 18 would just see no new release.
- **Does the release before it still work with that?** Yes.

If this release misbehaves, roll back to
`a049a3cf544ae89691eec547dea46773febfdf05` (release 17).

Made by Claude Opus 5.5 in Claude Code, running in T3 Code.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ci CI, shared testing infrastructure, development scripts and tooling area:cli Touches the cli area area:docs Touches the docs area area:server Touches the server area area:ui Portal and first-party browser pages, shared rendering and styles size:xl Over 1,000 changed lines; review in multiple passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(auth): setup links tie the agent's fetch, login and key to the person's Final step panel

1 participant