Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/backend/ipc/release-channel-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,4 +372,24 @@ describe("ReleaseChannelIPCHandlers — PR releases require a healthy GitHub tok
expect(verifyGitHubTokenMock).not.toHaveBeenCalled();
expect(result).toEqual({ available: false, currentVersion: "1.2.3" });
});

it("does not attach a saved (possibly invalid/expired) token as an auth header when switching to the latest stable (public) channel (#600)", async () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] New regression test doesn't verify clearing of a pre-existing stale Authorization header

The new test sets autoUpdater.requestHeaders = {} (empty) before calling configureAutoUpdater({ type: "latest" }), then asserts no Authorization header appears. This correctly pins the original bug (unconditional token attach before branching), but it would still pass even if the header-stripping destructure (const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {}) were deleted entirely — starting from an empty object there's nothing to strip. It doesn't exercise the scenario the added code comment explicitly claims to guard: "Explicitly clear any Authorization header a previous PR-channel configuration may have left behind."

Suggested fix: add/extend a test that first sets autoUpdater.requestHeaders = { Authorization: 'Bearer stale-pr-token', 'X-Some-Other-Header': 'keep-me' } (simulating state left over from a prior PR-channel configuration) before calling configureAutoUpdater({ type: 'latest' }), then assert Authorization is gone and the unrelated header survives.

flagged by: code-review + codex-rescue (independent agreement)

// The stable channel is served from a public GitHub repo and must not require — or send — a
// GitHub token at all. Before the fix, configureAutoUpdater() unconditionally attached any
// saved token as a Bearer header before branching on channel type, so an invalid/expired token
// (which the user is never prompted to fix, since the stable channel doesn't gate on health)
// was still sent to the public GitHub releases provider.
getChannelMock.mockResolvedValue({ type: "latest" });
getTokenMock.mockResolvedValue("saved-but-possibly-invalid-token");

const { autoUpdater } = (await import("electron-updater")) as unknown as {
autoUpdater: { requestHeaders?: Record<string, string> };
};
autoUpdater.requestHeaders = {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] New regression test doesn't simulate the actual leftover-header regression scenario

The new test resets autoUpdater.requestHeaders = {} immediately before calling configureAutoUpdater({type:"latest"}), so the destructure-and-clear logic this PR adds (const { Authorization: _unused, ...headersWithoutAuth } = ...) is a no-op in this test — verified empirically by removing the two clearing lines from the source and re-running just this test, which still passed.

The test as written only proves a token present in the token store isn't newly read/attached for the latest branch (the weaker claim — already guaranteed just by deleting the old unconditional getToken() call at the top of the method). It doesn't prove the actual regression scenario described in the PR's own commit message: a leftover Authorization header from a prior PR-channel configureAutoUpdater() call actually gets stripped when switching to latest.

Suggested strengthening: seed autoUpdater.requestHeaders = { Authorization: "Bearer stale-token" } before calling configureAutoUpdater({type:"latest"}), then assert it's cleared afterward.

flagged by: codex-rescue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] New #600 regression test doesn't seed a leftover Authorization header, so it doesn't prove the strip

The test sets autoUpdater.requestHeaders = {} (already empty) immediately before calling configureAutoUpdater({ type: "latest" }), then asserts Authorization is undefined. Since there was never an Authorization key present to begin with, this only proves the latest branch doesn't add a header — it doesn't exercise the actual regression the PR's own comment describes ("clear any Authorization header a previous PR-channel configuration may have left behind"). getTokenMock.mockResolvedValue(...) is also a red herring here since the latest" branch never calls tokenStore.getToken()`.

Suggested fix: seed autoUpdater.requestHeaders = { Authorization: "Bearer stale-token" } (simulating a prior PR-channel config) before calling configureAutoUpdater for latest, then assert it's stripped — ideally also with a sibling header present to prove only Authorization is removed.

flagged independently by: code-review + codex-rescue


const handlers = new ReleaseChannelIPCHandlers();
await handlers.configureAutoUpdater({ type: "latest" });

expect(autoUpdater.requestHeaders?.Authorization).toBeUndefined();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] New #600 regression test doesn't actually seed a stale Authorization header, so it can't prove the claimed fix

Both review lenses independently flagged this. The test sets autoUpdater.requestHeaders = {} (empty) immediately before calling configureAutoUpdater({ type: "latest" }), then asserts Authorization is undefined afterward. Since there was never an Authorization key present to begin with, this test would pass identically even if the destructure-and-strip logic in configureAutoUpdater were deleted outright — it never exercises the 'leftover header from a previous PR-channel configuration' scenario that the PR description and the inline code comment both call out as the actual bug being fixed. It also doesn't add coverage beyond the pre-existing 'no token attached at all for stable' test.

Suggested fix: seed autoUpdater.requestHeaders = { Authorization: 'Bearer stale-pr-token' } before calling configureAutoUpdater({ type: "latest" }), then assert the header is gone afterward — that actually exercises (and would fail without) the strip logic.

