Skip to content

Commit 95fdc24

Browse files
Hardening v2.7claude
andcommitted
fix(natively-key): stop parking a user on a model their key cannot reach
CredentialsManager.setNativelyApiKey() auto-promotes the default model (and STT) to 'natively' and SAVES before anything has checked the key works. When the server then refused the key, the failure branch only console.log'd: no revert, no UI. The user pasted a key, saw "saved", and sat on an endpoint that rejects every request — which is how a 2.8.7 win32 user ended up unable to get any answer at all. set-natively-api-key now acts on the keyRejected verdict from the premium LicenseManager (bumped here): it undoes the promotion, re-syncs LLMHelper and the UI, rebuilds the STT pipeline if that moved too, and returns { success:false, error } instead of the unconditional { success:true } it returned even for a key that authenticates nowhere. The settings panel already renders `error` on a failed save, so the server's real reason reaches the user without a renderer change. Only the 4xx branch does any of this. A standard-plan key still authenticates against /v1/chat — PRO_PLANS gates only /v1/pro/verify — and a 5xx or network verdict says nothing about the key, so neither may tear down working state. The revert keys on the CURRENT value being 'natively', not on a pre-call snapshot: re-saving a key that was already stored leaves the snapshot reading 'natively' too, so restoring it would restore the broken state. It touches nothing else, so a deliberately chosen model is not collateral. The key itself is retained. A lapsed subscription should not require re-pasting on renewal, but the UI reports failure because the key does not currently work. Also makes withStubbedFetch in LicenseNativeModuleAbsent a faithful Response double by giving it `ok`. A double that omits it silently misreports a 200 to any code that reads the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BibR1zU9YXQgzT8Ty18yTY
1 parent 4d050fe commit 95fdc24

6 files changed

Lines changed: 503 additions & 11 deletions

