Skip to content
Draft
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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,39 @@ 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 codex`

Codex has a dedicated native Responses integration. Start the persistent PXPipe
listener normally, then launch Codex through it:

```bash
pxpipe codex
pxpipe codex --binary codex-ar
```

The launcher preserves Codex-owned ChatGPT authentication and `CODEX_HOME`,
routes `/backend-api/codex/responses` through PXPipe, and leaves native
`/responses/compact` traffic untouched. If the listener is unavailable it
falls back to a direct Codex launch without rewriting the caller environment.

Use `pxpipe codex --direct` to request the direct path explicitly.

See [docs/CODEX_INTEGRATION.md](docs/CODEX_INTEGRATION.md) for the routing and
authentication contract.

### `pxpipe warp`

```bash
pxpipe warp -- claude # also: cursor-agent, codex, or a shell alias
pxpipe warp -- claude # also: cursor-agent 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.

For Codex, prefer the dedicated `pxpipe codex` launcher above rather than the
Anthropic-oriented Warp path.

`api.anthropic.com/v1/messages` is routed by default. Agents that reach their
provider over some other base URL need a rule for it, and a rule that names a
port matches only that port:
Expand Down
6 changes: 4 additions & 2 deletions bin/cli.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node
// Tiny shim: just runs the bundled Node entry. Real CLI logic lives in src/node.ts.
import('../dist/node.js').catch((err) => {
// Tiny shim: dispatch the dedicated Codex launcher before the bundled server
// entry. All other CLI logic remains in src/node.ts.
const entry = process.argv[2] === 'codex' ? '../dist/codex-entry.js' : '../dist/node.js';
import(entry).catch((err) => {
console.error('[pxpipe] failed to start:', err);
console.error('[pxpipe] did you forget to `npm run build`?');
process.exit(1);
Expand Down
45 changes: 45 additions & 0 deletions docs/CODEX_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Codex integration

PXPipe can launch Codex through the existing persistent loopback listener without
rewriting Codex authentication files or replacing the native Responses API.

```bash
pxpipe codex
pxpipe codex --binary codex-ar
```

The launcher installs temporary Codex provider overrides for the child process:

- provider id: `pxpipe`;
- provider display name: `OpenAI`;
- wire API: `responses`;
- auth: Codex/OpenAI auth remains owned by the caller;
- base URL: `http://127.0.0.1:<PORT>/providers/codex/backend-api/codex`.

`CODEX_HOME` is not changed, so alternate wrappers/accounts keep their own
configuration and authentication state. While routing through PXPipe,
`OPENAI_BASE_URL` and inherited loopback proxy variables are removed from the
child because provider routing is expressed through Codex's model-provider
configuration instead. Explicit `--direct` mode and the automatic fallback used
when no persistent listener is available preserve the caller environment
unchanged.

The persistent Node listener owns an isolated `codex` provider route whose
Anthropic/default and OpenAI upstream bases both point to `https://chatgpt.com`.
The route does not inherit alternate gateway routing, gateway headers, API keys,
or Cloudflare provider credentials from the default listener. That lets normal
`/backend-api/codex/responses` requests use PXPipe's existing Responses
transform/accounting path while native endpoints such as `/responses/compact`
remain pass-through requests on the same authenticated origin.

PXPipe does not claim WebSocket Responses support here; the provider override
sets `supports_websockets=false`. Use the dedicated `pxpipe codex` launcher,
not the Anthropic-oriented Warp path.

If the persistent listener is unavailable, the launcher prints a warning and
starts Codex directly. `--direct` requests that behavior explicitly.

Model selection is read-only. Explicit Codex model arguments win, then the
selected profile model, then top-level `config.toml`. When no persistent model
can be resolved, PXPipe leaves model selection to the installed Codex CLI
instead of injecting a reference model.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"assets/*LICENSE.txt",
"README.md",
"SECURITY.md",
"docs/CODEX_INTEGRATION.md",
"docs/SECURITY_MODEL.md",
"LICENSE"
],
Expand Down
87 changes: 87 additions & 0 deletions src/codex-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';

import {
buildCodexCommandArgs,
buildCodexEnvironment,
parseCodexInvocation,
resolveCodexPersistentProxy,
} from './core/codex.js';
import { resolveCodexModelSelection } from './core/codex-model.js';

const REFERENCE_MODEL = 'gpt-5.6-sol';

function readCodexConfig(env: NodeJS.ProcessEnv): string | undefined {
const root = env.CODEX_HOME?.trim() || join(homedir(), '.codex');
try { return readFileSync(join(root, 'config.toml'), 'utf8'); }
catch { return undefined; }
}

function launch(binary: string, args: string[], env: NodeJS.ProcessEnv): void {
const child = spawn(binary, args, { stdio: 'inherit', env });
const handlers = new Map<NodeJS.Signals, () => void>();
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const) {
const handler = (): void => { child.kill(signal); };
handlers.set(signal, handler);
process.on(signal, handler);
}
const cleanup = (): void => {
for (const [signal, handler] of handlers) process.off(signal, handler);
};
child.on('error', (error) => {
cleanup();
console.error(`[pxpipe] codex: cannot run ${binary}: ${error.message}`);
process.exitCode = 127;
});
child.on('exit', (code, signal) => {
cleanup();
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exitCode = code ?? 0;
});
}

async function main(): Promise<void> {
let invocation;
try {
invocation = parseCodexInvocation(process.argv.slice(2));
} catch (error) {
console.error(`[pxpipe] codex: ${(error as Error).message}`);
process.exitCode = 2;
return;
}

const directEnv = buildCodexEnvironment(process.env, 'direct');
if (invocation.direct) {
launch(invocation.binary, invocation.args, directEnv);
return;
}

const proxy = await resolveCodexPersistentProxy(process.env);
if (!proxy) {
console.warn('[pxpipe] codex: persistent listener unavailable; launching Codex direct');
launch(invocation.binary, invocation.args, directEnv);
return;
}

const childEnv = buildCodexEnvironment(process.env, 'proxied');

const selection = resolveCodexModelSelection(
invocation.args,
readCodexConfig(process.env),
REFERENCE_MODEL,
);
// Do not inject the diagnostic reference fallback into Codex. If no user
// model can be resolved, leave model selection to the installed Codex CLI.
const resolvedModel = selection.source === 'reference' ? undefined : selection.model;
const args = buildCodexCommandArgs(proxy.baseUrl, invocation.args, resolvedModel);
console.error(`[pxpipe] codex → 127.0.0.1:${proxy.port} (native Responses route)`);
launch(invocation.binary, args, childEnv);
}

void main();
155 changes: 155 additions & 0 deletions src/core/codex-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/** Narrow, read-only Codex model-selection helpers. */

export type CodexModelSource = 'cli' | 'profile' | 'config' | 'reference';

export interface CodexModelSelection {
model: string;
source: CodexModelSource;
profile?: string;
}

interface ParsedCodexConfig {
model?: string;
profile?: string;
profileModels: Map<string, string>;
}

function stripTomlComment(raw: string): string {
let quote: 'single' | 'double' | null = null;
let escaped = false;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i]!;
if (quote === 'double') {
if (escaped) { escaped = false; continue; }
if (ch === '\\') { escaped = true; continue; }
if (ch === '"') quote = null;
continue;
}
if (quote === 'single') {
if (ch === "'") quote = null;
continue;
}
if (ch === '"') quote = 'double';
else if (ch === "'") quote = 'single';
else if (ch === '#') return raw.slice(0, i);
}
return raw;
}