flagged by: code-review + codex-rescue

});
});
28 changes: 20 additions & 8 deletions src/backend/ipc/release-channel-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,16 +571,17 @@ export class ReleaseChannelIPCHandlers {
return;
}

const githubToken = await this.tokenStore.getToken();
if (githubToken) {
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
Authorization: `Bearer ${githubToken}`,
};
}

// Configure autoUpdater based on channel
if (channel.type === "latest") {
// #600 — the stable channel is served from this repo's *public* GitHub releases, which never
// need authentication. Don't attach a saved token here at all: a saved-but-invalid/expired
// token (which this channel never gates on, unlike PR channels below) would otherwise be sent
// as a Bearer header on every request to the public provider, risking spurious 401s instead of
// the clean unauthenticated request GitHub expects for public repos. Explicitly clear any
// Authorization header a previous PR-channel configuration may have left behind.
const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Shared autoUpdater.requestHeaders singleton creates cross-channel header staleness/races

Two related gaps in how the fix manages the shared, module-level autoUpdater.requestHeaders object (flagged independently by both review lenses):

  1. Concurrent clear can wipe a PR-channel token mid-flight. configureAutoUpdater({type:"latest"}) now unconditionally clears Authorization from the shared singleton. checkForUpdates()'s manual button and the 10-minute periodic timer both call into this asynchronously and independently. If a periodic-timer PR-channel check is in flight (relying on the Authorization header a previous configureAutoUpdater(pr) call attached) at the same moment a user manually checks updates on the "latest" channel, the "latest" call's header-clear can strip the token out from under the concurrent PR request. This interaction didn't exist before this diff, since the header used to be reattached unconditionally at the top of every call regardless of channel.
  2. Token rotation while already on a PR channel doesn't refresh the header. checkForUpdates()'s PR-channel branch (~line 458, same file) re-verifies token health via isGitHubTokenHealthy() but never itself re-attaches requestHeaders.Authorization — it purely inherits whatever value was set by the last configureAutoUpdater() call. If a user revokes and replaces their token via Settings without switching channels afterward, the health check reports "healthy" for the new token, but the actual outbound request still carries the old, stale token value. This undermines the PR's own stated goal ("only attach the token once confirmed healthy") on the very code path it was trying to make safer.

Suggested fix: give request-header attachment a single, per-request source of truth (e.g. compute/attach the current header value at the point of each autoUpdater.checkForUpdates() call, or centralize all header mutation behind one method both configureAutoUpdater and checkForUpdates call), rather than mutating a long-lived shared object from multiple independent call sites.

flagged by: code-review + codex-rescue (independently, from different angles)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Unused-var alias in destructuring-omit is inconsistent with the codebase's style

const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {} uses an explicit _unused alias for the omitted key. Other rest-destructure-to-omit patterns elsewhere in this codebase (e.g. const { hotkeys, ...restOfPatch } = ... in user-settings-handlers.ts, const { execute, ...rest } = ... in language-model-provider.ts) don't use an _-prefixed alias, since Biome's ruleset here doesn't flag rest-destructured bindings as unused. Not a lint failure, just a minor style inconsistency — could drop the alias to const { Authorization, ...headersWithoutAuth } = ... (with an eslint-disable comment if a rule does complain) to match convention.

flagged by: code-review

autoUpdater.requestHeaders = headersWithoutAuth;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Stale Authorization header can survive when channel.type === "pr" but channel.channel is falsy

configureAutoUpdater() only clears/sets the Authorization header inside the channel.type === "latest" branch (here) and inside the channel.type === "pr" && channel.channel branch further down (~line 627). ReleaseChannel.channel is typed as optional (channel?: string), so a value of { type: "pr" } with a missing/empty channel satisfies neither branch — autoUpdater.requestHeaders is left completely untouched, so any Authorization header attached by a previous, successful PR-channel configuration would persist.

This is exactly the class of bug #600 is about (a stale/invalid token being sent when it shouldn't be). Today the UI always supplies a non-empty channel string when setting a pr-type channel (setChannel({ type: "pr", channel: \beta.${value}` })), so it isn't reachable through the current UI flow — but configureAutoUpdater`'s own contract doesn't defend against it (persisted storage from a migration, manual edit, or a future caller could hit it). Consider hoisting the header-clearing so it runs on any branch that doesn't end up explicitly attaching a fresh token, rather than only inside the two known-good branches.

flagged by: code-review

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] No synchronization around configureAutoUpdater lets concurrent channel-switch/periodic-check calls interleave and leak headers either direction

autoUpdater (electron-updater) and its requestHeaders are process-wide singleton state, but configureAutoUpdater() is invoked from multiple independent, uncoordinated call sites: app startup (src/backend/index.ts:506, fire-and-forget, not awaited), setChannel() (line ~197), and checkForUpdates()'s latest arm (line ~546) — plus the 10-minute periodic timer that re-invokes checkForUpdates() on its own schedule.

