Skip to content

fix(vscode): pair the extension so inbox calls carry a bearer token - #1290

Merged
github-actions[bot] merged 4 commits into
mainfrom
schedule/issue-sweep/2026-10-15/718-vscode-sync-401
Oct 16, 2026
Merged

github-actions[bot] merged 4 commits into
mainfrom
schedule/issue-sweep/2026-10-15/718-vscode-sync-401

Conversation

@JSdotNet

Copy link
Copy Markdown
Owner

Closes #718

Summary

The VS Code extension now joins the desktop's owner the way a second device does, so its inbox calls stop getting 401. A new vscode-free module, src/sync-client.ts, redeems a desktop-issued pairing code at POST /api/sync/devices/pair, keeps {deviceId, credential} in VS Code SecretStorage, trades the credential at /devices/token for a short-lived bearer token (cached, renewed 2 minutes early), and sends Authorization: Bearer on every /api/sync/inbox call. An inbox 401 retries once with a fresh token; a token-endpoint 401 forgets the credential and offers re-pairing. Two commands: Backlog: Pair with Desktop and Backlog: Forget Pairing. The extension gets its first test harness (npm test: tsc then node --test, 13 tests), run in the vscode-extension CI job. The building-block view, runtime view and tech/ide.md show the pairing step. No server change: every route already existed.

Decision taken without asking (follow it, or ask for the alternative)

Question. Should the VS Code extension get the full device-pairing flow (option 1), or be parked with a not-yet-paired message and lose the 'editor extension' claim from the endpoint docs (option 2)?

Chosen. Option 1, implemented as pairing by code redemption. The extension redeems a desktop-issued pairing code through POST /api/sync/devices/pair (not /devices/register, which would mint a separate empty owner). It keeps deviceId and credential in VS Code SecretStorage, exchanges them at /devices/token for a short-lived bearer token, sends it on every inbox call, and on a 401 retries once with a fresh token, then prompts to re-pair when the credential is rejected. The logic sits in a vscode-free syncClient.ts module covered by node:test.

Alternative. Option 2: change refreshInbox and capture so they report 'Backlog for VS Code is not yet paired-device aware' and make no network call, remove 'the editor extension' from the InboxEndpoints.cs summary (and from the InboxContracts.cs and InboxCaptureEndpointTests.cs comments that repeat it), mark the extension as parked in the building-block and runtime views and tech/ide.md, and record the parked channel as a TDR under .devbook/arc42/tdr/, because it knowingly leaves an ADR-0005 channel unserved.

Why the choice. Local ADR 0005 (Scope, and the deferred-transport section) names 'an IDE extension on a second machine' as one of the two channels the sync service exists to serve. Its Identity section prescribes exactly this flow: pair once by short code, keep a long-lived credential in the OS store, exchange it for a 15-60 minute JWT. The building-block view documents the extension as a live sync client. Every piece the flow needs already ships: the anonymous /devices/pair and /devices/token routes, the desktop's Devices settings tab that issues codes, and a C# reference client (DevicePairingClient, SyncTokenProvider) to mirror. Option 2 would contradict an accepted ADR and leave a documented channel dead, which needs a debt record. Option 1 makes the docs true with a small, self-contained TypeScript change and gives the extension its first tests. SecretStorage is available within the held ^1.90.0 engine, so the reach decision pinned by VsCodeExtensionReachTests is untouched.

To follow the alternative instead. Following option 2 instead would remove syncClient.ts, the pair and unpair commands and most of the new tests, replacing them with a short not-paired guard in extension.ts and one test (or README note) asserting no inbox fetch is made. The 'editor extension' wording would come out of InboxEndpoints.cs, InboxContracts.cs and the InboxCaptureEndpointTests.cs comments. The devbook edits would flip from 'pairs and sends a bearer token' to 'parked', and a new TDR would be added. The CI npm test step could stay or go. No server or desktop code changes in either option, so the swap is confined to src/App/Backlog.Ide.VsCode, the doc comments and the devbook chapters.

