Skip to content
Closed
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
13 changes: 11 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ on:
push:
tags: ['v*']

# Least-privilege: nessun job deve avere più permessi del necessario.
permissions:
contents: write
contents: read

env:
BIN_NAME: claude-code-proxy

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
strategy:
fail-fast: false
matrix:
Expand All @@ -29,7 +33,8 @@ jobs:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Pinned for reproducible builds and supply-chain integrity.
bun-version: "1.3.10"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
Expand Down Expand Up @@ -57,6 +62,7 @@ jobs:
release:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
Expand All @@ -74,6 +80,9 @@ jobs:
update-tap:
needs: release
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/download-artifact@v5
with:
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
node_modules/
dist/
*.log
.env
.env.*
auth.json
device_id
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"name": "claude-code-proxy",
"version": "0.0.5",
"description": "Local proxy: Claude Code to ChatGPT subscription via Codex Responses API",
"private": true,
"license": "MIT",
"type": "module",
"bin": {
Expand All @@ -11,11 +13,24 @@
"auth": "bun run src/cli.ts auth login",
"typecheck": "tsc --noEmit"
},
"repository": {
"type": "git",
"url": "https://github.com/raine/claude-code-proxy.git"
},
"author": "Raine Virta <raine.virta@gmail.com>",
"bugs": {
"url": "https://github.com/raine/claude-code-proxy/issues"
},
"homepage": "https://github.com/raine/claude-code-proxy#readme",
"engines": {
"bun": ">=1.0.0"
},
"packageManager": "bun@1.3.10",
"dependencies": {
"gpt-tokenizer": "^2.9.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/bun": "1.3.12",
"typescript": "^5.6.0"
}
}
1 change: 0 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,5 @@ Models: ${models}

main().catch((err) => {
log.error("cli fatal", { err: String(err), stack: (err as Error)?.stack })
console.error(err)
process.exit(1)
})
25 changes: 20 additions & 5 deletions src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ const REDACT_KEYS = new Set([
"ChatGPT-Account-Id",
"chatgpt-account-id",
"x-api-key",
"password",
"secret",
"api_key",
"apikey",
"cookie",
"private_key",
"credentials",
"bearer",
"proxy-authorization",
"x-api-key",
"x-auth-token",
"token",
])

