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 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.1",
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",
@@ -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 start with workos:");
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?: typeof fetch;
373
+ fetchImpl?: FetchImpl;
372
374
  reqId?: string;
373
375
  /**
374
376
  * Device-login refresher. When present, a stale slot refreshes once before
@@ -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
- return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX) ? token : `${WORKOS_TOKEN_PREFIX}${token}`;
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 = typeof fetch;
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 = fetch,
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 ?? fetch;
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 = fetch,
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 = fetch,
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
- ctx.ui.notify(`Cline login [${slot}]: open ${link} and enter code ${started.userCode} (expires in ~${minutes} min)`, "info");
1461
- ctx.ui.notify("Waiting for approval in the browser — approve or cancel there; this finishes on its own.", "info");
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("Still waiting for Cline approval — approve or cancel in the browser.", "info");
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("Still waiting for Cline approval — approve or cancel in the browser.", "info");
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
+ }