Acceptance criteria

    1. Every GET and POST to /api/sync/inbox sends Authorization: Bearer <token from /devices/token>; a stubbed-fetch test asserts it on both calls — test: src/App/Backlog.Ide.VsCode/src/test/syncClient.test.ts: 'listing and capturing send the bearer token the token endpoint issued'
    1. Pair command posts {code, deviceName} to /devices/pair and on 201 stores deviceId and credential in secrets, never in settings, globalState or a log; a test asserts what is stored and the request body — test: syncClient.test.ts: 'pairing redeems the code and keeps the device id and credential in secret storage' and 'no failure message carries the credential'. The command's wiring to context.secrets in extension.ts is verified by reading the code only.
    1. An expired or mistyped code (4xx ProblemDetails) reports a readable failure and stores nothing — test: syncClient.test.ts: 'an expired pairing code reports the service detail and stores nothing', 'a mistyped pairing code without a problem body still fails readably and stores nothing', 'an empty pairing code is refused before any request'
    1. A cached token is reused until shortly before expiresAt and then renewed; two calls inside the lifetime make one token request — test: syncClient.test.ts: 'a token is reused until the renewal window and then renewed'
    1. An inbox 401 drops the token and retries once with a fresh one; a token-endpoint 401 clears the credential and asks to re-pair; both paths tested — test: syncClient.test.ts: 'a 401 from the inbox drops the cached token and retries once with a fresh one', 'an inbox that refuses the fresh token too is retried only once', 'a 401 from the token endpoint clears the stored credential and asks to re-pair'. The re-pair prompt in the VS Code UI itself (the warning with its 'Pair with Desktop' button) is not covered by a test.
    1. With no stored credential, refresh and capture make no inbox request and prompt to pair; a test asserts zero fetches to /inbox — test: syncClient.test.ts: 'an unpaired extension makes no inbox request and asks to pair' and 'forgetting the pairing drops the credential and the cached token'
    1. npm test compiles and runs the tests and passes; the CI vscode-extension job runs it after npm run compile — not proved by a test: I ran npm test locally: 13 of 13 pass. The CI step is a property of the pull-request.yml diff and only runs on a pull request.
    1. engines.vscode and @types/vscode stay at ^1.90.0 (VsCodeExtensionReachTests passes) and no API newer than 1.90 is used — test: tests/Backlog.ArchitectureTests/VsCodeExtensionReachTests.cs (green in the full run). That no API newer than 1.90 is used was checked by reading the code only: SecretStorage dates from 1.53, env.appName and showInputBox are older still.
    1. The extension README documents pairing (code from the desktop's Devices settings), re-pairing on 401, and how to run the tests — not proved by a test: no test
    1. The InboxEndpoints.cs doc comment stays accurate and the server's authorization on the inbox group is unchanged — test: No server file changed. tests/Backlog.Modules.Sync.Api.UnitTests SyncEndpointAuthenticationTests and InboxCaptureEndpointTests stay green. That the comment is accurate was judged by reading it.
    1. The building-block view, runtime view and tech/ide.md describe the pair, token and bearer flow with valid meta blocks; both devbook checks pass — not proved by a test: Checked by running node .devbook/_tools/devbook-meta/build.mjs --check (exit 0) and node tools/devbook/check-metadata.mjs (exit 0, 0 blocking). Whether the prose is accurate was judged by reading it.
    1. dotnet build Backlog.sln and dotnet test Backlog.sln stay green, judged by the reported counts — test: Build: 0 warnings, 0 errors. Full test run: total 16261, failed 0, succeeded 16248, skipped 13. The first full run had 3 Desktop.UI devbook-pane failures (unrelated to this change); that project passed 5134/5134 on its own and the second full run was clean.

Testing

  • Command: cd "D:/iswp/718-vscode-sync-401" && dotnet build Backlog.sln && dotnet test Backlog.sln --no-build ; cd src/App/Backlog.Ide.VsCode && npm test
  • Passed 16259, failed 2; repair attempts 2.
  • Owner session, after the review fixes: npm test in src/App/Backlog.Ide.VsCode — 13 passed, 0 failed. dotnet test of Backlog.UI.Components.UnitTests (3429) and Backlog.Desktop.UI.UnitTests (5134) — 0 failed. Both devbook checks pass (build.mjs --check; check-metadata.mjs: 0 blocking).

Review

  • Fixed in the owner session: minor — new files renamed to kebab-case (sync-client.ts, test/sync-client.test.ts) per .agents/rules/naming.md; imports, README, building-block view and tech/ide.md follow.
  • Fixed in the owner session: minor — the expired-code test stubbed a 410 the service never sends; it now stubs the 409 pairing.code_expired that RedeemPairingCodeCommand returns through SyncResults.StatusFor.
  • Fixed in the owner session: minor — Forget Pairing now refreshes the explorer tree, so it stops listing the owner's inbox after the pairing is gone.
  • Left open, minor (src/App/Backlog.Ide.VsCode/package.json:67): The test script passes node --test a quoted glob ("out/test/**/*.test.js"), and Node only expands globs in --test from v21. .devbook/tech/shared.md#nodejs records the Node floor as >=18. On Node 18 or 20, which are inside that range, npm test compiles and then fails with "Could not find .../out/test/**/*.test.js". CI does not catch this because it pins Node 24.x.
  • Left open, minor (src/App/Backlog.Ide.VsCode/src/syncClient.ts:220): When the token endpoint answers 401, the stored pairing is deleted unconditionally, without checking that it is still the pairing this request read and sent. In this sequence a new pairing is lost: the tree refresh's POST /devices/token with a revoked credential is still in flight, the person accepts the re-pair prompt, and pair() stores a new deviceId and credential. The stale request then returns 401 and runs secrets.delete(DEVICE_SECRET_KEY). That deletes the new pairing, and its single-use code is already burned.
  • Left open, minor (src/App/Backlog.Ide.VsCode/src/extension.ts:38): Capture failures used to go through showErrorMessage. They now go through the shared reportFailure, which shows every unavailable result with showWarningMessage. A failed capture loses the title the person just typed, but it now shows at the same warning level as a tree refresh, which is a quiet change from how the module reported capture failures before.
  • Left open, minor (.devbook/arc42/06-runtime-view.md:517): The opt 401 from the token endpoint block comes after the inbox POST and its alt. In the code (SyncClient.accessToken), a 401 from the token endpoint returns credentialRejected before any inbox request is sent. A reader of the diagram would conclude that an inbox POST goes out before the credential is found to be rejected.
  • Left open, minor (src/App/Backlog.Ide.VsCode/src/syncClient.ts:67): TokenResult copies SyncResult<T> field by field and only renames value to token. Every notPaired, credentialRejected or unavailable case now has to be kept the same in two unions. send already depends on the two matching, because it returns a TokenResult where a SyncResult<T> is expected.
  • Left open, minor (.github/workflows/pull-request.yml:349): The vscode-extension job now runs npm test, but it is still named 'Compile VS Code extension'. The sibling .NET job is named 'Build and test .NET', so a failing extension test shows up in the PR checks under a compile-only name.

What could not be proved

  • The workflow reported red, and that verdict is wrong for this change: the two failures were bUnit WaitForAssertion timeouts in SearchBoxTests.Typing_reports_the_query_once_the_debounce_has_elapsed and HomeDevbookPaneTests.Pressing_an_entry_s_devbook_reference_opens_it_in_the_devbook_pane, in .NET projects this TypeScript-and-docs change does not touch. Both pass alone, and both whole projects passed on a re-run (3429 and 5134, 0 failed). They are known timing flakes under full-suite load.
  • Nothing drives extension.ts in a real VS Code host. Check against the running AppHost (ide-vscode-host plus the cloud resource): (1) unpaired, the Backlog Inbox view warns 'not paired yet' and Capture Selection asks to pair first; (2) a code from the desktop's Settings › Devices › Let another device in, entered through Backlog: Pair with Desktop, pairs and the inbox lists the desktop owner's captures; (3) a capture lands in the desktop inbox; (4) an expired or wrong code shows a readable 'pairing failed' message; (5) after the device is revoked, refresh offers re-pairing rather than 'cloud sync unavailable'; (6) Backlog: Forget Pairing returns to the unpaired state and clears the tree.
  • The npm test glob (out/test/**/*.test.js) relies on Node 21+ expanding --test globs. CI pins Node 24.x, but .devbook/tech/shared.md records a Node floor of 18; watch the vscode-extension job on this pull request.
  • Criteria 7, 9 and 11 (the CI step, the README, the devbook chapters) are properties of the diff, not of a test.

Merge Danger

Door. Two-way: a revert undoes it. The only stored state is one SecretStorage entry (backlog.sync.device) on the editor's machine; no server, schema or setting changes. No decision record is needed: local ADR 0005 already prescribes this pairing flow.

Blast radius. local — the VS Code extension and the vscode-extension CI job (now also runs npm test).

Assumptions taken instead of asking

  • The issue's option 1 says 'register via /devices/register'. Assumed: the extension should redeem a pairing code at /devices/pair, because registering mints a new owner whose inbox is separate from the desktop's. The desktop's Devices settings tab is assumed to be where the person gets the code.
  • Test harness choice. Assumed: node's built-in test runner (node --test) over tsc output, with no new devDependencies and no @vscode/test-electron, testing a module that does not import vscode. The extension.ts glue (command registration) is covered by compilation, not executed tests.
  • Device name sent at pairing. Assumed: something like VS Code on <os.hostname()>, with no setting.
  • Whether the token is cached only in memory (assumed) or also persisted. Assumed: only the long-lived deviceId and credential go to SecretStorage, and the short-lived JWT lives in memory.
  • Whether the InboxEndpoints.cs comment needs an edit at all under option 1. Assumed: at most a clarifying 'as a paired device'. The issue's literal wording only demands removal under option 2.
  • Whether the devbook chapter edits (building-block view, runtime view, tech/ide.md) belong in this flow-code pull request or a separate flow-spec change. Assumed in the same change, per the run policy's 'write it in the same change', with both devbook checks run.
  • This sub-agent is one stage of the issue-sweep workflow, which plays the delivery:flow-code role, so I implemented directly rather than invoking the flow skill myself. Surface reporting, Personal Validation and the PR stay with the orchestrating session.
  • The pairing is stored as one SecretStorage entry ('backlog.sync.device', JSON {deviceId, credential}) rather than two keys, so a device id is never stored without its credential. ownerId is not stored because the extension never uses it.
  • The device name sent on pairing is ${vscode.env.appName} on ${os.hostname()}.
  • A second 401 from the inbox after the one retry is reported as 'unavailable (HTTP 401)'. It does not clear the credential, because like SyncTokenProvider I treat only the token endpoint's 401 as a verdict on the credential.
  • A non-401 failure from the token endpoint (such as 503) or a network error keeps the pairing and reports unavailable.
  • The test script uses a quoted glob ('out/test/**/*.test.js') because node --test out/test/ treats the directory as a module and fails. Node expands the glob itself, so the same script works under Windows cmd and Linux sh.
  • The InboxEndpoints.cs doc comment is left unchanged: it already names the editor extension as a caller, and that is now true.
  • No .vscodeignore was added. The extension has no packaging step (no vsce), so the compiled out/test files only matter if someone packages it later.
  • The CI job keeps its name 'Compile VS Code extension', in case a required-check configuration refers to it.
  • No TDR or ADR was written: option 1 carries out what local ADR 0005 already decides (device pairing, a long-lived credential exchanged for a short-lived JWT, the IDE channel in scope), so no new decision record is needed.

Opened as a draft by an attended run of delivery-schedule:schedule-issue-sweep (2026-10-15), told by the owner to decide without asking and to name the choice and the alternative.

🤖 Generated with Claude Code

Every /api/sync/inbox call from the extension went out without an
Authorization header and got 401. The extension now redeems a
desktop-issued pairing code, keeps the credential in SecretStorage,
exchanges it for a short-lived device token and sends it on every
inbox call; a rejected credential offers re-pairing. Adds the
extension's first test harness (npm test) and runs it in CI, and
shows the pairing step in the building-block and runtime views.

Decision taken without asking: the full pairing flow (option 1) over
parking the extension; the alternative is named in the pull request.

Refs #718

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JSdotNet
JSdotNet marked this pull request as ready for review October 15, 2026 18:09
@github-actions
github-actions Bot enabled auto-merge October 15, 2026 18:09
JSdotNet and others added 3 commits October 15, 2026 20:11
CodeQL flagged the stub fetch's url.startsWith(baseUrl) as incomplete
URL substring sanitization: it would also accept a host such as
sync.test.example.com. Compare the parsed origin instead.

Refs #718

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions
github-actions Bot merged commit 218a4b4 into main Oct 16, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix the VS Code extension getting 401 from the Sync API

2 participants