The pr branch does two awaits (isGitHubTokenHealthy() → network call, then tokenStore.getToken()) before attaching Authorization; the latest branch strips it synchronously. If a PR-channel configuration is in-flight (e.g. triggered by the periodic timer) while the user switches to latest (or vice versa), the two calls' effects can interleave on the shared autoUpdater object — e.g. latest strips the header, then the still-in-flight pr call's header-attach completes afterward and re-attaches a token onto an autoUpdater now configured with the public 'github' feed for latest. This isn't addressed by the PR and isn't covered by any test. A fix would need per-invocation isolation (e.g. a single in-flight promise/queue guarding configureAutoUpdater, or a version/generation check before mutating shared state).

flagged by: codex-rescue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Header-clearing only handles the exact key "Authorization"; no defense against a differently-cased or additional leftover header

The destructure const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {} is correct today only because this codebase is the sole writer of autoUpdater.requestHeaders and always uses the literal key "Authorization" (confirmed by grep — no other casing is ever written). But this is an implicit assumption with no guard: if electron-updater or a future contributor ever sets a differently-cased key (e.g. lowercase "authorization") or another auth-adjacent header, it would silently survive this strip and still be sent to the public unauthenticated "latest" endpoint — exactly the class of bug this PR is trying to eliminate. Consider clearing requestHeaders wholesale for the "latest" branch (autoUpdater.requestHeaders = {}) rather than selectively deleting one known key, since "latest" never needs any custom headers at all.

flagged by: codex-rescue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Destructure-to-omit pattern is a one-off compared to existing idioms in this file

const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {}; autoUpdater.requestHeaders = headersWithoutAuth; works correctly, but the rest of this file always builds requestHeaders additively via spread ({ ...autoUpdater.requestHeaders, Authorization: ... }) rather than via destructure-omit. Not a correctness issue, just a minor stylistic inconsistency — a small shared helper (e.g. clearAuthHeader()/buildRequestHeaders(token?)) would read more consistently with the surrounding code and reduce duplication between the two places headers are now built.

flagged by: code-review


autoUpdater.channel = "latest";
autoUpdater.allowPrerelease = false;
autoUpdater.allowDowngrade = false;
Expand Down Expand Up @@ -618,6 +619,17 @@ export class ReleaseChannelIPCHandlers {
return;
}

// Only attach the token once it's confirmed healthy above (#600) — PR release assets are
// downloaded via a "generic" feed pointed at this repo's own GitHub releases, which needs
// authentication headers to avoid anonymous rate limits during the download.
const githubToken = await this.tokenStore.getToken();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Redundant token fetch in PR branch of configureAutoUpdater

isGitHubTokenHealthy() (called just above, gating this branch) already reads the token from this.tokenStore.getToken() internally to perform its health check, and then this line reads it again from the same store just to attach the Authorization header. Not a correctness bug — the store is a simple local read with no intervening async work that could change the value — but it's an avoidable double read. Consider having isGitHubTokenHealthy() return the token it already fetched (or threading it through) to avoid the duplicate call.

flagged by: codex-rescue

if (githubToken) {
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
Authorization: `Bearer ${githubToken}`,
};
}

// For PR channels, we need to find the latest release tag first
const prNumber = this.extractPRNumberFromChannel(channel.channel);
if (prNumber) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,28 @@ describe("ReleaseChannelSetting (#423)", () => {
});
});

it("keeps 'Check for Updates' enabled for the Latest Stable channel with no GitHub token saved (#600)", async () => {
hasToken.mockResolvedValue(false);
verifyToken.mockResolvedValue({ isValid: false });

render(<ReleaseChannelSetting isActive={true} />);
await screen.findByText("1.2.3");

const button = await screen.findByRole("button", { name: /check for updates/i });
await waitFor(() => expect(button).toBeEnabled());
});

it("keeps 'Check for Updates' enabled for the Latest Stable channel with an invalid/expired GitHub token saved (#600)", async () => {
hasToken.mockResolvedValue(true);
verifyToken.mockResolvedValue({ isValid: false, error: "Invalid or expired token" });

render(<ReleaseChannelSetting isActive={true} />);
await screen.findByText("1.2.3");

const button = await screen.findByRole("button", { name: /check for updates/i });
await waitFor(() => expect(button).toBeEnabled());
});

it("keeps the version card's bump label consistent with the toast when the update check resolves before loadCurrentVersion", async () => {
// loadCurrentVersion() never resolves (simulates it not having finished yet), so
// `currentVersion` state starts empty; the version card's label must still match
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,12 @@ export function ReleaseChannelSetting({ isActive }: ReleaseChannelSettingProps)
<Button
onClick={handleCheckUpdates}
disabled={
isLoading || !selectValue || (channel.type === "pr" ? !isTokenHealthy : !hasGitHubToken)
isLoading ||
!selectValue ||
// #600 — the stable ("latest") channel is a public GitHub release and never needs a
// token; only PR channels are token-gated (#919). A user with no token, or an
// invalid/expired one, must still be able to check for stable updates.
(channel.type === "pr" ? !isTokenHealthy : false)
}
>
Check for Updates
Expand Down
Loading