fix(claude-local): skip the 2.1.291 copy hint in the setup-token prompt parser (#15550)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - AI connections let a user connect a Claude subscription for all
agents of an organization
> - In a Docker deployment the server runs `claude setup-token` in a PTY
and parses its output for the sign-in URL
(`server/src/services/local-ai-browser-login.ts`,
`packages/adapters/claude-local/src/server/setup-token-parse.ts`)
> - The Docker image installs `@anthropic-ai/claude-code@latest`, and
`claude` 2.1.291 adds a copy hint line between the URL and the code
prompt
> - The parser requires the prompt on the first non-blank line after the
URL, so it returns null and the UI waits without an error
> - This pull request lets the parser skip that exact hint line
> - The benefit is that Claude subscription sign-in works again with the
current CLI

## Linked Issues or Issue Description

No issue exists. I searched open and closed issues and pull requests for
"Preparing browser sign-in" and the setup-token parser.

**What happened?**
In the "Claude - Subscription" connection dialog, the UI shows
"Preparing browser sign-in…" and "Start sign-in again". It shows no
sign-in link and no error. "Start sign-in again" starts the same wait
again. After 300 s the run fails with "Sign-in ended before the account
was connected".

**Expected behavior**
The dialog shows the sign-in link and the code field a few seconds after
the start.

**Steps to reproduce**
1. Run the Docker image with `claude` 2.1.291 (`@latest` at the time of
writing).
2. Open the AI connection dialog and select "Claude - Subscription".
3. The dialog stays on "Preparing browser sign-in…".

**Paperclip version or commit**
`master` at 03cf6a6ec (includes #14970). `claude --version`: 2.1.291.

**Deployment mode**
Authenticated, private exposure, Docker.

**Relevant logs or output**
The server logs nothing for this flow. A manual run in the container
shows the output that the parser reads (URL values removed):

```
docker exec -it -u node <container> sh -c 'CLAUDE_CONFIG_DIR=$(mktemp -d) CLAUDE_CODE_OAUTH_TOKEN= ANTHROPIC_API_KEY= ANTHROPIC_AUTH_TOKEN= BROWSER=true claude setup-token'

 Browser didn't open? Use the url below to sign in (c to copy)

https://claude.com/cai/oauth/authorize?code=true&client_id=…&response_type=code&redirect_uri=ht
tps%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=…
…&code_challenge_method=S256&state=…

 Hold Shift while selecting to use your terminal's native copy


 Paste code here if prompted >
```

`findBrowserCodePrompt` reads the first non-blank, non-URL line after
the URL. Here that line is the copy hint, so the parser returns null.
The runner never calls `onPrompt`, and `check()` returns
`sign_in_required` with no `authorizationUrl` and no error.

## What Changed

- `setup-token-parse.ts`: add `COPY_HINT_LINE_RE`, which matches only
the exact copy hint (CHA-spaced words and a curly apostrophe are
allowed). `findBrowserCodePrompt` skips a line that matches it. The
prompt line rules do not change.
- `setup-token-parse.test.ts`: add a test with the 2.1.291 layout
(hard-wrapped URL, CHA-spaced hint, blank lines, prompt).

## Verification

- `bun test
packages/adapters/claude-local/src/server/setup-token-parse.test.ts
packages/adapters/claude-local/src/server/setup-token-characterization.test.ts`:
71 pass, 1 skip, 0 fail.
- The 2.1.291 output above returns `null` before the change and the URL
plus prompt after the change.
- The test "returns null when a code-like value sits on the line after
the URL" still passes. Other prose between the URL and the prompt still
fails closed.

## Risks

- Low risk. The change skips one fixed line and binds no value from it.
- A later CLI version can change the hint text again. The flow then
hangs the same way until the 300 s timeout. An earlier error (for
example, when no prompt appears after 60 s) and a `log` option for
`runSetupTokenLogin` in `local-ai-browser-login.ts` would make this
visible. This pull request does not include them.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(code search, shell, test runs). A human ran the container commands and
reviewed the change.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris KaubischandClaude Opus 5.5 authored and GitHub committed 2026-11-02 13:00:20 -06:00
1 parent a92f616cd0
commit b0f5451fdf
2 files changed
+18 -3

No files matched your search

@@ -337,6 +337,14 @@ describe("parseSetupTokenPrompt", () => {
expect(parseSetupTokenPrompt(completeOutput(withKey))).toBeNull();
});
it("skips the 2.1.291 copy hint between the URL and the prompt", () => {
const hint = chaSpaced("Hold Shift while selecting to use your terminal's native copy".split(" "));
const text = [PREAMBLE_LINE, hardWrap(CLAUDE_COM_URL, 130), "", hint, "", "", PROMPT_LINE].join("\n");
const result = parseSetupTokenPrompt(text);
expect(result?.url).toBe(CLAUDE_COM_URL);
expect(result?.prompt).toBe(SETUP_TOKEN_PROMPT);
});
it("returns null when a code-like value sits on the line after the URL", () => {
// The line right after the URL must hold the browser-code prompt. A code-like
// value on that line cannot bind, so the parser returns null.
@@ -181,6 +181,10 @@ const URL_PREAMBLE_RE = /the url below to sign in/i;
// the line.
const PROMPT_LINE_RE = /^Paste code here if prompted\b[\s>*]*$/;
// The copy hint that `claude` 2.1.291 prints between the URL block and the
// prompt. The parser skips this exact line and binds no value from it.
const COPY_HINT_LINE_RE = /^Hold\s+Shift\s+while\s+selecting\s+to\s+use\s+your\s+terminal['’]s\s+native\s+copy\.?$/i;
// The maximum number of characters between the end of the URL preamble line and
// the start of the URL. The Claude UI prints the URL right after the preamble.
// The parser accepts a URL start only inside this window, so a URL far from the
@@ -363,9 +367,11 @@ function reassembleUrl(lines: string[], startLine: number): SetupTokenUrlMatch |
* line after the URL block. The parser reads the non-blank lines after the URL
* end line. The Claude UI can emit one full URL line per wrapped display row, so
* it repeats the same full authorization URL on a few consecutive lines. The
* parser skips a line that repeats `url`, then binds the prompt on the first
* non-blank, non-repeat line. That line must match the exact prompt shape.
* Returns null when the first non-blank, non-repeat line is not the prompt.
* parser skips a line that repeats `url`. The `claude` 2.1.291 UI also prints a
* copy hint before the prompt, so the parser skips a line that matches {@link
* COPY_HINT_LINE_RE}. The parser then binds the prompt on the first non-blank
* line that is not a repeat and not the copy hint. That line must match the
* exact prompt shape. Returns null when that line is not the prompt.
*/
function findBrowserCodePrompt(lines: string[], endLine: number, url: string): string | null {
for (let i = endLine + 1; i < lines.length; i += 1) {
@@ -375,6 +381,7 @@ function findBrowserCodePrompt(lines: string[], endLine: number, url: string): s
// punctuation first, the same way the repeated-URL comparison needs, so a
// repeated URL with a trailing punctuation mark still matches.
if (trimmed.replace(TRAILING_PUNCTUATION_RE, "") === url) continue;
if (COPY_HINT_LINE_RE.test(trimmed)) continue;
return PROMPT_LINE_RE.test(trimmed) ? SETUP_TOKEN_PROMPT : null;
}
return null;