diff --git a/README.md b/README.md index ca13381..4653b28 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,16 @@ normally — pxpipe compresses the *request* only, never the model's output. Recent turns stay text; the system prompt, tool docs, and older bulk history are imaged. +### `pxpipe warp` + +```bash +pxpipe warp -- claude # also: cursor-agent, codex, or a shell alias +``` + +Same thing without `ANTHROPIC_BASE_URL`, so `/remote-control`, claude.ai +connectors, and first-party gates keep working. Full instructions in the +dashboard. + ## Offline export (no proxy) You can render text, files, or diffs to PNG pages without running the proxy or diff --git a/src/core/pin.ts b/src/core/pin.ts index 240229d..5173c02 100644 --- a/src/core/pin.ts +++ b/src/core/pin.ts @@ -346,7 +346,7 @@ function filePathOf(block: string): string | undefined { } /** True when this message's only typed content is pin commands. */ -function isCommandOnlyTurn(m: Message): boolean { +function isCommandOnlyTurn(m: Message, live = false): boolean { if (m.role !== 'user') return false; const blocks: ContentBlock[] = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] @@ -358,9 +358,12 @@ function isCommandOnlyTurn(m: Message): boolean { const raw = (blk as TextBlock).text; if (typeof raw !== 'string') return false; // The CLAUDE.md envelope rides in the same block as the user's first typed - // line. Stripping reminders before the test would classify that turn as - // command-only and drop the project's instructions from every later request. - if (LEADING_REMINDER_RE.test(raw)) return false; + // line, so `pin` as the opening line of a Claude Code session arrives behind + // it. Answering that turn locally is safe: nothing is forwarded, and the + // client resends message 0 intact on the next request. Rewriting it is not — + // the outbound path would drop the whole block and take the project's + // instructions with it, so only the live test tolerates the envelope. + if (!live && LEADING_REMINDER_RE.test(raw)) return false; const text = stripReminders(raw); for (const line of text.split('\n')) { if (!line.trim()) continue; @@ -661,8 +664,26 @@ export function pinReplyText(pins: Pin[], verb: PinVerb = 'pin'): string { * forwarded normally with the command lines merely stripped. */ export function isPinOnlyRequest(messages: Message[] | undefined): boolean { - const last = (messages ?? [])[messages!.length - 1]; - return !!last && isCommandOnlyTurn(last); + const live = liveTurn(messages); + return !!live && isCommandOnlyTurn(live, true); +} + +/** + * The turn the request is actually asking about. Claude Code appends a + * system-role message (its agent-type catalogue) *after* the user's turn, so + * the literal last element is client metadata, not work — every cc pin command + * went upstream unanswered because of it. Only `system` is skipped: a trailing + * tool_result or assistant prefill is real work and must stay on the normal + * path, which is what keeps a mid-tool-loop turn from being hijacked. + */ +function liveTurn(messages: Message[] | undefined): Message | undefined { + const list = messages ?? []; + for (let i = list.length - 1; i >= 0; i--) { + const m = list[i]; + if (m && (m as { role?: string }).role === 'system') continue; + return m; + } + return undefined; } /** @@ -704,7 +725,7 @@ export function pinCommandResponse( } if (!Array.isArray(req.messages) || !isPinOnlyRequest(req.messages)) return undefined; return synthesizeReply( - pinReplyText(foldPins(req.messages), liveVerb(req.messages[req.messages.length - 1])), + pinReplyText(foldPins(req.messages), liveVerb(liveTurn(req.messages))), typeof req.model === 'string' ? req.model : 'pxpipe', req.stream === true, ); @@ -772,3 +793,210 @@ export function synthesizeReply( ev('message_stop', { type: 'message_stop' }), }; } + +/** Which OpenAI wire schema the reply has to imitate. */ +export type OpenAIPinWire = 'chat' | 'responses'; + +interface OpenAIItem { + type?: string; + role?: string; + content?: unknown; +} + +/** Text parts of one OpenAI message. `input_text`/`output_text` are the + * Responses spellings of Chat Completions' `text`; images and file parts carry + * no pin commands, so they drop out. */ +function openAITextBlocks(content: unknown): ContentBlock[] { + if (typeof content === 'string') return [{ type: 'text', text: content }]; + if (!Array.isArray(content)) return []; + const out: ContentBlock[] = []; + for (const raw of content) { + const part = raw as { type?: string; text?: unknown }; + if (!part || typeof part.text !== 'string') continue; + if (part.type === undefined || part.type === 'text' + || part.type === 'input_text' || part.type === 'output_text') { + out.push({ type: 'text', text: part.text }); + } + } + return out; +} + +/** + * Rewrite an OpenAI request into the Messages shape the pin folder understands. + * `instructions` and system/developer turns become the system field, which is + * where file-sourced pins live on this wire. + */ +function normalizeOpenAIRequest( + req: { messages?: unknown; input?: unknown; instructions?: unknown }, +): { messages: Message[]; system?: SystemField } | undefined { + const items = Array.isArray(req.input) ? req.input + : Array.isArray(req.messages) ? req.messages + : typeof req.input === 'string' ? [{ role: 'user', content: req.input }] + : undefined; + if (!items) return undefined; + const system: Array = []; + if (typeof req.instructions === 'string' && req.instructions) { + system.push({ type: 'text', text: req.instructions }); + } + const messages: Message[] = []; + for (const raw of items) { + if (!raw || typeof raw !== 'object') return undefined; + const item = raw as OpenAIItem; + // reasoning / function_call / function_call_output are not messages. They + // still occupy a slot: a tool result in the last position must not let an + // older pin command look like the live turn and replay its answer. + if (item.type !== undefined && item.type !== 'message') { + messages.push({ role: 'assistant', content: [] }); + continue; + } + const blocks = openAITextBlocks(item.content); + if (item.role === 'system' || item.role === 'developer') { + for (const blk of blocks) system.push(blk as TextBlock); + continue; + } + messages.push({ role: item.role === 'user' ? 'user' : 'assistant', content: blocks }); + } + return { messages, system: system.length > 0 ? system : undefined }; +} + +/** `pinCommandResponse` for the OpenAI routes. Codex talks Responses and other + * clients talk Chat Completions, so the same command has to be answered in + * whichever schema the caller used. */ +export function pinCommandResponseOpenAI( + bodyIn: Uint8Array, + wire: OpenAIPinWire, +): { body: string; contentType: string } | undefined { + let req: { + messages?: unknown; + input?: unknown; + instructions?: unknown; + model?: string; + stream?: boolean; + }; + try { + req = JSON.parse(new TextDecoder().decode(bodyIn)); + } catch { + return undefined; + } + const norm = normalizeOpenAIRequest(req); + if (!norm || !isPinOnlyRequest(norm.messages)) return undefined; + const text = pinReplyText( + foldPins(norm.messages, norm.system), + liveVerb(norm.messages[norm.messages.length - 1]), + ); + const model = typeof req.model === 'string' ? req.model : 'pxpipe'; + return wire === 'responses' + ? synthesizeResponsesReply(text, model, req.stream === true) + : synthesizeChatReply(text, model, req.stream === true); +} + +/** Local `/v1/chat/completions` reply carrying `text`. Zero usage: nothing was + * billed, and reporting otherwise would corrupt the dashboard's accounting. */ +export function synthesizeChatReply( + text: string, + model: string, + stream: boolean, +): { body: string; contentType: string } { + const id = `chatcmpl_pxpipe_pin_${Date.now().toString(36)}`; + const created = Math.floor(Date.now() / 1000); + if (!stream) { + return { + contentType: 'application/json', + body: JSON.stringify({ + id, + object: 'chat.completion', + created, + model, + choices: [{ + index: 0, + message: { role: 'assistant', content: text }, + finish_reason: 'stop', + logprobs: null, + }], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }), + }; + } + const chunk = (delta: unknown, finish: string | null) => + `data: ${JSON.stringify({ + id, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta, finish_reason: finish }], + })}\n\n`; + return { + contentType: 'text/event-stream', + body: chunk({ role: 'assistant', content: '' }, null) + + chunk({ content: text }, null) + + chunk({}, 'stop') + + 'data: [DONE]\n\n', + }; +} + +/** Local `/v1/responses` reply carrying `text`, in the event order the Responses + * API emits: clients read the final item from `response.completed`, but Codex + * renders the streamed deltas, so both have to be present. */ +export function synthesizeResponsesReply( + text: string, + model: string, + stream: boolean, +): { body: string; contentType: string } { + const stamp = Date.now().toString(36); + const id = `resp_pxpipe_pin_${stamp}`; + const itemId = `msg_pxpipe_pin_${stamp}`; + const created_at = Math.floor(Date.now() / 1000); + const usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; + const item = { + id: itemId, + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text, annotations: [] }], + }; + const response = (status: string, output: unknown[]) => ({ + id, + object: 'response', + created_at, + status, + model, + output, + error: null, + incomplete_details: null, + usage: status === 'completed' ? usage : null, + }); + if (!stream) { + return { + contentType: 'application/json', + body: JSON.stringify(response('completed', [item])), + }; + } + let seq = 0; + const ev = (type: string, data: Record) => + `event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...data })}\n\n`; + const part = (t: string) => ({ type: 'output_text', text: t, annotations: [] }); + return { + contentType: 'text/event-stream', + body: + ev('response.created', { response: response('in_progress', []) }) + + ev('response.in_progress', { response: response('in_progress', []) }) + + ev('response.output_item.added', { + output_index: 0, + item: { id: itemId, type: 'message', status: 'in_progress', role: 'assistant', content: [] }, + }) + + ev('response.content_part.added', { + item_id: itemId, output_index: 0, content_index: 0, part: part(''), + }) + + ev('response.output_text.delta', { + item_id: itemId, output_index: 0, content_index: 0, delta: text, + }) + + ev('response.output_text.done', { + item_id: itemId, output_index: 0, content_index: 0, text, + }) + + ev('response.content_part.done', { + item_id: itemId, output_index: 0, content_index: 0, part: part(text), + }) + + ev('response.output_item.done', { output_index: 0, item }) + + ev('response.completed', { response: response('completed', [item]) }), + }; +} diff --git a/src/core/proxy.ts b/src/core/proxy.ts index 93b36b8..a657dea 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -20,7 +20,7 @@ import { chatCompletionsUrl, openAIChatToAnthropicResponse, } from './messages-chat-bridge.js'; -import { pinCommandResponse } from './pin.js'; +import { pinCommandResponse, pinCommandResponseOpenAI } from './pin.js'; import { parseGoogleModelFromPath, transformGoogleGenerateContent } from './google.js'; import { isGeminiModel } from './gemini-model-profiles.js'; import { resolveGptProfile } from './gpt-model-profiles.js'; @@ -1154,8 +1154,10 @@ export function createProxy(config: ProxyConfig = {}) { // configuration, not a question. Answer it here: forwarding it would bill // a full prefix to have the model paraphrase a list the proxy already // holds, and the reply would be a guess about state it cannot see. - if (isMessages) { - const pinReply = pinCommandResponse(bodyIn); + if (isMessages || isOpenAIChat || isOpenAIResponses) { + const pinReply = isMessages + ? pinCommandResponse(bodyIn) + : pinCommandResponseOpenAI(bodyIn, isOpenAIResponses ? 'responses' : 'chat'); if (pinReply) { return new Response(pinReply.body, { status: 200, diff --git a/src/dashboard/fragments.ts b/src/dashboard/fragments.ts index 8c85c17..a0f5cb8 100644 --- a/src/dashboard/fragments.ts +++ b/src/dashboard/fragments.ts @@ -188,7 +188,7 @@ export function renderSessionSummaryFragment(s: StatsPayload): string { `
` + `
Since start
` + `
Warming up…
` + - `
Point Claude Code at this proxy and send a message. The moment a request flows through, your running savings show up right here.
` + + `
Point Claude Code at this proxy with ANTHROPIC_BASE_URL, or launch it with pxpipe warp -- claude to keep /remote-control and claude.ai connectors working. Send a message and your running savings show up right here.
` + `
` ); } @@ -1225,6 +1225,21 @@ export function renderPage(port: number): string { +
+ Connect an agent warp launches any CLI through this proxy · pin keeps instructions last in the request +

Warp starts the agent with the proxy already wired, no env or config edits:

+
pxpipe warp -- claude
+pxpipe warp -- codex
+pxpipe warp -- cursor-agent
+

Aliases work too (pxpipe warp -- pp), and --route pattern=http://host:port adds routes beyond api.anthropic.com. Without warp, point the agent at ANTHROPIC_BASE_URL=http://127.0.0.1:${port} yourself.

+

Pin instructions from inside the session — they get moved to the end of every request, where the model actually reads them:

+
@pxpipe pin be concise, no walls of text
+@pxpipe unpin 2
+@pxpipe unpin all
+

@pxpipe pin with no text lists what is pinned.

+

A @pxpipe pin … line in your global or project CLAUDE.md (AGENTS.md under Codex / OpenCode) is relocated the same way, on every session, with no typing. Those are file-backed, so unpin and unpin all never touch them — edit the file to remove one.

+
+
Image model scope Fable 5 and Gemini 3.6 Flash by default · expand to experiment with other families
⚠ Image compression is validated for Fable 5 and Gemini 3.6 Flash — other families can use more tokens, not less. Opt in only for deliberate experiments.
diff --git a/src/node.ts b/src/node.ts index ae12630..76f7744 100644 --- a/src/node.ts +++ b/src/node.ts @@ -7,6 +7,7 @@ */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { createWarpRuntime } from './warp/index.js'; import { once } from 'node:events'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -201,6 +202,9 @@ function printHelp(): void { Usage: pxpipe run the proxy (no flags) pxpipe export [...] render files/diff to PNG pages + cost report (see pxpipe export --help) + pxpipe warp -- CMD run CMD behind the proxy without a custom base URL, so + client-side first-party gates (/remote-control, + claude.ai connectors) keep working The proxy compresses eligible tools, schemas, reminders, tool_results, and history; tracks events to disk; and measures real saved_pct via @@ -1023,9 +1027,28 @@ async function main(): Promise { await runExport(argv.slice(1)); return; // server never starts } - // No subcommands — pxpipe is just the proxy. Stats / sessions / cleanup - // tools live in the dashboard (see http://127.0.0.1:${port}/). - const opts = parseCli(argv); + // `warp` runs an agent behind a CONNECT proxy and redirects its inference + // traffic into the pxpipe already running. It starts no proxy of its own, so + // it exits through its own branch below rather than falling through here. + let warpCommand: string[] | undefined; + let cliArgv = argv; + if (argv[0] === 'warp') { + const sep = argv.indexOf('--'); + warpCommand = sep < 0 ? [] : argv.slice(sep + 1); + cliArgv = argv.slice(1, sep < 0 ? argv.length : sep); + } + // Stats / sessions / cleanup tools live in the dashboard + // (see http://127.0.0.1:${port}/). + const opts = parseCli(cliArgv); + + // warp only redirects traffic: it decrypts the agent's TLS and re-points the + // inference path at the pxpipe you already have running, so that instance + // does the transforming, the tracking and the dashboard. Everything below — + // tracker, proxy pipeline, listener — belongs to that instance, not to us. + if (warpCommand) { + createWarpRuntime({ port: opts.port }).launch(warpCommand); + return; + } // A/B harness passthrough switch (see the `transform` callback below). const forcePassthrough = /^(1|true|yes|on)$/i.test(process.env.PXPIPE_DISABLE ?? ''); if (forcePassthrough) { @@ -1234,14 +1257,7 @@ async function main(): Promise { const displayHost = opts.host.includes(':') ? `[${opts.host}]` : opts.host; const isLoopbackHost = opts.host === '127.0.0.1' || opts.host === 'localhost' || opts.host === '::1'; - server.listen(opts.port, opts.host, () => { - console.log(`[pxpipe] listening on http://${displayHost}:${opts.port}`); - if (!isLoopbackHost) { - console.warn( - `[pxpipe] bound to ${opts.host}; proxy API is reachable off-host, ` + - `but dashboard routes remain loopback-only.`, - ); - } + const announce = () => { const routes = resolveUpstreams(config); console.log(`[pxpipe] anthropic upstream → ${routes.anthropic}`); console.log(`[pxpipe] openai upstream → ${routes.openai}`); @@ -1252,7 +1268,6 @@ async function main(): Promise { ); } console.log(`[pxpipe] tracking events → ${opts.eventsFile}`); - console.log(`[pxpipe] dashboard → http://127.0.0.1:${opts.port}/`); if (opts.captureErrorReqBody) { console.warn( `[pxpipe] PXPIPE_DEBUG_CAPTURE_4XX=1 — persisting full 4xx request and ` + @@ -1260,6 +1275,18 @@ async function main(): Promise { `Debugging only.`, ); } + }; + + server.listen(opts.port, opts.host, () => { + console.log(`[pxpipe] listening on http://${displayHost}:${opts.port}`); + if (!isLoopbackHost) { + console.warn( + `[pxpipe] bound to ${opts.host}; proxy API is reachable off-host, ` + + `but dashboard routes remain loopback-only.`, + ); + } + announce(); + console.log(`[pxpipe] dashboard → http://127.0.0.1:${opts.port}/`); }); // server.close() only stops accepting new connections and waits for open diff --git a/src/warp/ca.ts b/src/warp/ca.ts new file mode 100644 index 0000000..e5c1eae --- /dev/null +++ b/src/warp/ca.ts @@ -0,0 +1,271 @@ +/** + * warp's local certificate authority. + * + * On first use it generates a self-signed P-256 root, persists it under + * ~/.pxpipe, and mints leaf certs per SNI host on demand so the CONNECT proxy + * can terminate (and therefore route) the agent's TLS. Only the child process + * trusts it: warp points the child's NODE_EXTRA_CA_CERTS at the root, so + * nothing is installed in the system keychain and no other process on the + * machine is affected. + * + * Ported from wardex ca.go. The certificate assembly is hand-rolled because + * Node has no equivalent of Go's x509.CreateCertificate — see ./der.ts. + */ + +import { + createPrivateKey, + generateKeyPairSync, + randomBytes, + sign, + X509Certificate, + type KeyObject, +} from 'node:crypto'; +import { createSecureContext, type SecureContext } from 'node:tls'; +import { isIP } from 'node:net'; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + bitString, + bool, + contextTag, + integer, + octetString, + oid, + pem, + seq, + set, + utcTime, + utf8String, +} from './der.js'; + +const textEncoder = new TextEncoder(); + +const OID_ECDSA_SHA256 = '1.2.840.10045.4.3.2'; +const OID_COMMON_NAME = '2.5.4.3'; +const OID_ORGANIZATION = '2.5.4.10'; +const OID_BASIC_CONSTRAINTS = '2.5.29.19'; +const OID_KEY_USAGE = '2.5.29.15'; +const OID_EXT_KEY_USAGE = '2.5.29.37'; +const OID_SUBJECT_ALT_NAME = '2.5.29.17'; +const OID_SERVER_AUTH = '1.3.6.1.5.5.7.3.1'; + +const CA_COMMON_NAME = 'pxpipe warp local CA'; +const CA_ORGANIZATION = 'pxpipe'; + +/** ecdsa-with-SHA256 takes no parameters, so the AlgorithmIdentifier is bare. */ +const SIG_ALGORITHM = seq(oid(OID_ECDSA_SHA256)); + +function distinguishedName(commonName: string, organization?: string): Uint8Array { + const rdns = [set(seq(oid(OID_COMMON_NAME), utf8String(commonName)))]; + if (organization) rdns.push(set(seq(oid(OID_ORGANIZATION), utf8String(organization)))); + return seq(...rdns); +} + +function extension(id: string, critical: boolean, value: Uint8Array): Uint8Array { + const parts = [oid(id)]; + if (critical) parts.push(bool(true)); + parts.push(octetString(value)); + return seq(...parts); +} + +/** + * KeyUsage is a BIT STRING indexed from the most significant bit: + * digitalSignature(0), keyCertSign(5), cRLSign(6). The trailing count of unused + * bits has to match the last bit we actually set or strict verifiers complain. + */ +function keyUsageExtension(bits: number[]): Uint8Array { + const highest = Math.max(...bits); + const byteCount = Math.floor(highest / 8) + 1; + const bytes = new Uint8Array(byteCount); + for (const bit of bits) bytes[Math.floor(bit / 8)]! |= 0x80 >> bit % 8; + const unused = byteCount * 8 - (highest + 1); + return extension(OID_KEY_USAGE, true, bitString(bytes, unused)); +} + +/** GeneralName: dNSName is [2] IMPLICIT IA5String, iPAddress is [7] OCTET STRING. */ +function subjectAltNameExtension(host: string): Uint8Array { + if (isIP(host)) { + const octets = + isIP(host) === 4 + ? Uint8Array.from(host.split('.').map((o) => Number.parseInt(o, 10))) + : ipv6Bytes(host); + return extension(OID_SUBJECT_ALT_NAME, false, seq(contextTag(7, octets, false))); + } + // [2] IMPLICIT IA5String is the IA5String content under a different tag, so + // re-encode the content rather than slicing the header off ia5String(): that + // header is 3 bytes, not 2, once the host reaches 128 chars, and the leftover + // length byte lands inside the name. + return extension( + OID_SUBJECT_ALT_NAME, + false, + seq(contextTag(2, textEncoder.encode(host), false)), + ); +} + +function ipv6Bytes(host: string): Uint8Array { + const [head, tail] = host.split('::'); + const parse = (part: string) => (part ? part.split(':').filter(Boolean) : []); + const left = parse(head ?? ''); + const right = parse(tail ?? ''); + const groups = + tail === undefined + ? left + : [...left, ...Array(8 - left.length - right.length).fill('0'), ...right]; + const out = new Uint8Array(16); + groups.forEach((group, i) => { + const v = Number.parseInt(group, 16); + out[i * 2] = (v >> 8) & 0xff; + out[i * 2 + 1] = v & 0xff; + }); + return out; +} + +interface CertificateSpec { + subject: Uint8Array; + issuer: Uint8Array; + subjectPublicKey: KeyObject; + signingKey: KeyObject; + notBefore: Date; + notAfter: Date; + extensions: Uint8Array[]; +} + +/** + * Assemble and self-sign a TBSCertificate. The SubjectPublicKeyInfo comes + * straight out of node:crypto's SPKI export, so we never hand-encode a curve + * point; everything else is RFC 5280 boilerplate. + */ +function buildCertificate(spec: CertificateSpec): Uint8Array { + const spki = new Uint8Array(spec.subjectPublicKey.export({ type: 'spki', format: 'der' })); + const tbs = seq( + contextTag(0, integer(2)), // v3 + integer(new Uint8Array(randomBytes(16))), + SIG_ALGORITHM, + spec.issuer, + seq(utcTime(spec.notBefore), utcTime(spec.notAfter)), + spec.subject, + spki, + contextTag(3, seq(...spec.extensions)), + ); + const signature = new Uint8Array(sign('sha256', tbs, spec.signingKey)); + return seq(tbs, SIG_ALGORITHM, bitString(signature)); +} + +function newKeyPair() { + return generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +} + +function privateKeyPem(key: KeyObject): string { + return key.export({ type: 'pkcs8', format: 'pem' }).toString(); +} + +export class CertificateAuthority { + private readonly leaves = new Map(); + + private constructor( + private readonly certPem: string, + private readonly caKey: KeyObject, + private readonly leafKey: KeyObject, + private readonly leafKeyPem: string, + readonly certPath: string, + ) {} + + /** + * 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 + * state, and the only cost of regenerating is that the child process trusts a + * new root it is about to be handed anyway. + */ + static loadOrCreate(dir: string): CertificateAuthority { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const certPath = join(dir, 'warp-ca.pem'); + const keyPath = join(dir, 'warp-ca-key.pem'); + + try { + const certPem = readFileSync(certPath, 'utf8'); + const keyPem = readFileSync(keyPath, 'utf8'); + const parsed = new X509Certificate(certPem); + if (new Date(parsed.validTo).getTime() > Date.now()) { + const caKey = createPrivateKey(keyPem); + const leaf = newKeyPair(); + return new CertificateAuthority( + certPem, + caKey, + leaf.publicKey, + privateKeyPem(leaf.privateKey), + certPath, + ); + } + } catch { + // fall through and mint a fresh CA + } + + const ca = newKeyPair(); + const name = distinguishedName(CA_COMMON_NAME, CA_ORGANIZATION); + const now = Date.now(); + const der = buildCertificate({ + subject: name, + issuer: name, + subjectPublicKey: ca.publicKey, + signingKey: ca.privateKey, + notBefore: new Date(now - 60 * 60 * 1000), + notAfter: new Date(now + 10 * 365 * 24 * 60 * 60 * 1000), + extensions: [ + // pathLen 0: this root may sign leaves, never intermediates. + extension(OID_BASIC_CONSTRAINTS, true, seq(bool(true), integer(0))), + keyUsageExtension([0, 5, 6]), + ], + }); + + const certPem = pem('CERTIFICATE', der); + writeFileSync(certPath, certPem, { mode: 0o644 }); + writeFileSync(keyPath, privateKeyPem(ca.privateKey), { mode: 0o600 }); + chmodSync(keyPath, 0o600); + + const leaf = newKeyPair(); + return new CertificateAuthority( + certPem, + ca.privateKey, + leaf.publicKey, + privateKeyPem(leaf.privateKey), + certPath, + ); + } + + /** + * Cached-or-minted TLS context for an SNI host. One leaf key is reused across + * every host: these certs never leave the machine and are only trusted by the + * child we spawned, so per-host keygen would buy nothing but latency on the + * first request to each host. + */ + secureContextFor(host: string): SecureContext { + const name = host.replace(/\.$/, ''); + const cached = this.leaves.get(name); + if (cached) return cached; + + const now = Date.now(); + const der = buildCertificate({ + subject: distinguishedName(name), + issuer: distinguishedName(CA_COMMON_NAME, CA_ORGANIZATION), + subjectPublicKey: this.leafKey, + signingKey: this.caKey, + notBefore: new Date(now - 60 * 60 * 1000), + notAfter: new Date(now + 365 * 24 * 60 * 60 * 1000), + extensions: [ + keyUsageExtension([0]), + extension(OID_EXT_KEY_USAGE, false, seq(oid(OID_SERVER_AUTH))), + subjectAltNameExtension(name), + ], + }); + + const context = createSecureContext({ + key: this.leafKeyPem, + // Ship the root alongside the leaf so the child validates the whole chain + // from NODE_EXTRA_CA_CERTS without a separate fetch. + cert: pem('CERTIFICATE', der) + this.certPem, + }); + this.leaves.set(name, context); + return context; + } +} diff --git a/src/warp/connect.ts b/src/warp/connect.ts new file mode 100644 index 0000000..c31af92 --- /dev/null +++ b/src/warp/connect.ts @@ -0,0 +1,288 @@ +/** + * The CONNECT proxy warp puts in front of the child process. + * + * Ported from wardex proxy.go, with two deliberate narrowings: + * + * - wardex decrypts every host once --mitm is on; warp decrypts only hosts a + * route could match and blindly tunnels the rest. Under warp the agent + * believes it is talking straight to api.anthropic.com, so the less we + * terminate, the fewer ways that belief can break. + * - wardex owns its listener; warp attaches to the proxy server pxpipe is + * already running. CONNECT is a distinct event from a normal request, so one + * port serves both the origin-form traffic pxpipe handles and the + * absolute-form/CONNECT traffic a forward proxy handles. + */ + +import { Agent as HttpAgent, request as httpRequest } from 'node:http'; +import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'; +import { + Agent as HttpsAgent, + createServer as createHttpsServer, + request as httpsRequest, +} from 'node:https'; +import { connect as netConnect, type Socket } from 'node:net'; +import { connect as tlsConnect, type TLSSocket } from 'node:tls'; + +import type { CertificateAuthority } from './ca.js'; +import { hostCouldMatch, matchRoute, rewriteUrl, type Route } from './route.js'; + +/** + * Hop-by-hop headers are meaningful only on a single connection, so they must + * not be copied onto the re-originated request (RFC 9110 7.6.1). + */ +const HOP_HEADERS = new Set([ + 'connection', + 'proxy-connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +export interface WarpHandlerOptions { + routes: readonly Route[]; + ca: CertificateAuthority; + /** Called the first time each route diverts a request, for the log. */ + onDivert?: (host: string, path: string, target: string) => void; +} + +export interface WarpHandlers { + /** Attach as the server's 'connect' listener. */ + handleConnect: (req: IncomingMessage, socket: Socket, head: Buffer) => void; + /** Call from the request handler for absolute-form request targets. */ + handleAbsoluteForm: (req: IncomingMessage, res: ServerResponse) => void; +} + +/** + * A CONNECT handler reachable off-host is an open relay, and pxpipe's HOST env + * var permits a non-loopback bind. The dashboard already refuses non-loopback + * callers; the proxy duty needs the same guard for the same reason. + */ +function isLoopbackAddress(address: string | undefined): boolean { + if (!address) return false; + return ( + address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.') + ); +} + +function stripPort(value: string): string { + if (value.startsWith('[')) return value.slice(1, value.indexOf(']')); + const i = value.lastIndexOf(':'); + return i > 0 ? value.slice(0, i) : value; +} + +function splitHostPort(value: string, fallbackPort: string): { host: string; port: string } { + if (value.startsWith('[')) { + const end = value.indexOf(']'); + const rest = value.slice(end + 1); + return { host: value.slice(1, end), port: rest.startsWith(':') ? rest.slice(1) : fallbackPort }; + } + const i = value.lastIndexOf(':'); + if (i < 0) return { host: value, port: fallbackPort }; + return { host: value.slice(0, i), port: value.slice(i + 1) }; +} + +/** `host:port`, bracketing IPv6 literals so the result is URL-parseable. */ +function authority(host: string, port: string): string { + return host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`; +} + +function forwardHeaders(headers: IncomingHttpHeaders): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined || HOP_HEADERS.has(key.toLowerCase())) continue; + out[key] = value; + } + return out; +} + +function pipeSockets(a: Socket, b: Socket): void { + a.pipe(b); + b.pipe(a); + const destroy = () => { + a.destroy(); + b.destroy(); + }; + a.on('error', destroy); + b.on('error', destroy); + a.on('close', () => b.destroy()); + b.on('close', () => a.destroy()); +} + +/** + * Every request the agent makes crosses this hop. Node's global agents default + * to keepAlive:false, so without these each call pays a fresh TCP handshake — + * plus a full TLS handshake to the real host on the passthrough path. + */ +const httpAgent = new HttpAgent({ keepAlive: true }); +const httpsAgent = new HttpsAgent({ keepAlive: true }); + +export function createWarpHandlers(options: WarpHandlerOptions): WarpHandlers { + const { routes, ca, onDivert } = options; + const announced = new Set(); + + /** Re-originate one request, to a route target or to the real host. */ + const forward = ( + req: IncomingMessage, + res: ServerResponse, + host: string, + requestUri: string, + // Where the bytes actually go when no route matches. Passed in rather than + // rebuilt from `host`: a plain `http://host:8080` request reaching us in + // absolute form must not be re-originated as `https://host:443`. + origin: string, + ) => { + const path = requestUri.split('?')[0] ?? '/'; + const route = matchRoute(routes, host, path); + + const target = new URL(route ? rewriteUrl(route, requestUri) : `${origin}${requestUri}`); + if (route && !announced.has(route.pattern)) { + announced.add(route.pattern); + onDivert?.(host, path, target.origin); + } + + const send = target.protocol === 'https:' ? httpsRequest : httpRequest; + const outbound = send({ + protocol: target.protocol, + hostname: target.hostname, + port: target.port || (target.protocol === 'https:' ? 443 : 80), + method: req.method, + path: target.pathname + target.search, + // Keep the agent's original Host: the downstream routes (and an upstream + // may sign) on the vhost, not on wherever we happened to send the bytes. + headers: forwardHeaders(req.headers), + setHost: false, + agent: target.protocol === 'https:' ? httpsAgent : httpAgent, + }); + + let upstreamRes: IncomingMessage | undefined; + outbound.on('response', (upstream) => { + upstreamRes = upstream; + res.writeHead(upstream.statusCode ?? 502, forwardHeaders(upstream.headers)); + // Streamed, never buffered: /v1/messages is SSE and must arrive token by + // token or the agent appears to hang until the response completes. + upstream.pipe(res); + }); + outbound.on('error', (err) => { + // Our own teardown below surfaces here as ECONNRESET; the client is + // already gone, so writing a 502 into it would throw. + if (res.writableEnded || res.destroyed) return; + if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' }); + res.end(`pxpipe warp: upstream error: ${err.message}`); + }); + // An SSE completion only ends when the model stops. If the agent is killed + // mid-stream nothing else cancels the upstream: the response keeps draining + // into a dead socket, and the keepAlive agent later hands that same + // half-consumed connection to the next request. + res.on('close', () => { + if (res.writableFinished) return; + upstreamRes?.destroy(); + outbound.destroy(); + }); + req.pipe(outbound); + }; + + const handleDecrypted = (req: IncomingMessage, res: ServerResponse): void => { + const servername = (req.socket as TLSSocket).servername || ''; + // A client that CONNECTed to a non-443 port repeats it in Host, which is + // the only place the original port survives the hijack into mitmServer. + const { host, port } = splitHostPort(req.headers.host || servername, '443'); + forward(req, res, host, req.url ?? '/', `https://${authority(host, port)}`); + }; + + /** + * Upgrades always go to the real host, never to a route. Route targets speak + * HTTP, and the control plane behind /remote-control pairing is exactly the + * traffic that has to stay first-party, so this splices raw TLS instead of + * re-originating through an HTTP client (which would drop the Upgrade and can + * never produce a 101). + */ + const handleUpgrade = (req: IncomingMessage, clientSocket: Socket, head: Buffer): void => { + const servername = (req.socket as TLSSocket).servername || ''; + const { host, port } = splitHostPort(req.headers.host || servername, '443'); + const upstream = tlsConnect({ host, port: Number(port), servername: host }, () => { + const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`]; + for (const [key, value] of Object.entries(req.headers)) { + for (const v of Array.isArray(value) ? value : [value]) { + if (v !== undefined) lines.push(`${key}: ${v}`); + } + } + upstream.write(`${lines.join('\r\n')}\r\n\r\n`); + if (head?.length) upstream.write(head); + pipeSockets(clientSocket, upstream); + }); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstream.destroy()); + }; + + // Forcing http/1.1 via ALPN keeps the decrypted stream parseable by + // http.Server; without it Node negotiates h2 and the request handler never + // fires. This server never listens — it exists to be handed hijacked sockets. + const mitmServer = createHttpsServer({ + SNICallback: (name, cb) => { + try { + cb(null, ca.secureContextFor(name)); + } catch (err) { + cb(err as Error); + } + }, + ALPNProtocols: ['http/1.1'], + }); + mitmServer.on('request', handleDecrypted); + mitmServer.on('upgrade', handleUpgrade); + mitmServer.on('clientError', (_err, socket) => socket.destroy()); + + const handleConnect = (req: IncomingMessage, clientSocket: Socket, head: Buffer): void => { + if (!isLoopbackAddress(req.socket.remoteAddress)) { + clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + return; + } + const { host, port } = splitHostPort(req.url ?? '', '443'); + + if (!hostCouldMatch(routes, host)) { + const upstream = netConnect({ host, port: Number(port) }, () => { + clientSocket.write('HTTP/1.1 200 Connection established\r\n\r\n'); + if (head?.length) upstream.write(head); + pipeSockets(clientSocket, upstream); + }); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstream.destroy()); + return; + } + + clientSocket.write('HTTP/1.1 200 Connection established\r\n\r\n'); + if (head?.length) clientSocket.unshift(head); + // Handing the raw socket to the TLS server lets Node do the handshake, the + // HTTP parsing and the upgrade detection for us. + mitmServer.emit('connection', clientSocket); + }; + + const handleAbsoluteForm = (req: IncomingMessage, res: ServerResponse): void => { + if (!isLoopbackAddress(req.socket.remoteAddress)) { + res.writeHead(403, { 'content-type': 'text/plain' }); + res.end('pxpipe warp: forward proxy is loopback-only'); + return; + } + let target: URL; + try { + target = new URL(req.url ?? ''); + } catch { + res.writeHead(400, { 'content-type': 'text/plain' }); + res.end('pxpipe warp: bad absolute URI'); + return; + } + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + res.writeHead(400, { 'content-type': 'text/plain' }); + res.end('pxpipe warp: unsupported scheme'); + return; + } + // target.origin, not a rebuilt https:// URL: scheme and non-default port + // are part of where this request was actually going. + forward(req, res, stripPort(target.host), target.pathname + target.search, target.origin); + }; + + return { handleConnect, handleAbsoluteForm }; +} diff --git a/src/warp/der.ts b/src/warp/der.ts new file mode 100644 index 0000000..7a851ae --- /dev/null +++ b/src/warp/der.ts @@ -0,0 +1,158 @@ +/** + * Minimal DER writer — just enough ASN.1 to mint an X.509 certificate. + * + * Node can generate keys and sign bytes, but unlike Go's crypto/x509 it has no + * certificate *issuer*: `new X509Certificate()` only parses. Everything here + * exists to build the handful of structures in RFC 5280 that a leaf and a root + * need, so warp can run its own CA without pulling in a dependency. + * + * Only the write path is implemented. Parsing is left to node:crypto's + * X509Certificate, which is why nothing here reads DER back. + */ + +const textEncoder = new TextEncoder(); + +export const TAG = { + BOOLEAN: 0x01, + INTEGER: 0x02, + BIT_STRING: 0x03, + OCTET_STRING: 0x04, + OID: 0x06, + UTF8_STRING: 0x0c, + SEQUENCE: 0x30, + SET: 0x31, + IA5_STRING: 0x16, + UTC_TIME: 0x17, +} as const; + +export function concatBytes(chunks: Uint8Array[]): Uint8Array { + let total = 0; + for (const chunk of chunks) total += chunk.length; + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +/** + * DER length: short form below 128, else a leading count-of-bytes marked with + * the high bit. Indefinite length is forbidden in DER, so this is total. + */ +function encodeLength(n: number): Uint8Array { + if (n < 0x80) return Uint8Array.of(n); + const bytes: number[] = []; + for (let v = n; v > 0; v = Math.floor(v / 256)) bytes.unshift(v % 256); + return Uint8Array.from([0x80 | bytes.length, ...bytes]); +} + +/** tag + length + contents, the shape every other helper is built from. */ +export function tlv(tag: number, body: Uint8Array): Uint8Array { + return concatBytes([Uint8Array.of(tag), encodeLength(body.length), body]); +} + +export function seq(...items: Uint8Array[]): Uint8Array { + return tlv(TAG.SEQUENCE, concatBytes(items)); +} + +export function set(...items: Uint8Array[]): Uint8Array { + return tlv(TAG.SET, concatBytes(items)); +} + +export function bool(value: boolean): Uint8Array { + return tlv(TAG.BOOLEAN, Uint8Array.of(value ? 0xff : 0x00)); +} + +/** + * INTEGER is signed two's complement: strip redundant leading zero bytes, then + * re-add one if the top bit would otherwise read as negative. Serial numbers + * are the reason this matters — a random 16-byte serial is negative half the + * time, and a negative serial is a spec violation some verifiers reject. + */ +export function integer(value: number | Uint8Array): Uint8Array { + let bytes: number[]; + if (typeof value === 'number') { + if (value === 0) return tlv(TAG.INTEGER, Uint8Array.of(0)); + bytes = []; + for (let v = value; v > 0; v = Math.floor(v / 256)) bytes.unshift(v % 256); + } else { + bytes = Array.from(value); + } + while (bytes.length > 1 && bytes[0] === 0x00 && (bytes[1]! & 0x80) === 0) bytes.shift(); + if ((bytes[0]! & 0x80) !== 0) bytes.unshift(0x00); + return tlv(TAG.INTEGER, Uint8Array.from(bytes)); +} + +/** + * OID: first two arcs pack into one byte (40*a + b), the rest are base-128 with + * a continuation bit on every byte but the last. + */ +export function oid(dotted: string): Uint8Array { + const arcs = dotted.split('.').map((a) => Number.parseInt(a, 10)); + if (arcs.length < 2 || arcs.some((a) => !Number.isFinite(a))) { + throw new Error(`bad OID: ${dotted}`); + } + const out: number[] = [40 * arcs[0]! + arcs[1]!]; + for (const arc of arcs.slice(2)) { + const septets: number[] = []; + let v = arc; + do { + septets.unshift(v & 0x7f); + v = Math.floor(v / 128); + } while (v > 0); + for (let i = 0; i < septets.length - 1; i++) septets[i]! |= 0x80; + out.push(...septets); + } + return tlv(TAG.OID, Uint8Array.from(out)); +} + +export function utf8String(value: string): Uint8Array { + return tlv(TAG.UTF8_STRING, textEncoder.encode(value)); +} + +export function ia5String(value: string): Uint8Array { + return tlv(TAG.IA5_STRING, textEncoder.encode(value)); +} + +export function octetString(body: Uint8Array): Uint8Array { + return tlv(TAG.OCTET_STRING, body); +} + +/** BIT STRING carries a leading count of unused bits in its final byte. */ +export function bitString(body: Uint8Array, unusedBits = 0): Uint8Array { + return tlv(TAG.BIT_STRING, concatBytes([Uint8Array.of(unusedBits), body])); +} + +/** Context-specific tag, e.g. [0] EXPLICIT / [2] IMPLICIT in a SAN. */ +export function contextTag(n: number, body: Uint8Array, constructed = true): Uint8Array { + return tlv(0x80 | (constructed ? 0x20 : 0x00) | n, body); +} + +/** + * UTCTime is YYMMDDHHMMSSZ and is only unambiguous through 2049; RFC 5280 + * requires GeneralizedTime past that. Both certs we mint are short-lived + * relative to that ceiling, but assert rather than silently emit a date that + * verifiers will read as 19xx. + */ +export function utcTime(date: Date): Uint8Array { + const year = date.getUTCFullYear(); + if (year >= 2050) throw new Error('utcTime: dates past 2049 need GeneralizedTime'); + const p2 = (v: number) => String(v).padStart(2, '0'); + const stamp = + p2(year % 100) + + p2(date.getUTCMonth() + 1) + + p2(date.getUTCDate()) + + p2(date.getUTCHours()) + + p2(date.getUTCMinutes()) + + p2(date.getUTCSeconds()) + + 'Z'; + return tlv(TAG.UTC_TIME, textEncoder.encode(stamp)); +} + +export function pem(label: string, der: Uint8Array): string { + const b64 = Buffer.from(der).toString('base64'); + const lines = b64.match(/.{1,64}/g) ?? []; + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} diff --git a/src/warp/index.ts b/src/warp/index.ts new file mode 100644 index 0000000..087c8c3 --- /dev/null +++ b/src/warp/index.ts @@ -0,0 +1,221 @@ +/** + * `pxpipe warp -- ` + * + * Runs the agent behind a CONNECT proxy that decrypts api.anthropic.com and + * re-points only /v1/messages at the local pxpipe proxy. The agent never sees a + * custom ANTHROPIC_BASE_URL, so the client-side "firstParty" checks that hide + * /remote-control (and disable claude.ai connectors) still pass, while pxpipe + * gets the one path it transforms. + * + * Compare the manual equivalent, which needs two extra tools and a CA trusted + * process-wide: + * + * mitmdump --map-remote '|^https://api\.anthropic\.com/v1/messages|http://127.0.0.1:47821/v1/messages' + * HTTPS_PROXY=http://127.0.0.1:8080 NODE_EXTRA_CA_CERTS=~/.mitmproxy/mitmproxy-ca-cert.pem claude + */ + +import { spawn, spawnSync } from 'node:child_process'; +import { accessSync, constants, existsSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { CertificateAuthority } from './ca.js'; +import { createWarpHandlers } from './connect.js'; +import { parseRoute, routeDestination, type Route } from './route.js'; + +export interface WarpRuntimeOptions { + /** Port the pxpipe proxy is already serving on: where matches are sent. */ + port: number; +} + +export interface WarpRuntime { + /** Bind the child's proxy port, then spawn the child. */ + launch: (command: string[]) => void; +} + +/** + * Only the inference path is diverted. Everything else on the host — OAuth, + * telemetry, the control plane — is re-originated untouched, which is what + * keeps the agent's client-side gates satisfied. + */ +function defaultRoutes(port: number): Route[] { + return [parseRoute(`api.anthropic.com/v1/messages*=http://127.0.0.1:${port}`)]; +} + +export function createWarpRuntime(options: WarpRuntimeOptions): WarpRuntime { + const { port } = options; + const routes = defaultRoutes(port); + const ca = CertificateAuthority.loadOrCreate(join(homedir(), '.pxpipe')); + + const handlers = createWarpHandlers({ + routes, + ca, + // The child inherits this terminal and Claude Code draws a full-screen TUI + // over it, so anything written after the child starts corrupts the display. + // Keep the line when stdout is redirected (piping, CI, debugging); stay + // silent when a human is looking at the agent. events.jsonl records the + // request either way. + onDivert: (host, path, target) => { + if (!process.stdout.isTTY) console.error(`[pxpipe] warp: ${host}${path} → ${target}`); + }, + }); + + // warp's own listener, and the only reason a port is involved at all: the + // child is configured through HTTPS_PROXY, which can only name a host:port. + // The kernel picks it, nothing else needs to know it, so there is nothing to + // collide with a pxpipe already running. + const proxy = createServer(handlers.handleAbsoluteForm); + proxy.on('connect', handlers.handleConnect); + + /** + * Does the user's interactive shell consider this word an alias? Both zsh + * ("cc is an alias for ...") and bash ("cc is aliased to ...") answer through + * `type`, and only an interactive shell has sourced the rc file that defines + * one. Costs a single shell start at launch. + */ + const shellAliasTarget = (name: string, shell: string, env: NodeJS.ProcessEnv): string | null => { + if (name.includes('/')) return null; + const probe = spawnSync(shell, ['-ic', `type -- ${name}`], { encoding: 'utf8', env }); + const match = /\bis (?:an alias for|aliased to)\s+(.+)$/m.exec(probe.stdout ?? ''); + if (!match) return null; + // bash wraps the expansion in `backtick quote'; zsh leaves it bare. + return match[1]!.trim().replace(/^`/, '').replace(/'$/, ''); + }; + + /** Minimal `which`: is this bare name an executable on PATH? */ + const whichSync = (name: string, env: NodeJS.ProcessEnv): boolean => { + if (name.includes('/')) return false; // a path, already handled by existsSync + for (const dir of (env.PATH ?? '').split(':')) { + if (!dir) continue; + try { + accessSync(join(dir, name), constants.X_OK); + return true; + } catch { + // not here, keep looking + } + } + return false; + }; + + /** Can this word actually be executed: a path that exists, or a name on PATH. */ + const isRunnable = (word: string, env: NodeJS.ProcessEnv): boolean => + word.includes('/') ? existsSync(word) : whichSync(word, env); + + /** POSIX single-quote: safe for anything except a single quote itself. */ + const shellQuote = (arg: string): string => `'${arg.replaceAll("'", `'\\''`)}'`; + + /** + * Run the child, falling back to the user's interactive shell when the + * command is not an executable on PATH. + * + * spawn() is execvp, which only knows files. An alias like `cc` exists solely + * inside an interactive zsh, and `sh -c` will not find it either: aliases come + * from ~/.zshrc, which only an *interactive* shell sources. So the fallback is + * `$SHELL -ic`, which loads the rc file and expands the alias — including any + * env assignments baked into it, which no PATH lookup could have carried. + * + * Direct spawn stays the fast path: the shell is only involved when execvp + * would have failed outright. + */ + const spawnResolved = (command: string[], env: NodeJS.ProcessEnv) => { + const direct = { stdio: 'inherit', env } as const; + const shell = env.SHELL || '/bin/sh'; + // An alias can shadow a real binary: `cc` is Apple clang on PATH and a + // Claude Code alias in the user's zsh, so PATH alone would silently run + // the wrong program. Ask the interactive shell what the word means first. + const alias = shellAliasTarget(command[0]!, shell, env); + // But a stale alias must not shadow a working binary either. An alias + // pointing at an uninstalled path (`claude` -> /opt/homebrew/bin/claude + // after a move to a node-managed install) would otherwise fail the launch + // outright, with a real claude sitting on PATH. An env-assignment prefix + // (`FOO=1 claude`) is unverifiable, so it is taken at its word. + const aliasWord = alias?.split(/\s+/)[0] ?? ''; + const aliasUsable = alias !== null && (aliasWord.includes('=') || isRunnable(aliasWord, env)); + if (alias !== null && !aliasUsable) { + console.error(`[pxpipe] warp: ignoring stale alias ${command[0]} → ${aliasWord} (not executable)`); + } + if (!aliasUsable && isRunnable(command[0]!, env)) { + return spawn(command[0]!, command.slice(1), direct); + } + console.error(`[pxpipe] warp: resolving ${command[0]} via interactive shell fallback`); + // The command word is deliberately left unquoted: a shell only expands + // aliases on unquoted words, so quoting it would defeat the entire point of + // this fallback. Arguments are still quoted — they are data, never aliases. + const script = [command[0]!, ...command.slice(1).map(shellQuote)].join(' '); + return spawn(shell, ['-ic', script], direct); + }; + + const spawnChild = (command: string[], proxyUrl: string): void => { + const env = { ...process.env }; + // The agent must believe it is talking to api.anthropic.com. Both of these + // would defeat that, and either may be left over in the user's shell from a + // previous non-warp session. + delete env.ANTHROPIC_BASE_URL; + delete env.ANTHROPIC_UNIX_SOCKET; + env.HTTP_PROXY = proxyUrl; + env.http_proxy = proxyUrl; + env.HTTPS_PROXY = proxyUrl; + env.https_proxy = proxyUrl; + // Trust is scoped to this child; nothing is added to the system keychain. + // Which variable actually works depends on the agent's runtime, and the + // answer is not stable: the macOS `claude` is a native Mach-O binary, not a + // 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. + 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 + + const child = spawnResolved(command, env); + child.on('error', (err) => { + console.error(`[pxpipe] warp: cannot run ${command[0]}: ${err.message}`); + process.exit(127); + }); + const forwarded = ['SIGINT', 'SIGTERM'] as const; + child.on('exit', (code, signal) => { + // Reproduce the child's own exit status so warp is transparent to callers. + // Re-raising means routing the signal back through our own handlers, so + // drop them first: Ctrl-C kills the child with SIGINT, and without this + // the re-raise just re-enters the forwarder, kills an already-dead child + // and leaves warp running forever. + if (signal) { + for (const s of forwarded) process.removeAllListeners(s); + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); + for (const signal of forwarded) { + process.on(signal, () => child.kill(signal)); + } + }; + + const launch = (command: string[]): void => { + if (command.length === 0) { + console.error('[pxpipe] warp: nothing to run — usage: pxpipe warp -- [args...]'); + process.exit(2); + } + // Startup banner goes to stderr: stdout belongs to the child, so a caller + // piping the agent's output gets the agent's bytes and nothing of ours. + for (const route of routes) { + console.error(`[pxpipe] warp route → ${route.pattern} → ${routeDestination(route)}`); + } + console.error(`[pxpipe] warp CA → ${ca.certPath}`); + console.error(`[pxpipe] warp exec → ${command.join(' ')}`); + + proxy.on('error', (err) => { + console.error(`[pxpipe] warp: proxy listener failed: ${err.message}`); + process.exit(1); + }); + proxy.listen(0, '127.0.0.1', () => { + const address = proxy.address(); + const proxyUrl = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + console.error(`[pxpipe] warp proxy → ${proxyUrl} (child only)`); + spawnChild(command, proxyUrl); + }); + }; + + return { launch }; +} diff --git a/src/warp/route.ts b/src/warp/route.ts new file mode 100644 index 0000000..6f657b8 --- /dev/null +++ b/src/warp/route.ts @@ -0,0 +1,124 @@ +/** + * Routes re-point a matching request at a different upstream, so pxpipe can + * transform it without the agent ever seeing a non-first-party base URL. + * + * That indirection is the whole point. Claude Code hides /remote-control the + * moment ANTHROPIC_BASE_URL is custom (and gates connectors on the same + * "firstParty" check), so pointing the agent at pxpipe directly costs you the + * feature. Under warp the agent still talks to api.anthropic.com; only the one + * path we rewrite is diverted, and auth, telemetry and the control plane go to + * the real host untouched. + * + * Ported from wardex route.go. + */ + +export interface Route { + /** Original spec text, for banners and logs. */ + readonly pattern: string; + /** Anchored matcher against "host/path". */ + readonly re: RegExp; + /** + * Host-only matcher, used to decide whether a CONNECT is worth decrypting at + * all — a CONNECT carries no path. + */ + readonly hostRe: RegExp; + readonly target: URL; + /** + * Target path prefix, normalized to "" or "/foo" (no trailing slash). Kept + * separately because URL.pathname cannot represent the empty path: assigning + * "" to a special-scheme URL silently snaps it back to "/", which would make + * every rewrite emit a doubled "//v1/messages". + */ + readonly prefix: string; +} + +function quoteMeta(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Turn a host/path glob into an anchored regexp. "*" spans anything including + * "/", matching wardex's semantics. + */ +function compilePattern(pattern: string): RegExp { + const withPath = pattern.includes('/') ? pattern : `${pattern}/*`; + const parts = withPath.toLowerCase().split('*').map(quoteMeta); + return new RegExp(`^${parts.join('.*')}$`); +} + +/** + * parseRoute reads one PATTERN=TARGET rule: + * + * api.anthropic.com/v1/messages*=http://127.0.0.1:47821 + * re:^api\.anthropic\.com/v1/messages(/.*)?$=http://127.0.0.1:47821 + * + * PATTERN matches "host/path" with no scheme and no query. TARGET is + * scheme://host[:port][/prefix]; the original path and query ride along. + */ +export function parseRoute(spec: string): Route { + // The target always ends the spec and always carries a scheme, so split on + // the scheme rather than on "=" — patterns are regexps and may contain "=". + let i = spec.lastIndexOf('=http://'); + const j = spec.lastIndexOf('=https://'); + if (j > i) i = j; + if (i < 0) throw new Error(`route ${JSON.stringify(spec)}: want PATTERN=http://host:port`); + + const pattern = spec.slice(0, i); + const rawTarget = spec.slice(i + 1); + if (!pattern) throw new Error(`route ${JSON.stringify(spec)}: empty pattern`); + + let re: RegExp; + try { + re = compilePattern(pattern); + } catch (err) { + throw new Error(`route ${JSON.stringify(spec)}: bad pattern: ${(err as Error).message}`); + } + + let target: URL; + try { + target = new URL(rawTarget); + } catch { + throw new Error(`route ${JSON.stringify(spec)}: bad target: ${rawTarget}`); + } + if (!target.host) throw new Error(`route ${JSON.stringify(spec)}: target has no host`); + if (target.search || target.hash) { + throw new Error(`route ${JSON.stringify(spec)}: target must not carry a query or fragment`); + } + const prefix = target.pathname === '/' ? '' : target.pathname.replace(/\/$/, ''); + + const hostGlob = pattern.split('/')[0]!; + const hostRe = new RegExp(`^${hostGlob.toLowerCase().split('*').map(quoteMeta).join('.*')}$`); + + return { pattern, re, hostRe, target, prefix }; +} + +/** First match wins, so earlier rules shadow later ones. */ +export function matchRoute(routes: readonly Route[], host: string, path: string): Route | null { + const subject = host.toLowerCase() + path; + for (const route of routes) { + if (route.re.test(subject)) return route; + } + return null; +} + +/** + * Whether any rule could match this host. Decryption is decided at CONNECT + * time, before a path exists, so this is deliberately permissive: guessing + * "yes" costs a needless MITM, guessing "no" silently breaks the route. + */ +export function hostCouldMatch(routes: readonly Route[], host: string): boolean { + const lower = host.toLowerCase(); + return routes.some((route) => route.hostRe.test(lower)); +} + +/** + * Absolute URL a matched request is sent to. Path and query are preserved so + * the downstream sees the same request the agent made. + */ +export function rewriteUrl(route: Route, requestUri: string): string { + return `${route.target.protocol}//${route.target.host}${route.prefix}${requestUri}`; +} + +export function routeDestination(route: Route): string { + return `${route.target.protocol}//${route.target.host}${route.prefix}`; +}