diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3b5e1c..66456eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,14 @@ All notable changes to pxpipe are documented here. This project adheres to [Semantic Versioning](https://semver.org/) (pre-1.0: minor = features / behavioral changes, patch = fixes). -## Unreleased +## 0.13.2 — 2026-08-18 ### Added +- `createProviderRouter`: explicit `/providers//` + multiplexing of several proxy configs behind one listener, exported from + core. Provider ids are taken from the URL path only, never from headers or + the body. No in-tree callers yet; groundwork for the Codex integration + (#223, #224). - **Rendered-page cache, now documented.** It landed in #158 and shipped in 0.13.0 with no changelog entry, so this backfills it: identical render inputs return the identical pages instead of being re-rasterized, bounded by total @@ -38,11 +43,28 @@ behavioral changes, patch = fixes). ceiling. ### Fixed +- Gemini history collapse is capped at 32 images (was 72) to prevent + vision-side TTFT stalls on long sessions. +- Claude Code's `cc_automode_session_rules` / `cc_automode_permissions` / + `severity` / `category` blocks route into the dynamic tail instead of baking + into the static slab image. On newer Claude Code builds they change between + turns, which re-rendered every slab page each request and forced a full + cache write per turn — the likely mechanism behind the repeated-429 loop in + #234 (#236). +- `truncateForBudget` no longer over-truncates reflowed tool results. The + per-segment row charge overstated visual rows ~6× on ↵-joined text (the + renderer packs many segments per row), so a result that fit ~280k chars kept + ~44k and wasted most of its image budget (#226). - The render cache byte counter no longer drifts upward when two concurrent requests render the same content. Both miss (the lookup precedes the await), both store, and the second store previously added its bytes without crediting back the entry it replaced — so the counter climbed until it evicted a cache that was nowhere near its budget. +- Typecheck survives `@cloudflare/workers-types` 5.20260809.1, which added a + global `declare const process: any` that clobbered `@types/node` — + `process.exit()` stopped narrowing and `process.env` went untyped. The + package left tsconfig `types`; `worker.ts` imports `ExecutionContext` as a + module instead (#231). ## 0.13.1 — 2026-08-11 diff --git a/package.json b/package.json index ee596733..9ec530c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pxpipe-proxy", - "version": "0.13.1", + "version": "0.13.2", "description": "Token-saving proxy for Claude Code: renders bulky context (system prompt, tool docs, old history) as dense PNGs to cut input tokens. Runs on Node and Cloudflare Workers.", "type": "module", "bin": { diff --git a/src/warp/ca.ts b/src/warp/ca.ts index e5c1eae7..618dea1b 100644 --- a/src/warp/ca.ts +++ b/src/warp/ca.ts @@ -22,7 +22,7 @@ import { } from 'node:crypto'; import { createSecureContext, type SecureContext } from 'node:tls'; import { isIP } from 'node:net'; -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { @@ -160,6 +160,31 @@ function privateKeyPem(key: KeyObject): string { return key.export({ type: 'pkcs8', format: 'pem' }).toString(); } +/** + * Where the OS keeps its public root bundle. `SSL_CERT_FILE`, + * `CURL_CA_BUNDLE` and `REQUESTS_CA_BUNDLE` REPLACE the trust store rather + * than extend it, so a file holding only our CA would make every non-pxpipe + * HTTPS call in the child fail verification (gcloud, gws, pip: #245). The + * first path that exists wins; none found means the bundle is CA-only and the + * caller is told so. + */ +const SYSTEM_ROOT_BUNDLES = [ + '/etc/ssl/cert.pem', // macOS, Alpine, FreeBSD + '/etc/ssl/certs/ca-certificates.crt', // Debian, Ubuntu, Arch + '/etc/pki/tls/certs/ca-bundle.crt', // RHEL, Fedora, CentOS + '/etc/ssl/ca-bundle.pem', // openSUSE +]; + +export function findSystemRootBundle(candidates: readonly string[] = SYSTEM_ROOT_BUNDLES): string | null { + const fromEnv = process.env.SSL_CERT_FILE; + // An operator-supplied bundle is the trust store the child would have had + // without us; prefer it over the platform guess. Skip it if it is already + // one of our own files, or the bundle would nest on every warp restart. + if (fromEnv && existsSync(fromEnv) && !/warp-ca(-bundle)?\.pem$/.test(fromEnv)) return fromEnv; + for (const p of candidates) if (existsSync(p)) return p; + return null; +} + export class CertificateAuthority { private readonly leaves = new Map(); @@ -169,8 +194,33 @@ export class CertificateAuthority { private readonly leafKey: KeyObject, private readonly leafKeyPem: string, readonly certPath: string, + /** Our CA followed by the system roots; see {@link writeBundle}. */ + readonly bundlePath: string, + /** Null when no system root bundle was found and `bundlePath` is CA-only. */ + readonly systemRootsPath: string | null, ) {} + /** + * Write `warp-ca-bundle.pem` = our CA + the system roots, for the env vars + * that replace the trust store. Regenerated on every load: the system bundle + * rotates underneath us and the cost is one file write. + */ + private static writeBundle(dir: string, certPem: string): { bundlePath: string; systemRootsPath: string | null } { + const bundlePath = join(dir, 'warp-ca-bundle.pem'); + const systemRootsPath = findSystemRootBundle(); + let roots = ''; + if (systemRootsPath) { + try { + roots = readFileSync(systemRootsPath, 'utf8'); + } catch { + /* unreadable: fall back to CA-only, reported via systemRootsPath */ + } + } + const sep = roots && !roots.endsWith('\n') ? '\n' : ''; + writeFileSync(bundlePath, certPem + roots + sep, { mode: 0o644 }); + return { bundlePath, systemRootsPath: roots ? systemRootsPath : null }; + } + /** * Load the persisted CA, or create and persist one. A CA that fails to load * or has expired is replaced rather than reported: it is entirely derived @@ -189,12 +239,15 @@ export class CertificateAuthority { if (new Date(parsed.validTo).getTime() > Date.now()) { const caKey = createPrivateKey(keyPem); const leaf = newKeyPair(); + const bundle = CertificateAuthority.writeBundle(dir, certPem); return new CertificateAuthority( certPem, caKey, leaf.publicKey, privateKeyPem(leaf.privateKey), certPath, + bundle.bundlePath, + bundle.systemRootsPath, ); } } catch { @@ -224,12 +277,15 @@ export class CertificateAuthority { chmodSync(keyPath, 0o600); const leaf = newKeyPair(); + const bundle = CertificateAuthority.writeBundle(dir, certPem); return new CertificateAuthority( certPem, ca.privateKey, leaf.publicKey, privateKeyPem(leaf.privateKey), certPath, + bundle.bundlePath, + bundle.systemRootsPath, ); } diff --git a/src/warp/index.ts b/src/warp/index.ts index b744cd65..0e7ec419 100644 --- a/src/warp/index.ts +++ b/src/warp/index.ts @@ -174,10 +174,15 @@ export function createWarpRuntime(options: WarpRuntimeOptions): WarpRuntime { // Node script, so the Node-only variable alone would silently fail to make // it trust us. Set every convention — they are inert for runtimes that // ignore them, and one of them is the one that counts. + // NODE_EXTRA_CA_CERTS appends to Node's built-in roots, so the CA-only + // file is right there. The other three REPLACE the trust store: handing + // them a 1-cert file strips the public roots from every other HTTPS client + // in the session (gcloud, gws, pip all fail verification — #245). They get + // the bundle: our CA followed by the system roots. env.NODE_EXTRA_CA_CERTS = ca.certPath; // Node, and Bun-compiled binaries - env.SSL_CERT_FILE = ca.certPath; // OpenSSL: curl, Rust, Go with cgo - env.CURL_CA_BUNDLE = ca.certPath; // libcurl - env.REQUESTS_CA_BUNDLE = ca.certPath; // Python requests / httpx + env.SSL_CERT_FILE = ca.bundlePath; // OpenSSL: curl, Rust, Go with cgo + env.CURL_CA_BUNDLE = ca.bundlePath; // libcurl + env.REQUESTS_CA_BUNDLE = ca.bundlePath; // Python requests / httpx const child = spawnResolved(command, env); // The child's proxy and CA point at this process. If warp dies for any @@ -263,6 +268,14 @@ export function createWarpRuntime(options: WarpRuntimeOptions): WarpRuntime { console.error(`[pxpipe] warp route → ${route.pattern} → ${routeDestination(route)}`); } console.error(`[pxpipe] warp CA → ${ca.certPath}`); + if (ca.systemRootsPath) { + console.error(`[pxpipe] warp CA bundle → ${ca.bundlePath} (+ system roots from ${ca.systemRootsPath})`); + } else { + console.error( + `[pxpipe] warp CA bundle → ${ca.bundlePath} (no system root bundle found; ` + + `non-pxpipe HTTPS in the child may fail verification — set SSL_CERT_FILE to your OS bundle before warp)`, + ); + } console.error(`[pxpipe] warp exec → ${command.join(' ')}`); proxy.on('error', (err) => { diff --git a/tests/warp-ca-bundle.test.ts b/tests/warp-ca-bundle.test.ts new file mode 100644 index 00000000..7bb79293 --- /dev/null +++ b/tests/warp-ca-bundle.test.ts @@ -0,0 +1,75 @@ +/** + * SSL_CERT_FILE / CURL_CA_BUNDLE / REQUESTS_CA_BUNDLE replace the trust store + * rather than extend it. A warp CA file holding only our root made every + * non-pxpipe HTTPS client in the child fail verification (#245). The bundle + * handed to those variables must carry the system roots after our CA. + * + * Run just this file: pnpm vitest run tests/warp-ca-bundle.test.ts + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CertificateAuthority, findSystemRootBundle } from '../src/warp/ca.js'; + +const CERT_RE = /-----BEGIN CERTIFICATE-----/g; +const count = (pem: string): number => (pem.match(CERT_RE) ?? []).length; + +describe('warp CA bundle (#245)', () => { + const dirs: string[] = []; + const savedEnv = process.env.SSL_CERT_FILE; + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + if (savedEnv === undefined) delete process.env.SSL_CERT_FILE; + else process.env.SSL_CERT_FILE = savedEnv; + }); + const tmp = (): string => { + const d = mkdtempSync(join(tmpdir(), 'pxpipe-warp-ca-')); + dirs.push(d); + return d; + }; + + it('keeps warp-ca.pem CA-only and writes a separate bundle', () => { + delete process.env.SSL_CERT_FILE; + const ca = CertificateAuthority.loadOrCreate(tmp()); + expect(count(readFileSync(ca.certPath, 'utf8'))).toBe(1); + expect(ca.bundlePath).not.toBe(ca.certPath); + const bundle = readFileSync(ca.bundlePath, 'utf8'); + // Our CA comes first so a client that stops at the first match still trusts us. + expect(bundle.startsWith(readFileSync(ca.certPath, 'utf8'))).toBe(true); + if (ca.systemRootsPath) { + expect(count(bundle)).toBeGreaterThan(1); + expect(count(bundle)).toBe(1 + count(readFileSync(ca.systemRootsPath, 'utf8'))); + } else { + expect(count(bundle)).toBe(1); + } + }); + + it('prefers an operator-supplied SSL_CERT_FILE over the platform guess', () => { + const d = tmp(); + const fake = join(d, 'corp-roots.pem'); + const ca0 = CertificateAuthority.loadOrCreate(tmp()); + const oneCert = readFileSync(ca0.certPath, 'utf8'); + writeFileSync(fake, oneCert + oneCert + oneCert); + process.env.SSL_CERT_FILE = fake; + expect(findSystemRootBundle()).toBe(fake); + const ca = CertificateAuthority.loadOrCreate(d); + expect(ca.systemRootsPath).toBe(fake); + expect(count(readFileSync(ca.bundlePath, 'utf8'))).toBe(4); + }); + + it('does not nest its own bundle when SSL_CERT_FILE already points at it (warp restart)', () => { + const d = tmp(); + delete process.env.SSL_CERT_FILE; + const first = CertificateAuthority.loadOrCreate(d); + const n = count(readFileSync(first.bundlePath, 'utf8')); + process.env.SSL_CERT_FILE = first.bundlePath; + const second = CertificateAuthority.loadOrCreate(d); + expect(count(readFileSync(second.bundlePath, 'utf8'))).toBe(n); + }); + + it('falls back to CA-only and reports it when no system bundle exists', () => { + delete process.env.SSL_CERT_FILE; + expect(findSystemRootBundle(['/nonexistent/a.pem', '/nonexistent/b.pem'])).toBeNull(); + }); +});