function unquoteTomlScalar(raw: string): string | undefined {
const value = stripTomlComment(raw).trim();
if (!value) return undefined;
if (value.startsWith('"')) {
try {
const parsed = JSON.parse(value) as unknown;
return typeof parsed === 'string' && parsed.trim() ? parsed.trim() : undefined;
} catch { return undefined; }
}
if (value.startsWith("'")) {
const end = value.indexOf("'", 1);
if (end < 0) return undefined;
return value.slice(1, end).trim() || undefined;
}
return value.split(/\s+/)[0]?.trim() || undefined;
}

function assignmentValue(raw: string, key: string): string | undefined {
const eq = raw.indexOf('=');
if (eq < 0 || raw.slice(0, eq).trim() !== key) return undefined;
return unquoteTomlScalar(raw.slice(eq + 1));
}

function configOverride(args: readonly string[], key: string): string | undefined {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i]!;
if (arg === '-c' || arg === '--config') {
const next = args[i + 1];
if (next !== undefined) {
const value = assignmentValue(next, key);
if (value !== undefined) return value;
i += 1;
}
} else if (arg.startsWith('-c=')) {
const value = assignmentValue(arg.slice(3), key);
if (value !== undefined) return value;
} else if (arg.startsWith('--config=')) {
const value = assignmentValue(arg.slice('--config='.length), key);
if (value !== undefined) return value;
}
}
return undefined;
}

export function codexModelFromArgs(args: readonly string[]): string | undefined {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i]!;
if (arg === '-m' || arg === '--model') {
const next = args[i + 1]?.trim();
if (next) return next;
} else if (arg.startsWith('--model=')) {
const value = arg.slice('--model='.length).trim();
if (value) return value;
}
}
return configOverride(args, 'model');
}

export function codexProfileFromArgs(args: readonly string[]): string | undefined {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i]!;
if (arg === '-p' || arg === '--profile') {
const next = args[i + 1]?.trim();
if (next) return next;
} else if (arg.startsWith('--profile=')) {
const value = arg.slice('--profile='.length).trim();
if (value) return value;
}
}
return configOverride(args, 'profile');
}

function parseCodexConfig(text: string | undefined): ParsedCodexConfig {
const parsed: ParsedCodexConfig = { profileModels: new Map() };
if (!text) return parsed;
let profileSection: string | null = null;
let inOtherSection = false;
for (const rawLine of text.split(/\r?\n/)) {
const line = stripTomlComment(rawLine).trim();
if (!line) continue;
const section = /^\[([^\]]+)\]$/.exec(line);
if (section) {
const profile = /^profiles\.([A-Za-z0-9_.-]+)$/.exec(section[1]!.trim());
profileSection = profile?.[1] ?? null;
inOtherSection = profileSection === null;
continue;
}
const model = assignmentValue(line, 'model');
if (model !== undefined) {
if (profileSection !== null) parsed.profileModels.set(profileSection, model);
else if (!inOtherSection) parsed.model = model;
continue;
}
if (!inOtherSection && profileSection === null) {
const profile = assignmentValue(line, 'profile');
if (profile !== undefined) parsed.profile = profile;
}
}
return parsed;
}

export function resolveCodexModelSelection(
args: readonly string[],
configText: string | undefined,
referenceModel: string,
): CodexModelSelection {
const explicit = codexModelFromArgs(args);
if (explicit) return { model: explicit, source: 'cli' };
const config = parseCodexConfig(configText);
const profile = codexProfileFromArgs(args) ?? config.profile;
if (profile) {
const model = config.profileModels.get(profile);
if (model) return { model, source: 'profile', profile };
}
if (config.model) return { model: config.model, source: 'config' };
return { model: referenceModel, source: 'reference', ...(profile ? { profile } : {}) };
}
Loading