function stateDir(): string {
Expand All @@ -34,9 +46,9 @@ let rotating: Promise<void> | undefined
async function ensureStream(): Promise<WriteStream> {
if (stream) return stream
const dir = stateDir()
await mkdir(dir, { recursive: true })
await mkdir(dir, { recursive: true, mode: 0o700 })
const file = join(dir, "proxy.log")
stream = createWriteStream(file, { flags: "a" })
stream = createWriteStream(file, { flags: "a", mode: 0o600 })
return stream
}

Expand All @@ -45,6 +57,7 @@ async function maybeRotate(): Promise<void> {
rotating = (async () => {
try {
const dir = stateDir()
await mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => undefined)
const file = join(dir, "proxy.log")
const s = await stat(file).catch(() => undefined)
if (!s || s.size < MAX_LOG_BYTES) return
Expand All @@ -65,21 +78,23 @@ async function maybeRotate(): Promise<void> {

const VERBOSE = !!process.env.CCP_LOG_VERBOSE

function redact(value: unknown, depth = 0): unknown {
function redact(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
if (depth > 6) return "[depth-limit]"
if (value == null) return value
if (typeof value === "string") {
if (!VERBOSE && value.length > 4000) return value.slice(0, 4000) + `…[${value.length - 4000} more]`
return value
}
if (typeof value !== "object") return value
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1))
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1, seen))
if (seen.has(value)) return "[circular]"
seen.add(value)
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (REDACT_KEYS.has(k)) {
out[k] = typeof v === "string" ? `[redacted len=${v.length}]` : "[redacted]"
} else {
out[k] = redact(v, depth + 1)
out[k] = redact(v, depth + 1, seen)
}
}
return out
Expand Down
2 changes: 0 additions & 2 deletions src/providers/codex/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
export const ISSUER = "https://auth.openai.com"
export const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
export const OAUTH_PORT = 1455
export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback`
export const ORIGINATOR = "claude-code-proxy"
export const REFRESH_MARGIN_MS = 5 * 60 * 1000
7 changes: 6 additions & 1 deletion src/providers/codex/auth/manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CLIENT_ID, ISSUER, REFRESH_MARGIN_MS } from "./constants.ts"
import { extractAccountId, type TokenResponse } from "./jwt.ts"
import { loadAuth, saveAuth, type StoredAuth } from "./token-store.ts"
import { clearAuth, loadAuth, saveAuth, type StoredAuth } from "./token-store.ts"

let cached: StoredAuth | undefined
let inflight: Promise<StoredAuth> | undefined
Expand Down Expand Up @@ -44,6 +44,11 @@ async function refreshNow(current: StoredAuth): Promise<StoredAuth> {
client_id: CLIENT_ID,
}).toString(),
})
if (resp.status === 401 || resp.status === 403) {
cached = undefined
await clearAuth().catch(() => undefined)
throw new Error(`Token refresh unauthorized (${resp.status})`)
}
if (!resp.ok) throw new Error(`Token refresh failed: ${resp.status} ${await resp.text()}`)
const tokens = (await resp.json()) as TokenResponse
const accountId = extractAccountId(tokens) || current.accountId
Expand Down
47 changes: 32 additions & 15 deletions src/providers/codex/auth/pkce.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createServer } from "node:http"
import { CLIENT_ID, ISSUER, OAUTH_PORT, OAUTH_REDIRECT_URI, ORIGINATOR } from "./constants.ts"
import type { AddressInfo } from "node:net"
import { CLIENT_ID, ISSUER, ORIGINATOR } from "./constants.ts"
import type { TokenResponse } from "./jwt.ts"

export interface PkceCodes {
Expand All @@ -8,17 +9,14 @@ export interface PkceCodes {
}

export async function generatePKCE(): Promise<PkceCodes> {
const verifier = generateRandomString(43)
const verifier = generateRandomString(128)
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
return { verifier, challenge: base64UrlEncode(hash) }
}

function generateRandomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const bytes = crypto.getRandomValues(new Uint8Array(length))
return Array.from(bytes)
.map((b) => chars[b % chars.length])
.join("")
return base64UrlEncode(bytes.buffer).slice(0, length)
}

function base64UrlEncode(buffer: ArrayBuffer): string {
Expand All @@ -28,15 +26,24 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}

function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}

export function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}

export function buildAuthorizeUrl(pkce: PkceCodes, state: string): string {
export function buildAuthorizeUrl(pkce: PkceCodes, state: string, redirectUri: string): string {
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: OAUTH_REDIRECT_URI,
redirect_uri: redirectUri,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
Expand All @@ -48,14 +55,14 @@ export function buildAuthorizeUrl(pkce: PkceCodes, state: string): string {
return `${ISSUER}/oauth/authorize?${params.toString()}`
}

export async function exchangeCodeForTokens(code: string, pkce: PkceCodes): Promise<TokenResponse> {
export async function exchangeCodeForTokens(code: string, pkce: PkceCodes, redirectUri: string): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: OAUTH_REDIRECT_URI,
redirect_uri: redirectUri,
client_id: CLIENT_ID,
code_verifier: pkce.verifier,
}).toString(),
Expand All @@ -67,7 +74,6 @@ export async function exchangeCodeForTokens(code: string, pkce: PkceCodes): Prom
export async function runBrowserLogin(): Promise<TokenResponse> {
const pkce = await generatePKCE()
const state = generateState()
const authUrl = buildAuthorizeUrl(pkce, state)

return new Promise<TokenResponse>((resolve, reject) => {
const cleanup = () => {
Expand All @@ -76,24 +82,32 @@ export async function runBrowserLogin(): Promise<TokenResponse> {
server.closeAllConnections?.()
}
const server = createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
const port = (server.address() as AddressInfo | null)?.port ?? 0
const url = new URL(req.url || "/", `http://localhost:${port}`)
if (url.pathname !== "/auth/callback") {
res.writeHead(404)
res.end("Not found")
return
}
const host = req.headers.host
if (host !== `localhost:${port}` && host !== `127.0.0.1:${port}`) {
res.writeHead(403)
res.end("Invalid host")
return
}
const code = url.searchParams.get("code")
const receivedState = url.searchParams.get("state")
const error = url.searchParams.get("error")
if (error || !code || receivedState !== state) {
const msg = error || "Invalid callback"
res.writeHead(400, { "Content-Type": "text/plain" })
res.end(`Auth failed: ${msg}`)
res.end(`Auth failed: ${escapeHtml(msg)}`)
cleanup()
reject(new Error(msg))
return
}
exchangeCodeForTokens(code, pkce)
const redirectUri = `http://localhost:${port}/auth/callback`
exchangeCodeForTokens(code, pkce, redirectUri)
.then((tokens) => {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(
Expand All @@ -109,7 +123,10 @@ export async function runBrowserLogin(): Promise<TokenResponse> {
reject(err)
})
})
server.listen(OAUTH_PORT, () => {
server.listen(0, () => {
const port = (server.address() as AddressInfo).port
const redirectUri = `http://localhost:${port}/auth/callback`
const authUrl = buildAuthorizeUrl(pkce, state, redirectUri)
console.log(`Open this URL in your browser to authorize:\n\n ${authUrl}\n`)
})
server.on("error", reject)
Expand Down
2 changes: 1 addition & 1 deletion src/providers/codex/auth/token-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function saveAuth(auth: StoredAuth): Promise<void> {
return
}

await mkdir(dirname(FILE), { recursive: true })
await mkdir(dirname(FILE), { recursive: true, mode: 0o700 })
const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp`
await writeFile(tmp, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 })
try {
Expand Down
18 changes: 16 additions & 2 deletions src/providers/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,24 @@ interface SessionTimelineState {
lastMessage?: SessionMessageSnapshot
}

const MAX_SESSION_TIMELINE_ENTRIES = 10000
const sessionTimeline = new Map<string, SessionTimelineState>()
let sessionTimelineLastCleanup = Date.now()

function maybeCleanupSessionTimeline(): void {
if (sessionTimeline.size < MAX_SESSION_TIMELINE_ENTRIES) return
if (Date.now() - sessionTimelineLastCleanup < 60000) return
const cutoff = Date.now() - 3600000 // 1h TTL based on last access
for (const [k] of sessionTimeline) {
sessionTimeline.delete(k)
if (sessionTimeline.size <= MAX_SESSION_TIMELINE_ENTRIES * 0.75) break
}
sessionTimelineLastCleanup = Date.now()
}

function sessionState(sessionId?: string): SessionTimelineState | undefined {
if (!sessionId) return undefined
maybeCleanupSessionTimeline()
let state = sessionTimeline.get(sessionId)
if (!state) {
state = {}
Expand Down Expand Up @@ -212,7 +226,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom
log.warn("codex error", { status: err.status, detail: err.detail })
if (err.status === 429) {
const headers: Record<string, string> = { "content-type": "application/json" }
if (err.meta?.retryAfter) headers["retry-after"] = err.meta.retryAfter
if (err.meta?.retryAfter && /^\d+$/.test(err.meta.retryAfter)) headers["retry-after"] = err.meta.retryAfter
return new Response(
JSON.stringify({
type: "error",
Expand Down Expand Up @@ -315,7 +329,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom
})
if (err.kind === "rate_limit") {
const headers: Record<string, string> = { "content-type": "application/json" }
if (err.retryAfterSeconds) headers["retry-after"] = String(err.retryAfterSeconds)
if (err.retryAfterSeconds && Number.isInteger(err.retryAfterSeconds) && err.retryAfterSeconds > 0) headers["retry-after"] = String(err.retryAfterSeconds)
return new Response(
JSON.stringify({
type: "error",
Expand Down
Loading