Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/<upstream-path>`
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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
58 changes: 57 additions & 1 deletion src/warp/ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, SecureContext>();

Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
);
}

Expand Down
19 changes: 16 additions & 3 deletions src/warp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
75 changes: 75 additions & 0 deletions tests/warp-ca-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading