mirror of
https://github.com/cline/cline.git
synced 2026-09-21 05:10:09 +08:00
* fix: auto-discover OS trust anchors in the CLI wrapper
The 3.x CLI ships as a Bun-compiled binary. Bun does not read the OS
trust store unless NODE_USE_SYSTEM_CA is set, and even with the flag its
Windows enumeration covers only the `Root` store, not `CA`/Intermediate
(verified empirically across the CLINE-2353 Windows repro rounds). So a
corporate MITM root is not trusted out of the box and inference fails
with "unable to get local issuer certificate". The pre-3.0 (Node) CLI
had no app-level CA handling either; users only succeeded by setting
NODE_EXTRA_CA_CERTS manually. The reporter's ask: have it just work
without the env var.
This follows the CLINE-2353 SDK fetch-threading change. That made the
inference client honor a host-provided proxy/CA-aware fetch, but on the
CLI Bun's global fetch is already proxy-aware and a fetch function
cannot cross the hub-daemon process boundary, so the CLI's missing piece
is trust material, not the fetch. Env vars do inherit across spawns.
The npm `bin/cline` wrapper runs on Node (not Bun), so it can read the
full OS store via tls.getCACertificates("system") (Node >= 22, no flag
required) — including the Windows `CA` store Bun skips — and hand the
certs to the Bun child via NODE_EXTRA_CA_CERTS, which both runtimes
honor. This mirrors the JetBrains plugin's configureCertificates(),
replacing "harvest from the IDE trust store" with "harvest from the OS".
The merge logic lives in a dependency-free, injectable-module CommonJS
helper (bin/ca-certs.cjs) so it is unit-testable and ships verbatim in
the generated wrapper package (publish copies bin/ wholesale). A
user-set NODE_EXTRA_CA_CERTS is merged ahead of the system certs; a
self-reference to the managed bundle is detected to avoid re-appending
every launch; when no system certs are available the user's setting is
left untouched. Writes are atomic (temp + rename) and owner-only.
Adds ca-certs.test.ts (13 cases) covering harvest filtering, user-bundle
PEM/DER/missing handling, newline-separated merge, managed-path
self-reference, and the no-system-certs no-op.
* fix: harden CLI auto-CA harvesting (review follow-ups)
Follow-ups from the CLINE-2353 review of the CLI auto-CA wrapper.
- H1: a legacy NODE_EXTRA_CA_CERTS set to an OS-path-delimited list
("a.pem;b.pem", the CLINE-2324 footgun Node never split) was stat'd as
one file, failed, and silently dropped the user's certs. readUserCerts
now tries the whole value as one file first, then splits on the OS path
delimiter and reads each existing PEM, merging them all.
- M1: skip the rewrite when the managed bundle is already current, instead
of re-harvesting and rewriting on every launch (mirrors the JetBrains
hash-and-skip). configureNodeExtraCaCerts now returns a typed outcome
(unchanged | written | write-failed-reused | write-failed |
no-system-certs) with cert counts.
- M2: tolerate rename-over-existing failures (Windows EPERM/EBUSY when a
concurrent child holds the file open) by removing the target and
retrying, then falling back to a previously-written bundle. Combined
with M1 the steady state no longer rewrites at all.
- M3: the wrapper prints a one-line diagnostic under CLINE_DEBUG=1
(cert counts + managed path, or a warning when no OS certs were found
or the write failed). Runs once per startup.
- M4: corrected the now-stale CLI guidance in shared/net.ts (the CLI no
longer requires users to set NODE_EXTRA_CA_CERTS manually).
- L1: documented the auto-trust behavior, the managed ~/.cline bundle,
the merge-not-replace override semantics, and CLINE_DEBUG in the CLI
README.
- L4: trimmed the helper's file header; DI is still injectable for tests.
ca-certs.test.ts grows to 20 cases: adds readUserCerts (single path,
delimited split, missing-segment skip, managed-bundle exclusion, empty),
the unchanged/second-run skip, and a write-failure outcome via an
fs that throws.
* fix: address CLI auto-CA review issues (temp cleanup, cert count, test)
- writeBundle now hoists the temp path so the outer catch removes a
partially-written temp file (e.g. ENOSPC / ACL failure mid-write).
Previously only the inner double-rename failure cleaned up, so repeated
disk-full/permission failures left a stale .tmp per launch in ~/.cline.
The inner Windows-rename fallback now lets its failure fall through to
the single cleanup path instead of duplicating rmSync.
- userCertCount now counts individual certificates (via countCerts, which
tallies BEGIN CERTIFICATE markers) rather than the number of PEM files,
so a user bundle with N intermediates reports N and is comparable to
systemCertCount. countCerts is exported for testing.
- Adds tests for the write-failed-reused branch (stale bundle reused when
the rewrite fails but the old file is still readable) and for countCerts
(one file holding two certs reports 2).
* fix: warn when the CLI wrapper's Node cannot read the OS trust store
tls.getCACertificates("system") needs Node >= 22.15; on older hosts the
auto-CA harvest silently did nothing, which is indistinguishable from a
broken corporate proxy. Distinguish the missing-API case as its own
outcome (api-unavailable) and print a non-debug warning when the user
has no NODE_EXTRA_CA_CERTS of their own. Found in round-5 Windows
validation (wrapper under Node 22.1.0).
* fix: copy only certificate blocks into the managed CA bundle
Combined cert+key PEMs (nginx/haproxy-style server.pem) passed the
old contains-a-certificate check, so a user NODE_EXTRA_CA_CERTS
pointing at one duplicated the private key into the managed bundle,
where it outlives rotation of the original and gets no permission
tightening on Windows. Extract complete BEGIN/END CERTIFICATE blocks
instead; files with none are treated as not PEM, and certificates-only
files pass through byte-identical so the unchanged-skip stays stable.
Raised in PR review.
* fix: show the old-Node trust warning once per Node version
The api-unavailable warning printed on every CLI invocation, turning
an actionable nudge into stderr noise for users pinned to an old Node.
Stamp the warning per Node version under the cline dir: it shows once,
re-arms when the Node version changes, and a bookkeeping failure never
suppresses the diagnostic. Raised in PR review.
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { delimiter, join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
// The helper ships as CommonJS in the published wrapper package, so it is
|
|
// loaded via require rather than an ESM import.
|
|
const caCerts = require("../../bin/ca-certs.cjs") as {
|
|
harvestSystemCerts: (tls?: unknown) => string[];
|
|
readUserBundle: (fs: unknown, p: string | null) => string | null;
|
|
readUserCerts: (
|
|
fs: unknown,
|
|
path: unknown,
|
|
value: string | null,
|
|
managedPath: string | null,
|
|
) => string[];
|
|
buildBundle: (input: {
|
|
systemCerts: string[];
|
|
userPems?: string[];
|
|
}) => string;
|
|
countCerts: (pems: string[]) => number;
|
|
configureNodeExtraCaCerts: (
|
|
env: Record<string, string>,
|
|
deps?: { tls?: unknown; fs?: unknown },
|
|
) => {
|
|
action: string;
|
|
path: string | null;
|
|
systemCertCount: number;
|
|
userCertCount: number;
|
|
};
|
|
shouldWarnApiUnavailable: (
|
|
env: Record<string, string>,
|
|
deps?: { fs?: unknown; nodeVersion?: string },
|
|
) => boolean;
|
|
};
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const certSystem =
|
|
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
|
|
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
|
|
|
|
function fakeTls(certs: unknown) {
|
|
return { getCACertificates: () => certs };
|
|
}
|
|
|
|
describe("ca-certs", () => {
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("harvestSystemCerts", () => {
|
|
it("returns only PEM strings from the system store", () => {
|
|
expect(
|
|
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
|
|
).toEqual([certSystem]);
|
|
});
|
|
|
|
it("returns [] when getCACertificates is unavailable", () => {
|
|
expect(caCerts.harvestSystemCerts({})).toEqual([]);
|
|
});
|
|
|
|
it("returns [] when getCACertificates throws", () => {
|
|
expect(
|
|
caCerts.harvestSystemCerts({
|
|
getCACertificates: () => {
|
|
throw new Error("nope");
|
|
},
|
|
}),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("readUserBundle", () => {
|
|
it("returns PEM contents for a PEM file", () => {
|
|
const p = join(dir, "user.pem");
|
|
writeFileSync(p, certUser);
|
|
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
|
|
});
|
|
|
|
it("returns null for a non-PEM (DER) file", () => {
|
|
const p = join(dir, "user.der");
|
|
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
|
|
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
|
});
|
|
|
|
it("returns null for a missing file and for null path", () => {
|
|
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
|
|
expect(caCerts.readUserBundle(fs, null)).toBeNull();
|
|
});
|
|
|
|
it("strips non-certificate sections such as private keys", () => {
|
|
// Combined cert+key files (nginx/haproxy style) are common; the key
|
|
// must never reach the managed bundle.
|
|
const p = join(dir, "combined.pem");
|
|
writeFileSync(
|
|
p,
|
|
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
|
|
);
|
|
const out = caCerts.readUserBundle(fs, p);
|
|
expect(out).toContain("USER");
|
|
expect(out).not.toContain("PRIVATE KEY");
|
|
expect(out).not.toContain("SECRET");
|
|
});
|
|
|
|
it("keeps certificates-only files verbatim", () => {
|
|
// Byte-identical passthrough keeps the unchanged-skip hash stable.
|
|
const p = join(dir, "clean.pem");
|
|
writeFileSync(p, `${certUser}\n${certSystem}`);
|
|
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
|
|
});
|
|
|
|
it("returns null for a BEGIN marker without a complete block", () => {
|
|
const p = join(dir, "truncated.pem");
|
|
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
|
|
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("readUserCerts", () => {
|
|
it("reads a single PEM file path", () => {
|
|
const p = join(dir, "corp.pem");
|
|
writeFileSync(p, certUser);
|
|
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
|
|
});
|
|
|
|
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
|
|
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
|
|
const a = join(dir, "a.pem");
|
|
const b = join(dir, "b.pem");
|
|
writeFileSync(a, certUser);
|
|
writeFileSync(b, certSystem);
|
|
expect(
|
|
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
|
|
).toEqual([certUser, certSystem]);
|
|
});
|
|
|
|
it("skips missing segments in a delimited value", () => {
|
|
const a = join(dir, "a.pem");
|
|
writeFileSync(a, certUser);
|
|
const value = [a, join(dir, "missing.pem")].join(delimiter);
|
|
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
|
|
});
|
|
|
|
it("excludes the managed bundle from user certs", () => {
|
|
const managed = join(dir, "cli-node-extra-ca-certs.pem");
|
|
writeFileSync(managed, certUser);
|
|
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
|
|
});
|
|
|
|
it("returns [] for empty value", () => {
|
|
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("buildBundle", () => {
|
|
it("merges user PEMs before system certs", () => {
|
|
expect(
|
|
caCerts.buildBundle({
|
|
systemCerts: [certSystem],
|
|
userPems: [certUser],
|
|
}),
|
|
).toBe(`${certUser}\n${certSystem}`);
|
|
});
|
|
|
|
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
|
|
// certUser has no trailing newline, so this proves the boundary fix.
|
|
const merged = caCerts.buildBundle({
|
|
systemCerts: [certSystem],
|
|
userPems: [certUser],
|
|
});
|
|
expect(merged).not.toContain(
|
|
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
|
|
);
|
|
});
|
|
|
|
it("handles no user PEMs", () => {
|
|
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
|
|
certSystem,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("configureNodeExtraCaCerts", () => {
|
|
it("writes a managed bundle and points the env var at it", () => {
|
|
const env: Record<string, string> = { CLINE_DIR: dir };
|
|
const out = caCerts.configureNodeExtraCaCerts(env, {
|
|
tls: fakeTls([certSystem]),
|
|
});
|
|
expect(out.action).toBe("written");
|
|
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
|
|
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
|
|
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
|
|
});
|
|
|
|
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
|
|
const userPath = join(dir, "corp.pem");
|
|
writeFileSync(userPath, certUser);
|
|
const env: Record<string, string> = {
|
|
CLINE_DIR: dir,
|
|
NODE_EXTRA_CA_CERTS: userPath,
|
|
};
|
|
const out = caCerts.configureNodeExtraCaCerts(env, {
|
|
tls: fakeTls([certSystem]),
|
|
});
|
|
expect(out.userCertCount).toBe(1);
|
|
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
|
|
expect(written).toContain("USER");
|
|
expect(written).toContain("SYSTEM");
|
|
});
|
|
|
|
it("reports unchanged and skips rewrite on the second run", () => {
|
|
const env: Record<string, string> = { CLINE_DIR: dir };
|
|
expect(
|
|
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
|
.action,
|
|
).toBe("written");
|
|
expect(
|
|
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
|
.action,
|
|
).toBe("unchanged");
|
|
});
|
|
|
|
it("does not re-append when the user already points at the managed bundle", () => {
|
|
const env: Record<string, string> = { CLINE_DIR: dir };
|
|
const first = caCerts.configureNodeExtraCaCerts(env, {
|
|
tls: fakeTls([certSystem]),
|
|
}).path as string;
|
|
const env2: Record<string, string> = {
|
|
CLINE_DIR: dir,
|
|
NODE_EXTRA_CA_CERTS: first,
|
|
};
|
|
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
|
|
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
|
|
expect(written.match(/SYSTEM/g)?.length).toBe(1);
|
|
});
|
|
|
|
it("no-ops when no system certs are available", () => {
|
|
const env: Record<string, string> = {
|
|
CLINE_DIR: dir,
|
|
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
|
};
|
|
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
|
|
expect(out.action).toBe("no-system-certs");
|
|
expect(out.path).toBeNull();
|
|
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
|
});
|
|
|
|
it("reports api-unavailable on Nodes without getCACertificates", () => {
|
|
const env: Record<string, string> = {
|
|
CLINE_DIR: dir,
|
|
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
|
};
|
|
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
|
|
expect(out.action).toBe("api-unavailable");
|
|
expect(out.path).toBeNull();
|
|
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
|
});
|
|
|
|
it("reports write-failed when the bundle cannot be written", () => {
|
|
const realFs = require("node:fs");
|
|
const failingFs = {
|
|
...realFs,
|
|
mkdirSync: () => {
|
|
throw new Error("EACCES");
|
|
},
|
|
writeFileSync: () => {
|
|
throw new Error("EACCES");
|
|
},
|
|
};
|
|
const env: Record<string, string> = { CLINE_DIR: dir };
|
|
const out = caCerts.configureNodeExtraCaCerts(env, {
|
|
tls: fakeTls([certSystem]),
|
|
fs: failingFs,
|
|
});
|
|
expect(out.action).toBe("write-failed");
|
|
expect(out.path).toBeNull();
|
|
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
|
|
});
|
|
|
|
it("reuses a stale bundle when the rewrite fails", () => {
|
|
// First run writes the bundle normally.
|
|
const env: Record<string, string> = { CLINE_DIR: dir };
|
|
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
|
|
tls: fakeTls([certSystem]),
|
|
}).path as string;
|
|
|
|
// Second run: writes fail, but the stale bundle is still readable.
|
|
const realFs = require("node:fs");
|
|
const failingFs = {
|
|
...realFs,
|
|
mkdirSync: () => {
|
|
throw new Error("EACCES");
|
|
},
|
|
writeFileSync: () => {
|
|
throw new Error("EACCES");
|
|
},
|
|
};
|
|
const env2: Record<string, string> = { CLINE_DIR: dir };
|
|
const out = caCerts.configureNodeExtraCaCerts(env2, {
|
|
// A different system cert forces a rewrite attempt (not "unchanged").
|
|
tls: fakeTls([certUser]),
|
|
fs: failingFs,
|
|
});
|
|
|
|
expect(out.action).toBe("write-failed-reused");
|
|
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
|
|
});
|
|
});
|
|
|
|
describe("countCerts", () => {
|
|
it("counts individual certificates, not files", () => {
|
|
// One file holding two certs must report 2, not 1.
|
|
const twoInOne = `${certUser}\n${certSystem}`;
|
|
expect(caCerts.countCerts([twoInOne])).toBe(2);
|
|
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
|
|
expect(caCerts.countCerts([])).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("shouldWarnApiUnavailable", () => {
|
|
it("warns once per Node version, then stays quiet", () => {
|
|
const env = { CLINE_DIR: dir };
|
|
const deps = { nodeVersion: "22.1.0" };
|
|
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
|
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
|
|
});
|
|
|
|
it("re-arms when the Node version changes", () => {
|
|
const env = { CLINE_DIR: dir };
|
|
expect(
|
|
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
|
).toBe(true);
|
|
expect(
|
|
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
|
|
).toBe(true);
|
|
expect(
|
|
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("still warns when the stamp cannot be written", () => {
|
|
const realFs = require("node:fs");
|
|
const failingFs = {
|
|
...realFs,
|
|
mkdirSync: () => {
|
|
throw new Error("EACCES");
|
|
},
|
|
writeFileSync: () => {
|
|
throw new Error("EACCES");
|
|
},
|
|
};
|
|
const env = { CLINE_DIR: dir };
|
|
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
|
|
// Bookkeeping failure must never suppress the diagnostic.
|
|
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
|
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
|
});
|
|
});
|
|
});
|