pi-freeflow 1.22.1 → 1.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/cline-accounts.ts +6 -4
- package/src/cline-device-auth.ts +19 -6
- package/src/commands.ts +12 -5
- package/src/proxy.ts +15 -1
- package/src/system-ca-fetch.ts +235 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fix Cline login flow: the browser link and code now arrive in a single copy-paste block and stay pinned until login finishes, instead of the link scrolling away behind a separate waiting notice. Saved logins also refresh on their own again before expiring, and chatting with no logins saved tells you to add one instead of showing a rate-limit hint.
|
|
8
|
+
|
|
3
9
|
## 1.22.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.22.
|
|
4
|
+
"version": "1.22.2",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/cline-accounts.ts
CHANGED
|
@@ -11,6 +11,8 @@ import fs from "node:fs";
|
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { RELAY_STATE_FILE } from "./config.ts";
|
|
13
13
|
import { logWarn } from "./logger.ts";
|
|
14
|
+
import type { FetchImpl } from "./cline-device-auth.ts";
|
|
15
|
+
import { isWorkosJwt } from "./cline-device-auth.ts";
|
|
14
16
|
|
|
15
17
|
/** Env override for the pool file location (tests/CI sandbox). */
|
|
16
18
|
export const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
|
|
@@ -144,7 +146,7 @@ function readPoolFile(): ClinePoolState {
|
|
|
144
146
|
const rec: Record<string, unknown> = entry as Record<string, unknown>;
|
|
145
147
|
const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
|
|
146
148
|
const token = typeof rec.token === "string" ? rec.token : "";
|
|
147
|
-
if (!slot || !token.startsWith("workos:")) continue;
|
|
149
|
+
if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) continue;
|
|
148
150
|
const refreshRaw: unknown = rec.refreshToken;
|
|
149
151
|
const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
|
|
150
152
|
const expiresRaw: unknown = rec.expiresAt;
|
|
@@ -238,7 +240,7 @@ export function addAccount(
|
|
|
238
240
|
const cleanSlot = (slot || "").trim();
|
|
239
241
|
if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
|
|
240
242
|
if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
|
|
241
|
-
if (!token.startsWith("workos:")) throw new Error("Cline token must
|
|
243
|
+
if (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_")) throw new Error("Cline token must be a workos: login grant or a clp_ API key");
|
|
242
244
|
const pool = loadPool();
|
|
243
245
|
const existing = pool.accounts.find((a) => a.slot === cleanSlot);
|
|
244
246
|
if (existing) {
|
|
@@ -308,7 +310,7 @@ async function refreshAccountInPlace(
|
|
|
308
310
|
void e;
|
|
309
311
|
return null;
|
|
310
312
|
}
|
|
311
|
-
if (!fresh || typeof fresh.token !== "string" || !fresh.token.startsWith("workos:")) {
|
|
313
|
+
if (!fresh || typeof fresh.token !== "string" || (!fresh.token.startsWith("workos:") && !isWorkosJwt(fresh.token) && !fresh.token.startsWith("clp_"))) {
|
|
312
314
|
logWarn("cline slot refresh rejected — grant is dead", { slot: account.slot });
|
|
313
315
|
return false;
|
|
314
316
|
}
|
|
@@ -368,7 +370,7 @@ export interface ClineRollOpts {
|
|
|
368
370
|
chatUrl: string;
|
|
369
371
|
/** Restrict the roll to these slots; default is the whole pool. */
|
|
370
372
|
slots?: string[];
|
|
371
|
-
fetchImpl?:
|
|
373
|
+
fetchImpl?: FetchImpl;
|
|
372
374
|
reqId?: string;
|
|
373
375
|
/**
|
|
374
376
|
* Device-login refresher. When present, a stale slot refreshes once before
|
package/src/cline-device-auth.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* mocked tests only, no live calls here.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
11
|
+
|
|
10
12
|
export interface OAuthCredentials {
|
|
11
13
|
access: string;
|
|
12
14
|
refresh: string;
|
|
@@ -61,9 +63,20 @@ export function resolveClineWorkosClientId(): string {
|
|
|
61
63
|
/** Resolved client id used by start/poll. */
|
|
62
64
|
export const CLINE_WORKOS_CLIENT_ID = resolveClineWorkosClientId();
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Wire form of a Cline credential. Cline OAuth access tokens are WorkOS JWTs
|
|
68
|
+
* (base64url eyJ header) and must ride as workos:<jwt>; dashboard API keys
|
|
69
|
+
* (clp_ apikey category) go verbatim — prefixing them 401s. Mirrors the
|
|
70
|
+
* 9router getClineAccessToken guard (open-sse/shared/clineAuth.js).
|
|
71
|
+
*/
|
|
72
|
+
export function isWorkosJwt(token: string): boolean {
|
|
73
|
+
return /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(token.trim());
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
export function toApiKey(accessToken: string): string {
|
|
65
77
|
const token = accessToken.trim();
|
|
66
|
-
|
|
78
|
+
if (token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)) return token;
|
|
79
|
+
return isWorkosJwt(token) ? `${WORKOS_TOKEN_PREFIX}${token}` : token;
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
const DEVICE_AUTHORIZATION_PATH = "/user_management/authorize/device";
|
|
@@ -73,7 +86,7 @@ const REFRESH_PATH = "/api/v1/auth/refresh";
|
|
|
73
86
|
|
|
74
87
|
const HTTP_TIMEOUT_MS = 30 * 1000;
|
|
75
88
|
|
|
76
|
-
export type FetchImpl =
|
|
89
|
+
export type FetchImpl = (input: string, init?: RequestInit) => Promise<Response>;
|
|
77
90
|
|
|
78
91
|
function joinUrl(base: string, path: string): string {
|
|
79
92
|
return base.replace(/\/+$/, "") + path;
|
|
@@ -126,7 +139,7 @@ export function deriveExpiry(explicitMs: number | undefined, accessToken: string
|
|
|
126
139
|
|
|
127
140
|
export async function startDeviceAuth(
|
|
128
141
|
workosBase: string = DEFAULT_WORKOS_BASE,
|
|
129
|
-
fetchImpl: FetchImpl =
|
|
142
|
+
fetchImpl: FetchImpl = fetchWithSystemCA,
|
|
130
143
|
): Promise<DeviceAuth> {
|
|
131
144
|
const response = await fetchImpl(joinUrl(workosBase, DEVICE_AUTHORIZATION_PATH), {
|
|
132
145
|
method: "POST",
|
|
@@ -173,7 +186,7 @@ export async function pollDeviceToken(
|
|
|
173
186
|
options?: PollOptions,
|
|
174
187
|
): Promise<DevicePollTokens> {
|
|
175
188
|
if (!deviceCode) throw new Error("deviceCode is required");
|
|
176
|
-
const fetchFn = options?.fetchImpl ??
|
|
189
|
+
const fetchFn = options?.fetchImpl ?? fetchWithSystemCA;
|
|
177
190
|
const outerSignal = options?.signal;
|
|
178
191
|
const deadline = Date.now() + (options?.maxWaitMs ?? intervalSeconds * 1000 * 60 * 10);
|
|
179
192
|
let interval = Math.max(1, intervalSeconds);
|
|
@@ -295,7 +308,7 @@ export async function registerClineToken(
|
|
|
295
308
|
apiBase: string = DEFAULT_API_BASE,
|
|
296
309
|
accessToken = "",
|
|
297
310
|
refreshToken = "",
|
|
298
|
-
fetchImpl: FetchImpl =
|
|
311
|
+
fetchImpl: FetchImpl = fetchWithSystemCA,
|
|
299
312
|
): Promise<OAuthCredentials> {
|
|
300
313
|
if (!accessToken || !refreshToken) throw new Error("accessToken and refreshToken are required");
|
|
301
314
|
const response = await fetchImpl(joinUrl(apiBase, REGISTER_PATH), {
|
|
@@ -316,7 +329,7 @@ export async function registerClineToken(
|
|
|
316
329
|
export async function refreshClineToken(
|
|
317
330
|
apiBase: string = DEFAULT_API_BASE,
|
|
318
331
|
refreshToken = "",
|
|
319
|
-
fetchImpl: FetchImpl =
|
|
332
|
+
fetchImpl: FetchImpl = fetchWithSystemCA,
|
|
320
333
|
): Promise<OAuthCredentials> {
|
|
321
334
|
if (!refreshToken) throw new Error("refreshToken is required");
|
|
322
335
|
const response = await fetchImpl(joinUrl(apiBase, REFRESH_PATH), {
|
package/src/commands.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
import { addAccount, isClineSlotHealthy, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
|
|
62
62
|
import type { ClinePoolState } from "./cline-accounts.ts";
|
|
63
63
|
import { pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
|
|
64
|
+
import { isCertError } from "./system-ca-fetch.ts";
|
|
64
65
|
import type {
|
|
65
66
|
ExtensionAPI,
|
|
66
67
|
ExtensionContext,
|
|
@@ -1452,20 +1453,25 @@ export function createCommandSpec(
|
|
|
1452
1453
|
try {
|
|
1453
1454
|
started = await startDeviceAuth();
|
|
1454
1455
|
} catch (e) {
|
|
1456
|
+
if (isCertError(e)) {
|
|
1457
|
+
ctx.ui.notify("Could not reach Cline: your antivirus or network proxy intercepts TLS and Node does not trust it. The login retries with your OS certificates automatically — if this persists, restart the host with NODE_USE_SYSTEM_CA=1 set, or point NODE_EXTRA_CA_CERTS at your proxy CA file.", "error");
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1455
1460
|
ctx.ui.notify(`Could not reach Cline to start login: ${clineErrorMessage(e)}`, "error");
|
|
1456
1461
|
return;
|
|
1457
1462
|
}
|
|
1458
1463
|
const link = started.verificationUriComplete ?? started.verificationUri;
|
|
1459
1464
|
const minutes = Math.max(1, Math.round(started.expiresIn / 60));
|
|
1460
|
-
|
|
1461
|
-
ctx.ui.notify(
|
|
1465
|
+
const loginLine = `Cline login [${slot}]\nOpen this link in your browser:\n${link}\nEnter code: ${started.userCode} (expires in ~${minutes} min)\nWaiting for approval — approve or cancel in the browser; this finishes on its own.`;
|
|
1466
|
+
ctx.ui.notify(loginLine, "info");
|
|
1467
|
+
try { ctx.ui.setStatus("cline-login", `Cline login [${slot}] code ${started.userCode}`); } catch { }
|
|
1462
1468
|
const progress = setInterval(() => {
|
|
1463
|
-
ctx.ui.notify(
|
|
1469
|
+
ctx.ui.notify(loginLine, "info");
|
|
1464
1470
|
}, 45000);
|
|
1465
1471
|
// @ts-ignore allow unref to not block process exit in CLI
|
|
1466
1472
|
progress.unref?.();
|
|
1467
1473
|
try {
|
|
1468
|
-
const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval);
|
|
1474
|
+
const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval, { maxWaitMs: Math.max(60_000, started.expiresIn * 1000) });
|
|
1469
1475
|
const creds = await registerClineToken(undefined, deviceTokens.accessToken, deviceTokens.refreshToken);
|
|
1470
1476
|
const apiKey = toApiKey(creds.access);
|
|
1471
1477
|
addAccount(slot, apiKey, {
|
|
@@ -1483,7 +1489,7 @@ export function createCommandSpec(
|
|
|
1483
1489
|
code = e.errorCode;
|
|
1484
1490
|
}
|
|
1485
1491
|
if (code === "authorization_pending") {
|
|
1486
|
-
ctx.ui.notify(
|
|
1492
|
+
ctx.ui.notify(loginLine, "info");
|
|
1487
1493
|
} else if (code === "access_denied" || code === "cancelled") {
|
|
1488
1494
|
ctx.ui.notify("Cline login cancelled.", "warning");
|
|
1489
1495
|
} else if (code === "expired_token") {
|
|
@@ -1493,6 +1499,7 @@ export function createCommandSpec(
|
|
|
1493
1499
|
}
|
|
1494
1500
|
} finally {
|
|
1495
1501
|
clearInterval(progress);
|
|
1502
|
+
try { ctx.ui.setStatus("cline-login", undefined); } catch { }
|
|
1496
1503
|
}
|
|
1497
1504
|
} else if (action === "accounts" || action === "list") {
|
|
1498
1505
|
const pool = loadPool();
|
package/src/proxy.ts
CHANGED
|
@@ -42,7 +42,9 @@ import {
|
|
|
42
42
|
} from "./opencode-fingerprint.ts";
|
|
43
43
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
44
44
|
import { getModelUpstream, isClineModel, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
|
|
45
|
+
import { refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
45
46
|
import { rollChat } from "./cline-accounts.ts";
|
|
47
|
+
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
46
48
|
import {
|
|
47
49
|
chatResponsesJsonFromChatCompletion,
|
|
48
50
|
chatResponsesSseFromChatCompletion,
|
|
@@ -133,6 +135,9 @@ const CLINE_MODEL_HINT =
|
|
|
133
135
|
* bodies — passes through untouched.
|
|
134
136
|
*/
|
|
135
137
|
export function mapClineError(status: number, data: string): string {
|
|
138
|
+
// Synthetic pool-exhausted bodies already tell the user exactly what to do
|
|
139
|
+
// (add a login / wait out cooldowns) — a rate-limit hint would mislead.
|
|
140
|
+
if (data.includes("cline_pool_exhausted")) return data;
|
|
136
141
|
const hint = status === 429
|
|
137
142
|
? CLINE_RATE_LIMIT_HINT
|
|
138
143
|
: status === 403
|
|
@@ -151,6 +156,15 @@ export function mapClineError(status: number, data: string): string {
|
|
|
151
156
|
} catch { }
|
|
152
157
|
return data;
|
|
153
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Device-login refresher for the Cline pool: one refresh per stale slot per
|
|
161
|
+
* turn. A throw keeps the stale bearer (transient); only a well-formed fresh
|
|
162
|
+
* grant replaces it — the pool module owns that decision.
|
|
163
|
+
*/
|
|
164
|
+
async function clineRefreshImpl(refreshToken: string): Promise<{ token: string; refreshToken?: string; expiresAt?: number } | null> {
|
|
165
|
+
const creds = await refreshClineToken(undefined, refreshToken);
|
|
166
|
+
return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
|
|
167
|
+
}
|
|
154
168
|
|
|
155
169
|
/**
|
|
156
170
|
* Serve a Cline-model request direct (never relay pool, no opencode
|
|
@@ -181,7 +195,7 @@ async function handleClineRequest(opts: {
|
|
|
181
195
|
chatBody.stream = true;
|
|
182
196
|
let upstreamRes: Response;
|
|
183
197
|
try {
|
|
184
|
-
const result = await rollChat({ body: JSON.stringify(chatBody), chatUrl: CLINE_CHAT_URL });
|
|
198
|
+
const result = await rollChat({ body: JSON.stringify(chatBody), chatUrl: CLINE_CHAT_URL, refreshImpl: clineRefreshImpl, fetchImpl: fetchWithSystemCA });
|
|
185
199
|
upstreamRes = result.res;
|
|
186
200
|
if (typeof result.slot === "string" && result.slot.length > 0) {
|
|
187
201
|
log("debug", `cline served by slot ${result.slot}`, { model }, reqId);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilient fetch for Cline endpoints on MITM boxes.
|
|
3
|
+
*
|
|
4
|
+
* Node's global fetch trusts only its bundled CA list, so antivirus TLS
|
|
5
|
+
* interception (Kaspersky on Windows, corporate proxies anywhere) fails with
|
|
6
|
+
* `self-signed certificate in certificate chain` while browsers and curl keep
|
|
7
|
+
* working off the OS store. fetchWithSystemCA tries the global fetch first
|
|
8
|
+
* and, only on chain/issuer cert errors, retries the same request through
|
|
9
|
+
* node:https with the OS trust bundle appended. No verification is ever
|
|
10
|
+
* disabled; the fallback just trusts the same roots the OS trusts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { request as httpRequest } from "node:http";
|
|
14
|
+
import { request as httpsRequest } from "node:https";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
const CHAIN_ERROR_CODES: Record<string, true> = {
|
|
21
|
+
"SELF_SIGNED_CERT_IN_CHAIN": true,
|
|
22
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE": true,
|
|
23
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT": true,
|
|
24
|
+
"UNABLE_TO_GET_ISSUER_CERT": true,
|
|
25
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY": true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function errorCode(value: unknown): string | undefined {
|
|
29
|
+
if (value && typeof value === "object" && "code" in value) {
|
|
30
|
+
const code = value.code;
|
|
31
|
+
return typeof code === "string" ? code : undefined;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function errorCause(value: unknown): unknown {
|
|
37
|
+
if (value && typeof value === "object" && "cause" in value) return value.cause;
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when the failure is an OS-trust gap, not a real endpoint problem. */
|
|
42
|
+
export function isCertError(e: unknown): boolean {
|
|
43
|
+
let cur: unknown = e;
|
|
44
|
+
for (let i = 0; i < 5 && cur && typeof cur === "object"; i++) {
|
|
45
|
+
const code = errorCode(cur);
|
|
46
|
+
if (code !== undefined && CHAIN_ERROR_CODES[code]) return true;
|
|
47
|
+
cur = errorCause(cur);
|
|
48
|
+
}
|
|
49
|
+
const msg = e instanceof Error ? e.message : String(e ?? "");
|
|
50
|
+
return /self[-\s]?signed certificate|unable to verify/i.test(msg);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BUNDLE_CACHE = path.join(os.tmpdir(), "pi-freeflow-system-ca.pem");
|
|
54
|
+
const BUNDLE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
function readPemFile(p: string): string[] {
|
|
57
|
+
try {
|
|
58
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
59
|
+
const blocks = raw.match(/-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----/g);
|
|
60
|
+
return blocks ?? [];
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function nodeExtraCa(): string[] {
|
|
67
|
+
const p = (process.env.NODE_EXTRA_CA_CERTS || "").trim();
|
|
68
|
+
return p ? readPemFile(p) : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function linuxBundle(): string[] {
|
|
72
|
+
const candidates = [
|
|
73
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
74
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
75
|
+
"/etc/ssl/cert.pem",
|
|
76
|
+
];
|
|
77
|
+
for (const p of candidates) {
|
|
78
|
+
const blocks = readPemFile(p);
|
|
79
|
+
if (blocks.length > 0) return blocks;
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Cert: drive is unavailable in some hosts; X509Store works everywhere.
|
|
85
|
+
const EXPORT_PS1 = [
|
|
86
|
+
"$stores = @(",
|
|
87
|
+
" [System.Security.Cryptography.X509Certificates.X509Store]::new('Root', 'LocalMachine'),",
|
|
88
|
+
" [System.Security.Cryptography.X509Certificates.X509Store]::new('Root', 'CurrentUser')",
|
|
89
|
+
")",
|
|
90
|
+
"$pem = foreach ($s in $stores) {",
|
|
91
|
+
" $s.Open('ReadOnly')",
|
|
92
|
+
" foreach ($c in $s.Certificates) {",
|
|
93
|
+
" $b64 = [Convert]::ToBase64String($c.Export('Cert'))",
|
|
94
|
+
" '-----BEGIN CERTIFICATE-----'",
|
|
95
|
+
" for ($i = 0; $i -lt $b64.Length; $i += 64) { $b64.Substring($i, [Math]::Min(64, $b64.Length - $i)) }",
|
|
96
|
+
" '-----END CERTIFICATE-----'",
|
|
97
|
+
" }",
|
|
98
|
+
" $s.Close()",
|
|
99
|
+
"}",
|
|
100
|
+
"$pem | Out-File -FilePath $args[0] -Encoding ascii",
|
|
101
|
+
].join("\r\n");
|
|
102
|
+
|
|
103
|
+
function windowsBundle(): string[] {
|
|
104
|
+
try {
|
|
105
|
+
const stat = fs.statSync(BUNDLE_CACHE);
|
|
106
|
+
if (Date.now() - stat.mtimeMs < BUNDLE_TTL_MS) {
|
|
107
|
+
const cached = readPemFile(BUNDLE_CACHE);
|
|
108
|
+
if (cached.length > 0) return cached;
|
|
109
|
+
}
|
|
110
|
+
} catch { }
|
|
111
|
+
try {
|
|
112
|
+
const script = `${BUNDLE_CACHE}.ps1`;
|
|
113
|
+
fs.writeFileSync(script, EXPORT_PS1, "utf8");
|
|
114
|
+
execFileSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, BUNDLE_CACHE], {
|
|
115
|
+
timeout: 30_000,
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
});
|
|
118
|
+
return readPemFile(BUNDLE_CACHE);
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let systemCaCache: string[] | null = null;
|
|
125
|
+
|
|
126
|
+
/** OS trust bundle: platform store plus NODE_EXTRA_CA_CERTS when set. */
|
|
127
|
+
export function systemCaBundle(): string[] {
|
|
128
|
+
if (systemCaCache) return systemCaCache;
|
|
129
|
+
const extra = nodeExtraCa();
|
|
130
|
+
const platform = process.platform === "win32" ? windowsBundle() : linuxBundle();
|
|
131
|
+
const seen = new Set<string>();
|
|
132
|
+
systemCaCache = [...extra, ...platform].filter((b) => {
|
|
133
|
+
if (seen.has(b)) return false;
|
|
134
|
+
seen.add(b);
|
|
135
|
+
return true;
|
|
136
|
+
});
|
|
137
|
+
return systemCaCache;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Test-only: drop the cached bundle so tests re-read the platform store. */
|
|
141
|
+
export function _resetSystemCaCacheForTest(): void {
|
|
142
|
+
systemCaCache = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toNodeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
146
|
+
const out: Record<string, string> = {};
|
|
147
|
+
if (!headers) return out;
|
|
148
|
+
if (headers instanceof Headers) {
|
|
149
|
+
headers.forEach((v, k) => { out[k] = v; });
|
|
150
|
+
} else if (Array.isArray(headers)) {
|
|
151
|
+
for (const [k, v] of headers) out[k] = v;
|
|
152
|
+
} else {
|
|
153
|
+
for (const [k, v] of Object.entries(headers)) out[k] = v;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function toNodeBody(body: BodyInit | undefined): string | undefined {
|
|
159
|
+
if (body === undefined || body === null) return undefined;
|
|
160
|
+
if (typeof body === "string") return body;
|
|
161
|
+
if (body instanceof URLSearchParams) return body.toString();
|
|
162
|
+
if (body instanceof ArrayBuffer) return Buffer.from(body).toString("utf8");
|
|
163
|
+
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString("utf8");
|
|
164
|
+
throw new Error("fetchWithSystemCA fallback supports string/form bodies only");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Same request through node:https with the OS trust bundle. Only used after
|
|
169
|
+
* the global fetch already failed with a cert error, so behavior on healthy
|
|
170
|
+
* machines is byte-identical to today.
|
|
171
|
+
*/
|
|
172
|
+
export function requestViaNodeHttp(input: string, init?: RequestInit): Promise<Response> {
|
|
173
|
+
const { promise, resolve, reject } = Promise.withResolvers<Response>();
|
|
174
|
+
const url = new URL(input);
|
|
175
|
+
const sender = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
176
|
+
let body: string | undefined;
|
|
177
|
+
try {
|
|
178
|
+
body = toNodeBody(init?.body as BodyInit | undefined);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
reject(e);
|
|
181
|
+
return promise;
|
|
182
|
+
}
|
|
183
|
+
const headers = toNodeHeaders(init?.headers as HeadersInit | undefined);
|
|
184
|
+
if (body !== undefined && !headers["content-length"] && !headers["Content-Length"]) {
|
|
185
|
+
headers["content-length"] = String(Buffer.byteLength(body));
|
|
186
|
+
}
|
|
187
|
+
// undici's AbortSignal vs node:http's: structurally identical, and the
|
|
188
|
+
// runtime accepts it — named const carries the one-line reason.
|
|
189
|
+
const nodeSignal = init?.signal as never;
|
|
190
|
+
const bundle = systemCaBundle();
|
|
191
|
+
const req = sender(url, {
|
|
192
|
+
method: init?.method ?? "GET",
|
|
193
|
+
headers,
|
|
194
|
+
ca: bundle.length > 0 ? bundle : undefined,
|
|
195
|
+
signal: nodeSignal,
|
|
196
|
+
}, (res) => {
|
|
197
|
+
const chunks: Buffer[] = [];
|
|
198
|
+
res.on("data", (c: Buffer) => chunks.push(c));
|
|
199
|
+
res.on("end", () => {
|
|
200
|
+
const outHeaders = new Headers();
|
|
201
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
202
|
+
if (Array.isArray(v)) { for (const item of v) outHeaders.append(k, item); }
|
|
203
|
+
else if (v !== undefined) outHeaders.set(k, v);
|
|
204
|
+
}
|
|
205
|
+
resolve(new Response(Buffer.concat(chunks), { status: res.statusCode ?? 500, headers: outHeaders }));
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
req.on("error", reject);
|
|
209
|
+
if (init?.signal && typeof (init.signal as AbortSignal).addEventListener === "function") {
|
|
210
|
+
const s = init.signal as AbortSignal;
|
|
211
|
+
if (s.aborted) req.destroy(new Error("fetchWithSystemCA fallback aborted"));
|
|
212
|
+
else s.addEventListener("abort", () => req.destroy(new Error("fetchWithSystemCA fallback aborted")), { once: true });
|
|
213
|
+
}
|
|
214
|
+
if (body !== undefined) req.write(body);
|
|
215
|
+
req.end();
|
|
216
|
+
return promise;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Drop-in fetch replacement for Cline endpoints: global fetch first, OS-trust
|
|
221
|
+
* retry only on chain/issuer cert failures. Every other error passes through
|
|
222
|
+
* untouched, so healthy machines see zero behavior change.
|
|
223
|
+
*/
|
|
224
|
+
export async function fetchWithSystemCA(input: string, init?: RequestInit): Promise<Response> {
|
|
225
|
+
try {
|
|
226
|
+
return await fetch(input, init);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (!isCertError(e)) throw e;
|
|
229
|
+
try {
|
|
230
|
+
return await requestViaNodeHttp(input, init);
|
|
231
|
+
} catch {
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|