From c922ce4fbfde7941a606fffac5d374a613cab3ee Mon Sep 17 00:00:00 2001 From: Sean Carey Date: Mon, 28 Sep 2026 13:05:19 +0900 Subject: [PATCH 1/2] Run as a LiteLLM sidecar: reasoning_content, failover-friendly errors, usage, container image Fixes found against the live gateway and the reverse-engineered wire protocol: reasoning streamed as content, errors swallowed into a 200 (and late events writing to an ended response), zero usage, tool-call input sent as a JSON string, image parts in the wrong shape, missing session headers, and a CLI version header pinned 12 releases stale. Adds a Dockerfile, unit tests, and an image workflow that publishes sha- tags. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 3 + .github/workflows/image.yml | 54 ++++ Dockerfile | 9 + README.md | 18 +- package.json | 3 +- server.js | 490 ++++++++++++++++++++++-------------- test/transform.test.js | 60 +++++ 7 files changed, 441 insertions(+), 196 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/image.yml create mode 100644 Dockerfile create mode 100644 test/transform.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8a20fb5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +* +!package.json +!server.js diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..c7ae346 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,54 @@ +name: Test and publish image +# Every branch push publishes an immutable sha- tag; deployments pin the +# digest, so a pre-merge branch build is as deployable as a main build. +on: + pull_request: + push: + branches: ['**'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: image-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + test: + # Public repo: the org's self-hosted runner groups refuse public repos, so + # this runs on GitHub-hosted runners (no minutes charge for public repos). + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '22' + - run: npm test + image: + needs: test + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + if: github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v7 + id: image + with: + context: . + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ghcr.io/seankoji-com/proxy-commandcode:sha-${{ github.sha }} + cache-from: type=gha,scope=proxy-commandcode + cache-to: type=gha,scope=proxy-commandcode,mode=max + - if: github.event_name != 'pull_request' + env: + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + run: echo "Deploy ghcr.io/seankoji-com/proxy-commandcode@${IMAGE_DIGEST}" >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36310b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json server.js ./ +ENV NODE_ENV=production PCMC_PORT=3456 +USER node +EXPOSE 3456 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+process.env.PCMC_PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 9490083..68cccb2 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,22 @@ A transparent reverse proxy that translates **OpenAI-compatible** requests (`/v1 Use any CommandCode model (including the Go plan) with **ZCode**, **9router**, **Cursor**, **Continue**, **Aider**, and any editor that supports custom OpenAI endpoints. +## Fork changes (seankoji-com) + +Forked from [nasrulhadi/proxy-commandcode](https://github.com/nasrulhadi/proxy-commandcode) at `1a95fbb` to run as a LiteLLM sidecar in a container. Differences: + +| Area | Change | +|---|---| +| Reasoning | Streamed and returned as `reasoning_content`, not mixed into `content` | +| Errors | An upstream `error` event before any output returns HTTP 502 (so a router can fail over); later events are ignored instead of writing to an ended response; network errors carry the code/addresses instead of an empty message | +| Usage | `finish-step`/`finish` usage mapped to OpenAI `usage` (sync body; extra stream chunk with empty `choices`) | +| Finish reason | `length` / `tool_calls` / `content_filter` mapped from the upstream reason | +| Request shape | Tool-call `input` sent as an object; consecutive tool results merged; `developer` role folded into `system`; image parts use `{image, mediaType}`; `temperature` and `max_completion_tokens` passed through; `x-session-id` and `x-cli-environment` headers sent | +| CLI version | `x-command-code-version` tracks `command-code@latest` on npm (checked at start and every 6 h, default `1.66.0`); set `PCMC_VERSION` to pin | +| Runtime | Logs to stdout only (no `proxy.log`, no ANSI without a TTY); Windows `netstat`/`taskkill` port handling removed; graceful `SIGTERM`; `Dockerfile` + `npm test` | + +Image: `ghcr.io/seankoji-com/proxy-commandcode:sha-`, built by `.github/workflows/image.yml` on every branch push. Deploy by digest. + ## Why CommandCode has two API surfaces: @@ -48,7 +64,7 @@ Zero dependencies. Node.js 18+ only. | Variable | Default | |---|---| | `PCMC_PORT` | `3456` | -| `PCMC_VERSION` | `1.54.0` | +| `PCMC_VERSION` | unset — tracks npm `command-code@latest` (floor `1.66.0`); set to pin | | `PCMC_DEBUG` | off (set `1` to enable) | ### Enabling debug mode diff --git a/package.json b/package.json index fa523e4..274950e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "server.js", "scripts": { "start": "node server.js", - "dev": "node --watch server.js" + "dev": "node --watch server.js", + "test": "node --test" }, "dependencies": {} } diff --git a/server.js b/server.js index 80ccb7d..98ad2b7 100644 --- a/server.js +++ b/server.js @@ -1,77 +1,88 @@ /** * Proxy: OpenAI /v1/chat/completions → CommandCode /alpha/generate + * + * seankoji-com fork — see "Fork changes" in README.md for what differs from + * nasrulhadi/proxy-commandcode. */ const http = require('http'); const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const PORT = process.env.PCMC_PORT || 3456; +const crypto = require('crypto'); + +const PORT = Number(process.env.PCMC_PORT || 3456); const HOST = 'api.commandcode.ai'; const PATH = '/alpha/generate'; -const CC_VERSION = process.env.PCMC_VERSION || '1.54.0'; -const DEBUG = process.env.PCMC_DEBUG === '1'; // set PCMC_DEBUG=1 to enable +// Floor used when npm is unreachable. PCMC_VERSION pins it and disables the +// npm lookup; otherwise the gateway's stale-version rejection is avoided by +// tracking the published CLI version. +const DEFAULT_CC_VERSION = '1.66.0'; +const PINNED_VERSION = process.env.PCMC_VERSION || ''; +const VERSION_REFRESH_MS = 6 * 60 * 60 * 1000; +const DEBUG = process.env.PCMC_DEBUG === '1'; + +let ccVersion = PINNED_VERSION || DEFAULT_CC_VERSION; + +const TTY = process.stdout.isTTY; +const C = TTY + ? { reset: '\x1b[0m', cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m' } + : { reset: '', cyan: '', green: '', yellow: '', dim: '', bold: '' }; -const logFile = fs.createWriteStream(path.join(__dirname, 'proxy.log'), { flags: 'a' }); function writeLog(level, msg) { const ts = `[${new Date().toISOString()}]`; - const colors = { req: C.cyan, upstream: C.green, done: C.bold, error: C.yellow, default: C.reset }; + const colors = { req: C.cyan, upstream: C.green, done: C.bold, error: C.yellow }; const c = colors[level] || C.reset; - const tag = level ? `${c}${level}${C.reset}` : ''; - const full = `${C.dim}${ts}${C.reset} ${tag ? `[${tag}] ` : ''}${msg}`; - process.stdout.write(full + '\n'); - logFile.write(`${ts}${tag ? ` [${level}] ` : ' '}${msg}\n`); + const tag = level ? `[${c}${level}${C.reset}] ` : ''; + process.stdout.write(`${C.dim}${ts}${C.reset} ${tag}${msg}\n`); } -function logReq(...a) { writeLog('req', a.join(' ')); } -function logUp(...a) { writeLog('upstream', a.join(' ')); } -function logDone(...a) { writeLog('done', a.join(' ')); } -function logErr(...a) { writeLog('error', a.join(' ')); } -function log(...a) { writeLog('', a.join(' ')); } - -const C = { reset: '\x1b[0m', cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m' }; -function banner() { - const strip = s => s.replace(/\x1b\[[0-9;]*m/g, ''); - const pad = (s, w) => s + ' '.repeat(Math.max(0, w - strip(s).length)); - const L = s => `${C.bold}${s}${C.reset}`; - - const title = `${C.cyan}${C.bold}Proxy CommandCode${C.reset}`; - const sub = `${C.dim}OpenAI → CommandCode /alpha/generate${C.reset}`; - const rows = [ - `${L('Listening')} http://localhost:${PORT}`, - `${L('Endpoint')} /v1/chat/completions`, - `${L('Upstream')} ${HOST}${PATH}`, - `${L('CC Version')} ${CC_VERSION}`, - `${L('Debug')} ${DEBUG ? `${C.green}ON${C.reset}` : `${C.yellow}OFF${C.reset}`}`, - ]; - - const all = [title, sub, ...rows]; - const w = Math.max(...all.map(s => strip(s).length)); - const box = s => `${C.cyan}│${C.reset} ${pad(s, w)} ${C.cyan}│${C.reset}`; - - console.log(`${C.cyan}┌${'─'.repeat(w + 4)}┐${C.reset}`); - console.log(box(title)); - console.log(box(sub)); - console.log(`${C.cyan}├${'─'.repeat(w + 4)}┤${C.reset}`); - for (const r of rows) console.log(box(r)); - console.log(`${C.cyan}└${'─'.repeat(w + 4)}┘${C.reset}`); +const logReq = (...a) => writeLog('req', a.join(' ')); +const logUp = (...a) => writeLog('upstream', a.join(' ')); +const logDone = (...a) => writeLog('done', a.join(' ')); +const logErr = (...a) => writeLog('error', a.join(' ')); +const log = (...a) => writeLog('', a.join(' ')); + +function refreshVersion() { + if (PINNED_VERSION) return; + https.get('https://registry.npmjs.org/command-code/latest', { timeout: 10000 }, res => { + let body = ''; + res.on('data', c => { body += c; }); + res.on('end', () => { + try { + const v = JSON.parse(body).version; + if (/^\d+\.\d+\.\d+$/.test(v) && v !== ccVersion) { log(`CC version ${ccVersion} -> ${v} (npm)`); ccVersion = v; } + } catch { logErr(`npm version lookup: unparseable response (keeping ${ccVersion})`); } + }); + }).on('error', e => logErr(`npm version lookup failed: ${netError(e)} (keeping ${ccVersion})`)) + .on('timeout', function () { this.destroy(new Error('timeout')); }); } -banner(); -log(`=== proxy started (debug: ${DEBUG ? 'ON' : 'OFF'}) ===`); - const agent = new https.Agent({ keepAlive: true, keepAliveMsecs: 30000, maxSockets: 10, timeout: 300000 }); const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' }; function sse(obj) { return `data: ${JSON.stringify(obj)}\n\n`; } const STATIC_CONFIG = { - workingDir: '', date: new Date().toISOString().slice(0, 10), environment: 'windows', - structure: [], isGitRepo: false, currentBranch: '', mainBranch: 'main', gitStatus: '', recentCommits: [], + workingDir: '', environment: 'linux', structure: [], isGitRepo: false, + currentBranch: '', mainBranch: 'main', gitStatus: '', recentCommits: [], }; // ── OpenAI → CommandCode body ──────────────────────────────────────────────── +function textOf(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.filter(p => p && p.type === 'text').map(p => p.text).join(''); + return content == null ? '' : String(content); +} + +function parseArgs(args) { + if (args && typeof args === 'object') return args; + try { return JSON.parse(args || '{}'); } catch { return {}; } +} + +function imagePart(url) { + const m = /^data:([^;,]+)[;,]/.exec(url || ''); + return m ? { type: 'image', image: url, mediaType: m[1] } : { type: 'image', image: url }; +} + function transform(oaiBody) { const model = oaiBody.model || 'deepseek/deepseek-v4-pro'; let systemText = ''; @@ -84,28 +95,38 @@ function transform(oaiBody) { } for (const m of oaiBody.messages || []) { - if (m.role === 'system') { systemText += (systemText ? '\n\n' : '') + (typeof m.content === 'string' ? m.content : String(m.content)); continue; } + if (m.role === 'system' || m.role === 'developer') { + systemText += (systemText ? '\n\n' : '') + textOf(m.content); + continue; + } if (m.role === 'tool') { - const c = typeof m.content === 'string' ? { type: 'text', value: m.content } : (m.content || { type: 'text', value: String(m.content) }); - messages.push({ role: 'tool', content: [{ type: 'tool-result', toolCallId: m.tool_call_id, toolName: toolNameMap[m.tool_call_id] || 'unknown', output: c }] }); + const part = { + type: 'tool-result', toolCallId: m.tool_call_id, toolName: toolNameMap[m.tool_call_id] || 'unknown', + output: { type: 'text', value: textOf(m.content) }, + }; + // Consecutive tool results merge into one role:"tool" message. + const prev = messages[messages.length - 1]; + if (prev && prev.role === 'tool') prev.content.push(part); + else messages.push({ role: 'tool', content: [part] }); continue; } if (m.role === 'assistant') { const parts = []; - if (m.content) { - if (typeof m.content === 'string') parts.push({ type: 'text', text: m.content }); - else if (Array.isArray(m.content)) for (const p of m.content) if (p.type === 'text') parts.push({ type: 'text', text: p.text }); - } - if (m.tool_calls) for (const tc of m.tool_calls) if (tc.type === 'function' && tc.function) parts.push({ type: 'tool-call', toolCallId: tc.id, toolName: tc.function.name, input: tc.function.arguments }); + const text = textOf(m.content); + if (text) parts.push({ type: 'text', text }); + if (m.tool_calls) for (const tc of m.tool_calls) if (tc.type === 'function' && tc.function) + parts.push({ type: 'tool-call', toolCallId: tc.id, toolName: tc.function.name, input: parseArgs(tc.function.arguments) }); messages.push({ role: 'assistant', content: parts }); continue; } - if (typeof m.content === 'string') messages.push({ role: m.role, content: [{ type: 'text', text: m.content }] }); - else if (Array.isArray(m.content)) { + if (Array.isArray(m.content)) { const parts = []; - for (const p of m.content) { if (p.type === 'text') parts.push({ type: 'text', text: p.text }); else if (p.type === 'image_url') parts.push({ type: 'image', url: p.image_url?.url }); } + for (const p of m.content) { + if (p.type === 'text') parts.push({ type: 'text', text: p.text }); + else if (p.type === 'image_url') parts.push(imagePart(p.image_url?.url)); + } messages.push({ role: m.role, content: parts }); - } else messages.push({ role: m.role, content: [{ type: 'text', text: String(m.content) }] }); + } else messages.push({ role: m.role, content: [{ type: 'text', text: textOf(m.content) }] }); } const tools = (oaiBody.tools || []).map(t => ({ @@ -113,15 +134,85 @@ function transform(oaiBody) { input_schema: t.function?.parameters || t.input_schema || { type: 'object', properties: {} }, })); + const params = { + model, system: systemText || undefined, messages, tools: tools.length > 0 ? tools : undefined, + max_tokens: oaiBody.max_completion_tokens || oaiBody.max_tokens || 32000, + // The gateway rejects stream:false; always stream upstream and buffer here. + stream: true, + }; + if (typeof oaiBody.temperature === 'number') params.temperature = oaiBody.temperature; + return JSON.stringify({ config: { ...STATIC_CONFIG, date: new Date().toISOString().slice(0, 10) }, - memory: '', taste: null, skills: null, permissionMode: 'standard', - params: { model, system: systemText || undefined, messages, tools: tools.length > 0 ? tools : undefined, max_tokens: oaiBody.max_tokens || 32000, stream: oaiBody.stream !== false }, + memory: '', taste: null, skills: null, permissionMode: 'standard', params, + }); +} + +// ── NDJSON → OpenAI helpers ────────────────────────────────────────────────── + +function toOpenAIUsage(u) { + if (!u) return undefined; + const prompt = u.inputTokens ?? u.raw?.prompt_tokens ?? 0; + const completion = u.outputTokens ?? u.raw?.completion_tokens ?? 0; + return { + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: u.totalTokens ?? prompt + completion, + prompt_tokens_details: { cached_tokens: u.cachedInputTokens ?? u.inputTokenDetails?.cacheReadTokens ?? 0 }, + completion_tokens_details: { reasoning_tokens: u.reasoningTokens ?? u.outputTokenDetails?.reasoningTokens ?? 0 }, + }; +} + +function toFinishReason(r, hasTools) { + if (r === 'length') return 'length'; + if (r === 'tool-calls' || hasTools) return 'tool_calls'; + if (r === 'content-filter') return 'content_filter'; + return 'stop'; +} + +// Connect failures surface as AggregateError with an empty message. +function netError(e) { + if (e.message) return e.message; + const inner = (e.errors || []).map(x => `${x.code || ''} ${x.address || ''}`.trim()).filter(Boolean); + return [e.code || e.name || 'network error', ...inner].join(' '); +} + +function errorText(evt) { + return evt.error?.message || evt.message || JSON.stringify(evt.error ?? evt); +} + +// Reads NDJSON lines off the upstream response and hands each parsed event to +// onEvent. Buffers partial lines across chunks without a size cap. +function readEvents(proxyRes, onEvent, onEnd) { + let buf = ''; + proxyRes.setEncoding('utf8'); + proxyRes.on('data', chunk => { + buf += chunk; + let nl; + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + if (DEBUG) log(`[debug] ${line}`); + let evt; try { evt = JSON.parse(line); } catch { continue; } + onEvent(evt); + } + }); + proxyRes.on('end', () => { + const line = buf.trim(); + if (line) { try { onEvent(JSON.parse(line)); } catch {} } + onEnd(); }); } // ── response handler ───────────────────────────────────────────────────────── +function sendError(res, status, message, type = 'upstream_error') { + if (res.headersSent) return; + res.writeHead(status, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message, type } })); +} + function handleUpstreamResponse(proxyRes, res, model, isStream, t0) { if (proxyRes.statusCode >= 400) { res.writeHead(proxyRes.statusCode, { ...CORS, 'Content-Type': 'application/json' }); @@ -129,177 +220,188 @@ function handleUpstreamResponse(proxyRes, res, model, isStream, t0) { return; } - const genId = 'chatcmpl-' + Date.now(); - const dump = DEBUG - ? (fs.mkdirSync(path.join(__dirname, 'dump'), { recursive: true }), - fs.createWriteStream(path.join(__dirname, 'dump', `dump-${genId}.txt`))) - : null; - if (dump) log(`[debug] dumping to dump/dump-${genId}.txt`); + const genId = 'chatcmpl-' + crypto.randomUUID(); + const created = Math.floor(Date.now() / 1000); + let text = '', reasoning = '', errorMsg = '', usage, finishRaw; + const toolCalls = []; + const toolById = new Map(); + + const done = (reason) => logDone(`${model} | ${text.length} text / ${reasoning.length} reasoning / ${toolCalls.length} tools | ${reason} | ${Date.now() - t0}ms`); if (!isStream) { - // Non-streaming: collect all events → single JSON - let buf = '', fullText = '', fullReasoning = '', errorMsg = ''; - const toolCalls = []; let toolPart = null; - - - proxyRes.on('data', chunk => { - if (dump) dump.write(chunk); - buf += chunk.toString(); - const lines = buf.split('\n'); buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); if (!t) continue; - let evt; try { evt = JSON.parse(t); } catch { continue; } - switch (evt.type) { - case 'error': errorMsg = evt.error?.message || JSON.stringify(evt.error); break; - case 'text-delta': fullText += evt.text || ''; break; - case 'reasoning-delta': fullReasoning += evt.text || ''; break; - case 'tool-input-start': toolPart = { id: evt.id, type: 'function', function: { name: evt.toolName, arguments: '' } }; toolCalls.push(toolPart); break; - case 'tool-input-delta': if (evt.delta && toolPart) toolPart.function.arguments += evt.delta; break; - case 'tool-input-end': case 'tool-call': toolPart = null; break; + readEvents(proxyRes, evt => { + switch (evt.type) { + case 'error': errorMsg = errorMsg || errorText(evt); break; + case 'text-delta': text += evt.text || ''; break; + case 'reasoning-delta': reasoning += evt.text || ''; break; + case 'tool-input-start': { + const tc = { id: evt.id, type: 'function', function: { name: evt.toolName, arguments: '' } }; + toolCalls.push(tc); toolById.set(evt.id, tc); break; } + case 'tool-input-delta': { const tc = toolById.get(evt.id); if (tc && evt.delta) tc.function.arguments += evt.delta; break; } + case 'tool-call': { + const id = evt.id ?? evt.toolCallId; + if (!toolById.has(id)) { + const tc = { id, type: 'function', function: { name: evt.toolName, arguments: JSON.stringify(evt.input ?? {}) } }; + toolCalls.push(tc); toolById.set(id, tc); + } + break; + } + case 'finish-step': finishRaw = evt.finishReason; usage = evt.usage || usage; break; + case 'finish': finishRaw = evt.finishReason || finishRaw; usage = evt.totalUsage || usage; break; } - }); - - proxyRes.on('end', () => { - if (buf.trim()) { - try { const evt = JSON.parse(buf.trim()); if (evt.type === 'error') errorMsg = evt.error?.message || JSON.stringify(evt.error); else if (evt.type === 'text-delta') fullText += evt.text || ''; else if (evt.type === 'reasoning-delta') fullReasoning += evt.text || ''; } catch {} - } - if (errorMsg) { - logErr(`[error] ${model} | ${errorMsg}`); - res.writeHead(502, { ...CORS, 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: errorMsg, type: 'upstream_error', code: 'context_length_exceeded' } })); - return; - } - const text = fullText || fullReasoning; - const msg = { role: 'assistant', content: text || null }; - if (toolCalls.length > 0) { msg.tool_calls = toolCalls; msg.content = text || null; } + }, () => { + if (errorMsg) { logErr(`${model} | ${errorMsg}`); sendError(res, 502, errorMsg); return; } + const reason = toFinishReason(finishRaw, toolCalls.length > 0); + const message = { role: 'assistant', content: text || null }; + if (reasoning) message.reasoning_content = reasoning; + if (toolCalls.length > 0) message.tool_calls = toolCalls; res.writeHead(200, { ...CORS, 'Content-Type': 'application/json' }); res.end(JSON.stringify({ - id: genId, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model, - choices: [{ index: 0, message: msg, finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop' }], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + id: genId, object: 'chat.completion', created, model, + choices: [{ index: 0, message, finish_reason: reason }], + usage: toOpenAIUsage(usage) ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, })); - logDone(`${model} | ${text.length} text / ${toolCalls.length} tools | stop | ${t0 ? Date.now() - t0 : 0}ms`); + done(reason); }); - } else { - // Streaming: CommandCode NDJSON → OpenAI SSE chunks - let buf = '', toolCalls = [], toolIdx = 0, roleSent = false, tChars = 0, rChars = 0, errorMsg = ''; - - const writeErr = () => { - if (!res.headersSent) res.writeHead(200, { ...CORS, 'Content-Type': 'text/event-stream' }); - res.end(sse({ error: { message: errorMsg, type: 'upstream_error' } })); - }; - + let roleSent = false, toolIdx = -1; + const base = () => ({ id: genId, object: 'chat.completion.chunk', created, model }); const write = (chunk) => { + if (res.writableEnded) return; if (!res.headersSent) res.writeHead(200, { ...CORS, 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); res.write(sse(chunk)); }; - const base = () => ({ id: genId, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model }); - const ensureRole = () => { if (!roleSent) { roleSent = true; write({ ...base(), choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] }); } }; - - proxyRes.on('data', chunk => { - if (dump) dump.write(chunk); - buf += chunk.toString(); - const lines = buf.split('\n'); buf = lines.pop(); - for (const line of lines) { - const t = line.trim(); if (!t) continue; - let evt; try { evt = JSON.parse(t); } catch { continue; } - switch (evt.type) { - case 'error': - errorMsg = evt.error?.message || JSON.stringify(evt.error); writeErr(); - log(`[error] ${model} | ${errorMsg}`); - break; - case 'text-start': toolCalls = []; toolIdx = 0; roleSent = true; write({ ...base(), choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] }); break; - case 'text-delta': if (evt.text) { tChars += evt.text.length; write({ ...base(), choices: [{ index: 0, delta: { content: evt.text }, finish_reason: null }] }); } break; - case 'reasoning-delta': if (evt.text) { rChars += evt.text.length; ensureRole(); write({ ...base(), choices: [{ index: 0, delta: { content: evt.text }, finish_reason: null }] }); } break; - case 'tool-input-start': - ensureRole(); toolIdx = toolCalls.length; toolCalls.push({ id: evt.id, name: evt.toolName }); - write({ ...base(), choices: [{ index: 0, delta: { tool_calls: [{ index: toolIdx, id: evt.id, type: 'function', function: { name: evt.toolName, arguments: '' } }] }, finish_reason: null }] }); - break; - case 'tool-input-delta': - if (evt.delta && toolCalls[toolIdx]) write({ ...base(), choices: [{ index: 0, delta: { tool_calls: [{ index: toolIdx, function: { arguments: evt.delta } }] }, finish_reason: null }] }); - break; - // skip: start, start-step, text-end, reasoning-start/end, tool-input-end, tool-call, finish-step, finish, provider-metadata, error + const delta = (d) => { + if (!roleSent) { roleSent = true; d = { role: 'assistant', ...d }; } + write({ ...base(), choices: [{ index: 0, delta: d, finish_reason: null }] }); + }; + + readEvents(proxyRes, evt => { + if (errorMsg || res.writableEnded) return; + switch (evt.type) { + case 'error': + errorMsg = errorText(evt); + logErr(`${model} | ${errorMsg}`); + // Before any output, a real HTTP error lets LiteLLM fail over. + if (!res.headersSent) sendError(res, 502, errorMsg); + else { res.write(sse({ error: { message: errorMsg, type: 'upstream_error' } })); res.end(); } + break; + case 'text-delta': if (evt.text) { text += evt.text; delta({ content: evt.text }); } break; + case 'reasoning-delta': if (evt.text) { reasoning += evt.text; delta({ reasoning_content: evt.text }); } break; + case 'tool-input-start': + toolIdx = toolCalls.length; + toolCalls.push({ id: evt.id, name: evt.toolName }); + toolById.set(evt.id, toolIdx); + delta({ tool_calls: [{ index: toolIdx, id: evt.id, type: 'function', function: { name: evt.toolName, arguments: '' } }] }); + break; + case 'tool-input-delta': { + const idx = toolById.get(evt.id) ?? toolIdx; + if (evt.delta && idx >= 0) delta({ tool_calls: [{ index: idx, function: { arguments: evt.delta } }] }); + break; } + case 'tool-call': { + const id = evt.id ?? evt.toolCallId; + if (!toolById.has(id)) { + const idx = toolCalls.length; + toolCalls.push({ id, name: evt.toolName }); + toolById.set(id, idx); + delta({ tool_calls: [{ index: idx, id, type: 'function', function: { name: evt.toolName, arguments: JSON.stringify(evt.input ?? {}) } }] }); + } + break; + } + case 'finish-step': finishRaw = evt.finishReason; usage = evt.usage || usage; break; + case 'finish': finishRaw = evt.finishReason || finishRaw; usage = evt.totalUsage || usage; break; } - }); - - proxyRes.on('end', () => { - if (errorMsg) return; // already handled by error event - const reason = toolCalls.length > 0 ? 'tool_calls' : 'stop'; - write({ id: genId, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: {}, finish_reason: reason }] }); + }, () => { + if (errorMsg || res.writableEnded) return; + const reason = toFinishReason(finishRaw, toolCalls.length > 0); + if (!roleSent) delta({ content: '' }); + write({ ...base(), choices: [{ index: 0, delta: {}, finish_reason: reason }] }); + const u = toOpenAIUsage(usage); + if (u) write({ ...base(), choices: [], usage: u }); res.write('data: [DONE]\n\n'); res.end(); - const dur = t0 ? Date.now() - t0 : 0; - log(`[done] ${model} | ${tChars} text / ${rChars} reasoning / ${toolCalls.length} tools | ${reason} | ${dur}ms`); + done(reason); }); } - proxyRes.on('error', () => { if (dump) dump.end(); if (!res.writableEnded) res.end(); }); + proxyRes.on('error', e => { + logErr(`[upstream stream] ${netError(e)}`); + if (!res.headersSent) sendError(res, 502, netError(e)); + else if (!res.writableEnded) res.end(); + }); } // ── main ───────────────────────────────────────────────────────────────────── function handleRequest(req, res) { if (req.method === 'OPTIONS') { res.writeHead(204, { ...CORS, 'Access-Control-Allow-Methods': 'POST,GET,OPTIONS', 'Access-Control-Max-Age': '86400' }); res.end(); return; } - if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, CORS); res.end(JSON.stringify({ status: 'ok' })); return; } - if (req.method !== 'POST' || !req.url.startsWith('/v1/chat/completions')) { res.writeHead(404, CORS); res.end(JSON.stringify({ error: 'POST /v1/chat/completions' })); return; } + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', ccVersion })); + return; + } + if (req.method !== 'POST' || !req.url.startsWith('/v1/chat/completions')) { + res.writeHead(404, { ...CORS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'POST /v1/chat/completions', type: 'not_found' } })); + return; + } const auth = req.headers['authorization'] || ''; - let body = ''; - req.on('data', c => { body += c; if (body.length > 10 * 1024 * 1024) { req.destroy(); res.writeHead(413, CORS); res.end('{}'); } }); + const chunks = []; let size = 0; + req.on('data', c => { + size += c.length; + if (size > 10 * 1024 * 1024) { req.destroy(); sendError(res, 413, 'request body over 10MB', 'invalid_request_error'); return; } + chunks.push(c); + }); req.on('end', () => { - let oai; try { oai = JSON.parse(body); } catch { res.writeHead(400, CORS); res.end(JSON.stringify({ error: 'Invalid JSON' })); return; } + if (res.headersSent) return; + const body = Buffer.concat(chunks).toString('utf8'); + let oai; try { oai = JSON.parse(body); } catch { sendError(res, 400, 'Invalid JSON', 'invalid_request_error'); return; } const model = oai.model || '-', isStream = oai.stream === true; - const ip = req.socket.remoteAddress || '-'; - const bytes = Buffer.byteLength(body); const t0 = Date.now(); - logReq(`${model} | ${ip} | ${isStream ? 'stream' : 'sync'} | ${bytes} bytes`); + logReq(`${model} | ${req.socket.remoteAddress || '-'} | ${isStream ? 'stream' : 'sync'} | ${size} bytes`); let upstream; - try { upstream = transform(oai); } catch (e) { res.writeHead(500, CORS); res.end(JSON.stringify({ error: 'Transform error' })); return; } + try { upstream = transform(oai); } catch (e) { sendError(res, 400, `transform error: ${e.message}`, 'invalid_request_error'); return; } const pr = https.request({ hostname: HOST, path: PATH, method: 'POST', agent, timeout: 300000, - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(upstream), 'Authorization': auth, 'x-command-code-version': CC_VERSION }, + headers: { + 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(upstream), 'Authorization': auth, + 'x-command-code-version': ccVersion, 'x-cli-environment': 'production', 'x-session-id': crypto.randomUUID(), + }, }, proxyRes => { const ok = proxyRes.statusCode >= 200 && proxyRes.statusCode < 300; logUp(`${proxyRes.statusCode} ${ok ? 'OK' : 'ERR'} | ${model} | ${Date.now() - t0}ms`); handleUpstreamResponse(proxyRes, res, model, isStream, t0); }); - pr.setTimeout(300000, () => { logErr('[upstream] timeout'); pr.destroy(); if (!res.headersSent) { res.writeHead(504, CORS); res.end('{}'); } }); - pr.on('error', e => { logErr(`[upstream] ${e.message}`); if (!res.headersSent) { res.writeHead(502, CORS); res.end(JSON.stringify({ error: e.message })); } }); - pr.write(upstream); pr.end(); + pr.setTimeout(300000, () => { logErr('[upstream] timeout'); pr.destroy(new Error('upstream timeout')); }); + pr.on('error', e => { + logErr(`[upstream] ${netError(e)}`); + if (!res.headersSent) sendError(res, e.message === 'upstream timeout' ? 504 : 502, netError(e)); + else if (!res.writableEnded) res.end(); + }); + res.on('close', () => { if (!res.writableFinished) pr.destroy(); }); + pr.end(upstream); }); } -// ── start: kill existing process on PORT ────────────────────────────────────── - -try { - const netstat = execSync(`netstat -ano | findstr :${PORT} | findstr LISTENING`, { encoding: 'utf8', timeout: 5000 }); - const match = netstat.trim().match(/(\d+)\s*$/m); - if (match) { - const pid = match[1]; - log(`killing existing process on port ${PORT} (PID ${pid})`); - execSync(`taskkill /F /PID ${pid}`, { timeout: 5000 }); - } -} catch {} // no process = nothing to kill - -const server = http.createServer(handleRequest); -server.timeout = 300000; server.keepAliveTimeout = 120000; -server.listen(PORT, () => log(`listening on http://localhost:${PORT}`)); -server.on('error', e => { if (e.code === 'EADDRINUSE') { logErr(`Port ${PORT} still in use after kill attempt`); process.exit(1); } throw e; }); -process.on('SIGINT', () => { - log('shutting down...'); - // kill any remaining process on this port (cleanup stale listeners) - try { - const netstat = execSync(`netstat -ano | findstr :${PORT} | findstr LISTENING`, { encoding: 'utf8', timeout: 3000 }); - const match = netstat.trim().match(/(\d+)\s*$/m); - if (match) { - const pid = match[1]; - log(`killing leftover process on port ${PORT} (PID ${pid})`); - execSync(`taskkill /F /PID ${pid}`, { timeout: 3000 }); - } - } catch {} - server.close(() => logFile.end(() => process.exit(0))); -}); +module.exports = { transform, toOpenAIUsage, toFinishReason }; + +if (require.main === module) { + log(`proxy-commandcode | listening :${PORT} | upstream ${HOST}${PATH} | CC ${ccVersion}${PINNED_VERSION ? ' (pinned)' : ''} | debug ${DEBUG ? 'on' : 'off'}`); + refreshVersion(); + setInterval(refreshVersion, VERSION_REFRESH_MS).unref(); + + const server = http.createServer(handleRequest); + server.timeout = 300000; server.keepAliveTimeout = 120000; + server.listen(PORT, () => log(`listening on http://0.0.0.0:${PORT}`)); + const shutdown = (sig) => { + log(`${sig}: shutting down`); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 5000).unref(); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} diff --git a/test/transform.test.js b/test/transform.test.js new file mode 100644 index 0000000..e989fbb --- /dev/null +++ b/test/transform.test.js @@ -0,0 +1,60 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { transform, toOpenAIUsage, toFinishReason } = require('../server.js'); + +test('builds the strict envelope and always streams upstream', () => { + const body = JSON.parse(transform({ model: 'zai-org/GLM-5.2', stream: false, max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] })); + for (const k of ['workingDir', 'date', 'environment', 'structure', 'isGitRepo', 'currentBranch', 'mainBranch', 'gitStatus', 'recentCommits']) + assert.ok(k in body.config, `config.${k}`); + assert.equal(body.memory, ''); + assert.equal(body.params.stream, true); + assert.equal(body.params.max_tokens, 100); + assert.deepEqual(body.params.messages, [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]); +}); + +test('maps system/developer to params.system', () => { + const body = JSON.parse(transform({ messages: [ + { role: 'system', content: 'a' }, { role: 'developer', content: [{ type: 'text', text: 'b' }] }, { role: 'user', content: 'q' }, + ] })); + assert.equal(body.params.system, 'a\n\nb'); + assert.equal(body.params.messages.length, 1); +}); + +test('tool calls carry object input and tool results merge into one message', () => { + const body = JSON.parse(transform({ messages: [ + { role: 'user', content: 'weather?' }, + { role: 'assistant', content: null, tool_calls: [ + { id: 'c1', type: 'function', function: { name: 'get', arguments: '{"city":"Sydney"}' } }, + { id: 'c2', type: 'function', function: { name: 'get', arguments: 'not json' } }, + ] }, + { role: 'tool', tool_call_id: 'c1', content: 'sunny' }, + { role: 'tool', tool_call_id: 'c2', content: [{ type: 'text', text: 'rain' }] }, + ] })); + const [, assistant, tool] = body.params.messages; + assert.deepEqual(assistant.content[0].input, { city: 'Sydney' }); + assert.deepEqual(assistant.content[1].input, {}); + assert.equal(body.params.messages.length, 3); + assert.deepEqual(tool.content.map(p => [p.toolCallId, p.toolName, p.output.value]), [['c1', 'get', 'sunny'], ['c2', 'get', 'rain']]); +}); + +test('images use the ModelMessage image part', () => { + const body = JSON.parse(transform({ messages: [{ role: 'user', content: [ + { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }, + ] }] })); + assert.deepEqual(body.params.messages[0].content[0], { type: 'image', image: 'data:image/png;base64,AAAA', mediaType: 'image/png' }); +}); + +test('usage maps to OpenAI shape', () => { + assert.deepEqual(toOpenAIUsage({ inputTokens: 10, outputTokens: 5, totalTokens: 15, cachedInputTokens: 4, reasoningTokens: 2 }), { + prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, + prompt_tokens_details: { cached_tokens: 4 }, completion_tokens_details: { reasoning_tokens: 2 }, + }); + assert.equal(toOpenAIUsage(undefined), undefined); +}); + +test('finish reasons', () => { + assert.equal(toFinishReason('length', false), 'length'); + assert.equal(toFinishReason('tool-calls', false), 'tool_calls'); + assert.equal(toFinishReason('stop', true), 'tool_calls'); + assert.equal(toFinishReason(undefined, false), 'stop'); +}); From ebfa2d470f286ae15a7830e9aa0848e86eb0ceff Mon Sep 17 00:00:00 2001 From: Sean Carey Date: Mon, 28 Sep 2026 13:33:08 +0900 Subject: [PATCH 2/2] ci: adopt the org caller workflows and CodeQL; pin actions to SHAs The org Universal ruleset requires the gatekeeper check and code-scanning results on main. The caller distributor had not reached this fork yet, so add the public-repo callers (gatekeeper, static analysis, agent readiness, Dependabot auto-merge) from the hub and a CodeQL workflow for JavaScript and Actions. Pin the image workflow's actions to commit SHAs. Co-Authored-By: Claude Opus 5.5 --- .../call-reusable-agent-readiness.yml | 26 ++++ .../call-reusable-dependabot-automerge.yml | 36 +++++ .../workflows/call-reusable-pr-gatekeeper.yml | 124 ++++++++++++++++++ .../call-reusable-static-analysis.yml | 27 ++++ .github/workflows/codeql.yml | 56 ++++++++ .github/workflows/image.yml | 16 ++- 6 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/call-reusable-agent-readiness.yml create mode 100644 .github/workflows/call-reusable-dependabot-automerge.yml create mode 100644 .github/workflows/call-reusable-pr-gatekeeper.yml create mode 100644 .github/workflows/call-reusable-static-analysis.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/call-reusable-agent-readiness.yml b/.github/workflows/call-reusable-agent-readiness.yml new file mode 100644 index 0000000..5bdd813 --- /dev/null +++ b/.github/workflows/call-reusable-agent-readiness.yml @@ -0,0 +1,26 @@ +# Agent Readiness — synced from seankoji-com/.github (panel/call-reusable-agent-readiness.yml) +# Ratchet on agentic token cost: reports what a PR makes worse — a new +# CLAUDE.md/AGENTS.md split, a file pushed over the line limit, a doc or +# .claude/ command now pointing at a missing path. Existing debt never fails. +# Advisory unless this repo's .agent-readiness.json sets "enforce": true. +# Standard, check list, and config: seankoji-com/.github docs/agent-readiness.md +name: Agent Readiness + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: agent-readiness-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + agent-readiness: + # Fork heads do not run on the organization runner. + if: github.event.pull_request.head.repo.full_name == github.repository + uses: seankoji-com/.github/.github/workflows/reusable-agent-readiness.yml@main + with: + runner-json: '["ubuntu-latest"]' diff --git a/.github/workflows/call-reusable-dependabot-automerge.yml b/.github/workflows/call-reusable-dependabot-automerge.yml new file mode 100644 index 0000000..895a433 --- /dev/null +++ b/.github/workflows/call-reusable-dependabot-automerge.yml @@ -0,0 +1,36 @@ +# Dependabot Auto-Merge — synced from seankoji-com/.github (panel/call-reusable-dependabot-automerge.yml) +# Enables auto-merge for dependabot PRs after CI + reviews pass. +# Major version bumps are excluded (require human approval). +# Uses the org-wide SEANKOJI_CI_APP_ID / SEANKOJI_CI_PRIVATE_KEY App identity +# (Contents:Write and PullRequests:Write) — no per-repo secrets required. +name: Dependabot Auto-Merge + +# Trigger is pull_request_target, NOT pull_request. Dependabot-triggered +# `pull_request` runs get a read-only GITHUB_TOKEN and cannot read Actions +# secrets, so the reusable workflow's create-github-app-token step failed with +# "private-key ... must be set to a non-empty string". That red check was then +# propagated by gatekeeper / all-checks-passed, blocking every Dependabot PR in +# the fleet. pull_request_target runs in the base-repo context with secrets +# available. This workflow never checks out or executes PR code, so it is safe +# on that trigger. +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +# A rebase or retry push to an open Dependabot PR fires another `synchronize` +# run before the prior one finishes. Without this, both runs evaluate +# auto-merge eligibility independently and the stale one can still act on an +# outdated diff after the newer push landed. +concurrency: + group: dependabot-automerge-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + auto-merge: + uses: seankoji-com/.github/.github/workflows/reusable-dependabot-automerge.yml@main + with: + runner-json: '["ubuntu-latest"]' + secrets: inherit diff --git a/.github/workflows/call-reusable-pr-gatekeeper.yml b/.github/workflows/call-reusable-pr-gatekeeper.yml new file mode 100644 index 0000000..c471d12 --- /dev/null +++ b/.github/workflows/call-reusable-pr-gatekeeper.yml @@ -0,0 +1,124 @@ +# PR Gatekeeper — synced from seankoji-com/.github (panel/call-reusable-pr-gatekeeper.yml) +# Posts the single aggregator check `gatekeeper / all-checks-passed`, required +# org-wide, which blocks on ANY red check rather than only the ones this repo +# happens to list as required. +# Uses this repository's own GITHUB_TOKEN to post the check run — never an App +# identity, so the ruleset's context match stays unambiguous. +name: PR Gatekeeper + +on: + # The wildcard is deliberate and load-bearing: `workflows` is a REQUIRED key + # with minItems 1, so it cannot be omitted, and an enumerated list would go + # stale the moment a repo adds a workflow. check_run/check_suite cannot be + # used at all — GitHub suppresses both for any head SHA that ran an Actions + # workflow, which is every PR here. + workflow_run: + workflows: ['*'] + types: [completed] + pull_request_review: + types: [submitted, edited, dismissed] + # Seed the required context the moment a PR opens or its head moves. + # pull_request_target, NOT pull_request: on a fork, pull_request hands the + # reusable a read-only token, the check-run POST 403s, and this job goes red — + # pinning the required context red permanently, which nothing can clear. + # pull_request_target runs this base-branch workflow with the base repo's + # write token and executes no PR code: the reusable checks nothing out, it + # only curls the evaluator from this org's raw URL at the reusable's own + # commit. The caller job's own composite check run already carries the + # required name and the evaluator drops every run with that name, so the + # seed cannot block on itself. The queued job's check run is what makes the + # gate visible before any evaluation, even with every runner busy. + pull_request_target: + types: [opened, synchronize, reopened] + # The reconciler's path, for a PR the event route never noticed. + workflow_dispatch: + inputs: + head_sha: + description: 'Commit SHA to evaluate and report the gate against.' + required: true + type: string + persona_state: + description: 'Private controller signal for the Grumpy review job.' + type: string + default: '' + persona_pr: + description: 'PR number for the exact-head persona signal.' + type: string + default: '' + +# Deliberately NOT `contents: read` alone. A reusable workflow can never be +# granted more than its caller holds, so narrowing here would 403 the check-run +# POST in every repo — invisibly, since a resolution failure produces no check +# run to go red. +permissions: + actions: read + checks: write + statuses: read + pull-requests: read + contents: read + +jobs: + recover-persona: + # An independent event path; the reusable sends only PR identity to the + # private controller and never checks out PR code. + # No continue-on-error and no secrets in this `if`: GitHub rejects both on + # a reusable-workflow caller job, and the whole file then fails to parse, + # so the required gate never posts. The reusable skips itself when the + # key is absent and marks every step advisory, so this job cannot go red. + if: github.event_name == 'pull_request_target' + # Must cover the called job's own grant (contents + pull-requests read): a + # called workflow can only downgrade permissions, so a narrower caller is + # also a load-time failure that actionlint cannot see for a remote reusable. + permissions: + contents: read + pull-requests: read + uses: seankoji-com/.github/.github/workflows/reusable-persona-recovery-request.yml@main + with: + target_repository: ${{ github.repository }} + pr_number: ${{ format('{0}', github.event.pull_request.number) }} + head_sha: ${{ github.event.pull_request.head.sha }} + secrets: + SEANKOJI_CI_PRIVATE_KEY: ${{ secrets.SEANKOJI_CI_PRIVATE_KEY }} + + review-event: + # Reviews on forks have read-only tokens. This signal finishes without + # fetching PR code; workflow_run then evaluates with default-branch code. + if: github.event_name == 'pull_request_review' + permissions: + contents: read + uses: seankoji-com/.github/.github/workflows/reusable-review-event.yml@main + + gatekeeper: + # Head, merge, and review-event evaluations publish to overlapping commits. + # Serialize repository-wide so an older approval cannot overtake a dismissal. + # queue:max preserves completion events when several PRs finish together. + # Only gate writers enter this queue. Skipped workflow events and independent + # recovery/review signals must not occupy the bounded writer queue. + concurrency: + group: pr-gatekeeper-${{ github.repository }} + cancel-in-progress: false + queue: max + # Guarding at the job level, not inside a step: an in-job early exit still + # claims a self-hosted runner first, and fleet capacity is scarce. + # - Skipping the gate's own runs stops it re-triggering itself, which + # would burn workflow_run's three-level chain cap. + # - `pull_requests` is empty on fork runs, so the event allowlist is what + # keeps fork PRs gated rather than ignored. + # - On `pull_request_target` the job's own queued composite check run is + # the pending seed: it carries the required name with no runner needed. + if: >- + github.event_name == 'workflow_dispatch' || + github.event_name == 'pull_request_target' || + (github.event_name == 'workflow_run' && + (github.event.workflow_run.name != 'PR Gatekeeper' || + github.event.workflow_run.event == 'pull_request_review') && + (github.event.workflow_run.pull_requests[0] != null || + contains(fromJSON('["pull_request", "pull_request_target", "pull_request_review", "dynamic"]'), github.event.workflow_run.event))) + uses: seankoji-com/.github/.github/workflows/reusable-pr-gatekeeper.yml@main + with: + head_sha: ${{ github.event.workflow_run.pull_requests[0].head.sha || github.event.workflow_run.head_sha || github.event.pull_request.head.sha || inputs.head_sha }} + review_run_id: ${{ github.event.workflow_run.event == 'pull_request_review' && format('{0}', github.event.workflow_run.id) || '' }} + runner-json: '["ubuntu-latest"]' + seed: ${{ github.event_name == 'pull_request_target' }} + persona_state: ${{ inputs.persona_state || '' }} + persona_pr: ${{ inputs.persona_pr || '' }} diff --git a/.github/workflows/call-reusable-static-analysis.yml b/.github/workflows/call-reusable-static-analysis.yml new file mode 100644 index 0000000..7cafcb2 --- /dev/null +++ b/.github/workflows/call-reusable-static-analysis.yml @@ -0,0 +1,27 @@ +# Static Analysis — synced from seankoji-com/.github (panel/call-reusable-static-analysis.yml) +# Runs Semgrep and zizmor against every PR via the reusable workflow. Both +# scanners are non-blocking (continue-on-error) on first release — see +# .github/workflows/reusable-static-analysis.yml in seankoji-com/.github. + +name: Static Analysis + +on: + pull_request: + types: [opened, synchronize, reopened] + +# Every push to a PR queues a fresh scan; without this, rapid successive +# pushes (force-push loops, auto-rebase branches) stack up several full runs +# of the same diff on the shared self-hosted pool. Cancel in favor of the +# latest push — an in-flight scan of a superseded commit has no value. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + static-analysis: + uses: seankoji-com/.github/.github/workflows/reusable-static-analysis.yml@main + with: + runner-json: '["ubuntu-latest"]' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9870e8e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,56 @@ +name: CodeQL analysis + +# The org's Universal ruleset requires code scanning results on main, and a +# repository with no analyses can never satisfy it: every pull request sits at +# mergeable_state "blocked" with nothing to point at. +# +# Scans the proxy's JavaScript (it parses untrusted upstream NDJSON and client +# request bodies) and the GitHub Actions workflows. + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '23 7 * * 1' # Mondays 07:23 UTC + workflow_dispatch: + +permissions: + contents: read + actions: read + security-events: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: Analyze + # Skip forks: they cannot write security events, so the job would only ever + # fail noisily on an outside contributor's pull request. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + language: [actions, javascript-typescript] + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + upload: true + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index c7ae346..8c0a0af 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -18,8 +18,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' - run: npm test @@ -31,15 +33,17 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v7 - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 if: github.event_name != 'pull_request' with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v7 + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 id: image with: context: .