electron/ipcHandlers.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7030,6 +7030,10 @@ export function initializeIpcHandlers(appState: AppState): void {
70307030
const PRICING_CACHE_TTL_MS = 5 * 60_000;
70317031

70327032
safeHandle('set-natively-api-key', async (_, apiKey: string) => {
7033+
// Set when the server REFUSES the key, so the handler can report the real
7034+
// reason instead of the unconditional { success: true } it used to return
7035+
// even for a key that authenticates nowhere.
7036+
let keyRejection: { error?: string } | null = null;
70337037
try {
70347038
const { CredentialsManager } = require('./services/CredentialsManager');
70357039
const cm = CredentialsManager.getInstance();
@@ -7112,6 +7116,39 @@ export function initializeIpcHandlers(appState: AppState): void {
71127116
'[IPC] set-natively-api-key: Pro inactive —',
71137117
result.error,
71147118
);
7119+
} else if (result.keyRejected) {
7120+
// The server REFUSED the key (4xx): it authenticates nowhere,
7121+
// /v1/chat included (both go through validateKey). By this point
7122+
// CredentialsManager.setNativelyApiKey has ALREADY auto-promoted the
7123+
// default model — and possibly the STT provider — to 'natively' and
7124+
// saved, so leaving it here parks the user on an endpoint that
7125+
// rejects every request, with nothing but a console line to say why.
7126+
// Undo the promotion and hand the server's own reason to the settings
7127+
// UI, which already renders `error` when a save reports failure.
7128+
//
7129+
// Only the 4xx branch does this. A standard-plan key ('no Pro') still
7130+
// authenticates against /v1/chat — the server gates only
7131+
// /v1/pro/verify on PRO_PLANS — and a 5xx/network verdict says
7132+
// nothing about the key, so neither may tear down working state.
7133+
console.warn(
7134+
'[IPC] set-natively-api-key: key REFUSED by server —',
7135+
result.code,
7136+
result.error,
7137+
);
7138+
const reverted = cm.revertNativelyAutoDefaults('Natively key refused by server');
7139+
if (reverted.defaultModel) {
7140+
const revertedProviders = [
7141+
...(cm.getCurlProviders() || []),
7142+
...(cm.getCustomProviders() || []),
7143+
];
7144+
llmHelper.setModel(reverted.defaultModel, revertedProviders);
7145+
appState.sendModelChanged(reverted.defaultModel);
7146+
}
7147+
if (reverted.sttProvider) {
7148+
await appState.reconfigureSttProvider();
7149+
}
7150+
broadcastCredentialsChanged();
7151+
keyRejection = { error: result.error };
71157152
} else {
71167153
console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);
71177154
}
@@ -7149,7 +7186,9 @@ export function initializeIpcHandlers(appState: AppState): void {
71497186
}
71507187
}
71517188

7152-
return { success: true };
7189+
return keyRejection
7190+
? { success: false, error: keyRejection.error }
7191+
: { success: true };
71537192
} catch (error: any) {
71547193
console.error('Error saving Natively API key:', error);
71557194
return { success: false, error: error.message };

electron/services/CredentialsManager.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,50 @@ export class CredentialsManager {
11851185
console.log(`[CredentialsManager] Default Model set to: ${model}`);
11861186
}
11871187

1188+
/**
1189+
* Undo the auto-promotions setNativelyApiKey() performs when a key is stored.
1190+
* Mutates only; the caller saves.
1191+
*
1192+
* Returns what actually changed so a caller can re-sync the runtime (LLMHelper
1193+
* model, STT pipeline) instead of guessing.
1194+
*/
1195+
private applyNativelyAutoDefaultRevert(reason: string): { defaultModel?: string; sttProvider?: string } {
1196+
const changed: { defaultModel?: string; sttProvider?: string } = {};
1197+
if (this.credentials.defaultModel === 'natively') {
1198+
this.credentials.defaultModel = 'gemini-3.1-flash-lite';
1199+
changed.defaultModel = this.credentials.defaultModel;
1200+
console.log(`[CredentialsManager] ${reason} — reset default model to Gemini Flash-Lite`);
1201+
}
1202+
if (this.credentials.sttProvider === 'natively') {
1203+
this.credentials.sttProvider = 'none';
1204+
changed.sttProvider = 'none';
1205+
console.log(`[CredentialsManager] ${reason} — reset STT provider to none`);
1206+
}
1207+
return changed;
1208+
}
1209+
1210+
/**
1211+
* Public revert, for when a stored key turns out NOT to authenticate.
1212+
*
1213+
* setNativelyApiKey() promotes the default model (and STT) to 'natively' and
1214+
* saves BEFORE anything has checked that the key works. When the server then
1215+
* refuses the key, the user is left routed at an endpoint that rejects them —
1216+
* silently, because the failure branch only logged. This is how that caller
1217+
* undoes the promotion.
1218+
*
1219+
* Deliberately keyed on the CURRENT value being 'natively' rather than on a
1220+
* pre-call snapshot: re-saving a key that was already stored leaves the
1221+
* snapshot reading 'natively' too, so restoring it would restore the broken
1222+
* state. Falling back to the same safe defaults the key-cleared path uses
1223+
* always lands somewhere that can actually serve a request.
1224+
*/
1225+
public revertNativelyAutoDefaults(reason: string): { defaultModel?: string; sttProvider?: string } {
1226+
if (this.refuseWriteWhileDegraded('revert natively auto defaults')) return {};
1227+
const changed = this.applyNativelyAutoDefaultRevert(reason);
1228+
if (changed.defaultModel || changed.sttProvider) this.saveCredentials();
1229+
return changed;
1230+
}
1231+
11881232
public setNativelyApiKey(key: string): void {
11891233
if (this.refuseWriteWhileDegraded('set natively api key')) return;
11901234
const trimmed = key.trim();
@@ -1225,14 +1269,7 @@ export class CredentialsManager {
12251269
}
12261270
} else {
12271271
// Key cleared — revert natively-auto-set defaults back to safe fallbacks
1228-
if (this.credentials.defaultModel === 'natively') {
1229-
this.credentials.defaultModel = 'gemini-3.1-flash-lite';
1230-
console.log('[CredentialsManager] Natively key cleared — reset default model to Gemini Flash-Lite');
1231-
}
1232-
if (this.credentials.sttProvider === 'natively') {
1233-
this.credentials.sttProvider = 'none';
1234-
console.log('[CredentialsManager] Natively key cleared — reset STT provider to none');
1235-
}
1272+
this.applyNativelyAutoDefaultRevert('Natively key cleared');
12361273
}
12371274

12381275
this.saveCredentials();
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// activateWithApiKey must tell three different failures apart.
2+
//
3+
// From a 2026-08-27 win32 user report: the log read
4+
// [LicenseManager] activateWithApiKey: plan has no Pro — undefined
5+
// [IPC] set-natively-api-key: Pro not activated — Your plan does not include Natively Pro.
6+
// The user was NOT on a free plan. natively-api's /v1/pro/verify (server.js:5716)
7+
// always emits `plan` on its success path (`auth.user.plan || 'standard'`), so a
8+
// genuine standard-plan user logs "— standard". `undefined` can only come from
9+
// the !auth.ok branch, which returns {ok:false, error} with NO plan field. The
10+
// server had REFUSED the key; the old `if (!data.ok || !data.has_pro)` read that
11+
// body as a plan verdict, discarded the server's actual reason (and its
12+
// account-specific `message`), and reported a wrong, unactionable cause.
13+
//
14+
// The distinction matters beyond the message. /v1/chat authenticates through the
15+
// same validateKey and has NO plan gate — PRO_PLANS is referenced only by
16+
// /v1/pro/verify — so:
17+
// - refused key (4xx) → authenticates nowhere; the caller must undo any
18+
// auto-promotion it made on the strength of this key
19+
// - no Pro (200) → chat still works; app state must NOT change
20+
// - 5xx / bad body → no verdict at all; app state must NOT change
21+
import { test, describe, before, after, beforeEach } from 'node:test';
22+
import assert from 'node:assert/strict';
23+
import fs from 'node:fs';
24+
import os from 'node:os';
25+
import path from 'node:path';
26+
import Module, { createRequire } from 'node:module';
27+
import { fileURLToPath } from 'node:url';
28+
29+
const USER_DATA = fs.mkdtempSync(path.join(os.tmpdir(), 'natively-license-verdict-'));
30+
process.env.NATIVELY_TEST_USERDATA = USER_DATA;
31+
const LICENSE_PATH = path.join(USER_DATA, 'license.enc');
32+
33+
const HERE = path.dirname(fileURLToPath(import.meta.url));
34+
const electronStub = path.join(HERE, '__electron_license_stub.mjs');
35+
const nativeStub = path.join(HERE, '__native_module_stub.cjs');
36+
createRequire(import.meta.url)(nativeStub);
37+
38+
const originalResolve = Module._resolveFilename;
39+
Module._resolveFilename = function (request, ...rest) {
40+
if (request === 'electron') return electronStub;
41+
if (typeof request === 'string' && request.endsWith('.node')) return nativeStub;
42+
return originalResolve.call(this, request, ...rest);
43+
};
44+
45+
const { LicenseManager } = await import(
46+
'../../../dist-electron/premium/electron/services/LicenseManager.js'
47+
);
48+
49+
after(() => {
50+
Module._resolveFilename = originalResolve;
51+
globalThis.fetch = originalFetch;
52+
fs.rmSync(USER_DATA, { recursive: true, force: true });
53+
});
54+
55+
function freshManager() {
56+
delete globalThis.__nativelyLicenseManagerV1__;
57+
LicenseManager.instance = undefined;
58+
return LicenseManager.getInstance();
59+
}
60+
61+
const originalFetch = globalThis.fetch;
62+
63+
/** Duck-typed Response: activateWithApiKey reads only ok/status/json(). */
64+
function reply(status, body, { jsonThrows = false } = {}) {
65+
globalThis.fetch = async () => ({
66+
ok: status >= 200 && status < 300,
67+
status,
68+
json: async () => {
69+
if (jsonThrows) throw new SyntaxError('Unexpected token < in JSON at position 0');
70+
return body;
71+
},
72+
});
73+
}
74+
75+
beforeEach(() => {
76+
// No stored license → perpetualLicenseGuard passes and the network path runs.
77+
fs.rmSync(LICENSE_PATH, { force: true });
78+
});
79+
80+
before(() => {
81+
assert.equal(
82+
typeof freshManager().activateWithApiKey,
83+
'function',
84+
'precondition failed: compiled LicenseManager did not load',
85+
);
86+
});
87+
88+
describe('4xx — the server refused the key', () => {
89+
test('an inactive subscription surfaces the SERVER\'s own next step', async () => {
90+
reply(403, { ok: false, error: 'subscription_inactive', message: 'Renew at natively.software/api' });
91+
92+
const r = await freshManager().activateWithApiKey('natively_sk_lapsed');
93+
94+
assert.equal(r.success, false);
95+
assert.equal(r.keyRejected, true, 'a 4xx refusal must be distinguishable from a plan verdict');
96+
assert.equal(r.code, 'subscription_inactive');
97+
assert.equal(r.status, 403);
98+
assert.equal(
99+
r.error,
100+
'Renew at natively.software/api',
101+
"the server's account-specific message is the only source that knows the next step — it must not be replaced",
102+
);
103+
assert.notEqual(
104+
r.error,
105+
'Your plan does not include Natively Pro.',
106+
'this is the exact misreport from the 2026-08-27 user log',
107+
);
108+
});
109+
110+
test('a refusal with no server message still names an actionable cause', async () => {
111+
reply(401, { ok: false, error: 'key_not_found' });
112+
113+
const r = await freshManager().activateWithApiKey('natively_sk_ghost');
114+
115+
assert.equal(r.keyRejected, true);
116+
assert.equal(r.code, 'key_not_found');
117+
assert.match(r.error, /not recognised/i, 'the user must learn the KEY was rejected, not their plan');
118+
});
119+
120+
test('a rate-limited refusal reports the retry window', async () => {
121+
reply(429, { ok: false, error: 'identity_blocked', retry_after: 42 });
122+
123+
const r = await freshManager().activateWithApiKey('natively_sk_blocked');
124+
125+
assert.equal(r.keyRejected, true);
126+
assert.match(r.error, /42/, 'retry_after is the only actionable detail on a 429');
127+
});
128+
});
129+
130+
describe('200 — the key authenticates, the plan simply has no Pro', () => {
131+
test('a standard plan is NOT a rejected key', async () => {
132+
reply(200, { ok: true, has_pro: false, plan: 'standard' });
133+
134+
const r = await freshManager().activateWithApiKey('natively_sk_standard');
135+
136+
assert.equal(r.success, false);
137+
assert.ok(
138+
!r.keyRejected,
139+
'CRITICAL: a standard-plan key authenticates against /v1/chat (the server gates only /v1/pro/verify on PRO_PLANS). Marking it rejected would tear down a WORKING configuration.',
140+
);
141+
assert.equal(r.error, 'Your plan does not include Natively Pro.');
142+
});
143+
});
144+
145+
describe('no verdict — nothing may be torn down', () => {
146+
test('a 5xx is transient, not a statement about the key', async () => {
147+
reply(503, { error: 'upstream_unavailable' });
148+
149+
const r = await freshManager().activateWithApiKey('natively_sk_valid');
150+
151+
assert.equal(r.success, false);
152+
assert.ok(!r.keyRejected, 'an outage must never be reported as a bad key');
153+
assert.equal(r.status, 503);
154+
});
155+
156+
test('an HTML error page (json() throws) is transient, not "could not reach server"', async () => {
157+
// A proxy answering 502 with HTML used to throw out of res.json() into the
158+
// outer catch and be reported as a network failure — wrong for a server that
159+
// answered, and it hid the status.
160+
reply(502, null, { jsonThrows: true });
161+
162+
const r = await freshManager().activateWithApiKey('natively_sk_valid');
163+
164+
assert.ok(!r.keyRejected);
165+
assert.equal(r.status, 502, 'the status must survive a body that will not parse');
166+
});
167+
168+
test('a 2xx with an unexpected body is not read as a plan verdict', async () => {
169+
reply(200, { unexpected: true });
170+
171+
const r = await freshManager().activateWithApiKey('natively_sk_valid');
172+
173+
assert.equal(r.success, false);
174+
assert.ok(!r.keyRejected, 'refuse to guess in either direction on a malformed body');
175+
assert.notEqual(r.error, 'Your plan does not include Natively Pro.');
176+
});
177+
});
178+
179+
describe('robustness of the success test itself', () => {
180+
test('a Response-like object with no `ok` property is judged by status, not by the missing field', async () => {
181+
// This is a real regression, not a hypothetical: the first cut of this fix
182+
// read `res.ok`, and the existing LicenseNativeModule*.test.mjs doubles
183+
// return { status, json } with no `ok`. An absent property is falsy, so a
184+
// plain 200 was read as a 4xx — a VALID pro key reported as refused, which
185+
// would then tear down the user's working configuration. Response.ok is
186+
// defined as status in [200,299]; derive it, never read it.
187+
globalThis.fetch = async () => ({
188+
status: 200,
189+
json: async () => ({ ok: true, has_pro: true, plan: 'ultra' }),
190+
});
191+
192+
const r = await freshManager().activateWithApiKey('natively_sk_pro');
193+
194+
assert.ok(!r.keyRejected, 'a 200 with no `ok` field on the Response must never read as a refused key');
195+
assert.equal(r.success, true);
196+
});
197+
});
198+
199+
describe('positive control', () => {
200+
test('a pro plan still activates (the happy path is not collateral damage)', async () => {
201+
reply(200, { ok: true, has_pro: true, plan: 'pro' });
202+
203+
const r = await freshManager().activateWithApiKey('natively_sk_pro');
204+
205+
assert.equal(r.success, true, 'a genuine pro key must still activate');
206+
assert.ok(!r.keyRejected);
207+
});
208+
});

electron/services/__tests__/LicenseNativeModuleAbsent.test.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ const PRO_VERIFY_OK = { status: 200, body: { ok: true, has_pro: true, plan: 'ult
9696
*/
9797
async function withStubbedFetch({ status, body }, fn) {
9898
const previous = globalThis.fetch;
99-
globalThis.fetch = async () => ({ status, json: async () => body });
99+
// `ok` included so the double is a faithful Response: it is defined as
100+
// status in [200,299], and a double that omits it silently misreports a
101+
// 200 as a failure to any code that reads it.
102+
globalThis.fetch = async () => ({ ok: status >= 200 && status < 300, status, json: async () => body });
100103
try {
101104
return await fn();
102105
} finally {

0 commit comments

Comments
 (0)