From cb5f3fcfc7ad7cca3b4aa74e5791cf54a769d961 Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 12:47:15 +0500 Subject: [PATCH 01/12] feat(gateway): headless provider gateway replacing the Electron runtime Rebuilds the provider access layer as a headless Playwright-Chromium service. No GUI, no REST, no WebSocket server, no Electron import. The existing automation client attaches unchanged over the documented loopback contract: TCP 127.0.0.1:19222, newline-delimited JSON, {requestId, action, provider, data} in and {requestId, success, ...} out. Components: state-store settings (preference) vs port fact (authority) cookie-store domain+name auth detection, backup, injection session-manager per-provider persistent contexts, A6-preserving unload engine-loader init-script injection with presence probing ipc-server framing, dispatch, loopback enforcement actions the action contract Engines carry all provider protocol knowledge; the gateway carries none. Tests: 53 unit + integration, plus 10 real-Chromium tests that skip cleanly when Playwright is absent. CI runs unit, real-browser and a syntax check in parallel. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gateway.yml | 95 +++++ gateway/engines/README.md | 105 +++++ gateway/engines/perplexity-engine.js | 163 ++++++++ gateway/package.json | 22 ++ gateway/src/actions.js | 354 +++++++++++++++++ gateway/src/cookie-store.js | 183 +++++++++ gateway/src/engine-loader.js | 151 +++++++ gateway/src/index.js | 84 ++++ gateway/src/ipc-server.js | 201 ++++++++++ gateway/src/providers.js | 123 ++++++ gateway/src/session-manager.js | 564 +++++++++++++++++++++++++++ gateway/src/state-store.js | 139 +++++++ gateway/test/browser.test.js | 292 ++++++++++++++ gateway/test/integration.test.js | 544 ++++++++++++++++++++++++++ gateway/test/unit.test.js | 290 ++++++++++++++ 15 files changed, 3310 insertions(+) create mode 100644 .github/workflows/gateway.yml create mode 100644 gateway/engines/README.md create mode 100644 gateway/engines/perplexity-engine.js create mode 100644 gateway/package.json create mode 100644 gateway/src/actions.js create mode 100644 gateway/src/cookie-store.js create mode 100644 gateway/src/engine-loader.js create mode 100644 gateway/src/index.js create mode 100644 gateway/src/ipc-server.js create mode 100644 gateway/src/providers.js create mode 100644 gateway/src/session-manager.js create mode 100644 gateway/src/state-store.js create mode 100644 gateway/test/browser.test.js create mode 100644 gateway/test/integration.test.js create mode 100644 gateway/test/unit.test.js diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml new file mode 100644 index 0000000..2cb234e --- /dev/null +++ b/.github/workflows/gateway.yml @@ -0,0 +1,95 @@ +name: gateway + +on: + push: + branches: [feat/cleanroom-gateway, main] + paths: + - 'gateway/**' + - '.github/workflows/gateway.yml' + pull_request: + paths: + - 'gateway/**' + - '.github/workflows/gateway.yml' + workflow_dispatch: + +concurrency: + group: gateway-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: unit + integration + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + working-directory: gateway + run: npm install --no-audit --no-fund + + - name: Run test suite + working-directory: gateway + run: npm test + + browser: + name: real chromium + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + working-directory: gateway + run: npm install --no-audit --no-fund + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('gateway/package.json') }} + + - name: Install Chromium + working-directory: gateway + run: npx playwright install --with-deps chromium + + - name: Real-browser tests + working-directory: gateway + run: node --test --test-reporter=tap --test-timeout=60000 test/browser.test.js + + - name: Upload failure artefacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + test-results/ + gateway/test-results/ + retention-days: 3 + if-no-files-found: ignore + + lint: + name: syntax check + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Parse every source file + working-directory: gateway + run: | + for f in src/*.js engines/*.js test/*.js; do + node --check "$f" || exit 1 + done + echo "all files parse" diff --git a/gateway/engines/README.md b/gateway/engines/README.md new file mode 100644 index 0000000..3a728d2 --- /dev/null +++ b/gateway/engines/README.md @@ -0,0 +1,105 @@ +# Writing a provider engine + +An engine is the only place provider API knowledge lives. The gateway knows how to +manage a session; it knows nothing about how to talk to ChatGPT. That separation is +what lets an engine be rewritten without touching the gateway — and what makes a +clean-room implementation possible. + +## The contract + +An engine is a plain JavaScript file evaluated **in the page's own origin context**. +It must assign a namespaced global before the gateway will accept it. + +File location: `gateway/engines/-engine.js` + +| Provider | Required global | +|---|---| +| chatgpt | `window.__proximaChatGPT` | +| perplexity | `window.__proximaPerplexity` | +| claude | `window.__proximaClaude` | +| gemini | `window.__proximaGemini` | +| qwen | `window.__proximaQwen` | + +The gateway **probes** for the global after injection. A missing global is reported as +an engine failure, distinct from a transport failure, so a caller can tell "the browser +broke" from "the provider rejected us". + +## Required methods + +| Method | Returns | Notes | +|---|---|---| +| `sendMessage(options)` | anything | `options.message` plus allowlisted provider options. Must throw a classified error on failure. | +| `newConversation()` | — | Clear conversation state. Required so an automated turn cannot land inside somebody's unrelated thread. | +| `getResponse()` | `string` | **Return an empty string if there is genuinely no answer yet.** Do not fabricate text — the gateway treats `''` and the `no response` sentinels as "retry", and anything else as final. | + +### Optional methods + +| Method | Used for | +|---|---| +| `getTypingStatus()` | `getTypingStatus` action | +| `getConversation()` | conversation introspection | +| `isReady()` | `waitForSendButton`; omit to mean "ready" | +| `getUploadToken(meta)` | attachment preflight | +| `checkAttachmentSupport(...)` | attachment preflight | +| `createUpload` / `finalizeUpload` / `uploadedFileInfo` | provider upload protocol | +| `listArtifacts(cid)` / `downloadArtifact(...)` | artifact access | +| `lastMeta()` | response metadata | + +## Rules + +**Same-origin fetch. Always.** +```js +fetch('/some/api/path', { credentials: 'include', headers: {...} }) +``` +The browser attaches the profile's session cookies and origin clearance. This is the +mechanism the whole system depends on — a request that escapes the page origin loses +authentication. + +**Classify your failures.** Attach `kind` and `retryable` so the caller can decide +between waiting and re-authenticating: +```js +err.kind = 'waf'; // or 'auth' | 'upstream' | 'unknown' +err.retryable = true; +``` + +**Do not fake a response.** Returning a placeholder string defeats the gateway's +retry logic (invariant A8) and hands the operator a fabricated answer. + +**Idempotent re-evaluation.** The engine is re-injected on every navigation. It must be +safe to run repeatedly, and must overwrite the global rather than merging into a stale +one. + +## Qwen has no DOM fallback + +Qwen's engine owns the conversation completely. There is no scrape-the-DOM retry path, +so a capture failure is a hard failure. Any other provider may fall back to reading the +rendered page if its API call fails. + +## Porting an existing engine + +If you are moving an engine you already own: + +1. Copy the file to `gateway/engines/-engine.js`. +2. Confirm the global name matches the table above. +3. Confirm `sendMessage` / `newConversation` / `getResponse` exist. +4. Check how it authenticates. Anything that depended on an Electron-specific API + (`net.request` with `useSessionCookies`, `ipcRenderer`, `webContents`) needs replacing + with an in-page `fetch`. +5. Run: `node --test test/integration.test.js` — the gateway is exercised against a stub + for every provider, so a contract mismatch fails fast. + +If the engine was contributed by someone else under a restrictive licence, it must be +independently written against this contract rather than copied. The contract above is +sufficient; nothing in it requires reading an existing implementation. + +## Testing an engine in isolation + +```js +// in a test +const loader = new EngineLoader(); +const src = loader.load('perplexity'); +assert.ok(src.includes('__proximaPerplexity')); +``` + +For live verification, point `PROXIMA_GATEWAY_STATE_DIR` at a scratch directory so you +never touch a real authenticated profile. diff --git a/gateway/engines/perplexity-engine.js b/gateway/engines/perplexity-engine.js new file mode 100644 index 0000000..b0a18df --- /dev/null +++ b/gateway/engines/perplexity-engine.js @@ -0,0 +1,163 @@ +/** + * REFERENCE ENGINE - clean-room implementation of the provider engine contract. + * + * Purpose: prove the gateway contract is sufficient to build against. This file was + * written from the engine interface documented in docs/product/capability-manifest.md + * and gateway/engines/README.md. It is NOT derived from any upstream engine source. + * + * Provider: perplexity + * Protocol: the page's own fetch(), with credentials included, so the browser's session + * cookies and origin clearance ride along automatically. No bearer extraction, no header + * spoofing, no DOM scraping. + * + * Lifecycle: + * - Evaluated as an init script, so it re-runs on every document. + * - Must expose window. or the gateway reports an engine failure. + * - Must be safe to evaluate repeatedly (navigations re-run it). + */ +(function () { + 'use strict'; + + var GLOBAL = '__proximaPerplexity'; + var ORIGIN = 'https://www.perplexity.ai'; + + // Conversation continuity is deliberate: consecutive calls chain onto one thread. + // reset() is how a caller forces independence, so an automated verdict never + // inherits a previous run's context. + var state = { + conversationId: null, + lastResponse: '', + typing: false, + conversationStartedAt: null, + }; + + function nowIso() { + return new Date().toISOString(); + } + + function isLoggedIn() { + return document.cookie.indexOf('pplx') !== -1 || + document.cookie.indexOf('session-token') !== -1; + } + + function newConversation() { + state.conversationId = null; + state.lastResponse = ''; + state.conversationStartedAt = null; + return { cleared: true }; + } + + function getConversation() { + return { conversationId: state.conversationId, startedAt: state.conversationStartedAt }; + } + + function isReady() { + return true; // no composer probe required for this provider + } + + function getTypingStatus() { + return { typing: state.typing }; + } + + function getResponse() { + return state.lastResponse; + } + + function classifyFailure(status, body) { + // Distinguishing a WAF block from an ordinary auth failure is what lets a caller + // decide between "wait and retry" and "re-login". The gateway surfaces this as a + // distinct error kind. + if (status === 403 || status === 429) { + return { kind: 'waf', retryable: true, status: status }; + } + if (status === 401 || status === 403) { + return { kind: 'auth', retryable: false, status: status }; + } + if (status >= 500) { + return { kind: 'upstream', retryable: true, status: status }; + } + return { kind: 'unknown', retryable: false, status: status, body: String(body || '').slice(0, 200) }; + } + + async function sendMessage(options) { + options = options || {}; + var message = options.message; + if (typeof message !== 'string' || !message.trim()) { + throw new Error('sendMessage requires a non-empty message'); + } + + state.typing = true; + state.lastResponse = ''; + state.conversationStartedAt = state.conversationStartedAt || nowIso(); + + try { + // The only authenticated egress this system needs: same-origin, credentials + // included. The browser attaches whatever the profile is holding. + var res = await fetch('/api/search/completions', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: message, + model_preference: options.modelPreference || 'default', + search_mode: options.researchMode ? 'on' : 'off', + is_structured_output_enabled: false, + source: 'proxima-gateway', + }), + }); + + if (!res.ok) { + var body = await res.text().catch(function () { return ''; }); + var failure = classifyFailure(res.status, body); + var err = new Error( + 'perplexity completion failed: ' + failure.kind + ' (status ' + res.status + ')' + ); + err.kind = failure.kind; + err.retryable = failure.retryable; + err.status = res.status; + throw err; + } + + var data = await res.json().catch(function () { return {}; }); + + state.conversationId = + data.conversation_id || data.conversationId || state.conversationId; + + state.lastResponse = extractText(data); + return { conversationId: state.conversationId, length: state.lastResponse.length }; + } finally { + state.typing = false; + } + } + + function extractText(data) { + // Defensive: an empty extraction must stay empty so the gateway's placeholder + // retry (A8) can see it and retry, rather than receiving a fabricated string. + if (!data) return ''; + if (typeof data.text === 'string') return data.text; + if (typeof data.answer === 'string') return data.answer; + if (Array.isArray(data.content)) { + return data.content + .map(function (c) { + if (typeof c === 'string') return c; + return c && typeof c.text === 'string' ? c.text : ''; + }) + .filter(Boolean) + .join('\n'); + } + return ''; + } + + // Expose the contract. Overwriting on re-eval is intentional: a navigation must + // leave exactly one live engine, not a stale one. + window[GLOBAL] = { + sendMessage: sendMessage, + newConversation: newConversation, + getConversation: getConversation, + getResponse: getResponse, + getTypingStatus: getTypingStatus, + isReady: isReady, + isLoggedIn: isLoggedIn, + version: '0.1.0-reference', + }; +})(); diff --git a/gateway/package.json b/gateway/package.json new file mode 100644 index 0000000..02d8c88 --- /dev/null +++ b/gateway/package.json @@ -0,0 +1,22 @@ +{ + "name": "proxima-gateway", + "version": "0.1.0", + "private": true, + "description": "Headless authenticated provider gateway. Replaces the Electron runtime with Playwright Chromium.", + "type": "commonjs", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js", + "test:browser": "node --test --test-reporter=tap --test-timeout=60000 test/browser.test.js", + "test:all": "npm run test && npm run test:browser", + "setup:browser": "npx playwright install --with-deps chromium", + "test:watch": "node --test --watch test/" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "playwright": "^1.49.0" + } +} diff --git a/gateway/src/actions.js b/gateway/src/actions.js new file mode 100644 index 0000000..7d18a5a --- /dev/null +++ b/gateway/src/actions.js @@ -0,0 +1,354 @@ +'use strict'; + +const { getProvider, isKnownProvider, PROVIDER_NAMES } = require('./providers'); +const { isPlaceholderResponse } = require('./cookie-store'); +const { stampTimestamp, allowlistOptions } = require('./session-manager'); + +/** + * The 28-action contract. + * + * GUI actions from the upstream contract are deliberately absent: P1-P3 removed the + * window, the REST API and the WebSocket server, so there is nothing for showWindow / + * hideWindow / toggleWindow / isWindowVisible to act on. They are listed in REJECTED + * so their absence is a decision on record rather than an oversight. + */ + +const CAPTURE_MAX_ATTEMPTS = 120; +const CAPTURE_RETRY_DELAY_MS = 2500; +const MEMORY_SETTLE_MS = 1500; + +const REJECTED_ACTIONS = { + showWindow: 'P1: the gateway has no window.', + hideWindow: 'P1: the gateway has no window.', + toggleWindow: 'P1: the gateway has no window.', + isWindowVisible: 'P1: the gateway has no window.', + claudeArtifacts: 'Artifact listing is provider-API work. Belongs in the claude engine, not the gateway.', +}; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function createHandler({ sessions, stateStore, startedAt, getFileReferenceEnabled }) { + const fileReferenceEnabled = getFileReferenceEnabled || (() => true); + + // ---- actions ---- + + const actions = { + ping: async () => ({ success: true, message: 'pong' }), + + getStatus: async () => ({ + success: true, + providers: sessions.initializedProviders(), + knownProviders: PROVIDER_NAMES, + activeProvider: sessions.activeProvider, + startedAt, + pid: process.pid, + port: stateStore.readPortFact()?.port ?? null, + fileReferenceEnabled: fileReferenceEnabled(), + }), + + memory: async () => ({ success: true, memory: processMemoryReport() }), + + initProvider: async (req) => { + await sessions.initProvider(req.provider); + return { success: true, provider: req.provider }; + }, + + isLoggedIn: async (req) => { + const result = await sessions.isLoggedIn(req.provider); + return { success: true, ...result }; + }, + + unloadProvider: async (req) => { + const targets = resolveTargets(req, sessions.initializedProviders()); + if (targets.length === 0) { + return { success: false, error: 'name a provider, or pass data.providers' }; + } + // AWAITED. An un-awaited unload reports success for every provider whether + // or not it worked, because the returned promise is truthy. + const reports = []; + for (const name of targets) { + reports.push(await sessions.unloadProvider(name)); + } + // Chromium reaps the renderer asynchronously; reading immediately understates + // the saving and reads as a failure. + await sleep(MEMORY_SETTLE_MS); + return { + success: true, + unloaded: reports.filter((r) => r.unloaded).map((r) => r.provider), + reports, + stillLoaded: sessions.initializedProviders(), + memory: processMemoryReport(), + }; + }, + + purgeProvider: async (req) => { + const targets = resolveTargets(req, sessions.initializedProviders()); + const results = []; + for (const name of targets) { + results.push(await sessions.purgeProvider(name)); + } + return { success: true, purged: results, memory: processMemoryReport() }; + }, + + sendMessage: async (req) => { + const data = req.data || {}; + let message = data.message; + + if (message === undefined || message === null) { + return { success: false, error: 'sendMessage requires data.message' }; + } + + // A3: attachment failure must fail the call, never degrade to text-only. + if (hasAttachments(data) && !fileReferenceEnabled()) { + return { + success: false, + error: 'File reference is disabled. Enable it in gateway settings.', + }; + } + + // A2: stamped exactly once, here, so every route into the gateway is covered. + if (data.timestamp !== false) { + message = stampTimestamp(message); + } + + // A1: allowlist before anything reaches an engine. + const options = allowlistOptions(data); + const payload = { ...options, message }; + + if (data.tag !== false && req.provider === 'claude' && typeof message === 'string') { + // Mark agent-authored turns so a human reading the thread can tell them + // apart from turns they typed themselves. Claude-only, by design. + if (!message.startsWith('[PROXIMA]')) { + payload.message = `[PROXIMA]\n${message}`; + } + } + + await sessions.sendMessage(req.provider, payload); + return { success: true, provider: req.provider, queued: true }; + }, + + getResponse: async (req) => { + const result = await sessions.getResponse(req.provider); + return { success: true, ...result }; + }, + + getTypingStatus: async (req) => { + const result = await sessions.getTypingStatus(req.provider); + return { success: true, ...result }; + }, + + /** + * Capture with placeholder retry (A8). + * + * An empty string or a "no response" sentinel is not an answer. Retrying is + * bounded so a rate-limited provider cannot be hammered indefinitely. + */ + getResponseWithTyping: async (req) => { + const maxAttempts = Number(req.data?.maxAttempts) > 0 + ? Number(req.data.maxAttempts) + : CAPTURE_MAX_ATTEMPTS; + const delay = Number(req.data?.retryDelayMs) > 0 + ? Number(req.data.retryDelayMs) + : CAPTURE_RETRY_DELAY_MS; + + let last = ''; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const { response } = await sessions.getResponse(req.provider); + last = response || ''; + if (!isPlaceholderResponse(last)) { + return { success: true, provider: req.provider, response: last, attempts: attempt }; + } + if (attempt < maxAttempts) await sleep(delay); + } + return { + success: false, + provider: req.provider, + error: `Response capture failed after ${maxAttempts} attempts`, + response: last, + attempts: maxAttempts, + }; + }, + + newConversation: async (req) => { + const result = await sessions.newConversation(req.provider); + return { success: true, ...result }; + }, + + waitForSendButton: async (req) => { + const session = await sessions._ensureLoaded(req.provider); + const spec = getProvider(req.provider); + const ready = await session.page + .evaluate((g) => { + const engine = window[g]; + if (!engine) return false; + if (typeof engine.isReady === 'function') return Boolean(engine.isReady()); + return true; // an engine without a readiness probe is assumed ready + }, spec.engineGlobal) + .catch(() => false); + return { success: true, provider: req.provider, ready }; + }, + + executeScript: async (req) => { + if (typeof req.data?.script !== 'string') { + return { success: false, error: 'executeScript requires data.script' }; + } + const result = await sessions.executeScript(req.provider, req.data.script); + return { success: true, ...result }; + }, + + navigate: async (req) => { + if (typeof req.data?.url !== 'string') { + return { success: false, error: 'navigate requires data.url' }; + } + const result = await sessions.navigate(req.provider, req.data.url); + return { success: true, ...result }; + }, + + debugDOM: async (req) => { + const session = await sessions._ensureLoaded(req.provider); + const html = await session.page + .content() + .catch(() => null); + return { + success: html !== null, + provider: req.provider, + length: html ? html.length : 0, + html: typeof req.data?.full === 'boolean' ? html : undefined, + }; + }, + + setCookies: async (req) => { + const result = await sessions.setCookies(req.provider, req.data?.cookies); + return { success: true, ...result }; + }, + + getCookies: async (req) => { + const result = await sessions.getCookies(req.provider); + return { success: true, ...result }; + }, + + getSettings: async () => ({ success: true, settings: stateStore.loadSettings() }), + + setSetting: async (req) => { + if (!req.data || typeof req.data.key !== 'string') { + return { success: false, error: 'setSetting requires data.key' }; + } + const settings = stateStore.saveSettings({ [req.data.key]: req.data.value }); + return { success: true, settings }; + }, + + /** + * Q3: flipping headless cannot reliably re-launch contexts in-process, so this + * persists the preference and reports that a restart is required. It is kept in + * the contract rather than dropped so the operator is not left guessing. + */ + setHeadlessMode: async (req) => { + const headless = req.data?.headless !== false; + stateStore.saveSettings({ headless }); + return { + success: true, + headless, + requiresRestart: true, + message: `Headless mode set to ${headless}. Restart the gateway to apply.`, + }; + }, + }; + + // ---- dispatch ---- + + return async function handle(request) { + const { action, provider } = request; + + if (REJECTED_ACTIONS[action]) { + return { + success: false, + error: `Action "${action}" is not available: ${REJECTED_ACTIONS[action]}`, + }; + } + + const handler = actions[action]; + if (!handler) { + return { + success: false, + error: `Unknown action "${action}". Known: ${Object.keys(actions).sort().join(', ')}`, + }; + } + + // Provider-scoped actions must name a real provider. + if (provider !== undefined && provider !== null && provider !== '') { + if (!isKnownProvider(provider)) { + return { + success: false, + error: `Unknown provider "${provider}". Known: ${PROVIDER_NAMES.join(', ')}`, + }; + } + } else if (NEEDS_PROVIDER.has(action)) { + return { success: false, error: `Action "${action}" requires a provider` }; + } + + try { + return await handler(request); + } catch (e) { + // Typed engine/transport failures keep their kind so a caller can tell + // "the browser broke" from "the provider rejected us" (F6). + return { + success: false, + error: e?.message || String(e), + ...(e?.kind ? { errorKind: e.kind } : {}), + ...(e?.provider ? { provider: e.provider } : {}), + }; + } + }; +} + +const NEEDS_PROVIDER = new Set([ + 'initProvider', + 'isLoggedIn', + 'sendMessage', + 'getResponse', + 'getTypingStatus', + 'getResponseWithTyping', + 'newConversation', + 'waitForSendButton', + 'executeScript', + 'navigate', + 'debugDOM', + 'setCookies', + 'getCookies', +]); + +function resolveTargets(req, fallback) { + if (Array.isArray(req.data?.providers) && req.data.providers.length) { + return req.data.providers; + } + if (req.provider) return [req.provider]; + return fallback; +} + +function hasAttachments(data) { + const raw = data.attachments || data.files || data.filePath; + if (!raw) return false; + if (Array.isArray(raw)) return raw.length > 0; + return true; +} + +function processMemoryReport() { + const m = process.memoryUsage(); + return { + rssMB: +(m.rss / 1048576).toFixed(1), + heapUsedMB: +(m.heapUsed / 1048576).toFixed(1), + heapTotalMB: +(m.heapTotal / 1048576).toFixed(1), + externalMB: +(m.external / 1048576).toFixed(1), + loadedProviders: null, + }; +} + +module.exports = { + createHandler, + CAPTURE_MAX_ATTEMPTS, + CAPTURE_RETRY_DELAY_MS, + MEMORY_SETTLE_MS, + REJECTED_ACTIONS, + NEEDS_PROVIDER, + processMemoryReport, +}; diff --git a/gateway/src/cookie-store.js b/gateway/src/cookie-store.js new file mode 100644 index 0000000..945c1ea --- /dev/null +++ b/gateway/src/cookie-store.js @@ -0,0 +1,183 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { AUTH_COOKIE_TTL_MS } = require('./providers'); + +/** + * Auth cookie management. + * + * Operates on a plain cookie array so it is testable without a browser. The session + * manager supplies cookies from Playwright's context; this module decides whether they + * mean "logged in", and owns backup/restore/injection. + */ + +/** + * Decide whether a cookie set represents a real authenticated session. + * + * Domain-scoped and name-matched on purpose. Counting cookies is invalid: Google sets + * many consent and tracking cookies, so a Gemini profile with 30 cookies and no session + * would otherwise read as logged in. + * + * @param {Array<{name:string,domain:string,value:string,expires?:number}>} cookies + * @param {{domain:string, cookies:string[]}} authConfig + * @returns {{loggedIn:boolean, matched:string[], domain:string}} + */ +function evaluateAuth(cookies, authConfig) { + const matched = []; + if (!Array.isArray(cookies) || !authConfig) { + return { loggedIn: false, matched, domain: authConfig ? authConfig.domain : null }; + } + + const domain = authConfig.domain; + const inDomain = cookies.filter( + (c) => c && typeof c.domain === 'string' && c.domain.includes(domain) + ); + + for (const name of authConfig.cookies) { + // startsWith covers Google's __Secure-1PSID / __Secure-3PSID families and + // Perplexity's pplx_* suffixes without enumerating every variant. + const hit = inDomain.some((c) => c.name === name || c.name.startsWith(name)); + if (hit) matched.push(name); + } + + return { loggedIn: matched.length > 0, matched, domain }; +} + +/** + * Normalize an operator-supplied cookie export into a shape safe to set. + * Accepts the array shape produced by EditThisCookie / Cookie-Editor. + */ +function normalizeImportedCookies(input) { + let parsed = input; + if (typeof input === 'string') { + try { + parsed = JSON.parse(input); + } catch { + throw new Error('Cookie payload is not valid JSON'); + } + } + if (!Array.isArray(parsed)) { + throw new Error('Cookies should be an array. Try exporting from EditThisCookie or Cookie-Editor.'); + } + + return parsed + .filter((c) => c && typeof c.name === 'string' && typeof c.domain === 'string') + .map((c) => { + const url = c.url || `https://${c.domain.replace(/^\./, '')}`; + return { + name: c.name, + value: String(c.value ?? ''), + domain: c.domain, + path: c.path || '/', + // Refreshed so an imported session cookie does not become a session cookie + // again on the next restart and silently log the operator out. + expires: Math.floor( + (typeof c.expires === 'number' && c.expires > Date.now() / 1000 + ? c.expires * 1000 + : Date.now() + AUTH_COOKIE_TTL_MS) / 1000 + ), + httpOnly: Boolean(c.httpOnly), + secure: c.secure !== false, + sameSite: normalizeSameSite(c.sameSite), + ...(url ? { url } : {}), + }; + }); +} + +function normalizeSameSite(value) { + if (!value) return 'Lax'; + const v = String(value).toLowerCase(); + if (v === 'no_restriction' || v === 'none') return 'None'; + if (v === 'strict') return 'Strict'; + return 'Lax'; +} + +/** Strip the fields we do not persist, keeping a portable backup blob. */ +function toBackupRecord(provider, cookies) { + return { + provider, + savedAt: new Date().toISOString(), + cookies: cookies.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path || '/', + expires: c.expires, + httpOnly: Boolean(c.httpOnly), + secure: c.secure !== false, + sameSite: normalizeSameSite(c.sameSite), + })), + }; +} + +class CookieStore { + constructor(stateStore) { + this.state = stateStore; + } + + _backupPath(provider) { + return path.join(this.state.cookieBackupDir, `${provider}.json`); + } + + saveBackup(provider, cookies) { + if (!Array.isArray(cookies) || cookies.length === 0) return null; + const record = toBackupRecord(provider, cookies); + const file = this._backupPath(provider); + fs.writeFileSync(file, JSON.stringify(record, null, 2), 'utf8'); + try { + fs.chmodSync(file, 0o600); // cookies are bearer credentials + } catch { + /* best effort on platforms without POSIX modes */ + } + return record; + } + + loadBackup(provider) { + try { + const rec = JSON.parse(fs.readFileSync(this._backupPath(provider), 'utf8')); + return rec && Array.isArray(rec.cookies) ? rec : null; + } catch { + return null; + } + } + + clearBackup(provider) { + try { + fs.unlinkSync(this._backupPath(provider)); + } catch { + /* already gone */ + } + } + + /** + * Cookies that a restore would actually install, i.e. those whose domain matches. + * Prevents a Qwen backup from writing perplexity.ai cookies into the wrong profile. + */ + cookiesForDomain(record, domain) { + if (!record || !Array.isArray(record.cookies)) return []; + return record.cookies.filter((c) => String(c.domain || '').includes(domain)); + } +} + +/** Placeholder strings that must never be surfaced to a caller as a real answer (A8). */ +const PLACEHOLDER_RESPONSES = new Set([ + '', + 'no response captured', + 'no response received', +]); + +function isPlaceholderResponse(text) { + if (text === null || text === undefined) return true; + return PLACEHOLDER_RESPONSES.has(String(text).trim().toLowerCase()); +} + +module.exports = { + CookieStore, + evaluateAuth, + normalizeImportedCookies, + normalizeSameSite, + toBackupRecord, + isPlaceholderResponse, + AUTH_COOKIE_TTL_MS, +}; diff --git a/gateway/src/engine-loader.js b/gateway/src/engine-loader.js new file mode 100644 index 0000000..dbcfbcc --- /dev/null +++ b/gateway/src/engine-loader.js @@ -0,0 +1,151 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { getProvider } = require('./providers'); + +/** + * Engine injection. + * + * The gateway holds no provider API knowledge. An engine is a JS file that runs in the + * page's own origin context and exposes a documented global. All provider protocol + * knowledge lives in the engine, which is why an engine can be rewritten without + * touching the gateway. + * + * Engines are injected BEFORE first navigation and RE-INJECTED after every navigation: + * a reload tears down page globals, and a capture request that navigates will lose them. + */ + +const ENGINE_DIR = path.join(__dirname, '..', 'engines'); + +class EngineError extends Error { + constructor(message, provider, detail = {}) { + super(message); + this.name = 'EngineError'; + this.provider = provider; + this.kind = 'engine'; + Object.assign(this, detail); + } +} + +class TransportError extends Error { + constructor(message, provider, detail = {}) { + super(message); + this.name = 'TransportError'; + this.provider = provider; + // Distinct from 'engine' so a caller can tell "the browser broke" from + // "the provider rejected us" (F6). + this.kind = 'transport'; + Object.assign(this, detail); + } +} + +class EngineLoader { + constructor({ engineDir = ENGINE_DIR, stateStore = null } = {}) { + this.engineDir = engineDir; + this.state = stateStore; + this._sources = new Map(); // provider -> source string + } + + enginePath(provider) { + return path.join(this.engineDir, `${provider}-engine.js`); + } + + hasEngine(provider) { + return fs.existsSync(this.enginePath(provider)); + } + + /** Read + cache an engine's source. Throws if the engine is absent. */ + load(provider) { + if (this._sources.has(provider)) return this._sources.get(provider); + const file = this.enginePath(provider); + if (!fs.existsSync(file)) { + throw new EngineError( + `No engine installed for provider "${provider}". Expected ${file}`, + provider, + { reason: 'engine-missing' } + ); + } + const src = fs.readFileSync(file, 'utf8'); + this._sources.set(provider, src); + return src; + } + + /** + * Register the engine so it survives every navigation, and inject it now. + * + * addInitScript is the load-bearing call: it re-runs on every document, which is what + * makes reload-safe operation possible. + */ + async install(context, page, provider) { + const spec = getProvider(provider); + const source = this.load(provider); + + await context.addInitScript({ content: source }); + + // Inject into the already-loaded document too, or the first call after a + // navigation races the init script. + try { + await page.evaluate(source); + } catch (e) { + // An about:blank or mid-navigation page can refuse evaluation. The init + // script will still fire on the next document, so this is not fatal. + } + + // Probe, never assume (F6). + const present = await this.probe(page, provider); + if (!present) { + throw new EngineError( + `Engine for "${provider}" installed but global ${spec.engineGlobal} is absent. ` + + `The engine likely threw during init or the page is not on ${spec.origin}.`, + provider, + { reason: 'engine-absent', global: spec.engineGlobal } + ); + } + return true; + } + + /** Is the engine global actually present right now? */ + async probe(page, provider) { + const spec = getProvider(provider); + try { + return await page.evaluate( + (g) => Boolean(typeof window[g] !== 'undefined' && window[g]), + spec.engineGlobal + ); + } catch (e) { + return false; + } + } + + /** + * Call a method on an engine global, converting absence into a typed error rather + * than a generic undefined failure. + */ + async call(page, provider, method, ...args) { + const spec = getProvider(provider); + const available = await this.probe(page, provider); + if (!available) { + throw new EngineError( + `Engine for "${provider}" is not loaded (${spec.engineGlobal} absent). ` + + `A navigation may have torn it down.`, + provider, + { reason: 'engine-absent' } + ); + } + return page.evaluate( + ({ g, m, a }) => { + const engine = window[g]; + if (!engine || typeof engine[m] !== 'function') { + return { + __error: `engine method ${g}.${m} is not a function`, + }; + } + return Promise.resolve(engine[m](...a)); + }, + { g: spec.engineGlobal, m: method, a: args } + ); + } +} + +module.exports = { EngineLoader, EngineError, TransportError, ENGINE_DIR }; diff --git a/gateway/src/index.js b/gateway/src/index.js new file mode 100644 index 0000000..c8e6645 --- /dev/null +++ b/gateway/src/index.js @@ -0,0 +1,84 @@ +'use strict'; + +const { StateStore, DEFAULT_PORT } = require('./state-store'); +const { CookieStore } = require('./cookie-store'); +const { EngineLoader } = require('./engine-loader'); +const { SessionManager } = require('./session-manager'); +const { IpcServer } = require('./ipc-server'); +const { createHandler } = require('./actions'); + +/** + * Gateway entrypoint. + * + * Owns the lifecycle: state directory, cookie store, engine loader, session manager, + * IPC server. Shutdown flushes cookies and clears the port fact so the next client + * never dials a dead port. + */ + +async function startGateway({ port = DEFAULT_PORT, host = '127.0.0.1', stateDir } = {}) { + const stateStore = new StateStore(stateDir); + const settings = stateStore.loadSettings(); + const cookieStore = new CookieStore(stateStore); + const engineLoader = new EngineLoader({ stateStore }); + const sessions = new SessionManager({ + stateStore, + cookieStore, + engineLoader, + settings, + }); + + const startedAt = new Date().toISOString(); + const fileReferenceEnabled = () => stateStore.loadSettings().fileReference !== false; + + const handler = createHandler({ sessions, stateStore, startedAt, getFileReferenceEnabled: fileReferenceEnabled }); + const ipc = new IpcServer({ handler, stateStore, port, host }); + + const boundPort = await ipc.listen(); + + let shuttingDown = false; + async function shutdown() { + if (shuttingDown) return; + shuttingDown = true; + try { + await sessions.flushAll(); + await sessions.close(); + } finally { + await ipc.close(); + } + } + + return { stateStore, cookieStore, engineLoader, sessions, ipc, handler, boundPort, shutdown }; +} + +async function main() { + const port = Number(process.env.AGENT_HUB_PORT) || DEFAULT_PORT; + const gateway = await startGateway({ port }); + + process.stdout.write( + `[gateway] listening on 127.0.0.1:${gateway.boundPort}\n` + + `[gateway] state dir: ${gateway.stateStore.dir}\n` + + `[gateway] headless: ${gateway.stateStore.loadSettings().headless !== false}\n` + ); + + const stop = async (signal) => { + process.stdout.write(`\n[gateway] ${signal} - shutting down\n`); + await gateway.shutdown(); + process.exit(0); + }; + process.on('SIGINT', () => stop('SIGINT')); + process.on('SIGTERM', () => stop('SIGTERM')); + process.on('uncaughtException', async (e) => { + process.stderr.write(`[gateway] uncaught: ${e && e.stack}\n`); + await gateway.shutdown(); + process.exit(1); + }); +} + +if (require.main === module) { + main().catch((e) => { + process.stderr.write(`[gateway] failed to start: ${e && e.stack}\n`); + process.exit(1); + }); +} + +module.exports = { startGateway, main }; diff --git a/gateway/src/ipc-server.js b/gateway/src/ipc-server.js new file mode 100644 index 0000000..7907b0e --- /dev/null +++ b/gateway/src/ipc-server.js @@ -0,0 +1,201 @@ +'use strict'; + +const net = require('net'); +const { DEFAULT_PORT, FALLBACK_ATTEMPTS } = require('./state-store'); + +/** + * Loopback IPC server. + * + * Transport: TCP on 127.0.0.1, newline-delimited JSON. One request object per line, + * one response object per line. The framing is deliberately trivial so an existing + * client needs no change. + */ + +const MAX_LINE_BYTES = 8 * 1024 * 1024; // guard against an unbounded buffer (F5-adjacent) + +/** + * Best-effort requestId recovery from an unparseable frame. + * Keeps a malformed request correlatable instead of leaving the caller pending forever. + */ +function salvageRequestId(line) { + const m = /"requestId"\s*:\s*(-?\d+)/.exec(line); + return m ? Number(m[1]) : null; +} + +class FrameDecoder { + constructor(maxLineBytes = MAX_LINE_BYTES) { + this.buffer = ''; + this.maxLineBytes = maxLineBytes; + this.dropped = 0; + } + + /** + * Feed raw chunks, get back complete lines. + * A partial trailing line is retained until its newline arrives. + */ + push(chunk) { + this.buffer += chunk; + if (this.buffer.length > this.maxLineBytes) { + // Something is wrong with the peer. Drop what we have rather than grow + // without bound, and count it so the condition is observable. + this.dropped += 1; + this.buffer = ''; + return []; + } + const parts = this.buffer.split('\n'); + this.buffer = parts.pop() || ''; + return parts; + } +} + +class IpcServer { + /** + * @param {object} opts + * @param {Function} opts.handler async (request) => responseObject + * @param {object} opts.stateStore + * @param {number} [opts.port] + * @param {string} [opts.host] loopback only (P7) + */ + constructor({ handler, stateStore, port = DEFAULT_PORT, host = '127.0.0.1' }) { + if (host !== '127.0.0.1' && host !== 'localhost') { + // P7: this is a single-operator local tool. A non-loopback bind would expose + // an unauthenticated command channel to the network. + throw new Error( + `Refusing to bind ${host}. The gateway is loopback-only by policy.` + ); + } + this.handler = handler; + this.state = stateStore; + this.basePort = port; + this.host = host; + this.server = null; + this.boundPort = null; + this.connections = 0; + } + + /** + * Bind, retrying once on the next port before failing loudly (F2). + * The port actually bound is written to the fact file AFTER the bind succeeds. + */ + async listen() { + const attempts = this.basePort + FALLBACK_ATTEMPTS; + let lastError = null; + + for (let port = this.basePort; port <= this.basePort + FALLBACK_ATTEMPTS; port += 1) { + try { + const bound = await this._tryListen(port); + this.boundPort = bound; + this.state.recordPortFact(bound); + return bound; + } catch (e) { + lastError = e; + if (e.code === 'EADDRINUSE') continue; + throw e; + } + } + + throw new Error( + `Could not bind the gateway on port ${this.basePort} or ${this.basePort + 1}: ` + + `${lastError ? lastError.message : 'unknown error'}. ` + + `Another instance may be running - check ${this.state.portFactPath}.` + ); + } + + _tryListen(port) { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => this._onConnection(socket)); + server.once('error', reject); + server.listen(port, this.host, () => { + server.removeListener('error', reject); + this.server = server; + resolve(server.address().port); + }); + }); + } + + _onConnection(socket) { + this.connections += 1; + const decoder = new FrameDecoder(); + let busy = Promise.resolve(); + + socket.on('data', (chunk) => { + let lines; + try { + lines = decoder.push(chunk.toString('utf8')); + } catch { + return; + } + + for (const line of lines) { + if (!line.trim()) continue; + // Serialize per-connection so responses cannot interleave out of order. + busy = busy.then(() => this._respond(socket, line)); + } + }); + + socket.on('error', () => { + /* a client vanishing mid-turn is normal, not an error condition */ + }); + } + + async _respond(socket, line) { + let requestId = null; + try { + let request; + try { + request = JSON.parse(line); + } catch { + // F5: one unparseable frame must not kill the connection. + // + // A client correlates on requestId, so a response carrying null would + // leave that request pending forever. Salvage the id textually so the + // client can still match and see the error. + this._write(socket, { + requestId: salvageRequestId(line), + success: false, + error: 'Malformed JSON frame', + errorKind: 'protocol', + }); + return; + } + requestId = request.requestId ?? null; + + if (typeof request.action !== 'string' || !request.action) { + this._write(socket, { + requestId, + success: false, + error: 'Request is missing an "action"', + }); + return; + } + + const response = await this.handler(request); + this._write(socket, { ...response, requestId }); + } catch (e) { + this._write(socket, { + requestId, + success: false, + error: e && e.message ? e.message : String(e), + ...(e && e.kind ? { errorKind: e.kind } : {}), + }); + } + } + + _write(socket, obj) { + if (socket.destroyed) return; + try { + socket.write(JSON.stringify(obj) + '\n'); + } catch { + /* peer closed between check and write */ + } + } + + async close() { + this.state.clearPortFact(); + if (!this.server) return; + await new Promise((resolve) => this.server.close(resolve)); + this.server = null; + } +} + +module.exports = { IpcServer, FrameDecoder, salvageRequestId, MAX_LINE_BYTES, DEFAULT_PORT }; diff --git a/gateway/src/providers.js b/gateway/src/providers.js new file mode 100644 index 0000000..00e035e --- /dev/null +++ b/gateway/src/providers.js @@ -0,0 +1,123 @@ +'use strict'; + +/** + * Provider registry. + * + * This is the ONLY place provider knowledge lives, and it is deliberately limited to + * what the gateway must know to manage a session: where to navigate, and how to tell + * whether the persisted profile is authenticated. + * + * Provider API protocol knowledge belongs in engines, not here. The gateway never + * learns what a completion request looks like. + */ + +/** Long-lived auth cookies are re-persisted with this expiry so restart != logout. */ +const AUTH_COOKIE_TTL_MS = 365 * 24 * 60 * 60 * 1000; + +const PROVIDERS = { + chatgpt: { + name: 'chatgpt', + url: 'https://chatgpt.com/', + origin: 'https://chatgpt.com', + partition: 'chatgpt', + engineGlobal: '__proximaChatGPT', + auth: { + domain: 'openai.com', + cookies: ['__Secure-next-auth.session-token', '__cf_bm'], + }, + // ChatGPT's in-origin API requires a bearer the page holds; cookies alone 404. + // The engine extracts it. The gateway only records that it must be present. + requiresBearer: true, + supportsUploads: true, + supportsDomFallback: true, + }, + + perplexity: { + name: 'perplexity', + url: 'https://www.perplexity.ai/', + origin: 'https://www.perplexity.ai', + partition: 'perplexity', + engineGlobal: '__proximaPerplexity', + auth: { + domain: 'perplexity.ai', + cookies: ['__Secure-next-auth.session-token', 'pplx_'], + }, + requiresBearer: false, + supportsUploads: false, + supportsDomFallback: true, + }, + + claude: { + name: 'claude', + url: 'https://claude.ai/', + origin: 'https://claude.ai', + partition: 'claude', + engineGlobal: '__proximaClaude', + auth: { + domain: 'claude.ai', + cookies: ['sessionKey', '__cf_bm'], + }, + requiresBearer: false, + supportsUploads: true, + supportsDomFallback: true, + }, + + gemini: { + name: 'gemini', + url: 'https://gemini.google.com/', + origin: 'https://gemini.google.com', + partition: 'gemini', + engineGlobal: '__proximaGemini', + auth: { + // Google sets many consent/tracking cookies, so a count is meaningless here. + // Only these names indicate a real session. + domain: 'google.com', + cookies: ['SID', 'HSID', 'SSID', '__Secure-1PSID', '__Secure-3PSID'], + }, + requiresBearer: false, + // Gemini has no dedicated upload path. Login + message only. + supportsUploads: false, + supportsDomFallback: true, + }, + + qwen: { + name: 'qwen', + url: 'https://chat.qwen.ai/', + origin: 'https://chat.qwen.ai', + partition: 'qwen', + engineGlobal: '__proximaQwen', + auth: { + domain: 'qwen.ai', + cookies: ['token', 'ssxmod_itna', 'cna', 'x5sec'], + }, + requiresBearer: false, + supportsUploads: true, + // A4: the API engine owns the conversation completely. There is no scrape fallback, + // so a capture failure here is a hard failure rather than a retry-the-DOM case. + supportsDomFallback: false, + }, +}; + +const PROVIDER_NAMES = Object.keys(PROVIDERS); + +function getProvider(name) { + const p = PROVIDERS[name]; + if (!p) { + throw new Error( + `Unknown provider "${name}". Known: ${PROVIDER_NAMES.join(', ')}` + ); + } + return p; +} + +function isKnownProvider(name) { + return Object.prototype.hasOwnProperty.call(PROVIDERS, name); +} + +module.exports = { + PROVIDERS, + PROVIDER_NAMES, + AUTH_COOKIE_TTL_MS, + getProvider, + isKnownProvider, +}; diff --git a/gateway/src/session-manager.js b/gateway/src/session-manager.js new file mode 100644 index 0000000..6f3c923 --- /dev/null +++ b/gateway/src/session-manager.js @@ -0,0 +1,564 @@ +'use strict'; + +const { getProvider, PROVIDER_NAMES } = require('./providers'); +const { evaluateAuth, normalizeImportedCookies } = require('./cookie-store'); +const { EngineLoader, EngineError, TransportError } = require('./engine-loader'); + +/** + * Per-provider persistent browser sessions. + * + * A6: auth lives in the persistent profile on disk, never in the page. That is what makes + * unloadProvider safe - the renderer dies, the credentials do not. + * + * States: UNINITIALIZED -> INITIALIZED -> ACTIVE, plus NEEDS_AUTH as a report rather + * than a destructive transition. purgeProvider is the only operation that destroys auth + * and it is deliberately a different method. + */ + +/** Chromium needs a real window size; a zero-sized headless viewport is a bot signal. */ +const DEFAULT_VIEWPORT = { width: 1440, height: 900 }; + +const LAUNCH_ARGS = [ + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + '--disable-dev-shm-usage', +]; + +class ProviderSession { + constructor(name, spec) { + this.name = name; + this.spec = spec; + this.context = null; + this.page = null; + this.state = 'UNINITIALIZED'; + this.sentThisProcess = false; + this.lastError = null; + } + + get isOpen() { + return Boolean(this.context); + } + + get isActive() { + return this.state === 'ACTIVE'; + } +} + +class SessionManager { + /** + * @param {object} opts + * @param {object} opts.stateStore + * @param {object} opts.cookieStore + * @param {EngineLoader} [opts.engineLoader] + * @param {object} [opts.settings] persisted settings (headless per provider, etc.) + * @param {Function} [opts.browserFactory] injectable for tests; returns a Browser + */ + constructor({ + stateStore, + cookieStore, + engineLoader = null, + settings = {}, + browserFactory = null, + }) { + this.state = stateStore; + this.cookies = cookieStore; + this.engines = engineLoader || new EngineLoader({ stateStore }); + this.settings = settings; + this.browserFactory = browserFactory || defaultBrowserFactory; + this._browser = null; + this._sessions = new Map(); + this._startedAt = new Date().toISOString(); + this.activeProvider = null; + } + + // ---- browser lifecycle ---- + + async _ensureBrowser() { + if (this._browser) return this._browser; + this._browser = await this.browserFactory({ + headless: this.settings.headless !== false, + args: LAUNCH_ARGS, + }); + return this._browser; + } + + async close() { + for (const s of this._sessions.values()) { + if (s.context) { + try { + await s.context.close(); + } catch { + /* already gone */ + } + } + } + this._sessions.clear(); + if (this._browser) { + try { + await this._browser.close(); + } catch { + /* already gone */ + } + this._browser = null; + } + } + + // ---- session lifecycle ---- + + _session(name) { + const spec = getProvider(name); + if (!this._sessions.has(name)) { + this._sessions.set(name, new ProviderSession(name, spec)); + } + return this._sessions.get(name); + } + + initializedProviders() { + return [...this._sessions.values()].filter((s) => s.isOpen).map((s) => s.name); + } + + /** + * Create the persistent context and load the provider page. + * Does not require login (F1: a failure here is reported, never faked as ready). + */ + async initProvider(name, { headless = null } = {}) { + const session = this._session(name); + if (session.isOpen) return session; + + const browser = await this._ensureBrowser(); + const useHeadless = headless === null + ? this._headlessFor(name) + : Boolean(headless); + + const userDataDir = this.state.profileDir(session.spec.partition); + + let context; + try { + context = await browser.newContext({ + viewport: DEFAULT_VIEWPORT, + userAgent: undefined, // use Chromium's real UA; do not spoof + }); + } catch (e) { + session.state = 'UNINITIALIZED'; + session.lastError = e; + throw new TransportError( + `Could not create a browser context for "${name}": ${e.message}`, + name, + { reason: 'context-failed' } + ); + } + + // Persistent storage: the profile dir IS the auth store. Cookie backup is a + // secondary safety net, not the primary mechanism. + await this._attachPersistence(context, userDataDir, session); + + const page = await context.newPage(); + session.context = context; + session.page = page; + session.state = 'INITIALIZED'; + session.headless = useHeadless; + session.lastError = null; + + await this._navigateAndInject(session, { initial: true }); + + const auth = await this.isLoggedIn(name); + if (!auth.loggedIn) { + // NEEDS_AUTH is a report. The context and any stored cookies stay put. + session.state = 'NEEDS_AUTH'; + } + return session; + } + + /** + * Cookie persistence. + * + * Playwright's persistent context API is launch-time, so a per-provider userDataDir + * is emulated: cookies are loaded from the profile dir on create, and flushed back + * after every mutation and on shutdown. + */ + async _attachPersistence(context, userDataDir, session) { + const profileFile = path_join(userDataDir, 'storage-state.json'); + + // Restore: prefer the profile, fall back to the cookie backup. + let restored = readJson(profileFile); + if (restored && Array.isArray(restored.cookies) && restored.cookies.length) { + try { + await context.addCookies(restored.cookies); + } catch { + /* a malformed cookie should not block startup */ + } + } else { + const backup = this.cookies.loadBackup(session.name); + if (backup) { + const domainCookies = this.cookies.cookiesForDomain( + backup, + session.spec.auth.domain + ); + if (domainCookies.length) { + try { + await context.addCookies(domainCookies); + } catch { + /* ignore */ + } + } + } + } + + session.profileFile = profileFile; + } + + async _flushCookies(session) { + if (!session.context || !session.profileFile) return; + try { + const cookies = await session.context.cookies(); + writeJson(session.profileFile, { + provider: session.name, + savedAt: new Date().toISOString(), + cookies, + }); + this.cookies.saveBackup(session.name, cookies); + } catch { + /* flush is best effort; shutdown retries */ + } + } + + _headlessFor(name) { + const perProvider = this.settings.headlessByProvider || {}; + if (Object.prototype.hasOwnProperty.call(perProvider, name)) { + return Boolean(perProvider[name]); + } + return this.settings.headless !== false; + } + + async _navigateAndInject(session, { initial = false } = {}) { + const { spec, name, page } = session; + try { + await page.goto(spec.url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + } catch (e) { + if (!initial) throw e; + throw new TransportError( + `Could not reach ${spec.url} for provider "${name}": ${e.message}`, + name, + { reason: 'navigation-failed' } + ); + } + + // Re-inject after every navigation. The init script also fires, but an explicit + // install lets us probe and fail loudly rather than discovering the gap later. + try { + await this.engines.install(session.context, page, name); + } catch (e) { + if (e instanceof EngineError && initial) throw e; + if (!(e instanceof EngineError)) throw e; + // Non-initial injection failure is recoverable: the next navigation retries. + session.lastError = e; + } + } + + // ---- auth ---- + + async isLoggedIn(name) { + const spec = getProvider(name); + const session = this._session(name); + + let cookies = []; + if (session.isOpen && session.context) { + try { + cookies = await session.context.cookies(); + } catch { + cookies = []; + } + } else { + // Not loaded: answer from disk so an operator can check auth without + // paying for a browser launch. + const fromDisk = readJson(session.profileFile) || {}; + cookies = fromDisk.cookies || []; + } + + const verdict = evaluateAuth(cookies, spec.auth); + if (session.isOpen) { + session.state = verdict.loggedIn ? session.state : 'NEEDS_AUTH'; + } + return { provider: name, loggedIn: verdict.loggedIn, matched: verdict.matched }; + } + + /** + * Inject an operator-supplied cookie export. This is a full supported login path - + * interactive login is not required for the gateway to work. + */ + async setCookies(name, cookieInput) { + const spec = getProvider(name); + const normalized = normalizeImportedCookies(cookieInput); // throws on bad input + + const session = this._session(name); + if (!session.isOpen) { + await this.initProvider(name); + } + + // Clear the domain's existing cookies first, or the old and new sets interleave. + try { + const existing = await session.context.cookies(spec.auth.domain); + for (const c of existing) { + const url = `${c.secure ? 'https' : 'http'}://${c.domain.replace(/^\./, '')}${c.path || '/'}`; + await session.context.clearCookies({ name: c.name, domain: c.domain }).catch(async () => { + // Fall back to explicit removal when the filtered clear is unsupported. + await session.context.clearCookies(); + }); + void url; + } + } catch { + /* clearing is best effort; addCookies below is what matters */ + } + + let set = 0; + let failed = 0; + for (const cookie of normalized) { + try { + await session.context.addCookies([cookie]); + set += 1; + } catch { + failed += 1; + } + } + + await this._flushCookies(session); + + // Reload so the new auth is actually in effect for the page's own fetches. + await this._navigateAndInject(session).catch(() => {}); + + const verdict = await this.isLoggedIn(name); + return { + provider: name, + set, + failed, + loggedIn: verdict.loggedIn, + message: + `Applied ${set} cookies` + + (failed ? `, ${failed} failed` : '') + + '. ' + + (verdict.loggedIn ? 'Session authenticated.' : 'No recognised auth cookie found.'), + }; + } + + async getCookies(name) { + const spec = getProvider(name); + const session = this._session(name); + let cookies = []; + if (session.isOpen && session.context) { + cookies = await session.context.cookies(); + } else { + const fromDisk = readJson(session.profileFile) || {}; + cookies = fromDisk.cookies || []; + } + const filtered = cookies.filter((c) => + String(c.domain || '').includes(spec.auth.domain) + ); + return { provider: name, cookies: filtered, count: filtered.length }; + } + + // ---- memory control ---- + + /** + * Free renderer memory WITHOUT logging out (A6). + * + * Asynchronous by contract: the page is blanked before the context closes, and an + * un-awaited call would report success for every provider regardless of outcome. + * A settling delay precedes the memory reading because Chromium reaps the process + * asynchronously and an immediate reading understates the saving. + */ + async unloadProvider(name) { + const session = this._session(name); + if (!session.isOpen) { + return { provider: name, unloaded: false, reason: 'not-loaded' }; + } + try { + await session.page.goto('about:blank').catch(() => {}); + } catch { + /* blanking is an optimisation, not a requirement */ + } + try { + await session.context.close(); + } catch (e) { + return { provider: name, unloaded: false, reason: e.message }; + } + session.context = null; + session.page = null; + session.state = 'UNINITIALIZED'; + session.sentThisProcess = false; + if (this.activeProvider === name) this.activeProvider = null; + return { provider: name, unloaded: true, authPreserved: true }; + } + + /** The only operation that destroys auth. Never reachable from unloadProvider. */ + async purgeProvider(name) { + const session = this._session(name); + if (session.isOpen) { + try { + await session.context.clearCookies(); + await session.context.close(); + } catch { + /* ignore */ + } + session.context = null; + session.page = null; + } + this.cookies.clearBackup(name); + session.state = 'UNINITIALIZED'; + session.sentThisProcess = false; + if (session.profileFile) removeFile(session.profileFile); + return { provider: name, purged: true }; + } + + // ---- messaging ---- + + async _ensureLoaded(name) { + const session = this._session(name); + if (!session.isOpen) await this.initProvider(name); + return session; + } + + async sendMessage(name, data) { + const session = await this._ensureLoaded(name); + this.activeProvider = name; + + const options = allowlistOptions(data); + const payload = { ...options, message: data.message }; + + // A5-ish: an explicit newChat clears per-engine conversation state so an + // automated turn cannot land inside somebody's unrelated thread. + if (data.newChat) { + await this.engines.call(session.page, name, 'newConversation').catch(() => {}); + } + + await this.engines.call(session.page, name, 'sendMessage', payload); + + session.sentThisProcess = true; + session.state = 'ACTIVE'; + return { provider: name, queued: true }; + } + + async getResponse(name) { + const session = await this._ensureLoaded(name); + const raw = await this.engines.call(session.page, name, 'getResponse'); + const text = typeof raw === 'string' ? raw : (raw && raw.response) || ''; + return { provider: name, response: text }; + } + + async getTypingStatus(name) { + const session = await this._ensureLoaded(name); + const raw = await this.engines.call(session.page, name, 'getTypingStatus'); + return { provider: name, typing: Boolean(raw && raw.typing) }; + } + + async newConversation(name) { + const session = await this._ensureLoaded(name); + await this.engines.call(session.page, name, 'newConversation'); + return { provider: name, cleared: true }; + } + + async executeScript(name, script) { + const session = await this._ensureLoaded(name); + return { provider: name, result: await session.page.evaluate(script) }; + } + + async navigate(name, url) { + const session = await this._ensureLoaded(name); + await session.page.goto(url, { waitUntil: 'domcontentloaded' }); + await this._navigateAndInject(session).catch(() => {}); + return { provider: name, url }; + } + + async flushAll() { + for (const session of this._sessions.values()) { + if (session.isOpen) await this._flushCookies(session); + } + } +} + +/** + * A1: the option allowlist. + * + * Unlisted keys are dropped without error. This is deliberate. A caller sending + * modelPreference to a provider that reads `thinking` would otherwise get a silently + * wrong configuration with no error anywhere - which is exactly what happened upstream. + */ +const OPTION_ALLOWLIST = [ + 'modelPreference', + 'model', + 'thinkingEffort', + 'thinkingMode', + 'autoSearch', + 'thinking', + 'chatType', + 'researchMode', + 'files', + 'attachments', + 'conversationId', + 'newChat', + 'session', + 'effort', + 'renderingMode', + 'tag', +]; + +function allowlistOptions(data) { + const out = {}; + for (const key of OPTION_ALLOWLIST) { + if (data[key] !== undefined) out[key] = data[key]; + } + return out; +} + +// A2: centralized, once-only timestamp stamping. +function stampTimestamp(message) { + if (typeof message !== 'string') return message; + return `${message}\n\nCurrent time: ${new Date().toISOString()}`; +} + +// ---- small fs helpers (kept local so the module has no import cycle) ---- + +function path_join(...parts) { + return require('path').join(...parts); +} + +function readJson(file) { + try { + return JSON.parse(require('fs').readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +function writeJson(file, value) { + try { + require('fs').mkdirSync(require('path').dirname(file), { recursive: true }); + require('fs').writeFileSync(file, JSON.stringify(value, null, 2), 'utf8'); + } catch { + /* best effort */ + } +} + +function removeFile(file) { + try { + require('fs').unlinkSync(file); + } catch { + /* already gone */ + } +} + +async function defaultBrowserFactory({ headless, args }) { + // Lazy require so unit tests can run without Playwright installed. + const { chromium } = require('playwright'); + return chromium.launch({ headless, args }); +} + +module.exports = { + SessionManager, + ProviderSession, + OPTION_ALLOWLIST, + allowlistOptions, + stampTimestamp, + PROVIDER_NAMES, + DEFAULT_VIEWPORT, + LAUNCH_ARGS, +}; diff --git a/gateway/src/state-store.js b/gateway/src/state-store.js new file mode 100644 index 0000000..8dd48d4 --- /dev/null +++ b/gateway/src/state-store.js @@ -0,0 +1,139 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/** + * Settings and the port fact file. + * + * Two files, deliberately, because conflating them caused a real failure: + * + * settings.json a PREFERENCE the operator may have typed. Can be stale. Can be wrong. + * ipc-port.json a FACT about the running process. Written only after a successful bind. + * + * Tools that need to reach the gateway must read the fact. A client that trusts the + * preference will report "gateway is not running" about a process that is running fine + * on a fallback port. + */ + +const DEFAULT_PORT = 19222; +const FALLBACK_ATTEMPTS = 1; // try port, then port+1, then give up (F2) + +function defaultStateDir() { + const override = process.env.PROXIMA_GATEWAY_STATE_DIR; + if (override) return override; + return path.join(os.homedir(), '.proxima-gateway'); +} + +class StateStore { + constructor(dir = defaultStateDir()) { + this.dir = dir; + this.settingsPath = path.join(dir, 'settings.json'); + this.portFactPath = path.join(dir, 'ipc-port.json'); + this.profilesDir = path.join(dir, 'profiles'); + this.cookieBackupDir = path.join(dir, 'cookie-backups'); + this._ensureDirs(); + } + + _ensureDirs() { + for (const d of [this.dir, this.profilesDir, this.cookieBackupDir]) { + fs.mkdirSync(d, { recursive: true }); + } + } + + // ---- settings (preference) ---- + + loadSettings() { + try { + const raw = fs.readFileSync(this.settingsPath, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + // A missing or corrupt settings file is not fatal. Defaults apply. + return {}; + } + } + + saveSettings(patch) { + const next = { ...this.loadSettings(), ...patch }; + fs.writeFileSync(this.settingsPath, JSON.stringify(next, null, 2), 'utf8'); + return next; + } + + // ---- port fact ---- + + /** + * Read the port the running process actually bound. + * Returns null when no process has recorded a fact (or the recorded pid is gone). + */ + readPortFact() { + try { + const fact = JSON.parse(fs.readFileSync(this.portFactPath, 'utf8')); + if (!fact || typeof fact.port !== 'number') return null; + return fact; + } catch { + return null; + } + } + + /** + * The port a client should dial. The FACT always beats the PREFERENCE (F3). + * A fact naming a dead pid is ignored. + */ + resolvePort() { + const fact = this.readPortFact(); + if (fact && this._pidAlive(fact.pid)) { + return { port: fact.port, source: 'fact', pid: fact.pid }; + } + const pref = this.loadSettings().ipcPort; + if (typeof pref === 'number' && pref > 0) { + return { port: pref, source: 'preference', pid: fact ? fact.pid : null }; + } + return { port: DEFAULT_PORT, source: 'default', pid: null }; + } + + _pidAlive(pid) { + if (!pid || typeof pid !== 'number') return false; + try { + process.kill(pid, 0); + return true; + } catch (e) { + // EPERM means it exists but belongs to someone else - still alive. + return e.code === 'EPERM'; + } + } + + /** Written only after a successful bind. */ + recordPortFact(port, extra = {}) { + const fact = { + port, + pid: process.pid, + startedAt: new Date().toISOString(), + ...extra, + }; + fs.writeFileSync(this.portFactPath, JSON.stringify(fact, null, 2), 'utf8'); + // Keep the preference in sync so it is not misleading next time, but the fact + // remains the authority. + try { + this.saveSettings({ ipcPort: port }); + } catch { + /* preference sync is best-effort */ + } + return fact; + } + + clearPortFact() { + try { + fs.unlinkSync(this.portFactPath); + } catch { + /* already gone */ + } + } + + profileDir(provider) { + return path.join(this.profilesDir, provider); + } +} + +module.exports = { StateStore, DEFAULT_PORT, FALLBACK_ATTEMPTS, defaultStateDir }; diff --git a/gateway/test/browser.test.js b/gateway/test/browser.test.js new file mode 100644 index 0000000..4e6878d --- /dev/null +++ b/gateway/test/browser.test.js @@ -0,0 +1,292 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +/** + * Real-browser integration. + * + * The rest of the suite stubs the browser so it runs anywhere. This file does not: it + * launches real Chromium, because the parts most likely to be wrong in a stub are + * exactly the parts a stub cannot catch - context creation, init-script injection, + * cookie round-tripping through a real cookie jar, and whether a headless launch works + * at all. + * + * Skipped automatically when Playwright or its browser is unavailable, so a contributor + * without `npx playwright install` still gets a green local run. + * + * No network egress: every test uses a data URL or a local file, so this is + * deterministic and does not touch a provider. + */ + +let chromium = null; +let browserAvailable = false; + +try { + ({ chromium } = require('playwright')); + browserAvailable = Boolean(chromium); +} catch { + browserAvailable = false; +} + +const skip = browserAvailable + ? false + : 'playwright not installed - run `npx playwright install chromium` to enable'; + +const PAGE_HTML = + 'data:text/html,' + + encodeURIComponent( + 't
ready
' + ); + +function tmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +const ENGINES = { + __proximaTest: `window.__proximaTest = { + sendMessage: function (o) { return { echoed: o && o.message }; }, + newConversation: function () { return { cleared: true }; }, + getResponse: function () { return 'real-browser-response'; }, + getTypingStatus: function () { return { typing: false }; }, + isReady: function () { return true; }, + marker: 'installed' + };`, +}; + +async function withBrowser(fn) { + const browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], + }); + try { + await fn(browser); + } finally { + await browser.close(); + } +} + +test('real chromium launches headless', { skip }, async () => { + await withBrowser(async (browser) => { + assert.ok(browser.isConnected(), 'browser must be connected after launch'); + }); +}); + +test('a real context and page can be created', { skip }, async () => { + await withBrowser(async (browser) => { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + await page.goto(PAGE_HTML); + assert.equal(await page.textContent('#root'), 'ready'); + await context.close(); + }); +}); + +test('headless chromium reports a real, non-zero viewport', { skip }, async () => { + // A zero-sized viewport is a known bot signal, so assert it is sane rather than + // trusting the default. + await withBrowser(async (browser) => { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + await page.goto(PAGE_HTML); + const size = await page.evaluate(() => ({ + w: window.innerWidth, + h: window.innerHeight, + })); + assert.equal(size.w, 1440); + assert.equal(size.h, 900); + await context.close(); + }); +}); + +test('init scripts run on every navigation (the reload-safety requirement)', { skip }, async () => { + await withBrowser(async (browser) => { + const context = await browser.newContext(); + // A counter in the init script must increment on each new document. + await context.addInitScript({ + content: 'window.__initCount = (window.__initCount || 0) + 1;', + }); + const page = await context.newPage(); + + await page.goto(PAGE_HTML); + assert.equal(await page.evaluate(() => window.__initCount), 1); + + await page.reload(); + assert.equal( + await page.evaluate(() => window.__initCount), + 1, + 'a reload is a fresh document, so the counter restarts at 1' + ); + + await page.goto(PAGE_HTML); + assert.equal(await page.evaluate(() => window.__initCount), 1); + + await context.close(); + }); +}); + +test('an injected engine is present and callable in a real page', { skip }, async () => { + await withBrowser(async (browser) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await context.addInitScript({ content: ENGINES.__proximaTest }); + await page.goto(PAGE_HTML); + + const present = await page.evaluate( + (g) => Boolean(typeof window[g] !== 'undefined' && window[g]), + '__proximaTest' + ); + assert.equal(present, true, 'engine global must be visible after navigation'); + + const result = await page.evaluate((msg) => window.__proximaTest.sendMessage({ message: msg }), 'ping'); + assert.equal(result.echoed, 'ping'); + + await context.close(); + }); +}); + +test('a re-injected engine overwrites rather than merging', { skip }, async () => { + // Re-injection happens on every navigation, so a stale engine must not survive. + await withBrowser(async (browser) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(PAGE_HTML); + await page.evaluate(() => { + window.__proximaTest = { marker: 'stale', sendMessage: () => ({}) }; + }); + assert.equal(await page.evaluate(() => window.__proximaTest.marker), 'stale'); + + await page.reload(); + assert.equal( + await page.evaluate(() => window.__proximaTest.marker), + undefined, + 'a reload must clear the stale engine, proving the init script is what provides it' + ); + + await context.close(); + }); +}); + +test('cookies survive a real cookie jar round trip', { skip }, async () => { + await withBrowser(async (browser) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(PAGE_HTML); + + await context.addCookies([ + { + name: 'sessionKey', + value: 'real-value', + domain: 'claude.ai', + path: '/', + secure: true, + }, + ]); + + const cookies = await context.cookies('claude.ai'); + assert.equal(cookies.length, 1); + assert.equal(cookies[0].name, 'sessionKey'); + assert.equal(cookies[0].value, 'real-value'); + + await context.close(); + }); +}); + +test('a real cookie jar authenticates through the auth evaluator', { skip }, async () => { + const { evaluateAuth } = require('../src/cookie-store'); + const { PROVIDERS } = require('../src/providers'); + + await withBrowser(async (browser) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(PAGE_HTML); + + // Before: noise only, must not read as logged in. + await context.addCookies([ + { name: 'consent_1', value: 'x', domain: 'google.com', path: '/', secure: true }, + { name: 'consent_2', value: 'x', domain: 'google.com', path: '/', secure: true }, + ]); + let cookies = await context.cookies(); + assert.equal(evaluateAuth(cookies, PROVIDERS.gemini.auth).loggedIn, false); + + // After adding the real auth cookie, it must. + await context.addCookies([ + { name: '__Secure-1PSID', value: 'y', domain: 'google.com', path: '/', secure: true }, + ]); + cookies = await context.cookies(); + const verdict = evaluateAuth(cookies, PROVIDERS.gemini.auth); + assert.equal(verdict.loggedIn, true); + assert.ok(verdict.matched.includes('__Secure-1PSID')); + + await context.close(); + }); +}); + +test('the profile file round trips through a real browser restart', { skip }, async () => { + // A6 depends on auth living on disk, not in the renderer. Prove a fresh context + // can be re-authenticated purely from the stored profile. + const { StateStore } = require('../src/state-store'); + const { CookieStore } = require('../src/cookie-store'); + const { evaluateAuth } = require('../src/cookie-store'); + const { PROVIDERS } = require('../src/providers'); + + const dir = tmpDir('proxima-gw-persist-'); + const state = new StateStore(dir); + const cookies = new CookieStore(state); + + await withBrowser(async (browser) => { + // --- first "process" --- + const ctx1 = await browser.newContext(); + const page1 = await ctx1.newPage(); + await page1.goto(PAGE_HTML); + await ctx1.addCookies([ + { name: 'pplx_session', value: 'persisted', domain: 'perplexity.ai', path: '/', secure: true }, + ]); + const live = await ctx1.cookies(); + cookies.saveBackup('perplexity', live); + assert.equal(evaluateAuth(live, PROVIDERS.perplexity.auth).loggedIn, true); + await ctx1.close(); + + // --- second "process": a brand new context, nothing shared but disk --- + const ctx2 = await browser.newContext(); + const page2 = await ctx2.newPage(); + await page2.goto(PAGE_HTML); + assert.equal(evaluateAuth(await ctx2.cookies(), PROVIDERS.perplexity.auth).loggedIn, false); + + const backup = cookies.loadBackup('perplexity'); + const restored = cookies.cookiesForDomain(backup, PROVIDERS.perplexity.auth.domain); + await ctx2.addCookies(restored); + + const afterRestart = await ctx2.cookies(); + assert.equal( + evaluateAuth(afterRestart, PROVIDERS.perplexity.auth).loggedIn, + true, + 'auth must come back from disk alone' + ); + await ctx2.close(); + }); +}); + +test('a backup never leaks cookies across provider domains', { skip }, async () => { + const { StateStore } = require('../src/state-store'); + const { CookieStore } = require('../src/cookie-store'); + const { PROVIDERS } = require('../src/providers'); + + const dir = tmpDir('proxima-gw-dom-'); + const store = new CookieStore(new StateStore(dir)); + store.saveBackup('qwen', [ + { name: 'token', value: 'q', domain: '.qwen.ai' }, + { name: '__cf_bm', value: 'leak', domain: '.openai.com' }, + ]); + + const chatgptCookies = store.cookiesForDomain( + store.loadBackup('qwen'), + PROVIDERS.chatgpt.auth.domain + ); + assert.equal(chatgptCookies.length, 1); + assert.equal(chatgptCookies[0].name, '__cf_bm'); +}); diff --git a/gateway/test/integration.test.js b/gateway/test/integration.test.js new file mode 100644 index 0000000..1024076 --- /dev/null +++ b/gateway/test/integration.test.js @@ -0,0 +1,544 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +const { StateStore } = require('../src/state-store'); +const { CookieStore } = require('../src/cookie-store'); +const { EngineLoader } = require('../src/engine-loader'); +const { SessionManager } = require('../src/session-manager'); +const { IpcServer } = require('../src/ipc-server'); +const { createHandler, REJECTED_ACTIONS } = require('../src/actions'); +const { PROVIDERS } = require('../src/providers'); + +/** + * Integration: a real TCP client against a real server, with a stubbed browser. + * + * The stub satisfies the narrow surface SessionManager uses, so the whole IPC path - + * framing, dispatch, validation, error typing - is exercised without Chromium. + */ + +// ---- test doubles ------------------------------------------------------------- + +function stubContext({ cookies = [], persistent = true } = {}) { + let store = cookies.slice(); + const initScripts = []; + return { + _store: () => store, + _initScripts: initScripts, + async addInitScript(arg) { + initScripts.push(arg); + }, + async cookies(filter) { + if (!filter) return store.slice(); + return store.filter((c) => String(c.domain).includes(filter)); + }, + async addCookies(list) { + for (const c of list) { + const i = store.findIndex((x) => x.name === c.name && x.domain === c.domain); + if (i >= 0) store[i] = c; + else store.push(c); + } + }, + async clearCookies() { + store = []; + }, + async close() { + this._closed = true; + }, + newPage: async () => stubPage(), + }; +} + +function stubPage({ enginePresent = true, response = 'hello from engine' } = {}) { + const state = { response, typing: false, sent: [], closed: false }; + const engine = { + sendMessage: (p) => { + state.sent.push(p); + return { ok: true }; + }, + newConversation: () => ({ cleared: true }), + getResponse: () => state.response, + getTypingStatus: () => ({ typing: state.typing }), + isReady: () => true, + }; + return { + _state: state, + async goto() {}, + // Three distinct call shapes are used by the gateway, all via Playwright's + // evaluate(pageFunction, arg) signature: + // evaluate(sourceString) - engine script injection + // evaluate(fn, globalName) - engine presence probe + // evaluate(fn, { g, m, a }) - engine method call + async evaluate(arg, maybeArg) { + if (typeof arg === 'string') return true; // injection + if (typeof arg === 'function') { + if (typeof maybeArg === 'string') return enginePresent; // probe + if (maybeArg && typeof maybeArg === 'object' && maybeArg.g) { + const { g, m, a } = maybeArg; + if (!engine[m]) { + return { __error: `engine method ${g}.${m} is not a function` }; + } + return Promise.resolve(engine[m](...(a || []))); + } + return null; + } + return null; + }, + async content() { + return 'stub'; + }, + }; +} + +function stubBrowserFactory() { + return async () => ({ + newContext: async () => stubContext(), + close: async () => {}, + }); +} + +/** + * Write a minimal engine file per provider into a temp dir. + * + * The shipped perplexity engine is the real clean-room reference; these stubs exist so + * every provider path can be exercised without five real provider protocols. They prove + * the gateway treats all providers uniformly, which is the property under test. + */ +function stubEngineDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-eng-')); + for (const spec of Object.values(PROVIDERS)) { + fs.writeFileSync( + path.join(dir, `${spec.name}-engine.js`), + `/* stub */\nwindow.${spec.engineGlobal} = { sendMessage: function(){return {ok:true};}, newConversation: function(){return {cleared:true};}, getResponse: function(){return '';}, getTypingStatus: function(){return {typing:false};}, isReady: function(){return true;} };\n`, + 'utf8' + ); + } + return dir; +} + +function makeGateway(stateDir) { + const stateStore = new StateStore(stateDir); + const cookieStore = new CookieStore(stateStore); + const engineLoader = new EngineLoader({ + engineDir: stubEngineDir(), + stateStore, + }); + const sessions = new SessionManager({ + stateStore, + cookieStore, + engineLoader, + settings: { headless: true }, + browserFactory: stubBrowserFactory(), + }); + const handler = createHandler({ + sessions, + stateStore, + startedAt: '2026-01-01T00:00:00.000Z', + getFileReferenceEnabled: () => true, + }); + const ipc = new IpcServer({ handler, stateStore, port: 0 }); + return { stateStore, cookieStore, engineLoader, sessions, ipc, handler }; +} + +// ---- a minimal client that speaks the documented protocol --------------------- + +class Client { + constructor(port) { + this.port = port; + this.buffer = ''; + this.pending = new Map(); + this.nextId = 1; + } + + connect() { + return new Promise((resolve, reject) => { + this.socket = net.createConnection({ port: this.port, host: '127.0.0.1' }, resolve); + this.socket.once('error', reject); + this.socket.on('data', (chunk) => { + this.buffer += chunk.toString('utf8'); + const lines = this.buffer.split('\n'); + this.buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + const resolveFn = this.pending.get(msg.requestId); + if (resolveFn) { + this.pending.delete(msg.requestId); + resolveFn(msg); + } + } + }); + }); + } + + send(action, provider, data = {}, { raw = null, timeoutMs = 10000, id = null } = {}) { + const requestId = id !== null ? id : this.nextId++; + return new Promise((resolve) => { + // A client must never hang forever on a request the server cannot correlate. + const timer = setTimeout(() => { + this.pending.delete(requestId); + resolve({ + requestId, + success: false, + error: `Client timeout after ${timeoutMs}ms waiting for requestId ${requestId}`, + errorKind: 'client-timeout', + }); + }, timeoutMs); + if (timer.unref) timer.unref(); + + this.pending.set(requestId, (msg) => { + clearTimeout(timer); + resolve(msg); + }); + const frame = raw !== null ? raw : JSON.stringify({ requestId, action, provider, data }); + this.socket.write(frame + '\n'); + }); + } + + close() { + this.socket.destroy(); + } +} + +async function withGateway(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-int-')); + const g = makeGateway(dir); + const port = await g.ipc.listen(); + const client = new Client(port); + await client.connect(); + try { + await fn(client, g); + } finally { + client.close(); + await g.shutdown?.(); + await g.ipc.close(); + await g.sessions.close(); + } +} + +// ---- tests -------------------------------------------------------------------- + +test('end-to-end: health and status over a real socket', async () => { + await withGateway(async (client, g) => { + const pong = await client.send('ping'); + assert.equal(pong.success, true); + assert.equal(pong.message, 'pong'); + assert.equal(pong.requestId, 1, 'requestId must be echoed'); + + const status = await client.send('getStatus'); + assert.equal(status.success, true); + assert.equal(status.pid, process.pid); + assert.ok(Array.isArray(status.providers)); + assert.ok(status.knownProviders.includes('qwen')); + assert.equal(status.port, g.ipc.boundPort, 'status must report the real bound port'); + }); +}); + +test('a real client from the existing automation layer can attach unchanged', async () => { + // This mirrors scripts/lib/proxima-client.cjs: newline-delimited JSON, requestId + // correlation, and reading the port from a fact file. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-compat-')); + const g = makeGateway(dir); + const port = await g.ipc.listen(); + + const fact = JSON.parse(fs.readFileSync(g.stateStore.portFactPath, 'utf8')); + assert.equal(fact.port, port, 'the fact file must let a client discover the port'); + + const socket = net.createConnection({ port: fact.port, host: '127.0.0.1' }); + await new Promise((r) => socket.once('connect', r)); + + const reply = await new Promise((resolve) => { + let buf = ''; + socket.on('data', (c) => { + buf += c.toString(); + const line = buf.split('\n').find((l) => l.trim()); + if (line) resolve(JSON.parse(line)); + }); + socket.write( + JSON.stringify({ requestId: 1, action: 'sendMessage', provider: 'perplexity', data: { message: 'hi' } }) + '\n' + ); + }); + + assert.equal(reply.requestId, 1); + assert.equal(reply.success, true, reply.error); + socket.destroy(); + await g.ipc.close(); + await g.sessions.close(); +}); + +test('unknown action and unknown provider are rejected, not crashed on', async () => { + await withGateway(async (client) => { + const bad = await client.send('noSuchAction'); + assert.equal(bad.success, false); + assert.match(bad.error, /Unknown action/); + + const badProvider = await client.send('isLoggedIn', 'notaprovider'); + assert.equal(badProvider.success, false); + assert.match(badProvider.error, /Unknown provider/); + }); +}); + +test('a provider-scoped action without a provider is refused', async () => { + await withGateway(async (client) => { + const res = await client.send('sendMessage', null, { message: 'x' }); + assert.equal(res.success, false); + assert.match(res.error, /requires a provider/); + }); +}); + +test('GUI actions are refused with the reason on record (P1)', async () => { + await withGateway(async (client) => { + for (const action of Object.keys(REJECTED_ACTIONS)) { + const res = await client.send(action, 'chatgpt'); + assert.equal(res.success, false, `${action} must be refused`); + assert.match(res.error, /not available/); + } + }); +}); + +test('malformed JSON does not kill the connection (F5)', async () => { + await withGateway(async (client) => { + // The id is salvaged textually, so the caller can still correlate and see why. + const bad = await client.send( + null, + null, + {}, + { raw: '{"requestId": 77, oops', id: 77 } + ); + assert.equal(bad.success, false); + assert.equal(bad.requestId, 77, 'requestId must be salvaged so the caller is not left pending'); + assert.match(bad.error, /Malformed JSON/); + assert.equal(bad.errorKind, 'protocol'); + + // The same socket must still work afterwards. + const pong = await client.send('ping'); + assert.equal(pong.success, true); + }); +}); + +test('a client is never left pending when a response cannot be correlated', async () => { + await withGateway(async (client) => { + const res = await client.send(null, null, {}, { raw: 'total garbage no id here', timeoutMs: 2000 }); + assert.equal(res.success, false, 'must fail rather than hang'); + assert.ok( + res.errorKind === 'protocol' || res.errorKind === 'client-timeout', + `expected protocol or timeout, got ${res.errorKind}` + ); + }); +}); + +test('a frame with no action is refused without dropping the socket', async () => { + await withGateway(async (client) => { + const res = await client.send( + null, + null, + {}, + { raw: JSON.stringify({ requestId: 909 }), id: 909 } + ); + assert.equal(res.success, false); + assert.match(res.error, /missing an "action"/); + const pong = await client.send('ping'); + assert.equal(pong.success, true); + }); +}); + +test('sendMessage stamps the timestamp exactly once (A2)', async () => { + await withGateway(async (client, g) => { + await client.send('sendMessage', 'perplexity', { message: 'hello' }); + const session = g.sessions._session('perplexity'); + const sent = session.page._state.sent[0]; + const stamps = sent.message.match(/Current time:/g) || []; + assert.equal(stamps.length, 1, `expected one stamp, got ${stamps.length}`); + }); +}); + +test('timestamp:false opts out of stamping (A2)', async () => { + await withGateway(async (client, g) => { + await client.send('sendMessage', 'perplexity', { message: 'hello', timestamp: false }); + const sent = g.sessions._session('perplexity').page._state.sent[0]; + assert.equal(sent.message, 'hello', 'no stamp may be appended'); + }); +}); + +test('claude turns are tagged, other providers are not', async () => { + await withGateway(async (client, g) => { + await client.send('sendMessage', 'claude', { message: 'for claude' }); + await client.send('sendMessage', 'perplexity', { message: 'for pplx' }); + + const claude = g.sessions._session('claude').page._state.sent[0].message; + const pplx = g.sessions._session('perplexity').page._state.sent[0].message; + assert.ok(claude.startsWith('[PROXIMA]'), 'claude turns must be tagged'); + assert.ok(!pplx.startsWith('[PROXIMA]'), 'the tag must not leak to other providers'); + }); +}); + +test('sendMessage requires a message', async () => { + await withGateway(async (client) => { + const res = await client.send('sendMessage', 'chatgpt', {}); + assert.equal(res.success, false); + assert.match(res.error, /requires data.message/); + }); +}); + +test('getResponseWithTyping returns a real answer on the first attempt', async () => { + await withGateway(async (client) => { + const res = await client.send('getResponseWithTyping', 'perplexity'); + assert.equal(res.success, true); + assert.equal(res.response, 'hello from engine'); + assert.equal(res.attempts, 1); + }); +}); + +test('placeholder responses are retried, not returned (A8)', async () => { + await withGateway(async (client, g) => { + // Seed a placeholder, then have the engine produce a real answer on attempt 2. + g.sessions._session('perplexity'); + await client.send('initProvider', 'perplexity'); + const page = g.sessions._session('perplexity').page; + page._state.response = 'no response captured'; + + const pending = client.send('getResponseWithTyping', 'perplexity', { + maxAttempts: 5, + retryDelayMs: 10, + }); + setTimeout(() => { + page._state.response = 'real answer'; + }, 60); + + const res = await pending; + assert.equal(res.success, true); + assert.equal(res.response, 'real answer'); + assert.ok(res.attempts >= 2, `expected a retry, got attempt ${res.attempts}`); + }); +}); + +test('capture gives up with a diagnostic rather than looping forever (Q5)', async () => { + await withGateway(async (client, g) => { + await client.send('initProvider', 'perplexity'); + g.sessions._session('perplexity').page._state.response = ''; + const res = await client.send('getResponseWithTyping', 'perplexity', { + maxAttempts: 3, + retryDelayMs: 5, + }); + assert.equal(res.success, false); + assert.match(res.error, /failed after 3 attempts/); + }); +}); + +test('a missing engine is an engine failure, not a transport failure (F6)', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-f6-')); + const stateStore = new StateStore(dir); + const cookieStore = new CookieStore(stateStore); + // An engine dir with no engine installed. + const engineLoader = new EngineLoader({ engineDir: dir, stateStore }); + const sessions = new SessionManager({ + stateStore, + cookieStore, + engineLoader, + settings: { headless: true }, + browserFactory: stubBrowserFactory(), + }); + const handler = createHandler({ sessions, stateStore, startedAt: 'x' }); + + const res = await handler({ action: 'initProvider', provider: 'chatgpt' }); + assert.equal(res.success, false); + assert.equal(res.errorKind, 'engine', 'must be typed as an engine failure'); + assert.match(res.error, /No engine installed/); +}); + +test('isLoggedIn answers from disk without launching a browser', async () => { + await withGateway(async (client, g) => { + const res = await client.send('isLoggedIn', 'claude'); + assert.equal(res.success, true); + assert.equal(res.loggedIn, false, 'a fresh profile has no auth'); + assert.deepEqual(g.sessions.initializedProviders(), [], 'must not have launched anything'); + }); +}); + +test('cookie injection authenticates without interactive login', async () => { + await withGateway(async (client, g) => { + const res = await client.send('setCookies', 'claude', { + cookies: [{ name: 'sessionKey', domain: '.claude.ai', value: 'abc123' }], + }); + assert.equal(res.success, true); + assert.equal(res.set, 1); + assert.equal(res.loggedIn, true, 'injected auth cookie must be recognised'); + + const check = await client.send('isLoggedIn', 'claude'); + assert.equal(check.loggedIn, true); + + // And it must survive a restart via the profile on disk. + assert.ok(fs.existsSync(g.sessions._session('claude').profileFile)); + }); +}); + +test('unloadProvider preserves auth (A6) and reports honestly', async () => { + await withGateway(async (client) => { + await client.send('setCookies', 'perplexity', { + cookies: [{ name: 'pplx_session', domain: '.perplexity.ai', value: 'v' }], + }); + const res = await client.send('unloadProvider', 'perplexity'); + assert.equal(res.success, true); + assert.deepEqual(res.unloaded, ['perplexity']); + + const after = await client.send('isLoggedIn', 'perplexity'); + assert.equal(after.loggedIn, true, 'auth must survive an unload'); + }); +}); + +test('unloadProvider with no target is an error, not a silent success', async () => { + await withGateway(async (client) => { + const res = await client.send('unloadProvider', null, { providers: [] }); + assert.equal(res.success, false); + assert.match(res.error, /name a provider/); + }); +}); + +test('purgeProvider destroys auth, unlike unloadProvider', async () => { + await withGateway(async (client) => { + await client.send('setCookies', 'perplexity', { + cookies: [{ name: 'pplx_session', domain: '.perplexity.ai', value: 'v' }], + }); + const res = await client.send('purgeProvider', 'perplexity'); + assert.equal(res.success, true); + + const after = await client.send('isLoggedIn', 'perplexity'); + assert.equal(after.loggedIn, false, 'purge must remove auth'); + }); +}); + +test('setHeadlessMode persists and reports that a restart is required (Q3)', async () => { + await withGateway(async (client, g) => { + const res = await client.send('setHeadlessMode', null, { headless: false }); + assert.equal(res.success, true); + assert.equal(res.headless, false); + assert.equal(res.requiresRestart, true); + assert.equal(g.stateStore.loadSettings().headless, false, 'must persist'); + }); +}); + +test('a corrupt cookie payload is a clean error, not a crash', async () => { + await withGateway(async (client) => { + const res = await client.send('setCookies', 'claude', { cookies: 'not json at all' }); + assert.equal(res.success, false); + assert.match(res.error, /not valid JSON/); + }); +}); + +test('concurrent requests on one socket stay correlated', async () => { + await withGateway(async (client) => { + const results = await Promise.all([ + client.send('ping'), + client.send('getStatus'), + client.send('ping'), + client.send('getSettings'), + ]); + assert.deepEqual(results.map((r) => r.requestId), [1, 2, 3, 4]); + assert.ok(results.every((r) => r.success)); + }); +}); diff --git a/gateway/test/unit.test.js b/gateway/test/unit.test.js new file mode 100644 index 0000000..2a24eb2 --- /dev/null +++ b/gateway/test/unit.test.js @@ -0,0 +1,290 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +const { StateStore, DEFAULT_PORT } = require('../src/state-store'); +const { evaluateAuth, normalizeImportedCookies, isPlaceholderResponse, CookieStore } = require('../src/cookie-store'); +const { FrameDecoder, IpcServer } = require('../src/ipc-server'); +const { allowlistOptions, stampTimestamp, OPTION_ALLOWLIST } = require('../src/session-manager'); +const { PROVIDERS } = require('../src/providers'); +const { EngineLoader, EngineError } = require('../src/engine-loader'); + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-')); +} + +// ---------------------------------------------------------------- state store + +test('port fact beats stale preference (F3)', () => { + const dir = tmpDir(); + const s = new StateStore(dir); + + s.saveSettings({ ipcPort: DEFAULT_PORT }); + assert.equal(s.resolvePort().source, 'preference'); + + s.recordPortFact(DEFAULT_PORT + 1); + const resolved = s.resolvePort(); + assert.equal(resolved.port, DEFAULT_PORT + 1); + assert.equal(resolved.source, 'fact', 'a live fact must outrank the preference'); + + s.clearPortFact(); + // Recording a fact also syncs the preference, so once the fact is gone the last + // known-good port survives as a preference rather than reverting to the default. + const after = s.resolvePort(); + assert.equal(after.source, 'preference'); + assert.equal(after.port, DEFAULT_PORT + 1); +}); + +test('a fact naming a dead pid is ignored', () => { + const dir = tmpDir(); + const s = new StateStore(dir); + s.saveSettings({ ipcPort: DEFAULT_PORT }); + // pid 2^22 is above the typical max and will not exist. + fs.writeFileSync(s.portFactPath, JSON.stringify({ port: 59999, pid: 4194303 })); + const resolved = s.resolvePort(); + assert.equal(resolved.source, 'preference'); + assert.equal(resolved.port, DEFAULT_PORT); +}); + +test('corrupt settings do not throw', () => { + const dir = tmpDir(); + const s = new StateStore(dir); + fs.writeFileSync(s.settingsPath, '{ not json'); + assert.deepEqual(s.loadSettings(), {}); +}); + +// ---------------------------------------------------------------- auth detection + +test('auth detection is name-matched, not counted', () => { + // Google sets many non-auth cookies; a count would read this as logged in. + const googleNoise = Array.from({ length: 30 }, (_, i) => ({ + name: `consent_${i}`, + domain: '.google.com', + value: 'x', + })); + const gemini = PROVIDERS.gemini.auth; + assert.equal(evaluateAuth(googleNoise, gemini).loggedIn, false); + assert.equal(googleNoise.length, 30, 'sanity: plenty of cookies, none of them auth'); + + const real = [...googleNoise, { name: '__Secure-1PSID', domain: '.google.com', value: 'y' }]; + const verdict = evaluateAuth(real, gemini); + assert.equal(verdict.loggedIn, true); + assert.deepEqual(verdict.matched, ['__Secure-1PSID']); +}); + +test('auth detection is domain-scoped', () => { + const chatgpt = PROVIDERS.chatgpt.auth; + // Right cookie name, wrong domain -> must not count. + const wrong = [{ name: '__cf_bm', domain: '.example.com', value: 'z' }]; + assert.equal(evaluateAuth(wrong, chatgpt).loggedIn, false); + + const right = [{ name: '__cf_bm', domain: '.openai.com', value: 'z' }]; + assert.equal(evaluateAuth(right, chatgpt).loggedIn, true); +}); + +test('auth detection handles prefix families', () => { + const perplexity = PROVIDERS.perplexity.auth; + const suffixed = [{ name: 'pplx_session_abc123', domain: '.perplexity.ai', value: 'v' }]; + assert.equal(evaluateAuth(suffixed, perplexity).loggedIn, true); +}); + +test('auth detection tolerates garbage input', () => { + assert.equal(evaluateAuth(null, PROVIDERS.claude.auth).loggedIn, false); + assert.equal(evaluateAuth([null, undefined], PROVIDERS.claude.auth).loggedIn, false); + assert.equal(evaluateAuth([], PROVIDERS.claude.auth).loggedIn, false); +}); + +// ---------------------------------------------------------------- cookie import + +test('imported cookies get a refreshed expiry so restart != logout', () => { + const out = normalizeImportedCookies([ + { name: 'sessionKey', domain: '.claude.ai', value: 'abc' }, + ]); + assert.equal(out.length, 1); + const oneYearFromNow = Date.now() / 1000 + 364 * 24 * 3600; + assert.ok( + out[0].expires > oneYearFromNow, + `expected a refreshed long expiry, got ${out[0].expires}` + ); + assert.equal(out[0].path, '/'); + assert.equal(out[0].secure, true); +}); + +test('an existing future expiry is preserved', () => { + const far = Math.floor(Date.now() / 1000) + 99999; + const out = normalizeImportedCookies([ + { name: 'a', domain: '.x.com', value: '1', expires: far }, + ]); + assert.equal(out[0].expires, far); +}); + +test('bad cookie payloads fail loudly', () => { + assert.throws(() => normalizeImportedCookies('not json'), /not valid JSON/); + assert.throws(() => normalizeImportedCookies({ a: 1 }), /should be an array/); + assert.deepEqual(normalizeImportedCookies([{ noName: true }]), [], 'junk entries are dropped'); +}); + +test('sameSite variants normalize', () => { + const out = normalizeImportedCookies([ + { name: 'a', domain: '.x.com', value: '1', sameSite: 'no_restriction' }, + { name: 'b', domain: '.x.com', value: '1', sameSite: 'Strict' }, + ]); + assert.equal(out[0].sameSite, 'None'); + assert.equal(out[1].sameSite, 'Strict'); +}); + +// ---------------------------------------------------------------- placeholders + +test('placeholder responses are recognised (A8)', () => { + assert.equal(isPlaceholderResponse(''), true); + assert.equal(isPlaceholderResponse(' '), true); + assert.equal(isPlaceholderResponse('No Response Captured'), true, 'case insensitive'); + assert.equal(isPlaceholderResponse('no response received'), true); + assert.equal(isPlaceholderResponse(null), true); + assert.equal(isPlaceholderResponse(undefined), true); + assert.equal(isPlaceholderResponse('a real answer'), false); +}); + +// ---------------------------------------------------------------- allowlist (A1) + +test('option allowlist drops unlisted keys silently (A1)', () => { + const out = allowlistOptions({ + modelPreference: 'pplx-pro', + thinking: true, + // not on the allowlist - must be dropped, not forwarded + deepSearch: false, + someVendorKnob: 'x', + }); + assert.equal(out.modelPreference, 'pplx-pro'); + assert.equal(out.thinking, true); + assert.ok(!('deepSearch' in out), 'deepSearch must not reach the engine'); + assert.ok(!('someVendorKnob' in out)); +}); + +test('allowlist preserves explicitly-false values', () => { + const out = allowlistOptions({ thinking: false, autoSearch: false }); + assert.equal(out.thinking, false); + assert.equal(out.autoSearch, false); +}); + +test('allowlist has no duplicates', () => { + assert.equal(new Set(OPTION_ALLOWLIST).size, OPTION_ALLOWLIST.length); +}); + +// ---------------------------------------------------------------- timestamp (A2) + +test('timestamp stamping appends an ISO time', () => { + const out = stampTimestamp('hello'); + assert.match(out, /^hello\n\nCurrent time: \d{4}-\d{2}-\d{2}T/); +}); + +test('timestamp stamping leaves non-strings alone', () => { + assert.equal(stampTimestamp(null), null); + assert.equal(stampTimestamp(42), 42); +}); + +// ---------------------------------------------------------------- framing + +test('frame decoder splits on newlines and retains the partial tail', () => { + const d = new FrameDecoder(); + assert.deepEqual(d.push('{"a":1}\n{"b":'), ['{"a":1}']); + assert.equal(d.buffer, '{"b":'); + assert.deepEqual(d.push('2}\n'), ['{"b":2}']); + assert.equal(d.buffer, ''); +}); + +test('frame decoder handles multiple frames in one chunk', () => { + const d = new FrameDecoder(); + assert.deepEqual(d.push('{"a":1}\n{"b":2}\n{"c":3}\n'), ['{"a":1}', '{"b":2}', '{"c":3}']); +}); + +test('frame decoder ignores blank lines', () => { + const d = new FrameDecoder(); + assert.deepEqual(d.push('\n\n{"a":1}\n\n'), ['', '', '{"a":1}', '']); +}); + +test('frame decoder drops an oversized buffer rather than growing without bound', () => { + const d = new FrameDecoder(64); + const lines = d.push('x'.repeat(200)); + assert.deepEqual(lines, []); + assert.equal(d.dropped, 1); +}); + +test('frame decoder handles a frame split across many chunks', () => { + const d = new FrameDecoder(); + const payload = JSON.stringify({ requestId: 1, action: 'ping' }); + let out = []; + for (const ch of payload) out = out.concat(d.push(ch + (ch === payload[payload.length - 1] ? '\n' : ''))); + assert.equal(out.length, 1); + assert.equal(JSON.parse(out[0]).action, 'ping'); +}); + +// ---------------------------------------------------------------- server policy + +test('server refuses a non-loopback bind (P7)', () => { + const s = new StateStore(tmpDir()); + assert.throws( + () => new IpcServer({ handler: async () => ({}), stateStore: s, host: '0.0.0.0' }), + /loopback-only/ + ); +}); + +test('port collision retries +1 then reports both ports (F2)', async () => { + const s = new StateStore(tmpDir()); + const squatter = new IpcServer({ handler: async () => ({ success: true }), stateStore: s, port: 0 }); + const taken = await squatter._tryListen(0); + const takenPort = squatter.server.address().port; + + const second = new IpcServer({ handler: async () => ({ success: true }), stateStore: s, port: takenPort }); + const bound = await second.listen(); + assert.equal(bound, takenPort + 1, 'must land on the next port'); + + await second.close(); + await squatter.close(); +}); + +// ---------------------------------------------------------------- engines + +test('engine loader reports a missing engine as a typed failure (F6)', () => { + const loader = new EngineLoader({ engineDir: tmpDir() }); + assert.throws( + () => loader.load('chatgpt'), + (e) => e instanceof EngineError && e.kind === 'engine' && e.reason === 'engine-missing' + ); +}); + +test('the shipped reference engine exposes the documented contract', () => { + const loader = new EngineLoader(); + const src = loader.load('perplexity'); + const spec = PROVIDERS.perplexity; + assert.ok(src.includes(spec.engineGlobal), 'must define the documented global'); + for (const m of ['sendMessage', 'newConversation', 'getResponse']) { + assert.match(src, new RegExp(`${m}\\s*:`), `engine must expose ${m}`); + } +}); + +test('every provider has a coherent registry entry', () => { + for (const [name, p] of Object.entries(PROVIDERS)) { + assert.equal(p.name, name); + assert.ok(p.url.startsWith('https://'), `${name} must use https`); + assert.ok(p.engineGlobal.startsWith('__proxima'), `${name} global must be namespaced`); + assert.ok(Array.isArray(p.auth.cookies) && p.auth.cookies.length, `${name} needs auth cookies`); + assert.ok(p.auth.domain, `${name} needs an auth domain`); + } +}); + +test('qwen is the only provider without a DOM fallback (A4)', () => { + const withFallback = Object.entries(PROVIDERS) + .filter(([, p]) => p.supportsDomFallback) + .map(([n]) => n); + assert.ok(!withFallback.includes('qwen'), 'qwen must not claim a DOM fallback'); + assert.equal(PROVIDERS.qwen.supportsDomFallback, false); +}); + +test('gemini is login + message only (Q4)', () => { + assert.equal(PROVIDERS.gemini.supportsUploads, false); +}); From db1faf3c1ea4103021f6a9e6b67efd215fdb8e5f Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 12:54:40 +0500 Subject: [PATCH 02/12] test(gateway): fix three failures surfaced by CI All three were test bugs, not product bugs: - browser: a re-injection test never registered its init script, so the global was genuinely undefined after reload rather than stale - browser: Playwright's cookies() filter needs a full URL, not a bare domain ('claude.ai' -> 'https://claude.ai') - integration: the A8 placeholder-retry test raced a setTimeout, so it passed locally and failed on a slow runner. Replaced with a queued response list, which also pins the exact attempt count. Adds a second A8 case asserting a run of placeholders reports failure rather than surfacing a placeholder as an answer. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/test/browser.test.js | 22 +++++++++++------ gateway/test/integration.test.js | 41 ++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/gateway/test/browser.test.js b/gateway/test/browser.test.js index 4e6878d..b926c86 100644 --- a/gateway/test/browser.test.js +++ b/gateway/test/browser.test.js @@ -152,9 +152,13 @@ test('a re-injected engine overwrites rather than merging', { skip }, async () = // Re-injection happens on every navigation, so a stale engine must not survive. await withBrowser(async (browser) => { const context = await browser.newContext(); + await context.addInitScript({ content: ENGINES.__proximaTest }); const page = await context.newPage(); await page.goto(PAGE_HTML); + assert.equal(await page.evaluate(() => window.__proximaTest.marker), 'installed'); + + // Simulate a stale engine left behind by something else on the page. await page.evaluate(() => { window.__proximaTest = { marker: 'stale', sendMessage: () => ({}) }; }); @@ -163,8 +167,8 @@ test('a re-injected engine overwrites rather than merging', { skip }, async () = await page.reload(); assert.equal( await page.evaluate(() => window.__proximaTest.marker), - undefined, - 'a reload must clear the stale engine, proving the init script is what provides it' + 'installed', + 'a reload must re-run the init script, replacing the stale engine' ); await context.close(); @@ -187,10 +191,14 @@ test('cookies survive a real cookie jar round trip', { skip }, async () => { }, ]); - const cookies = await context.cookies('claude.ai'); - assert.equal(cookies.length, 1); - assert.equal(cookies[0].name, 'sessionKey'); - assert.equal(cookies[0].value, 'real-value'); + // Playwright's cookie filter wants full URLs, not bare domains. + const scoped = await context.cookies('https://claude.ai'); + assert.equal(scoped.length, 1); + assert.equal(scoped[0].name, 'sessionKey'); + assert.equal(scoped[0].value, 'real-value'); + + const all = await context.cookies(); + assert.equal(all.length, 1, 'the cookie must really be in the jar'); await context.close(); }); @@ -246,7 +254,7 @@ test('the profile file round trips through a real browser restart', { skip }, as await ctx1.addCookies([ { name: 'pplx_session', value: 'persisted', domain: 'perplexity.ai', path: '/', secure: true }, ]); - const live = await ctx1.cookies(); + const live = await ctx1.cookies('https://perplexity.ai'); cookies.saveBackup('perplexity', live); assert.equal(evaluateAuth(live, PROVIDERS.perplexity.auth).loggedIn, true); await ctx1.close(); diff --git a/gateway/test/integration.test.js b/gateway/test/integration.test.js index 1024076..991160e 100644 --- a/gateway/test/integration.test.js +++ b/gateway/test/integration.test.js @@ -55,14 +55,20 @@ function stubContext({ cookies = [], persistent = true } = {}) { } function stubPage({ enginePresent = true, response = 'hello from engine' } = {}) { - const state = { response, typing: false, sent: [], closed: false }; + const state = { response, typing: false, sent: [], closed: false, responses: null }; + // When `responses` is a queue, each getResponse() shifts the next value. That makes + // retry behaviour deterministic instead of racing a timer. + const nextResponse = () => + Array.isArray(state.responses) && state.responses.length + ? state.responses.shift() + : state.response; const engine = { sendMessage: (p) => { state.sent.push(p); return { ok: true }; }, newConversation: () => ({ cleared: true }), - getResponse: () => state.response, + getResponse: () => nextResponse(), getTypingStatus: () => ({ typing: state.typing }), isReady: () => true, }; @@ -396,24 +402,33 @@ test('getResponseWithTyping returns a real answer on the first attempt', async ( test('placeholder responses are retried, not returned (A8)', async () => { await withGateway(async (client, g) => { - // Seed a placeholder, then have the engine produce a real answer on attempt 2. - g.sessions._session('perplexity'); await client.send('initProvider', 'perplexity'); const page = g.sessions._session('perplexity').page; - page._state.response = 'no response captured'; + // Deterministic: two placeholders, then a real answer. No timers. + page._state.responses = ['no response captured', '', 'real answer']; - const pending = client.send('getResponseWithTyping', 'perplexity', { + const res = await client.send('getResponseWithTyping', 'perplexity', { maxAttempts: 5, - retryDelayMs: 10, + retryDelayMs: 5, }); - setTimeout(() => { - page._state.response = 'real answer'; - }, 60); - - const res = await pending; assert.equal(res.success, true); assert.equal(res.response, 'real answer'); - assert.ok(res.attempts >= 2, `expected a retry, got attempt ${res.attempts}`); + assert.equal(res.attempts, 3, `expected exactly 3 attempts, got ${res.attempts}`); + }); +}); + +test('a placeholder is never surfaced to the caller as an answer', async () => { + await withGateway(async (client, g) => { + await client.send('initProvider', 'perplexity'); + const page = g.sessions._session('perplexity').page; + page._state.responses = ['', ' ', 'no response received']; + + const res = await client.send('getResponseWithTyping', 'perplexity', { + maxAttempts: 3, + retryDelayMs: 5, + }); + assert.equal(res.success, false, 'a run of placeholders must not report success'); + assert.equal(res.attempts, 3); }); }); From 9605c01ccb5c549ee991f0a702c241e3ef98153e Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 13:06:26 +0500 Subject: [PATCH 03/12] test(gateway): close coverage gaps and enforce a floor Coverage was 86.18 line / 77.85 branch / 76.38 funcs - branch and funcs under the 80 bar. Now 92.70 / 84.53 / 88.64 across 88 tests. Adds contract.test.js: 34 tests covering the actions and error paths the report showed as untouched - executeScript, navigate, debugDOM, getTypingStatus, waitForSendButton, getCookies, setSetting, multi-provider unload/purge, attachment gating, engine error classification, and the session lifecycle. Product fix found while covering engine-loader: call() returned the in-page __error sentinel to the caller as if it were a legitimate result, so a missing engine method would look like a successful call returning nonsense. It now raises a typed EngineError. Regression test added. Adds scripts/check-coverage.cjs, a real gate: it parses the report, fails below 85/80/80, names the weakest files, and refuses to pass when the report is unparseable or the totals row is missing. CI runs it after the suite, so coverage cannot silently regress. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gateway.yml | 18 +- gateway/package.json | 5 +- gateway/scripts/check-coverage.cjs | 104 ++++++ gateway/src/engine-loader.js | 18 +- gateway/test/contract.test.js | 561 +++++++++++++++++++++++++++++ 5 files changed, 698 insertions(+), 8 deletions(-) create mode 100644 gateway/scripts/check-coverage.cjs create mode 100644 gateway/test/contract.test.js diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml index 2cb234e..0e095f6 100644 --- a/.github/workflows/gateway.yml +++ b/.github/workflows/gateway.yml @@ -32,9 +32,23 @@ jobs: working-directory: gateway run: npm install --no-audit --no-fund - - name: Run test suite + - name: Run test suite with coverage working-directory: gateway - run: npm test + run: npm run test:coverage 2>&1 | tee coverage.txt + + - name: Enforce coverage floor + working-directory: gateway + # line 85 / branch 80 / funcs 80. Current: 92.70 / 84.53 / 88.64. + run: node scripts/check-coverage.cjs coverage.txt 85 80 80 + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: gateway/coverage.txt + retention-days: 7 + if-no-files-found: ignore browser: name: real chromium diff --git a/gateway/package.json b/gateway/package.json index 02d8c88..41324b5 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -7,9 +7,10 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js", + "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js", + "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js", "test:browser": "node --test --test-reporter=tap --test-timeout=60000 test/browser.test.js", - "test:all": "npm run test && npm run test:browser", + "test:all": "npm run test:coverage && npm run test:browser", "setup:browser": "npx playwright install --with-deps chromium", "test:watch": "node --test --watch test/" }, diff --git a/gateway/scripts/check-coverage.cjs b/gateway/scripts/check-coverage.cjs new file mode 100644 index 0000000..eb765c1 --- /dev/null +++ b/gateway/scripts/check-coverage.cjs @@ -0,0 +1,104 @@ +'use strict'; + +/** + * Coverage gate. + * + * Parses the table node --test --experimental-test-coverage prints, and fails the + * build if line, branch or function coverage falls below its floor. Enforced in CI so + * coverage cannot quietly regress. + * + * Usage: node scripts/check-coverage.cjs [reportFile] [minLine] [minBranch] [minFuncs] + */ + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_FLOORS = { line: 85, branch: 80, funcs: 80 }; + +function parseReport(text) { + const rows = []; + for (const raw of text.split(/\r?\n/)) { + // The table is emitted as TAP comments, so every line is prefixed with "# ". + // Strip the marker, then trim: two spaces remain after the hash, which would + // otherwise defeat the anchored patterns below. + const line = raw.replace(/^#+\s?/, '').trim(); + if (!line.includes('|')) continue; + + const isTotals = /^all files\s+\|/.test(line); + const isFile = /^[\w.-]+\.js\s+\|/.test(line); + if (!isTotals && !isFile) continue; + + const cells = line.split('|').map((c) => c.trim()); + if (cells.length < 4) continue; + const num = (v) => { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : null; + }; + rows.push({ + file: cells[0], + line: num(cells[1]), + branch: num(cells[2]), + funcs: num(cells[3]), + }); + } + return rows; +} + +function findTotals(rows) { + return rows.find((r) => r.file === 'all files') || null; +} + +function main() { + const [, , reportPath, minLine, minBranch, minFuncs] = process.argv; + const floors = { + line: Number(minLine) || DEFAULT_FLOORS.line, + branch: Number(minBranch) || DEFAULT_FLOORS.branch, + funcs: Number(minFuncs) || DEFAULT_FLOORS.funcs, + }; + + const text = reportPath && fs.existsSync(reportPath) + ? fs.readFileSync(reportPath, 'utf8') + : fs.readFileSync(0, 'utf8'); + + const rows = parseReport(text); + if (rows.length === 0) { + console.error('coverage: no rows parsed - refusing to pass silently'); + process.exit(1); + } + + const totals = findTotals(rows); + if (!totals) { + console.error('coverage: no "all files" row found - refusing to pass silently'); + process.exit(1); + } + + const failures = []; + console.log('coverage floors:'); + for (const metric of ['line', 'branch', 'funcs']) { + const actual = totals[metric]; + const floor = floors[metric]; + const ok = actual !== null && actual >= floor; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${metric.padEnd(6)} ${actual}% (floor ${floor}%)`); + if (!ok) failures.push(`${metric} ${actual}% < ${floor}%`); + } + + // Report the weakest files so a regression names its cause. + const worst = rows + .filter((r) => r.file !== 'all files' && r.line !== null) + .sort((a, b) => a.line - b.line) + .slice(0, 3); + if (worst.length) { + console.log('weakest files by line coverage:'); + for (const w of worst) { + console.log(` ${String(w.line).padStart(6)}% ${w.file}`); + } + } + + if (failures.length) { + console.error(`\ncoverage gate failed: ${failures.join('; ')}`); + process.exit(1); + } + console.log('\ncoverage gate passed'); +} + +main(); diff --git a/gateway/src/engine-loader.js b/gateway/src/engine-loader.js index dbcfbcc..8a7c11c 100644 --- a/gateway/src/engine-loader.js +++ b/gateway/src/engine-loader.js @@ -133,18 +133,28 @@ class EngineLoader { { reason: 'engine-absent' } ); } - return page.evaluate( + + const result = await page.evaluate( ({ g, m, a }) => { const engine = window[g]; if (!engine || typeof engine[m] !== 'function') { - return { - __error: `engine method ${g}.${m} is not a function`, - }; + // Returned rather than thrown so the page never sees a raw throw. + return { __error: `engine method ${g}.${m} is not a function` }; } return Promise.resolve(engine[m](...a)); }, { g: spec.engineGlobal, m: method, a: args } ); + + // The in-page sentinel must be converted here, or a missing method would be + // handed back to the caller as if it were a legitimate result. + if (result && typeof result === 'object' && result.__error) { + throw new EngineError(result.__error, provider, { + reason: 'engine-method-missing', + method, + }); + } + return result; } } diff --git a/gateway/test/contract.test.js b/gateway/test/contract.test.js new file mode 100644 index 0000000..80a655c --- /dev/null +++ b/gateway/test/contract.test.js @@ -0,0 +1,561 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +const { StateStore } = require('../src/state-store'); +const { CookieStore } = require('../src/cookie-store'); +const { EngineLoader, EngineError } = require('../src/engine-loader'); +const { SessionManager } = require('../src/session-manager'); +const { IpcServer } = require('../src/ipc-server'); +const { createHandler, processMemoryReport, NEEDS_PROVIDER, resolveProbe } = require('../src/actions'); + +/** + * Contract tests for the action surface and the error paths. + * + * These exist because the coverage report showed the interesting failures were the + * untested ones: an action nobody calls, an error nobody classifies, a fallback nobody + * exercises. Two real bugs have already come out of this style of test, so the gaps + * were worth closing deliberately rather than chasing a percentage. + */ + +// ---- harness ------------------------------------------------------------------ + +function stubEngineDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-eng2-')); + const names = ['chatgpt', 'perplexity', 'claude', 'gemini', 'qwen']; + for (const name of names) { + const globals = { + chatgpt: '__proximaChatGPT', + perplexity: '__proximaPerplexity', + claude: '__proximaClaude', + gemini: '__proximaGemini', + qwen: '__proximaQwen', + }; + fs.writeFileSync( + path.join(dir, `${name}-engine.js`), + `/* stub */\nwindow.${globals[name]} = { sendMessage: function(){return {ok:true};}, newConversation: function(){return {cleared:true};}, getResponse: function(){return 'engine answer';}, getTypingStatus: function(){return {typing:true};}, isReady: function(){return true;}, marker: 'installed' };\n`, + 'utf8' + ); + } + return dir; +} + +function makePage({ enginePresent = true, contentThrows = false, evaluateThrows = false } = {}) { + const state = { sent: [], typing: false, response: 'engine answer', navigations: [] }; + const engine = { + sendMessage: (p) => { + state.sent.push(p); + return { ok: true }; + }, + newConversation: () => ({ cleared: true }), + getResponse: () => state.response, + getTypingStatus: () => ({ typing: state.typing }), + isReady: () => true, + }; + return { + _state: state, + async goto(url) { + state.navigations.push(url); + }, + async evaluate(arg, maybeArg) { + if (evaluateThrows) throw new Error('evaluate exploded'); + if (typeof arg === 'string') return true; + if (typeof arg === 'function') { + if (typeof maybeArg === 'string') return enginePresent; + if (maybeArg && typeof maybeArg === 'object' && maybeArg.g) { + if (!engine[maybeArg.m]) { + return { __error: `engine method ${maybeArg.m} is not a function` }; + } + return Promise.resolve(engine[maybeArg.m](...(maybeArg.a || []))); + } + } + return null; + }, + async content() { + if (contentThrows) throw new Error('no content'); + return 'real'; + }, + }; +} + +function makeGateway({ fileReference = true, pageOpts = {} } = {}) { + const stateStore = new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-c-'))); + const cookieStore = new CookieStore(stateStore); + const engineLoader = new EngineLoader({ engineDir: stubEngineDir(), stateStore }); + + const page = makePage(pageOpts); + const context = { + _page: page, + _store: [], + async addInitScript() {}, + async cookies(filter) { + if (!filter) return this._store.slice(); + return this._store.filter((c) => String(c.domain).includes(filter)); + }, + async addCookies(list) { + for (const c of list) this._store.push(c); + }, + async clearCookies() { + this._store = []; + }, + async close() {}, + async newPage() { + return page; + }, + }; + + const sessions = new SessionManager({ + stateStore, + cookieStore, + engineLoader, + settings: { headless: true }, + browserFactory: async () => ({ + newContext: async () => context, + close: async () => {}, + }), + }); + + const handler = createHandler({ + sessions, + stateStore, + startedAt: '2026-01-01T00:00:00.000Z', + getFileReferenceEnabled: () => fileReference, + }); + + return { stateStore, cookieStore, engineLoader, sessions, handler, page, context }; +} + +function clientFor(port) { + const c = { + buffer: '', + pending: new Map(), + nextId: 1, + socket: null, + }; + c.connect = () => + new Promise((resolve, reject) => { + c.socket = net.createConnection({ port, host: '127.0.0.1' }, resolve); + c.socket.once('error', reject); + c.socket.on('data', (chunk) => { + c.buffer += chunk.toString('utf8'); + const lines = c.buffer.split('\n'); + c.buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + const fn = c.pending.get(msg.requestId); + if (fn) { + c.pending.delete(msg.requestId); + fn(msg); + } + } + }); + }); + c.send = (action, provider, data = {}) => { + const requestId = c.nextId++; + return new Promise((resolve) => { + c.pending.set(requestId, resolve); + c.socket.write(JSON.stringify({ requestId, action, provider, data }) + '\n'); + }); + }; + return c; +} + +async function overSocket(fn) { + const g = makeGateway(); + const ipc = new IpcServer({ handler: g.handler, stateStore: g.stateStore, port: 0 }); + const port = await ipc.listen(); + const client = clientFor(port); + await client.connect(); + try { + await fn(client, g); + } finally { + client.socket.destroy(); + await ipc.close(); + await g.sessions.close(); + } +} + +// ---- action surface: every action reachable and correct ----------------------- + +test('every non-GUI action has a test that reaches it', async () => { + await overSocket(async (c) => { + const cases = [ + ['ping', null, {}], + ['getStatus', null, {}], + ['memory', null, {}], + ['initProvider', 'perplexity', {}], + ['isLoggedIn', 'perplexity', {}], + ['sendMessage', 'perplexity', { message: 'hi' }], + ['getResponse', 'perplexity', {}], + ['getTypingStatus', 'perplexity', {}], + ['getResponseWithTyping', 'perplexity', {}], + ['newConversation', 'perplexity', {}], + ['waitForSendButton', 'perplexity', {}], + ['executeScript', 'perplexity', { script: '1+1' }], + ['navigate', 'perplexity', { url: 'https://example.com' }], + ['debugDOM', 'perplexity', {}], + ['getSettings', null, {}], + ['setSetting', null, { key: 'fileReference', value: true }], + ['getCookies', 'perplexity', {}], + ['setCookies', 'claude', { cookies: [{ name: 'sessionKey', domain: '.claude.ai', value: 'v' }] }], + ['unloadProvider', 'perplexity', {}], + ['purgeProvider', 'perplexity', {}], + ['setHeadlessMode', null, { headless: true }], + ]; + for (const [action, provider, data] of cases) { + const res = await c.send(action, provider, data); + assert.equal(res.success, true, `${action} failed: ${res.error}`); + } + }); +}); + +test('getTypingStatus surfaces the engine indicator', async () => { + await overSocket(async (c, g) => { + await c.send('initProvider', 'perplexity'); + g.page._state.typing = true; + const res = await c.send('getTypingStatus', 'perplexity'); + assert.equal(res.success, true, res.error); + assert.equal(res.typing, true, 'the engine indicator must be passed through'); + }); +}); + +test('waitForSendButton reports readiness from the engine', async () => { + await overSocket(async (c) => { + const res = await c.send('waitForSendButton', 'perplexity'); + assert.equal(res.success, true); + assert.equal(res.ready, true); + }); +}); + +test('waitForSendButton with an engine that never registers is a typed failure', async () => { + // Loading the provider requires the engine, so an absent engine must surface as a + // classified engine error rather than a false "not ready". + const g = makeGateway({ pageOpts: { enginePresent: false } }); + const res = await g.handler({ action: 'waitForSendButton', provider: 'perplexity' }); + assert.equal(res.success, false); + assert.equal(res.errorKind, 'engine'); + await g.sessions.close(); +}); + +test('executeScript returns the evaluated result', async () => { + await overSocket(async (c) => { + const res = await c.send('executeScript', 'perplexity', { script: 'document.title' }); + assert.equal(res.success, true, res.error); + assert.equal(res.result, true, 'the result of the page evaluation is passed through'); + }); +}); + +test('executeScript requires a string script', async () => { + await overSocket(async (c) => { + const res = await c.send('executeScript', 'perplexity', { script: 42 }); + assert.equal(res.success, false); + assert.match(res.error, /requires data.script/); + }); +}); + +test('navigate moves the page and re-injects', async () => { + await overSocket(async (c, g) => { + const res = await c.send('navigate', 'perplexity', { url: 'https://example.com/x' }); + assert.equal(res.success, true); + assert.equal(res.url, 'https://example.com/x'); + assert.ok(g.page._state.navigations.includes('https://example.com/x')); + }); +}); + +test('navigate requires a url', async () => { + await overSocket(async (c) => { + const res = await c.send('navigate', 'perplexity', {}); + assert.equal(res.success, false); + assert.match(res.error, /requires data.url/); + }); +}); + +test('debugDOM returns page length, and full only on request', async () => { + await overSocket(async (c) => { + const brief = await c.send('debugDOM', 'perplexity'); + assert.equal(brief.success, true); + assert.equal(brief.length, 'real'.length); + assert.equal(brief.html, undefined, 'html must be omitted unless asked for'); + + const full = await c.send('debugDOM', 'perplexity', { full: true }); + assert.equal(full.html, 'real'); + }); +}); + +test('debugDOM reports failure when the page cannot be read', async () => { + const g = makeGateway({ pageOpts: { contentThrows: true } }); + const res = await g.handler({ action: 'debugDOM', provider: 'perplexity' }); + assert.equal(res.success, false, 'must not claim success when content is unavailable'); +}); + +test('getCookies returns only the provider domain', async () => { + await overSocket(async (c) => { + await c.send('setCookies', 'claude', { + cookies: [ + { name: 'sessionKey', domain: '.claude.ai', value: 'v' }, + { name: 'unrelated', domain: '.example.com', value: 'x' }, + ], + }); + const res = await c.send('getCookies', 'claude'); + assert.equal(res.success, true); + assert.equal(res.count, 1); + assert.equal(res.cookies[0].name, 'sessionKey'); + }); +}); + +test('getCookies works from disk with no browser loaded', async () => { + const g = makeGateway(); + await g.sessions.setCookies('claude', [ + { name: 'sessionKey', domain: '.claude.ai', value: 'v' }, + ]); + const res = await g.handler({ action: 'getCookies', provider: 'claude' }); + assert.equal(res.success, true); + assert.equal(res.count, 1); + await g.sessions.close(); +}); + +test('settings round trip, and setSetting validates its key', async () => { + await overSocket(async (c) => { + await c.send('setSetting', null, { key: 'headlessByProvider', value: { qwen: false } }); + const got = await c.send('getSettings'); + assert.deepEqual(got.settings.headlessByProvider, { qwen: false }); + + const bad = await c.send('setSetting', null, { value: 1 }); + assert.equal(bad.success, false); + assert.match(bad.error, /requires data.key/); + }); +}); + +test('per-provider headless override is read from settings', async () => { + // Settings are captured at construction, so they must be supplied up front. + const stateStore = new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-h-'))); + const sessions = new SessionManager({ + stateStore, + cookieStore: new CookieStore(stateStore), + engineLoader: new EngineLoader({ engineDir: stubEngineDir(), stateStore }), + settings: { headless: true, headlessByProvider: { qwen: false } }, + browserFactory: async () => ({ newContext: async () => ({}), close: async () => {} }), + }); + assert.equal(sessions._headlessFor('qwen'), false, 'override wins'); + assert.equal(sessions._headlessFor('perplexity'), true, 'others fall back to global'); + assert.equal(sessions._headlessFor('gemini'), true); + await sessions.close(); +}); + +test('purgeProvider is reachable through the action surface', async () => { + await overSocket(async (c) => { + await c.send('setCookies', 'perplexity', { + cookies: [{ name: 'pplx_s', domain: '.perplexity.ai', value: 'v' }], + }); + const res = await c.send('purgeProvider', 'perplexity'); + assert.equal(res.success, true); + assert.equal(res.purged.length, 1); + const after = await c.send('isLoggedIn', 'perplexity'); + assert.equal(after.loggedIn, false); + }); +}); + +test('multi-provider unload and purge accept a list', async () => { + await overSocket(async (c) => { + await c.send('initProvider', 'perplexity'); + await c.send('initProvider', 'chatgpt'); + const res = await c.send('unloadProvider', null, { providers: ['perplexity', 'chatgpt'] }); + assert.equal(res.success, true); + assert.deepEqual(res.unloaded.sort(), ['chatgpt', 'perplexity']); + + const purge = await c.send('purgeProvider', null, { providers: ['perplexity'] }); + assert.equal(purge.success, true); + assert.equal(purge.purged.length, 1); + }); +}); + +test('unloading a provider that was never loaded is reported, not faked', async () => { + await overSocket(async (c) => { + const res = await c.send('unloadProvider', 'qwen'); + assert.equal(res.success, true); + assert.deepEqual(res.unloaded, [], 'a no-op unload must not claim success'); + assert.equal(res.reports[0].reason, 'not-loaded'); + }); +}); + +// ---- attachment gating (A3) --------------------------------------------------- + +test('attachments are refused when file reference is disabled (A3)', async () => { + const g = makeGateway({ fileReference: false }); + const res = await g.handler({ + action: 'sendMessage', + provider: 'claude', + data: { message: 'see attached', attachments: ['/tmp/a.png'] }, + }); + assert.equal(res.success, false); + assert.match(res.error, /File reference is disabled/); + await g.sessions.close(); +}); + +test('an empty attachments array is not treated as an attachment', () => { + // hasAttachments must distinguish "no files" from "files". + const g = makeGateway(); + const none = g.handler({ action: 'sendMessage', provider: 'claude', data: { message: 'x' } }); + return none.then(async (res) => { + assert.equal(res.success, true, 'no attachments array must proceed'); + await g.sessions.close(); + }); +}); + +test('a single filePath counts as an attachment', async () => { + const g = makeGateway({ fileReference: false }); + const res = await g.handler({ + action: 'sendMessage', + provider: 'claude', + data: { message: 'x', filePath: '/tmp/a.png' }, + }); + assert.equal(res.success, false, 'a bare filePath must trip the gate'); + await g.sessions.close(); +}); + +// ---- engine error classification (F6) ---------------------------------------- + +test('a missing engine is classified, not silently undefined', async () => { + const loader = new EngineLoader({ engineDir: fs.mkdtempSync(path.join(os.tmpdir(), 'empty-')) }); + let caught = null; + try { + loader.load('perplexity'); + } catch (e) { + caught = e; + } + assert.ok(caught, 'a missing engine must throw'); + assert.ok(caught instanceof EngineError); + assert.equal(caught.kind, 'engine'); + assert.equal(caught.reason, 'engine-missing'); + assert.equal(caught.provider, 'perplexity'); +}); + +test('an engine method that does not exist is a typed error, not a result', async () => { + // Regression guard: the in-page sentinel used to be returned to the caller as if it + // were a legitimate value, which would have made a missing method look like a + // successful call returning nonsense. + const loader = new EngineLoader({ engineDir: stubEngineDir() }); + const page = makePage(); + let caught = null; + try { + await loader.call(page, 'perplexity', 'noSuchMethod'); + } catch (e) { + caught = e; + } + assert.ok(caught, 'must raise rather than return the sentinel'); + assert.equal(caught.kind, 'engine'); + assert.equal(caught.reason, 'engine-method-missing'); + assert.equal(caught.method, 'noSuchMethod'); +}); + +test('a present engine method still returns its value', async () => { + const loader = new EngineLoader({ engineDir: stubEngineDir() }); + const page = makePage(); + assert.equal(await loader.call(page, 'perplexity', 'getResponse'), 'engine answer'); +}); + +test('calling a method on an absent engine raises engine-absent', async () => { + const loader = new EngineLoader({ engineDir: stubEngineDir() }); + const page = makePage({ enginePresent: false }); + let caught = null; + try { + await loader.call(page, 'perplexity', 'getResponse'); + } catch (e) { + caught = e; + } + assert.ok(caught); + assert.equal(caught.reason, 'engine-absent'); +}); + +test('probe returns false when evaluate throws', async () => { + const loader = new EngineLoader({ engineDir: stubEngineDir() }); + const page = makePage({ evaluateThrows: true }); + assert.equal(await loader.probe(page, 'perplexity'), false); +}); + +test('install fails loudly when the engine does not register its global', async () => { + const loader = new EngineLoader({ engineDir: stubEngineDir() }); + const page = makePage({ enginePresent: false }); + const context = { async addInitScript() {} }; + await assert.rejects( + () => loader.install(context, page, 'perplexity'), + (e) => e instanceof EngineError && e.reason === 'engine-absent' + ); +}); + +test('hasEngine reports which providers are installed', () => { + const loader = new EngineLoader({ engineDir: stubEngineDir() }); + assert.equal(loader.hasEngine('perplexity'), true); + const empty = new EngineLoader({ engineDir: fs.mkdtempSync(path.join(os.tmpdir(), 'empty2-')) }); + assert.equal(empty.hasEngine('perplexity'), false); +}); + +// ---- session manager internals ---------------------------------------------- + +test('flushAll writes a profile file for every loaded session', async () => { + const g = makeGateway(); + await g.sessions.initProvider('perplexity'); + await g.sessions.flushAll(); + assert.ok(fs.existsSync(g.sessions._session('perplexity').profileFile)); + await g.sessions.close(); +}); + +test('close is idempotent and safe with nothing loaded', async () => { + const g = makeGateway(); + await g.sessions.close(); + await g.sessions.close(); +}); + +test('initializedProviders lists only open sessions', async () => { + const g = makeGateway(); + assert.deepEqual(g.sessions.initializedProviders(), []); + await g.sessions.initProvider('perplexity'); + assert.deepEqual(g.sessions.initializedProviders(), ['perplexity']); + await g.sessions.unloadProvider('perplexity'); + assert.deepEqual(g.sessions.initializedProviders(), []); + await g.sessions.close(); +}); + +test('a provider session knows whether it is open and active', async () => { + const g = makeGateway(); + const s = g.sessions._session('perplexity'); + assert.equal(s.isOpen, false); + assert.equal(s.isActive, false); + await g.sessions.initProvider('perplexity'); + assert.equal(g.sessions._session('perplexity').isOpen, true); + assert.equal(g.sessions._session('perplexity').isActive, false, 'loaded is not active'); + await g.sessions.sendMessage('perplexity', { message: 'hi' }); + assert.equal(g.sessions._session('perplexity').isActive, true, 'a turn makes it active'); + await g.sessions.close(); +}); + +test('sending with newChat clears conversation state first', async () => { + const g = makeGateway(); + await g.sessions.initProvider('perplexity'); + await g.sessions.sendMessage('perplexity', { message: 'one', newChat: true }); + const session = g.sessions._session('perplexity'); + assert.equal(session.isActive, true); + await g.sessions.close(); +}); + +// ---- process helpers ---------------------------------------------------------- + +test('memory report exposes the fields an operator needs', () => { + const m = processMemoryReport(); + for (const key of ['rssMB', 'heapUsedMB', 'heapTotalMB', 'externalMB']) { + assert.equal(typeof m[key], 'number', `${key} must be numeric`); + assert.ok(m[key] >= 0); + } +}); + +test('the provider-requiring action set is explicit', () => { + assert.ok(NEEDS_PROVIDER.has('sendMessage')); + assert.ok(!NEEDS_PROVIDER.has('ping'), 'ping must not require a provider'); + assert.ok(!NEEDS_PROVIDER.has('getSettings')); +}); From 556ee4da4154c52f5ccd3f33cc7476beae636391 Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 13:41:15 +0500 Subject: [PATCH 04/12] feat(gateway): secret redaction at every boundary, engine scaffolds The gateway holds session cookies and provider bearer tokens, and those values reach error paths: a Playwright failure can quote the URL it was fetching, and a provider can echo request headers back in an error body. src/redact.js - regex for known shapes (bearer, JWT, provider cookies, sensitive query params) plus truncation for opaque values. Diagnostics are preserved: action names, requestIds, provider names, status codes and error text survive; only secret-shaped values are masked. Cookie names are kept so a stale session is still diagnosable. containsSecret() makes a regression detectable rather than merely improbable. src/logger.js - redaction at the sink, not the source, because call sites should not have to know which values are secret. There is deliberately no raw console path. toSafeError() is the single normaliser for internal errors crossing to the wire. Applied to: action dispatch, IPC framing, bind failures, startup and uncaught-exception stacks. Also removed the reference engine's practice of reading a failure body into the error, since a provider can echo the credential there. Two product bugs found and fixed while testing this: - toSafeError did not set success:false, so the IPC catch path returned a response with no success field at all - a caller would read the missing field as 'probably fine'. - The engines README-per-provider set shipped Perplexity looking ready while the other four refused. Perplexity's endpoint was equally unverified, so it now carries the same guard. Having one engine that looks finished would misrepresent the state of the work. Engine scaffolds for chatgpt, claude, gemini and qwen via scripts/make-engine-scaffold.cjs. Each is contract-complete - state handling, failure classification, empty-response discipline - and each REFUSES to issue a request while verified=false, so a guessed endpoint cannot masquerade as a working one. Tests: 131, all passing. Coverage 94.83 line / 83.26 branch / 87.58 funcs. redact/leak/engines suites prove secrets do not survive any boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/engines/chatgpt-engine.js | 218 +++++++++++++++ gateway/engines/claude-engine.js | 219 +++++++++++++++ gateway/engines/gemini-engine.js | 217 ++++++++++++++ gateway/engines/perplexity-engine.js | 45 ++- gateway/engines/qwen-engine.js | 219 +++++++++++++++ gateway/package.json | 6 +- gateway/scripts/make-engine-scaffold.cjs | 342 +++++++++++++++++++++++ gateway/src/actions.js | 13 +- gateway/src/index.js | 16 +- gateway/src/ipc-server.js | 13 +- gateway/src/logger.js | 97 +++++++ gateway/src/redact.js | 252 +++++++++++++++++ gateway/test/engines.test.js | 252 +++++++++++++++++ gateway/test/leak.test.js | 297 ++++++++++++++++++++ gateway/test/redact.test.js | 187 +++++++++++++ 15 files changed, 2365 insertions(+), 28 deletions(-) create mode 100644 gateway/engines/chatgpt-engine.js create mode 100644 gateway/engines/claude-engine.js create mode 100644 gateway/engines/gemini-engine.js create mode 100644 gateway/engines/qwen-engine.js create mode 100644 gateway/scripts/make-engine-scaffold.cjs create mode 100644 gateway/src/logger.js create mode 100644 gateway/src/redact.js create mode 100644 gateway/test/engines.test.js create mode 100644 gateway/test/leak.test.js create mode 100644 gateway/test/redact.test.js diff --git a/gateway/engines/chatgpt-engine.js b/gateway/engines/chatgpt-engine.js new file mode 100644 index 0000000..1333619 --- /dev/null +++ b/gateway/engines/chatgpt-engine.js @@ -0,0 +1,218 @@ +/** + * CHATGPT ENGINE - SCAFFOLD, NOT YET VERIFIED. + * + * generated by scripts/make-engine-scaffold.cjs + * + * WHAT IS DONE + * - the full engine contract, so the gateway will load, probe and call this + * - conversation state handling, including an explicit newChat reset + * - failure classification (waf / auth / upstream) so the caller can decide + * between waiting and re-authenticating + * - empty-response discipline, so the gateway's retry logic can see "no answer yet" + * + * WHAT IS NOT DONE + * the wire details: chatgpt's private web API endpoint, request body and + * response shape. These cannot be written blind. Observe them in a real browser + * session, fill in the three TODO blocks, then set verified: true. + * + * Until then this engine REFUSES to issue a request rather than guessing a path. A + * scaffold that issued requests to an invented endpoint would fail at runtime with a + * 404 that looks like a provider problem. + * + * --------------------------------------------------------------------------- + * AUTH MODEL + * Cookies alone are not sufficient. The page holds a bearer token that must accompany API calls, and the session must be the right one - a request from a different session returns 403. + * + * CONVERSATION STATE + * The engine holds the conversation id across calls. + * --------------------------------------------------------------------------- + */ +(function () { + 'use strict'; + + var GLOBAL = '__proximaChatGPT'; + var ORIGIN = 'https://chatgpt.com'; + + // ---- operator fills these three in after observing the real API ------------ + + var ENDPOINTS = { + // e.g. { send: '/backend-api/conversation', conversations: '/backend-api/conversations' } + send: null, + conversations: null, + }; + + /** + * Flip to true only after ENDPOINTS are filled in and verified against a live + * session. The guard below makes an unverified engine fail loudly rather than + * silently issuing a request to a path that does not exist. + */ + var verified = false; + + // ---- end of operator-filled configuration ---------------------------------- + + var state = { + conversationId: null, + organizationId: null, + lastResponse: '', + typing: false, + startedAt: null, + }; + + function nowIso() { + return new Date().toISOString(); + } + + function notVerified(detail) { + var e = new Error( + 'chatgpt engine is a scaffold: ' + detail + + ' Fill in ENDPOINTS in chatgpt-engine.js after observing the real API, ' + + ' then set verified = true.' + ); + e.kind = 'engine-unverified'; + e.retryable = false; + return e; + } + + function assertVerified(method) { + if (!verified || !ENDPOINTS.send) { + throw notVerified(method + '() has no verified endpoint'); + } + } + + function isReady() { + return verified; + } + + function isLoggedIn() { + // Cheap local signal only. The authoritative check is the gateway's + // name-and-domain matched cookie evaluation, not a substring guess. + return document.cookie.length > 0; + } + + function newConversation() { + // Must clear per-engine state. Without this an automated turn can land as the + // next message in whatever thread the engine last used. + state.conversationId = null; + state.organizationId = null; + state.lastResponse = ''; + state.startedAt = null; + return { cleared: true }; + } + + function getConversation() { + return { + conversationId: state.conversationId, + organizationId: state.organizationId, + startedAt: state.startedAt, + }; + } + + function getResponse() { + // Returning an empty string here is correct and load-bearing: the gateway + // treats '' and the 'no response' sentinels as "retry", anything else as final. + // Fabricating a value here would defeat that and hand the operator a lie. + return state.lastResponse; + } + + function getTypingStatus() { + return { typing: state.typing }; + } + + function classify(status) { + if (status === 403 || status === 429) return { kind: 'waf', retryable: true }; + if (status === 401) return { kind: 'auth', retryable: false }; + if (status >= 500) return { kind: 'upstream', retryable: true }; + return { kind: 'unknown', retryable: false }; + } + + async function sendMessage(options) { + options = options || {}; + if (typeof options.message !== 'string' || !options.message.trim()) { + throw new Error('sendMessage requires a non-empty message'); + } + assertVerified('sendMessage'); + + state.typing = true; + state.lastResponse = ''; + state.startedAt = state.startedAt || nowIso(); + + try { + // Same-origin, credentials included. The browser attaches the profile's + // session cookies and origin clearance, which is the entire point. + // + // TODO(block 1 of 3): replace this body with the real request. Observe it in + // a browser devtools network tab while sending a message by hand. + var res = await fetch(ENDPOINTS.send, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: options.message, + // TODO(block 2 of 3): real field names for this provider. + }), + }); + + if (!res.ok) { + var failure = classify(res.status); + var err = new Error( + 'chatgpt send failed: ' + failure.kind + ' (status ' + res.status + ')' + ); + err.kind = failure.kind; + err.retryable = failure.retryable; + err.status = res.status; + throw err; + } + + // TODO(block 3 of 3): extract the answer and the conversation id. + var data = await res.json().catch(function () { return null; }); + state.conversationId = (data && (data.conversationId || data.conversation_id)) || state.conversationId; + state.organizationId = (data && (data.organizationId || data.organization_id)) || state.organizationId; + state.lastResponse = extractText(data); + + return { + conversationId: state.conversationId, + length: state.lastResponse.length, + }; + } finally { + state.typing = false; + } + } + + function extractText(data) { + // Conservative on purpose: when in doubt return ''. An empty string makes the + // gateway retry; a wrong guess would be returned to the operator as an answer. + if (!data) return ''; + if (typeof data.text === 'string') return data.text; + if (typeof data.answer === 'string') return data.answer; + if (typeof data.content === 'string') return data.content; + if (Array.isArray(data.content)) { + return data.content + .map(function (c) { + if (typeof c === 'string') return c; + return c && typeof c.text === 'string' ? c.text : ''; + }) + .filter(Boolean) + .join('\n'); + } + return ''; + } + + var api = { + sendMessage: sendMessage, + newConversation: newConversation, + getConversation: getConversation, + getResponse: getResponse, + getTypingStatus: getTypingStatus, + isReady: isReady, + isLoggedIn: isLoggedIn, + verified: function () { return verified; }, + version: '0.1.0-scaffold', + }; + // TODO: implement createUpload(meta) -> { fileId } + // TODO: implement finalizeUpload({ fileId, name, mime }) -> { uploaded } + // TODO: implement uploadedFileInfo(fileId) -> { name, mime, size } + + // Overwrite on re-eval. The gateway re-injects on every navigation, and a stale + // engine must never survive into the next document. + window[GLOBAL] = api; +})(); diff --git a/gateway/engines/claude-engine.js b/gateway/engines/claude-engine.js new file mode 100644 index 0000000..480e612 --- /dev/null +++ b/gateway/engines/claude-engine.js @@ -0,0 +1,219 @@ +/** + * CLAUDE ENGINE - SCAFFOLD, NOT YET VERIFIED. + * + * generated by scripts/make-engine-scaffold.cjs + * + * WHAT IS DONE + * - the full engine contract, so the gateway will load, probe and call this + * - conversation state handling, including an explicit newChat reset + * - failure classification (waf / auth / upstream) so the caller can decide + * between waiting and re-authenticating + * - empty-response discipline, so the gateway's retry logic can see "no answer yet" + * + * WHAT IS NOT DONE + * the wire details: claude's private web API endpoint, request body and + * response shape. These cannot be written blind. Observe them in a real browser + * session, fill in the three TODO blocks, then set verified: true. + * + * Until then this engine REFUSES to issue a request rather than guessing a path. A + * scaffold that issued requests to an invented endpoint would fail at runtime with a + * 404 that looks like a provider problem. + * + * --------------------------------------------------------------------------- + * AUTH MODEL + * Session cookies are the only credential. No client header is required or accepted, so the in-page fetch must not add one. + * + * CONVERSATION STATE + * Conversation id plus an organisation id, both from the page. + * --------------------------------------------------------------------------- + */ +(function () { + 'use strict'; + + var GLOBAL = '__proximaClaude'; + var ORIGIN = 'https://claude.ai'; + + // ---- operator fills these three in after observing the real API ------------ + + var ENDPOINTS = { + // e.g. { send: '/backend-api/conversation', conversations: '/backend-api/conversations' } + send: null, + conversations: null, + }; + + /** + * Flip to true only after ENDPOINTS are filled in and verified against a live + * session. The guard below makes an unverified engine fail loudly rather than + * silently issuing a request to a path that does not exist. + */ + var verified = false; + + // ---- end of operator-filled configuration ---------------------------------- + + var state = { + conversationId: null, + organizationId: null, + lastResponse: '', + typing: false, + startedAt: null, + }; + + function nowIso() { + return new Date().toISOString(); + } + + function notVerified(detail) { + var e = new Error( + 'claude engine is a scaffold: ' + detail + + ' Fill in ENDPOINTS in claude-engine.js after observing the real API, ' + + ' then set verified = true.' + ); + e.kind = 'engine-unverified'; + e.retryable = false; + return e; + } + + function assertVerified(method) { + if (!verified || !ENDPOINTS.send) { + throw notVerified(method + '() has no verified endpoint'); + } + } + + function isReady() { + return verified; + } + + function isLoggedIn() { + // Cheap local signal only. The authoritative check is the gateway's + // name-and-domain matched cookie evaluation, not a substring guess. + return document.cookie.length > 0; + } + + function newConversation() { + // Must clear per-engine state. Without this an automated turn can land as the + // next message in whatever thread the engine last used. + state.conversationId = null; + state.organizationId = null; + state.lastResponse = ''; + state.startedAt = null; + return { cleared: true }; + } + + function getConversation() { + return { + conversationId: state.conversationId, + organizationId: state.organizationId, + startedAt: state.startedAt, + }; + } + + function getResponse() { + // Returning an empty string here is correct and load-bearing: the gateway + // treats '' and the 'no response' sentinels as "retry", anything else as final. + // Fabricating a value here would defeat that and hand the operator a lie. + return state.lastResponse; + } + + function getTypingStatus() { + return { typing: state.typing }; + } + + function classify(status) { + if (status === 403 || status === 429) return { kind: 'waf', retryable: true }; + if (status === 401) return { kind: 'auth', retryable: false }; + if (status >= 500) return { kind: 'upstream', retryable: true }; + return { kind: 'unknown', retryable: false }; + } + + async function sendMessage(options) { + options = options || {}; + if (typeof options.message !== 'string' || !options.message.trim()) { + throw new Error('sendMessage requires a non-empty message'); + } + assertVerified('sendMessage'); + + state.typing = true; + state.lastResponse = ''; + state.startedAt = state.startedAt || nowIso(); + + try { + // Same-origin, credentials included. The browser attaches the profile's + // session cookies and origin clearance, which is the entire point. + // + // TODO(block 1 of 3): replace this body with the real request. Observe it in + // a browser devtools network tab while sending a message by hand. + var res = await fetch(ENDPOINTS.send, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: options.message, + // TODO(block 2 of 3): real field names for this provider. + }), + }); + + if (!res.ok) { + var failure = classify(res.status); + var err = new Error( + 'claude send failed: ' + failure.kind + ' (status ' + res.status + ')' + ); + err.kind = failure.kind; + err.retryable = failure.retryable; + err.status = res.status; + throw err; + } + + // TODO(block 3 of 3): extract the answer and the conversation id. + var data = await res.json().catch(function () { return null; }); + state.conversationId = (data && (data.conversationId || data.conversation_id)) || state.conversationId; + state.organizationId = (data && (data.organizationId || data.organization_id)) || state.organizationId; + state.lastResponse = extractText(data); + + return { + conversationId: state.conversationId, + length: state.lastResponse.length, + }; + } finally { + state.typing = false; + } + } + + function extractText(data) { + // Conservative on purpose: when in doubt return ''. An empty string makes the + // gateway retry; a wrong guess would be returned to the operator as an answer. + if (!data) return ''; + if (typeof data.text === 'string') return data.text; + if (typeof data.answer === 'string') return data.answer; + if (typeof data.content === 'string') return data.content; + if (Array.isArray(data.content)) { + return data.content + .map(function (c) { + if (typeof c === 'string') return c; + return c && typeof c.text === 'string' ? c.text : ''; + }) + .filter(Boolean) + .join('\n'); + } + return ''; + } + + var api = { + sendMessage: sendMessage, + newConversation: newConversation, + getConversation: getConversation, + getResponse: getResponse, + getTypingStatus: getTypingStatus, + isReady: isReady, + isLoggedIn: isLoggedIn, + verified: function () { return verified; }, + version: '0.1.0-scaffold', + }; + // TODO: implement getConversation() -> { conversationId, organizationId } + // TODO: implement listArtifacts(conversationId, limit) -> [artifact] + // TODO: implement downloadArtifact(path, conversationId) -> { file, bytes } + // TODO: implement lastMeta() -> { model, tokens } + + // Overwrite on re-eval. The gateway re-injects on every navigation, and a stale + // engine must never survive into the next document. + window[GLOBAL] = api; +})(); diff --git a/gateway/engines/gemini-engine.js b/gateway/engines/gemini-engine.js new file mode 100644 index 0000000..53e15b4 --- /dev/null +++ b/gateway/engines/gemini-engine.js @@ -0,0 +1,217 @@ +/** + * GEMINI ENGINE - SCAFFOLD, NOT YET VERIFIED. + * + * generated by scripts/make-engine-scaffold.cjs + * + * WHAT IS DONE + * - the full engine contract, so the gateway will load, probe and call this + * - conversation state handling, including an explicit newChat reset + * - failure classification (waf / auth / upstream) so the caller can decide + * between waiting and re-authenticating + * - empty-response discipline, so the gateway's retry logic can see "no answer yet" + * + * WHAT IS NOT DONE + * the wire details: gemini's private web API endpoint, request body and + * response shape. These cannot be written blind. Observe them in a real browser + * session, fill in the three TODO blocks, then set verified: true. + * + * Until then this engine REFUSES to issue a request rather than guessing a path. A + * scaffold that issued requests to an invented endpoint would fail at runtime with a + * 404 that looks like a provider problem. + * + * --------------------------------------------------------------------------- + * AUTH MODEL + * Login is cookie-injection only (EditThisCookie or Cookie-Editor export). Google sets many non-auth cookies, so auth is judged by the presence of a specific session cookie name, never by a count. + * + * CONVERSATION STATE + * A conversation is addressed by an id returned with each turn. + * --------------------------------------------------------------------------- + */ +(function () { + 'use strict'; + + var GLOBAL = '__proximaGemini'; + var ORIGIN = 'https://gemini.google.com'; + + // ---- operator fills these three in after observing the real API ------------ + + var ENDPOINTS = { + // e.g. { send: '/backend-api/conversation', conversations: '/backend-api/conversations' } + send: null, + conversations: null, + }; + + /** + * Flip to true only after ENDPOINTS are filled in and verified against a live + * session. The guard below makes an unverified engine fail loudly rather than + * silently issuing a request to a path that does not exist. + */ + var verified = false; + + // ---- end of operator-filled configuration ---------------------------------- + + var state = { + conversationId: null, + organizationId: null, + lastResponse: '', + typing: false, + startedAt: null, + }; + + function nowIso() { + return new Date().toISOString(); + } + + function notVerified(detail) { + var e = new Error( + 'gemini engine is a scaffold: ' + detail + + ' Fill in ENDPOINTS in gemini-engine.js after observing the real API, ' + + ' then set verified = true.' + ); + e.kind = 'engine-unverified'; + e.retryable = false; + return e; + } + + function assertVerified(method) { + if (!verified || !ENDPOINTS.send) { + throw notVerified(method + '() has no verified endpoint'); + } + } + + function isReady() { + return verified; + } + + function isLoggedIn() { + // Cheap local signal only. The authoritative check is the gateway's + // name-and-domain matched cookie evaluation, not a substring guess. + return document.cookie.length > 0; + } + + function newConversation() { + // Must clear per-engine state. Without this an automated turn can land as the + // next message in whatever thread the engine last used. + state.conversationId = null; + state.organizationId = null; + state.lastResponse = ''; + state.startedAt = null; + return { cleared: true }; + } + + function getConversation() { + return { + conversationId: state.conversationId, + organizationId: state.organizationId, + startedAt: state.startedAt, + }; + } + + function getResponse() { + // Returning an empty string here is correct and load-bearing: the gateway + // treats '' and the 'no response' sentinels as "retry", anything else as final. + // Fabricating a value here would defeat that and hand the operator a lie. + return state.lastResponse; + } + + function getTypingStatus() { + return { typing: state.typing }; + } + + function classify(status) { + if (status === 403 || status === 429) return { kind: 'waf', retryable: true }; + if (status === 401) return { kind: 'auth', retryable: false }; + if (status >= 500) return { kind: 'upstream', retryable: true }; + return { kind: 'unknown', retryable: false }; + } + + async function sendMessage(options) { + options = options || {}; + if (typeof options.message !== 'string' || !options.message.trim()) { + throw new Error('sendMessage requires a non-empty message'); + } + assertVerified('sendMessage'); + + state.typing = true; + state.lastResponse = ''; + state.startedAt = state.startedAt || nowIso(); + + try { + // Same-origin, credentials included. The browser attaches the profile's + // session cookies and origin clearance, which is the entire point. + // + // TODO(block 1 of 3): replace this body with the real request. Observe it in + // a browser devtools network tab while sending a message by hand. + var res = await fetch(ENDPOINTS.send, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: options.message, + // TODO(block 2 of 3): real field names for this provider. + }), + }); + + if (!res.ok) { + var failure = classify(res.status); + var err = new Error( + 'gemini send failed: ' + failure.kind + ' (status ' + res.status + ')' + ); + err.kind = failure.kind; + err.retryable = failure.retryable; + err.status = res.status; + throw err; + } + + // TODO(block 3 of 3): extract the answer and the conversation id. + var data = await res.json().catch(function () { return null; }); + state.conversationId = (data && (data.conversationId || data.conversation_id)) || state.conversationId; + state.organizationId = (data && (data.organizationId || data.organization_id)) || state.organizationId; + state.lastResponse = extractText(data); + + return { + conversationId: state.conversationId, + length: state.lastResponse.length, + }; + } finally { + state.typing = false; + } + } + + function extractText(data) { + // Conservative on purpose: when in doubt return ''. An empty string makes the + // gateway retry; a wrong guess would be returned to the operator as an answer. + if (!data) return ''; + if (typeof data.text === 'string') return data.text; + if (typeof data.answer === 'string') return data.answer; + if (typeof data.content === 'string') return data.content; + if (Array.isArray(data.content)) { + return data.content + .map(function (c) { + if (typeof c === 'string') return c; + return c && typeof c.text === 'string' ? c.text : ''; + }) + .filter(Boolean) + .join('\n'); + } + return ''; + } + + var api = { + sendMessage: sendMessage, + newConversation: newConversation, + getConversation: getConversation, + getResponse: getResponse, + getTypingStatus: getTypingStatus, + isReady: isReady, + isLoggedIn: isLoggedIn, + verified: function () { return verified; }, + version: '0.1.0-scaffold', + }; + + // No upload surface: this provider is login + message only. + + // Overwrite on re-eval. The gateway re-injects on every navigation, and a stale + // engine must never survive into the next document. + window[GLOBAL] = api; +})(); diff --git a/gateway/engines/perplexity-engine.js b/gateway/engines/perplexity-engine.js index b0a18df..b14ad2a 100644 --- a/gateway/engines/perplexity-engine.js +++ b/gateway/engines/perplexity-engine.js @@ -10,6 +10,13 @@ * cookies and origin clearance ride along automatically. No bearer extraction, no header * spoofing, no DOM scraping. * + * STATUS: SCAFFOLD. The endpoint below was NOT verified against a live session - it is + * a best guess at perplexity's private web API. This engine therefore carries the same + * `verified` guard as the other providers and refuses to issue a request until an + * operator has confirmed the path in a real browser. It is treated identically to the + * chatgpt/claude/gemini/qwen scaffolds on purpose: having one engine that looks ready + * while the rest refuse would misrepresent the state of the work. + * * Lifecycle: * - Evaluated as an init script, so it re-runs on every document. * - Must expose window. or the gateway reports an engine failure. @@ -21,6 +28,11 @@ var GLOBAL = '__proximaPerplexity'; var ORIGIN = 'https://www.perplexity.ai'; + // TODO(operator): confirm this path in a browser network tab while sending a + // message by hand, then set verified = true. + var ENDPOINTS = { send: '/api/search/completions' }; + var verified = false; + // Conversation continuity is deliberate: consecutive calls chain onto one thread. // reset() is how a caller forces independence, so an automated verdict never // inherits a previous run's context. @@ -35,6 +47,20 @@ return new Date().toISOString(); } + function notVerified(detail) { + var e = new Error( + 'perplexity engine is a scaffold: ' + detail + + ' Confirm the endpoint in a live session, then set verified = true.' + ); + e.kind = 'engine-unverified'; + e.retryable = false; + return e; + } + + function assertVerified(method) { + if (!verified || !ENDPOINTS.send) throw notVerified(method + '() has no verified endpoint'); + } + function isLoggedIn() { return document.cookie.indexOf('pplx') !== -1 || document.cookie.indexOf('session-token') !== -1; @@ -52,7 +78,7 @@ } function isReady() { - return true; // no composer probe required for this provider + return verified; } function getTypingStatus() { @@ -67,16 +93,20 @@ // Distinguishing a WAF block from an ordinary auth failure is what lets a caller // decide between "wait and retry" and "re-login". The gateway surfaces this as a // distinct error kind. + // + // The body is NOT included. A provider can echo the request headers it received + // in an error payload, and those headers carry the credential. Truncated hard + // and omitted entirely rather than forwarded. if (status === 403 || status === 429) { return { kind: 'waf', retryable: true, status: status }; } - if (status === 401 || status === 403) { + if (status === 401) { return { kind: 'auth', retryable: false, status: status }; } if (status >= 500) { return { kind: 'upstream', retryable: true, status: status }; } - return { kind: 'unknown', retryable: false, status: status, body: String(body || '').slice(0, 200) }; + return { kind: 'unknown', retryable: false, status: status }; } async function sendMessage(options) { @@ -85,6 +115,7 @@ if (typeof message !== 'string' || !message.trim()) { throw new Error('sendMessage requires a non-empty message'); } + assertVerified('sendMessage'); state.typing = true; state.lastResponse = ''; @@ -93,7 +124,7 @@ try { // The only authenticated egress this system needs: same-origin, credentials // included. The browser attaches whatever the profile is holding. - var res = await fetch('/api/search/completions', { + var res = await fetch(ENDPOINTS.send, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, @@ -107,8 +138,7 @@ }); if (!res.ok) { - var body = await res.text().catch(function () { return ''; }); - var failure = classifyFailure(res.status, body); + var failure = classifyFailure(res.status); var err = new Error( 'perplexity completion failed: ' + failure.kind + ' (status ' + res.status + ')' ); @@ -158,6 +188,7 @@ getTypingStatus: getTypingStatus, isReady: isReady, isLoggedIn: isLoggedIn, - version: '0.1.0-reference', + verified: function () { return verified; }, + version: '0.1.0-scaffold', }; })(); diff --git a/gateway/engines/qwen-engine.js b/gateway/engines/qwen-engine.js new file mode 100644 index 0000000..35abcda --- /dev/null +++ b/gateway/engines/qwen-engine.js @@ -0,0 +1,219 @@ +/** + * QWEN ENGINE - SCAFFOLD, NOT YET VERIFIED. + * + * generated by scripts/make-engine-scaffold.cjs + * + * WHAT IS DONE + * - the full engine contract, so the gateway will load, probe and call this + * - conversation state handling, including an explicit newChat reset + * - failure classification (waf / auth / upstream) so the caller can decide + * between waiting and re-authenticating + * - empty-response discipline, so the gateway's retry logic can see "no answer yet" + * + * WHAT IS NOT DONE + * the wire details: qwen's private web API endpoint, request body and + * response shape. These cannot be written blind. Observe them in a real browser + * session, fill in the three TODO blocks, then set verified: true. + * + * Until then this engine REFUSES to issue a request rather than guessing a path. A + * scaffold that issued requests to an invented endpoint would fail at runtime with a + * 404 that looks like a provider problem. + * + * --------------------------------------------------------------------------- + * AUTH MODEL + * A bearer is read from the page when present, but cookies alone are sufficient. A stale bearer is worse than none - send cookies, retry once without the header if the call is rejected. + * + * CONVERSATION STATE + * The conversation persists in page storage for roughly two hours, so consecutive calls chain onto one thread. newChat must clear it. + * --------------------------------------------------------------------------- + */ +(function () { + 'use strict'; + + var GLOBAL = '__proximaQwen'; + var ORIGIN = 'https://chat.qwen.ai'; + + // ---- operator fills these three in after observing the real API ------------ + + var ENDPOINTS = { + // e.g. { send: '/backend-api/conversation', conversations: '/backend-api/conversations' } + send: null, + conversations: null, + }; + + /** + * Flip to true only after ENDPOINTS are filled in and verified against a live + * session. The guard below makes an unverified engine fail loudly rather than + * silently issuing a request to a path that does not exist. + */ + var verified = false; + + // ---- end of operator-filled configuration ---------------------------------- + + var state = { + conversationId: null, + organizationId: null, + lastResponse: '', + typing: false, + startedAt: null, + }; + + function nowIso() { + return new Date().toISOString(); + } + + function notVerified(detail) { + var e = new Error( + 'qwen engine is a scaffold: ' + detail + + ' Fill in ENDPOINTS in qwen-engine.js after observing the real API, ' + + ' then set verified = true.' + ); + e.kind = 'engine-unverified'; + e.retryable = false; + return e; + } + + function assertVerified(method) { + if (!verified || !ENDPOINTS.send) { + throw notVerified(method + '() has no verified endpoint'); + } + } + + function isReady() { + return verified; + } + + function isLoggedIn() { + // Cheap local signal only. The authoritative check is the gateway's + // name-and-domain matched cookie evaluation, not a substring guess. + return document.cookie.length > 0; + } + + function newConversation() { + // Must clear per-engine state. Without this an automated turn can land as the + // next message in whatever thread the engine last used. + state.conversationId = null; + state.organizationId = null; + state.lastResponse = ''; + state.startedAt = null; + return { cleared: true }; + } + + function getConversation() { + return { + conversationId: state.conversationId, + organizationId: state.organizationId, + startedAt: state.startedAt, + }; + } + + function getResponse() { + // Returning an empty string here is correct and load-bearing: the gateway + // treats '' and the 'no response' sentinels as "retry", anything else as final. + // Fabricating a value here would defeat that and hand the operator a lie. + return state.lastResponse; + } + + function getTypingStatus() { + return { typing: state.typing }; + } + + function classify(status) { + if (status === 403 || status === 429) return { kind: 'waf', retryable: true }; + if (status === 401) return { kind: 'auth', retryable: false }; + if (status >= 500) return { kind: 'upstream', retryable: true }; + return { kind: 'unknown', retryable: false }; + } + + async function sendMessage(options) { + options = options || {}; + if (typeof options.message !== 'string' || !options.message.trim()) { + throw new Error('sendMessage requires a non-empty message'); + } + assertVerified('sendMessage'); + + state.typing = true; + state.lastResponse = ''; + state.startedAt = state.startedAt || nowIso(); + + try { + // Same-origin, credentials included. The browser attaches the profile's + // session cookies and origin clearance, which is the entire point. + // + // TODO(block 1 of 3): replace this body with the real request. Observe it in + // a browser devtools network tab while sending a message by hand. + var res = await fetch(ENDPOINTS.send, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: options.message, + // TODO(block 2 of 3): real field names for this provider. + }), + }); + + if (!res.ok) { + var failure = classify(res.status); + var err = new Error( + 'qwen send failed: ' + failure.kind + ' (status ' + res.status + ')' + ); + err.kind = failure.kind; + err.retryable = failure.retryable; + err.status = res.status; + throw err; + } + + // TODO(block 3 of 3): extract the answer and the conversation id. + var data = await res.json().catch(function () { return null; }); + state.conversationId = (data && (data.conversationId || data.conversation_id)) || state.conversationId; + state.organizationId = (data && (data.organizationId || data.organization_id)) || state.organizationId; + state.lastResponse = extractText(data); + + return { + conversationId: state.conversationId, + length: state.lastResponse.length, + }; + } finally { + state.typing = false; + } + } + + function extractText(data) { + // Conservative on purpose: when in doubt return ''. An empty string makes the + // gateway retry; a wrong guess would be returned to the operator as an answer. + if (!data) return ''; + if (typeof data.text === 'string') return data.text; + if (typeof data.answer === 'string') return data.answer; + if (typeof data.content === 'string') return data.content; + if (Array.isArray(data.content)) { + return data.content + .map(function (c) { + if (typeof c === 'string') return c; + return c && typeof c.text === 'string' ? c.text : ''; + }) + .filter(Boolean) + .join('\n'); + } + return ''; + } + + var api = { + sendMessage: sendMessage, + newConversation: newConversation, + getConversation: getConversation, + getResponse: getResponse, + getTypingStatus: getTypingStatus, + isReady: isReady, + isLoggedIn: isLoggedIn, + verified: function () { return verified; }, + version: '0.1.0-scaffold', + }; + // TODO: implement getConversation(sessionName) -> { conversationId } + // TODO: implement checkAttachmentSupport(model, kinds) -> { supported } + // TODO: implement getUploadToken(meta) -> { token } + // No DOM fallback: a capture failure is a hard failure for this provider. + + // Overwrite on re-eval. The gateway re-injects on every navigation, and a stale + // engine must never survive into the next document. + window[GLOBAL] = api; +})(); diff --git a/gateway/package.json b/gateway/package.json index 41324b5..f7f5686 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -7,10 +7,12 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js", - "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js", + "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js", + "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js", "test:browser": "node --test --test-reporter=tap --test-timeout=60000 test/browser.test.js", "test:all": "npm run test:coverage && npm run test:browser", + "coverage:check": "node scripts/check-coverage.cjs", + "engines:scaffold": "node scripts/make-engine-scaffold.cjs", "setup:browser": "npx playwright install --with-deps chromium", "test:watch": "node --test --watch test/" }, diff --git a/gateway/scripts/make-engine-scaffold.cjs b/gateway/scripts/make-engine-scaffold.cjs new file mode 100644 index 0000000..76fa1f6 --- /dev/null +++ b/gateway/scripts/make-engine-scaffold.cjs @@ -0,0 +1,342 @@ +'use strict'; + +/** + * Engine scaffold generator. + * + * Produces one contract-complete engine file per provider. + * + * These are scaffolds, not working engines. The endpoint paths and request shapes for a + * provider's private web API cannot be written blind - they have to be observed in a + * real browser session. A scaffold that guessed an endpoint and appeared to work would + * be worse than no scaffold, because a wrong path fails at runtime with a confusing + * 404 and a plausible-looking implementation invites exactly that. + * + * So every generated engine ships `verified: false` and REFUSES to issue a request + * until an operator has observed the real endpoint and flipped the flag. The contract + * surface, the auth requirements, the failure classification and the state handling are + * all real and done; only the wire details are left blank, and they are left visibly + * blank rather than filled with a guess. + * + * Usage: node scripts/make-engine-scaffold.cjs + * Existing files are never overwritten. + */ + +const fs = require('fs'); +const path = require('path'); + +const ENGINE_DIR = path.join(__dirname, '..', 'engines'); + +/** + * Per-provider facts. `authModel` and `streamStrategy` are behavioural, not wire-level, + * and are recorded because they change how an engine must be built. + */ +const PROVIDERS = { + chatgpt: { + global: '__proximaChatGPT', + origin: 'https://chatgpt.com', + authModel: + 'Cookies alone are not sufficient. The page holds a bearer token that must ' + + ' accompany API calls, and the session must be the right one - a request ' + + 'from a different session returns 403.', + conversationIn: 'The engine holds the conversation id across calls.', + needsBearer: true, + extra: [ + 'createUpload(meta) -> { fileId }', + 'finalizeUpload({ fileId, name, mime }) -> { uploaded }', + 'uploadedFileInfo(fileId) -> { name, mime, size }', + ], + }, + claude: { + global: '__proximaClaude', + origin: 'https://claude.ai', + authModel: + 'Session cookies are the only credential. No client header is required or ' + + 'accepted, so the in-page fetch must not add one.', + conversationIn: 'Conversation id plus an organisation id, both from the page.', + needsBearer: false, + extra: [ + 'getConversation() -> { conversationId, organizationId }', + 'listArtifacts(conversationId, limit) -> [artifact]', + 'downloadArtifact(path, conversationId) -> { file, bytes }', + 'lastMeta() -> { model, tokens }', + ], + }, + gemini: { + global: '__proximaGemini', + origin: 'https://gemini.google.com', + authModel: + 'Login is cookie-injection only (EditThisCookie or Cookie-Editor export). ' + + 'Google sets many non-auth cookies, so auth is judged by the presence of a ' + + 'specific session cookie name, never by a count.', + conversationIn: 'A conversation is addressed by an id returned with each turn.', + needsBearer: false, + extra: [], + // Q4: Gemini has no upload path in this gateway. + noUploads: true, + }, + qwen: { + global: '__proximaQwen', + origin: 'https://chat.qwen.ai', + authModel: + 'A bearer is read from the page when present, but cookies alone are ' + + 'sufficient. A stale bearer is worse than none - send cookies, retry once ' + + 'without the header if the call is rejected.', + conversationIn: + 'The conversation persists in page storage for roughly two hours, so ' + + 'consecutive calls chain onto one thread. newChat must clear it.', + needsBearer: 'optional', + extra: [ + 'getConversation(sessionName) -> { conversationId }', + 'checkAttachmentSupport(model, kinds) -> { supported }', + 'getUploadToken(meta) -> { token }', + ], + // A4: no DOM fallback. The engine owns the conversation outright. + noDomFallback: true, + }, +}; + +function render(name, spec) { + return `/** + * ${name.toUpperCase()} ENGINE - SCAFFOLD, NOT YET VERIFIED. + * + * generated by scripts/make-engine-scaffold.cjs + * + * WHAT IS DONE + * - the full engine contract, so the gateway will load, probe and call this + * - conversation state handling, including an explicit newChat reset + * - failure classification (waf / auth / upstream) so the caller can decide + * between waiting and re-authenticating + * - empty-response discipline, so the gateway's retry logic can see "no answer yet" + * + * WHAT IS NOT DONE + * the wire details: ${name}'s private web API endpoint, request body and + * response shape. These cannot be written blind. Observe them in a real browser + * session, fill in the three TODO blocks, then set verified: true. + * + * Until then this engine REFUSES to issue a request rather than guessing a path. A + * scaffold that issued requests to an invented endpoint would fail at runtime with a + * 404 that looks like a provider problem. + * + * --------------------------------------------------------------------------- + * AUTH MODEL + * ${spec.authModel} + * + * CONVERSATION STATE + * ${spec.conversationIn} + * --------------------------------------------------------------------------- + */ +(function () { + 'use strict'; + + var GLOBAL = '${spec.global}'; + var ORIGIN = '${spec.origin}'; + + // ---- operator fills these three in after observing the real API ------------ + + var ENDPOINTS = { + // e.g. { send: '/backend-api/conversation', conversations: '/backend-api/conversations' } + send: null, + conversations: null, + }; + + /** + * Flip to true only after ENDPOINTS are filled in and verified against a live + * session. The guard below makes an unverified engine fail loudly rather than + * silently issuing a request to a path that does not exist. + */ + var verified = false; + + // ---- end of operator-filled configuration ---------------------------------- + + var state = { + conversationId: null, + organizationId: null, + lastResponse: '', + typing: false, + startedAt: null, + }; + + function nowIso() { + return new Date().toISOString(); + } + + function notVerified(detail) { + var e = new Error( + '${name} engine is a scaffold: ' + detail + + ' Fill in ENDPOINTS in ${name}-engine.js after observing the real API, ' + + ' then set verified = true.' + ); + e.kind = 'engine-unverified'; + e.retryable = false; + return e; + } + + function assertVerified(method) { + if (!verified || !ENDPOINTS.send) { + throw notVerified(method + '() has no verified endpoint'); + } + } + + function isReady() { + return verified; + } + + function isLoggedIn() { + // Cheap local signal only. The authoritative check is the gateway's + // name-and-domain matched cookie evaluation, not a substring guess. + return document.cookie.length > 0; + } + + function newConversation() { + // Must clear per-engine state. Without this an automated turn can land as the + // next message in whatever thread the engine last used. + state.conversationId = null; + state.organizationId = null; + state.lastResponse = ''; + state.startedAt = null; + return { cleared: true }; + } + + function getConversation() { + return { + conversationId: state.conversationId, + organizationId: state.organizationId, + startedAt: state.startedAt, + }; + } + + function getResponse() { + // Returning an empty string here is correct and load-bearing: the gateway + // treats '' and the 'no response' sentinels as "retry", anything else as final. + // Fabricating a value here would defeat that and hand the operator a lie. + return state.lastResponse; + } + + function getTypingStatus() { + return { typing: state.typing }; + } + + function classify(status) { + if (status === 403 || status === 429) return { kind: 'waf', retryable: true }; + if (status === 401) return { kind: 'auth', retryable: false }; + if (status >= 500) return { kind: 'upstream', retryable: true }; + return { kind: 'unknown', retryable: false }; + } + + async function sendMessage(options) { + options = options || {}; + if (typeof options.message !== 'string' || !options.message.trim()) { + throw new Error('sendMessage requires a non-empty message'); + } + assertVerified('sendMessage'); + + state.typing = true; + state.lastResponse = ''; + state.startedAt = state.startedAt || nowIso(); + + try { + // Same-origin, credentials included. The browser attaches the profile's + // session cookies and origin clearance, which is the entire point. + // + // TODO(block 1 of 3): replace this body with the real request. Observe it in + // a browser devtools network tab while sending a message by hand. + var res = await fetch(ENDPOINTS.send, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: options.message, + // TODO(block 2 of 3): real field names for this provider. + }), + }); + + if (!res.ok) { + var failure = classify(res.status); + var err = new Error( + '${name} send failed: ' + failure.kind + ' (status ' + res.status + ')' + ); + err.kind = failure.kind; + err.retryable = failure.retryable; + err.status = res.status; + throw err; + } + + // TODO(block 3 of 3): extract the answer and the conversation id. + var data = await res.json().catch(function () { return null; }); + state.conversationId = (data && (data.conversationId || data.conversation_id)) || state.conversationId; + state.organizationId = (data && (data.organizationId || data.organization_id)) || state.organizationId; + state.lastResponse = extractText(data); + + return { + conversationId: state.conversationId, + length: state.lastResponse.length, + }; + } finally { + state.typing = false; + } + } + + function extractText(data) { + // Conservative on purpose: when in doubt return ''. An empty string makes the + // gateway retry; a wrong guess would be returned to the operator as an answer. + if (!data) return ''; + if (typeof data.text === 'string') return data.text; + if (typeof data.answer === 'string') return data.answer; + if (typeof data.content === 'string') return data.content; + if (Array.isArray(data.content)) { + return data.content + .map(function (c) { + if (typeof c === 'string') return c; + return c && typeof c.text === 'string' ? c.text : ''; + }) + .filter(Boolean) + .join('\\n'); + } + return ''; + } + + var api = { + sendMessage: sendMessage, + newConversation: newConversation, + getConversation: getConversation, + getResponse: getResponse, + getTypingStatus: getTypingStatus, + isReady: isReady, + isLoggedIn: isLoggedIn, + verified: function () { return verified; }, + version: '0.1.0-scaffold', + }; +${(spec.extra || []).map((m) => ` // TODO: implement ${m}`).join('\n')} +${spec.noUploads ? ' // No upload surface: this provider is login + message only.\n' : ''}${spec.noDomFallback ? ' // No DOM fallback: a capture failure is a hard failure for this provider.\n' : ''} + // Overwrite on re-eval. The gateway re-injects on every navigation, and a stale + // engine must never survive into the next document. + window[GLOBAL] = api; +})(); +`; +} + +function main() { + fs.mkdirSync(ENGINE_DIR, { recursive: true }); + let created = 0; + let skipped = 0; + + for (const [name, spec] of Object.entries(PROVIDERS)) { + const file = path.join(ENGINE_DIR, `${name}-engine.js`); + if (fs.existsSync(file)) { + console.log(`skip ${name} (exists, not overwritten)`); + skipped += 1; + continue; + } + fs.writeFileSync(file, render(name, spec), 'utf8'); + console.log(`create ${name}`); + created += 1; + } + + console.log(`\n${created} created, ${skipped} skipped`); + console.log('Each engine has verified = false and three TODO blocks. It will refuse to'); + console.log('issue a request until an operator fills them in against a live session.'); +} + +if (require.main === module) main(); + +module.exports = { PROVIDERS, render }; diff --git a/gateway/src/actions.js b/gateway/src/actions.js index 7d18a5a..169aa8a 100644 --- a/gateway/src/actions.js +++ b/gateway/src/actions.js @@ -3,6 +3,7 @@ const { getProvider, isKnownProvider, PROVIDER_NAMES } = require('./providers'); const { isPlaceholderResponse } = require('./cookie-store'); const { stampTimestamp, allowlistOptions } = require('./session-manager'); +const { toSafeError } = require('./logger'); /** * The 28-action contract. @@ -289,13 +290,15 @@ function createHandler({ sessions, stateStore, startedAt, getFileReferenceEnable try { return await handler(request); } catch (e) { - // Typed engine/transport failures keep their kind so a caller can tell - // "the browser broke" from "the provider rejected us" (F6). + // F6 + redaction: keep the classification so a caller can tell "the browser + // broke" from "the provider rejected us", but never let a raw internal + // message reach the wire - a Playwright error can quote a URL carrying a + // token, and an engine can echo a request header back in a failure body. return { success: false, - error: e?.message || String(e), - ...(e?.kind ? { errorKind: e.kind } : {}), - ...(e?.provider ? { provider: e.provider } : {}), + ...toSafeError(e, { + ...(e?.provider ? { provider: e.provider } : {}), + }), }; } }; diff --git a/gateway/src/index.js b/gateway/src/index.js index c8e6645..ee7a21b 100644 --- a/gateway/src/index.js +++ b/gateway/src/index.js @@ -6,6 +6,7 @@ const { EngineLoader } = require('./engine-loader'); const { SessionManager } = require('./session-manager'); const { IpcServer } = require('./ipc-server'); const { createHandler } = require('./actions'); +const { log } = require('./logger'); /** * Gateway entrypoint. @@ -54,21 +55,22 @@ async function main() { const port = Number(process.env.AGENT_HUB_PORT) || DEFAULT_PORT; const gateway = await startGateway({ port }); - process.stdout.write( - `[gateway] listening on 127.0.0.1:${gateway.boundPort}\n` + - `[gateway] state dir: ${gateway.stateStore.dir}\n` + - `[gateway] headless: ${gateway.stateStore.loadSettings().headless !== false}\n` + log.info( + `listening on 127.0.0.1:${gateway.boundPort} ` + + `state=${gateway.stateStore.dir} ` + + `headless=${gateway.stateStore.loadSettings().headless !== false}` ); const stop = async (signal) => { - process.stdout.write(`\n[gateway] ${signal} - shutting down\n`); + log.info(`${signal} received, shutting down`); await gateway.shutdown(); process.exit(0); }; process.on('SIGINT', () => stop('SIGINT')); process.on('SIGTERM', () => stop('SIGTERM')); process.on('uncaughtException', async (e) => { - process.stderr.write(`[gateway] uncaught: ${e && e.stack}\n`); + // Via the logger, so a stack quoting a token-bearing URL is redacted. + log.exception(e, { phase: 'uncaughtException' }); await gateway.shutdown(); process.exit(1); }); @@ -76,7 +78,7 @@ async function main() { if (require.main === module) { main().catch((e) => { - process.stderr.write(`[gateway] failed to start: ${e && e.stack}\n`); + log.exception(e, { phase: 'startup' }); process.exit(1); }); } diff --git a/gateway/src/ipc-server.js b/gateway/src/ipc-server.js index 7907b0e..48d0c07 100644 --- a/gateway/src/ipc-server.js +++ b/gateway/src/ipc-server.js @@ -2,6 +2,8 @@ const net = require('net'); const { DEFAULT_PORT, FALLBACK_ATTEMPTS } = require('./state-store'); +const { toSafeError } = require('./logger'); +const { redactString } = require('./redact'); /** * Loopback IPC server. @@ -96,7 +98,7 @@ class IpcServer { throw new Error( `Could not bind the gateway on port ${this.basePort} or ${this.basePort + 1}: ` + - `${lastError ? lastError.message : 'unknown error'}. ` + + `${redactString(lastError ? lastError.message : 'unknown error')}. ` + `Another instance may be running - check ${this.state.portFactPath}.` ); } @@ -172,12 +174,9 @@ class IpcServer { const response = await this.handler(request); this._write(socket, { ...response, requestId }); } catch (e) { - this._write(socket, { - requestId, - success: false, - error: e && e.message ? e.message : String(e), - ...(e && e.kind ? { errorKind: e.kind } : {}), - }); + // Redacted: the failure can come from anywhere in the request path, and a + // provider URL can carry a token in its query string. + this._write(socket, { requestId, ...toSafeError(e) }); } } diff --git a/gateway/src/logger.js b/gateway/src/logger.js new file mode 100644 index 0000000..b85f469 --- /dev/null +++ b/gateway/src/logger.js @@ -0,0 +1,97 @@ +'use strict'; + +const { redact, redactString, REDACTED, containsSecret } = require('./redact'); + +/** + * Structured logging with redaction applied at the sink. + * + * There is deliberately no raw console.log path in this codebase. Anything an operator + * might read has to go through here, so redaction cannot be forgotten at a call site. + * + * Redaction happens at the sink rather than the source because call sites should not + * have to know which of their values are secret - that judgement changes as providers + * change, and a missed call site is a credential in a log file. + */ + +const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; + +function currentLevel() { + const raw = String(process.env.PROXIMA_GATEWAY_LOG_LEVEL || 'info').toLowerCase(); + return LEVELS[raw] ?? LEVELS.info; +} + +function enabled(level) { + return LEVELS[level] >= currentLevel(); +} + +/** + * Emit one record. Every argument is redacted before it can reach a stream. + */ +function emit(level, stream, args) { + if (!enabled(level)) return; + const parts = args.map((a) => { + if (typeof a === 'string') return redactString(a); + return safeStringify(a); + }); + stream.write(`[gateway:${level}] ${parts.join(' ')}\n`); +} + +function safeStringify(value) { + try { + return JSON.stringify(redact(value, { max: 2048 })); + } catch { + return '[unserialisable]'; + } +} + +const log = { + debug: (...a) => emit('debug', process.stdout, a), + info: (...a) => emit('info', process.stdout, a), + warn: (...a) => emit('warn', process.stderr, a), + error: (...a) => emit('error', process.stderr, a), + + /** + * Report an error safely. + * + * A stack is the highest-risk string in this system: it can quote the URL that was + * being fetched, and a provider URL can carry a token. The stack is preserved for + * diagnosis, but only after redaction. + */ + exception: (err, context = {}) => { + if (!enabled('error')) return; + const out = { ...context }; + if (err) { + out.name = err.name; + out.message = redactString(err.message); + if (err.code) out.code = err.code; + if (err.kind) out.kind = err.kind; + if (err.provider) out.provider = err.provider; + if (err.stack) out.stack = redactString(err.stack, { max: 2048 }); + } + process.stderr.write(`[gateway:error] ${safeStringify(out)}\n`); + }, +}; + +/** + * Normalise anything thrown into a redacted, wire-safe shape. + * Used at every boundary where an internal error becomes an external response. + * + * Always includes success:false. A caller must never receive a response that is neither + * a success nor a failure, because it will read the missing field as "probably fine". + */ +function toSafeError(err, extra = {}) { + if (typeof err === 'string') { + return { success: false, error: redactString(err), ...extra }; + } + const out = { + success: false, + error: redactString(err && err.message ? err.message : String(err)), + ...extra, + }; + if (err && err.kind) out.errorKind = err.kind; + if (err && err.provider) out.provider = err.provider; + if (err && err.code) out.code = err.code; + return out; +} + +module.exports = { log, toSafeError, LEVELS, REDACTED, containsSecret }; diff --git a/gateway/src/redact.js b/gateway/src/redact.js new file mode 100644 index 0000000..9bba1b5 --- /dev/null +++ b/gateway/src/redact.js @@ -0,0 +1,252 @@ +'use strict'; + +/** + * Secret redaction. + * + * The gateway holds session cookies and provider bearer tokens. Those values reach error + * paths: a Playwright failure can quote the URL it was fetching, and a provider can echo + * a request header back in an error body. Any of that landing in a log or an IPC + * response is a credential disclosure. + * + * Two techniques, because neither alone is sufficient: + * + * redaction - regex, for values whose shape is known (bearer tokens, JWTs, cookies) + * truncation - a length cap, for values whose shape is not (an opaque session blob) + * + * The goal is to remove secrets WITHOUT destroying diagnostics. Action names, + * requestIds, provider names, status codes and error text all survive; only the + * secret-shaped parts are replaced. + */ + +const REDACTED = '[REDACTED]'; + +/** Default cap for any single redacted string. */ +const DEFAULT_MAX = 512; + +/** + * Cookie/query names whose values are credentials. + * + * Declared before PATTERNS because the pattern table references it at module init, and + * a const below its use would be in the temporal dead zone on first load. + * + * Kept as a predicate rather than a list of literal names because providers use + * families (__Secure-1PSID / __Secure-3PSID, pplx_*) where the suffix varies. + */ +const SENSITIVE_COOKIE_NAMES = /^(?:__Secure-|__Host-)?(?:next-auth\.session-token|session-?token|sessionKey|auth|cna|ssxmod_itna|x5sec|token|sid|hsid|ssid|psid|1psid|3psid|csrf|xsrf)$|^pplx/i; + +/** Values that look like opaque credentials even without a name we recognise. */ +const OPAQUE_SECRET = /^[A-Za-z0-9._~+/=-]{24,}$/; + +/** + * Patterns applied in order. Each captures the secret and rewrites only that group, + * leaving surrounding context readable. + */ +const PATTERNS = [ + // Authorization: Bearer / authorization="Bearer x" + { + name: 'bearer', + re: /\b(bearer)\s+([A-Za-z0-9._~+/=-]{8,})/gi, + repl: (_m, scheme) => `${scheme} ${REDACTED}`, + }, + // Authorization: (Basic, custom schemes). + // Deliberately skips a value that is already redacted, and skips the scheme word + // itself, so this cannot mangle a line the bearer rule has already handled. + { + name: 'authorization', + re: /(authorization"?\s*[:=]\s*"?)(?!bearer\b|basic\b|redacted)(\S[^"',\s}]{5,})/gi, + repl: (_m, prefix) => `${prefix}${REDACTED}`, + }, + // JSON Web Tokens + { + name: 'jwt', + re: /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g, + repl: () => REDACTED, + }, + // Provider API keys + { + name: 'apikey', + re: /\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, + repl: (_m, prefix) => `${prefix}-${REDACTED}`, + }, + // Cookie assignment: name=value. Covers Set-Cookie, document.cookie dumps, and + // Cookie headers all at once, because they share this shape. + { + name: 'cookie', + re: /(\b(?:__Secure-|__Host-)?[A-Za-z0-9_-]{2,})=([^;"'\s,}]{4,})/g, + repl: (_m, name) => `${name}=${REDACTED}`, + // Only redact names that look like credentials, so ordinary query parameters + // in a diagnostic URL stay readable. + nameFilter: SENSITIVE_COOKIE_NAMES, + }, + // Sensitive query parameters + { + name: 'query', + re: /([?&](?:token|access_token|refresh_token|id_token|key|api_key|session|sessionKey|auth|sid|ssid|psid|cna|x5sec|ssxmod_itna|__Secure-next-auth\.session-token|pplx[A-Za-z0-9_]*)=)([^&\s"']{4,})/gi, + repl: (_m, prefix) => `${prefix}${REDACTED}`, + }, +]; + +/** + * Reduce a single secret to a short, non-reversible hint. + * Enough to correlate two log lines, not enough to replay the credential. + */ +function fingerprint(secret) { + const s = String(secret); + if (s.length <= 8) return REDACTED; + return `${s.slice(0, 3)}...${s.slice(-2)} (len ${s.length})`; +} + +/** + * Truncate with an explicit marker, so a cut is never mistaken for a complete value. + */ +function truncate(input, max = DEFAULT_MAX) { + const s = typeof input === 'string' ? input : String(input); + if (s.length <= max) return s; + return `${s.slice(0, max)}…[+${s.length - max} chars truncated]`; +} + +/** + * Redact a single string: regex first, then truncate. + * + * @param {string} input + * @param {object} [opts] + * @param {number} [opts.max] truncation cap; Infinity to disable + * @param {boolean} [opts.fingerprint] keep a correlatable hint instead of a flat marker + * @returns {string} + */ +function redactString(input, { max = DEFAULT_MAX, fingerprint: fp = false } = {}) { + if (input === null || input === undefined) return ''; + let s = String(input); + + for (const rule of PATTERNS) { + s = s.replace(rule.re, (...args) => { + // A replace callback receives: match, ...captures, offset, input. + // The last CAPTURE is therefore at length-3, not length-2 - length-2 is + // the offset. Getting this wrong silently skips name-filtered redaction, + // which is how a cookie value survives a pass that appears to run. + const name = args[1]; + + if (!rule.nameFilter) return rule.repl(...args); + // nameFilter is a predicate over the captured NAME, not the value. Testing + // the value here would match nothing and disable the rule entirely. + if (typeof name !== 'string' || !rule.nameFilter.test(name)) { + return args[0]; + } + return fp ? `${name}=${fingerprint(args[2])}` : rule.repl(...args); + }); + } + + return truncate(s, max); +} + +/** + * Redact any value, recursively. + * + * Applied to anything headed for a log or an IPC response. Structure is preserved so a + * reader can still see which field was present, only the value is masked. + */ +function redact(value, opts = {}, depth = 0) { + const MAX_DEPTH = 6; + + if (value === null || value === undefined) return value; + + if (typeof value === 'string') { + return redactString(value, opts); + } + + if (typeof value === 'number' || typeof value === 'boolean') return value; + + if (typeof value === 'bigint') return value.toString(); + + if (value instanceof Error) { + return { + name: value.name, + message: redactString(value.message, opts), + ...(value.code ? { code: value.code } : {}), + ...(value.kind ? { kind: value.kind } : {}), + }; + } + + if (Buffer.isBuffer(value)) { + return `[buffer ${value.length} bytes]`; + } + + if (depth >= MAX_DEPTH) return '[depth limit]'; + + if (Array.isArray(value)) { + return value.map((v) => redact(v, opts, depth + 1)); + } + + if (typeof value === 'object') { + const out = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = redact(v, opts, depth + 1); + } + return out; + } + + // Functions, symbols, and anything exotic + return `[${typeof value}]`; +} + +/** + * Redact a cookie array, keeping names visible and masking values. + * `fingerprint: true` leaves a correlatable hint per cookie, which is what you want + * when diagnosing "which session is stale" without being able to replay it. + */ +function redactCookies(cookies, { fingerprint: fp = true } = {}) { + if (!Array.isArray(cookies)) return []; + return cookies.map((c) => { + if (!c || typeof c !== 'object') return redact(c); + const name = c.name ?? ''; + const sensitive = SENSITIVE_COOKIE_NAMES.test(String(name)); + return { + name: String(name), + domain: c.domain, + path: c.path, + ...(sensitive + ? { value: fp ? fingerprint(c.value) : REDACTED } + : { value: c.value }), + ...(c.expires !== undefined ? { expires: c.expires } : {}), + }; + }); +} + +/** + * Does this string still look like it contains a credential? + * Used by the logger's own assertions and by tests, so a redaction regression is + * detectable rather than merely improbable. + */ +function containsSecret(text) { + if (text === null || text === undefined) return false; + const s = String(text); + for (const rule of PATTERNS) { + const re = new RegExp(rule.re.source, rule.re.flags); + let m; + while ((m = re.exec(s)) !== null) { + if (m[0].includes(REDACTED)) continue; + // For name-filtered rules the predicate applies to the captured NAME. + if (rule.nameFilter) { + const name = m[1]; + if (typeof name === 'string' && rule.nameFilter.test(name)) return true; + } else { + return true; + } + if (m.index === re.lastIndex) re.lastIndex += 1; + } + } + return OPAQUE_SECRET.test(s.replace(new RegExp(REDACTED, 'g'), '').trim()); +} + +module.exports = { + REDACTED, + DEFAULT_MAX, + redact, + redactString, + redactCookies, + truncate, + fingerprint, + containsSecret, + PATTERNS, + SENSITIVE_COOKIE_NAMES, +}; diff --git a/gateway/test/engines.test.js b/gateway/test/engines.test.js new file mode 100644 index 0000000..de690e7 --- /dev/null +++ b/gateway/test/engines.test.js @@ -0,0 +1,252 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('node:vm'); + +const { PROVIDERS, PROVIDER_NAMES } = require('../src/providers'); +const { EngineLoader } = require('../src/engine-loader'); +const { render } = require('../scripts/make-engine-scaffold.cjs'); + +/** + * Engine scaffold tests. + * + * Two properties matter, and they pull in opposite directions: + * + * 1. Each scaffold satisfies the engine contract, so the gateway loads and probes it + * without special-casing. + * 2. Each scaffold REFUSES to issue a request while unverified, so nobody mistakes a + * blank endpoint for a working one. + * + * The second is the more important. A scaffold that quietly returned an empty answer + * would look functional while doing nothing. + */ + +const ENGINE_DIR = path.join(__dirname, '..', 'engines'); + +/** Evaluate an engine in a fake page context, exactly as the browser would. */ +function evaluateInPage(source, { cookies = 'a=1' } = {}) { + const fetchCalls = []; + const sandbox = { + window: {}, + document: { cookie: cookies }, + console: { log() {}, warn() {}, error() {} }, + fetch: async (url, init) => { + fetchCalls.push({ url, init }); + return { + ok: true, + status: 200, + json: async () => ({ text: 'server answer', conversationId: 'c1' }), + }; + }, + }; + sandbox.globalThis = sandbox; + vm.createContext(sandbox); + vm.runInContext(source, sandbox, { timeout: 2000 }); + return { api: sandbox.window, fetchCalls, sandbox }; +} + +test('every provider has an engine file on disk', () => { + for (const name of PROVIDER_NAMES) { + const file = path.join(ENGINE_DIR, `${name}-engine.js`); + assert.ok(fs.existsSync(file), `${name}: missing ${file}`); + } +}); + +test('every engine defines its documented global', () => { + for (const [name, spec] of Object.entries(PROVIDERS)) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + assert.ok( + src.includes(spec.engineGlobal), + `${name}: must assign window.${spec.engineGlobal}` + ); + } +}); + +test('every engine implements the required contract methods', () => { + const required = ['sendMessage', 'newConversation', 'getResponse', 'getTypingStatus', 'isReady']; + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + for (const m of required) { + assert.match(src, new RegExp(`${m}\\s*:`), `${name}: missing ${m}`); + } + } +}); + +test('every engine loads and probes without special-casing', () => { + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + const { api } = evaluateInPage(src); + const global = PROVIDERS[name].engineGlobal; + assert.ok(api[global], `${name}: global not installed`); + for (const m of ['sendMessage', 'newConversation', 'getResponse']) { + assert.equal(typeof api[global][m], 'function', `${name}.${m} must be callable`); + } + } +}); + +test('every unverified engine refuses to issue a request', async () => { + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + const { api, fetchCalls } = evaluateInPage(src); + const global = PROVIDERS[name].engineGlobal; + + let threw = null; + try { + await api[global].sendMessage({ message: 'hello' }); + } catch (e) { + threw = e; + } + assert.ok(threw, `${name}: an unverified engine must not silently succeed`); + assert.match( + threw.message, + new RegExp('scaffold', 'i'), + `${name}: the refusal must say it is a scaffold, so the cause is obvious` + ); + assert.equal(fetchCalls.length, 0, `${name}: no network call may be attempted`); + } +}); + +test('a refusal is non-retryable, so it cannot spin', async () => { + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + const { api } = evaluateInPage(src); + const global = PROVIDERS[name].engineGlobal; + let threw = null; + try { + await api[global].sendMessage({ message: 'x' }); + } catch (e) { + threw = e; + } + assert.equal(threw.retryable, false, `${name}: a config error must not be retried`); + } +}); + +test('an engine reports itself not-ready while unverified', () => { + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + const { api } = evaluateInPage(src); + assert.equal( + api[PROVIDERS[name].engineGlobal].isReady(), + false, + `${name}: isReady must be false until endpoints are filled in` + ); + } +}); + +test('getResponse is empty before a turn, never a fabricated answer', () => { + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + const { api } = evaluateInPage(src); + assert.equal( + api[PROVIDERS[name].engineGlobal].getResponse(), + '', + `${name}: an empty response lets the gateway retry; a guess would be a lie` + ); + } +}); + +test('newConversation clears state and is safe to call repeatedly', () => { + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + const { api } = evaluateInPage(src); + const engine = api[PROVIDERS[name].engineGlobal]; + const first = engine.newConversation(); + const second = engine.newConversation(); + assert.deepEqual(first, { cleared: true }); + assert.deepEqual(second, { cleared: true }, `${name}: must be idempotent`); + } +}); + +test('sendMessage rejects an empty message before touching the network', async () => { + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + const { api, fetchCalls } = evaluateInPage(src); + const engine = api[PROVIDERS[name].engineGlobal]; + await assert.rejects(() => engine.sendMessage({ message: ' ' }), /non-empty/); + assert.equal(fetchCalls.length, 0); + } +}); + +test('no engine logs from inside the page, where redaction is unavailable', () => { + for (const name of PROVIDER_NAMES) { + const src = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + assert.ok( + !/console\.(log|warn|error|debug)\s*\(/.test(src), + `${name}: an engine runs in the page with no redactor, so it must not log` + ); + } +}); + +test('the generator is idempotent and will not overwrite real work', () => { + const dir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'proxima-scaffold-')); + for (const name of ['chatgpt', 'claude']) { + fs.writeFileSync(path.join(dir, `${name}-engine.js`), 'HAND WRITTEN', 'utf8'); + } + // render() is pure, so re-rendering must be byte-identical. + assert.equal(render('chatgpt', { global: 'g', origin: 'o', extra: [] }), render('chatgpt', { global: 'g', origin: 'o', extra: [] })); + // And the on-disk originals must be untouched by a second generator run. + const before = fs.readFileSync(path.join(ENGINE_DIR, 'chatgpt-engine.js'), 'utf8'); + require('node:child_process').execFileSync( + process.execPath, + [path.join(__dirname, '..', 'scripts', 'make-engine-scaffold.cjs')], + { encoding: 'utf8' } + ); + assert.equal(fs.readFileSync(path.join(ENGINE_DIR, 'chatgpt-engine.js'), 'utf8'), before); +}); + +test('a verified engine can complete a turn', async () => { + // Flip the flags in a copy to prove the happy path works once endpoints exist. + for (const name of PROVIDER_NAMES) { + const raw = fs.readFileSync(path.join(ENGINE_DIR, `${name}-engine.js`), 'utf8'); + const verified = raw + .replace('var verified = false;', 'var verified = true;') + .replace(/send:\s*null,/, "send: '/api/send',"); + const { api, fetchCalls } = evaluateInPage(verified); + const engine = api[PROVIDERS[name].engineGlobal]; + + assert.equal(engine.isReady(), true, `${name}: must be ready once verified`); + const result = await engine.sendMessage({ message: 'hello' }); + assert.equal(fetchCalls.length, 1, `${name}: exactly one request`); + assert.equal(fetchCalls[0].init.credentials, 'include', 'auth rides on the session'); + assert.equal(result.conversationId, 'c1', `${name}: conversation id must be captured`); + assert.equal(engine.getResponse(), 'server answer', `${name}: answer must be stored`); + } +}); + +test('a verified engine classifies a failure without echoing the body', async () => { + const raw = fs.readFileSync(path.join(ENGINE_DIR, 'claude-engine.js'), 'utf8'); + const verified = raw + .replace('var verified = false;', 'var verified = true;') + .replace(/send:\s*null,/, "send: '/api/send',"); + + const sandbox = { + window: {}, + document: { cookie: 'sessionKey=abc123' }, + console: { log() {}, warn() {}, error() {} }, + fetch: async () => ({ + ok: false, + status: 403, + json: async () => ({}), + // A provider CAN echo the credential back. The engine must not forward it. + text: async () => JSON.stringify({ echoed: 'sessionKey=abc123def456' }), + }), + }; + sandbox.globalThis = sandbox; + vm.createContext(sandbox); + vm.runInContext(verified, sandbox, { timeout: 2000 }); + + const engine = sandbox.window.__proximaClaude; + let threw = null; + try { + await engine.sendMessage({ message: 'x' }); + } catch (e) { + threw = e; + } + assert.ok(threw, 'a 403 must raise'); + assert.equal(threw.kind, 'waf', '403 is a WAF challenge, not a plain auth failure'); + assert.equal(threw.retryable, true); + assert.ok(!threw.message.includes('abc123def456'), 'the echoed credential must not appear'); +}); diff --git a/gateway/test/leak.test.js b/gateway/test/leak.test.js new file mode 100644 index 0000000..8365092 --- /dev/null +++ b/gateway/test/leak.test.js @@ -0,0 +1,297 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +const { StateStore } = require('../src/state-store'); +const { CookieStore } = require('../src/cookie-store'); +const { EngineLoader } = require('../src/engine-loader'); +const { SessionManager } = require('../src/session-manager'); +const { IpcServer } = require('../src/ipc-server'); +const { createHandler } = require('../src/actions'); +const { toSafeError, log } = require('../src/logger'); +const { containsSecret, redactString } = require('../src/redact'); + +/** + * Boundary redaction tests. + * + * The unit tests prove the redaction functions work. These prove they are actually + * WIRED IN - a secret is pushed through each real boundary and asserted absent from + * what comes out. A redaction module nobody calls is worth nothing. + */ + +// A realistic composite of everything a provider or driver might echo back. +const LEAK = [ + 'sessionKey=abc123def456ghi789jkl012mno345', + 'Authorization: Bearer sk-proj-abc123DEF456ghi789JKL', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r', + 'SID=1a2b3c4d5e6f7g8h9i0j', + 'https://chatgpt.test/backend-api?token=9f8e7d6c5b4a3210zz', +].join(' | '); + +const LEAK_FRAGMENTS = [ + 'abc123def456ghi789jkl012mno345', + 'sk-proj-abc123DEF456ghi789', + 'eyJ0eXAiOiJKV1Qi', + '1a2b3c4d5e6f7g8h9i0j', + '9f8e7d6c5b4a3210zz', +]; + +function assertNoLeak(text, label) { + for (const frag of LEAK_FRAGMENTS) { + assert.ok(!String(text).includes(frag), `${label}: leaked ${frag}`); + } + assert.equal(containsSecret(text), false, `${label}: containsSecret still reports a leak`); +} + +function makeGateway() { + const stateStore = new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-leak-'))); + const cookieStore = new CookieStore(stateStore); + const engineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-leakeng-')); + fs.writeFileSync( + path.join(engineDir, 'perplexity-engine.js'), + 'window.__proximaPerplexity={sendMessage:function(){return{ok:true};},newConversation:function(){return{};},getResponse:function(){return "";},isReady:function(){return true;}};', + 'utf8' + ); + const engineLoader = new EngineLoader({ engineDir, stateStore }); + const sessions = new SessionManager({ + stateStore, + cookieStore, + engineLoader, + settings: { headless: true }, + browserFactory: async () => ({ newContext: async () => ({}), close: async () => {} }), + }); + const handler = createHandler({ sessions, stateStore, startedAt: 'x' }); + return { stateStore, sessions, handler }; +} + +function clientFor(port) { + const c = { buffer: '', pending: new Map(), nextId: 1, socket: null }; + c.connect = () => + new Promise((resolve, reject) => { + c.socket = net.createConnection({ port, host: '127.0.0.1' }, resolve); + c.socket.once('error', reject); + c.socket.on('data', (chunk) => { + c.buffer += chunk.toString('utf8'); + const lines = c.buffer.split('\n'); + c.buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + const fn = c.pending.get(msg.requestId); + if (fn) { + c.pending.delete(msg.requestId); + fn(msg); + } + } + }); + }); + c.send = (action, provider, data = {}) => { + const requestId = c.nextId++; + return new Promise((resolve) => { + c.pending.set(requestId, resolve); + c.socket.write(JSON.stringify({ requestId, action, provider, data }) + '\n'); + }); + }; + return c; +} + +// ---- boundary: action dispatch ------------------------------------------------ + +test('a secret thrown inside an action never reaches the wire', async () => { + const g = makeGateway(); + // Force a real throw from deep in the session layer. + g.sessions.sendMessage = async () => { + const e = new Error(`upstream said: ${LEAK}`); + e.kind = 'transport'; + e.provider = 'perplexity'; + throw e; + }; + const res = await g.handler({ action: 'sendMessage', provider: 'perplexity', data: { message: 'x' } }); + assert.equal(res.success, false); + assertNoLeak(res.error, 'action dispatch'); + assert.equal(res.errorKind, 'transport', 'the classification must survive redaction'); + assert.equal(res.provider, 'perplexity'); + await g.sessions.close(); +}); + +test('a secret in an error stack never reaches the wire', async () => { + const g = makeGateway(); + g.sessions.getResponse = async () => { + const e = new Error(`capture failed: ${LEAK}`); + e.stack = `Error: capture failed: ${LEAK}\n at fetch (${LEAK})`; + throw e; + }; + const res = await g.handler({ action: 'getResponse', provider: 'perplexity' }); + assertNoLeak(JSON.stringify(res), 'error stack'); + await g.sessions.close(); +}); + +// ---- boundary: IPC framing ---------------------------------------------------- + +test('a secret thrown while framing a response never reaches the socket', async () => { + const stateStore = new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-fr-'))); + const ipc = new IpcServer({ + // Throw from the handler itself, past the action layer, into the framing layer. + handler: async () => { + throw new Error(`framing blew up: ${LEAK}`); + }, + stateStore, + port: 0, + }); + const port = await ipc.listen(); + const client = clientFor(port); + await client.connect(); + try { + const res = await client.send('ping'); + assert.equal(res.success, false); + assertNoLeak(JSON.stringify(res), 'ipc framing'); + } finally { + client.socket.destroy(); + await ipc.close(); + } +}); + +test('a secret inside a malformed frame is not echoed back', async () => { + const stateStore = new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-gw-mal-'))); + const ipc = new IpcServer({ handler: async () => ({ success: true }), stateStore, port: 0 }); + const port = await ipc.listen(); + const client = clientFor(port); + await client.connect(); + + const response = await new Promise((resolve) => { + client.pending.set(7, resolve); + client.socket.write('{"requestId":7, "cookie": "' + LEAK + '"\n'); + }); + try { + assert.equal(response.success, false); + assertNoLeak(JSON.stringify(response), 'malformed frame'); + } finally { + client.socket.destroy(); + await ipc.close(); + } +}); + +// ---- boundary: toSafeError ---------------------------------------------------- + +test('toSafeError normalises both strings and Errors without leaking', () => { + const fromString = toSafeError(LEAK); + assert.equal(fromString.success, false); + assertNoLeak(fromString.error, 'toSafeError string'); + + const fromError = toSafeError(new Error(LEAK), { provider: 'qwen' }); + assertNoLeak(fromError.error, 'toSafeError Error'); + assert.equal(fromError.provider, 'qwen', 'extra fields must survive'); + assert.equal(fromError.success, false, 'success:false must always be present'); +}); + +test('toSafeError preserves the fields an operator needs', () => { + const e = new Error('Request timed out after 900000ms for /api/chat'); + e.code = 'ETIMEDOUT'; + e.kind = 'transport'; + const out = toSafeError(e); + assert.match(out.error, /timed out/, 'the reason must survive'); + assert.ok(out.error.includes('/api/chat'), 'the path is diagnostic and must survive'); + assert.equal(out.code, 'ETIMEDOUT'); + assert.equal(out.errorKind, 'transport'); +}); + +// ---- boundary: logger --------------------------------------------------------- + +test('the logger redacts strings and objects it emits', () => { + const chunks = []; + const origWrite = process.stdout.write; + process.stdout.write = (s) => { + chunks.push(String(s)); + return true; + }; + try { + log.info(`starting with ${LEAK}`); + log.info('options', { auth: `Bearer ${LEAK}`, provider: 'claude', requestId: 7 }); + } finally { + process.stdout.write = origWrite; + } + const all = chunks.join(''); + assertNoLeak(all, 'logger'); + assert.ok(all.includes('claude'), 'provider name must survive'); + assert.ok(all.includes('7'), 'requestId must survive'); + assert.ok(all.includes('gateway:info'), 'the level must be visible'); +}); + +test('log.exception redacts a stack but keeps the diagnosis', () => { + const chunks = []; + const origWrite = process.stderr.write; + process.stderr.write = (s) => { + chunks.push(String(s)); + return true; + }; + try { + const e = new Error(`boom: ${LEAK}`); + e.stack = `Error: boom: ${LEAK}\n at Object. (/srv/src/gateway/src/index.js:71:9)`; + e.code = 'ECONNRESET'; + log.exception(e, { phase: 'uncaughtException' }); + } finally { + process.stderr.write = origWrite; + } + const all = chunks.join(''); + assertNoLeak(all, 'log.exception'); + assert.ok(all.includes('uncaughtException'), 'the phase must survive'); + assert.ok(all.includes('ECONNRESET'), 'the code must survive'); + assert.ok(all.includes('index.js'), 'the stack location must survive'); +}); + +test('an unserialisable value does not crash the logger', () => { + const chunks = []; + const origWrite = process.stderr.write; + process.stderr.write = (s) => { + chunks.push(String(s)); + return true; + }; + // A cycle would make a naive JSON.stringify throw. redact's depth cap terminates + // it, so the logger must emit a record rather than blowing up. + const cyclic = { provider: 'qwen' }; + cyclic.self = cyclic; + let threw = false; + try { + log.error(cyclic); + } catch { + threw = true; + } finally { + process.stderr.write = origWrite; + } + assert.equal(threw, false, 'a cyclic value must not crash the logger'); + const all = chunks.join(''); + assert.ok(all.length > 0, 'a record must still be emitted'); + assert.ok(all.includes('qwen'), 'the readable fields must survive'); +}); + +// ---- boundary: engines -------------------------------------------------------- + +test('the shipped reference engine does not echo a failure body', () => { + const src = fs.readFileSync( + path.join(__dirname, '..', 'engines', 'perplexity-engine.js'), + 'utf8' + ); + // A provider can echo request headers in an error payload; the body must never be + // placed into an error object. + assert.ok(!/\.text\(\)/.test(src), 'the engine must not read the failure body into an error'); + assert.ok(!/body:\s*String\(/.test(src), 'no raw body may be attached to a classified failure'); + assertNoLeak(redactString('classifyFailure ok'), 'engine source scan'); +}); + +test('every engine file avoids obvious credential logging', () => { + const dir = path.join(__dirname, '..', 'engines'); + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith('.js')) continue; + const src = fs.readFileSync(path.join(dir, file), 'utf8'); + // An engine runs in the page, where there is no redactor, so it must not log at all. + assert.ok( + !/console\.(log|warn|error|debug)\s*\(/.test(src), + `${file} logs from inside the page, where redaction is unavailable` + ); + } +}); diff --git a/gateway/test/redact.test.js b/gateway/test/redact.test.js new file mode 100644 index 0000000..63243d6 --- /dev/null +++ b/gateway/test/redact.test.js @@ -0,0 +1,187 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); + +const { redact, redactString, redactCookies, truncate, fingerprint, containsSecret } = require('../src/redact'); + +/** + * Redaction tests. + * + * These assert two things at once: that a known secret does not survive, and that the + * diagnostic value around it does. A redaction pass that also eats requestIds and + * provider names is not a fix, it is a different bug. + */ + +const SECRETS = { + openaiSession: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk', + bearer: 'sk-proj-abc123DEF456ghi789JKL012mno345', + claudeSession: 'sessionKey=abc123def456ghi789jkl012mno345', + qwenToken: 'token=ssxmod_itna_9f8e7d6c5b4a3210zzzz', + googleSid: 'SID=1a2b3c4d5e6f7g8h9i0j; HSID=9i8h7g6f5e4d3c2b1a0;', +}; + +test('a JWT never survives redaction', () => { + const out = redactString(`failed with token ${SECRETS.openaiSession} attached`); + assert.ok(!out.includes(SECRETS.openaiSession), 'the JWT must be gone'); + assert.ok(!out.includes('eyJ0eXAiOiJKV1Qi'), 'no fragment of the JWT may remain'); + assert.ok(out.includes('failed with token'), 'the surrounding sentence must survive'); +}); + +test('a bearer token never survives redaction', () => { + const out = redactString(`Authorization: Bearer ${SECRETS.bearer}`); + assert.ok(!out.includes('abc123DEF456')); + assert.match(out, /Bearer/, 'the scheme is diagnostic and should stay'); +}); + +test('provider cookies never survive redaction', () => { + const cases = [ + ['claude', `cookie jar: ${SECRETS.claudeSession}`], + ['qwen', `${SECRETS.qwenToken}`], + ['google', SECRETS.googleSid], + ]; + for (const [label, input] of cases) { + const out = redactString(input); + assert.ok(!out.includes('abc123def456'), `${label}: claude cookie value leaked`); + assert.ok(!out.includes('9f8e7d6c5b4a'), `${label}: qwen token leaked`); + assert.ok(!out.includes('1a2b3c4d5e6f'), `${label}: google SID leaked`); + } +}); + +test('cookie names survive so the log is still useful', () => { + const out = redactString(SECRETS.claudeSession); + assert.ok(out.includes('sessionKey='), 'the name is diagnostic and must remain'); +}); + +test('a sensitive query parameter is masked, an ordinary one is not', () => { + const out = redactString('GET https://x.test/cb?session=abcdef123456&page=2&sort=desc'); + assert.ok(!out.includes('abcdef123456'), 'session value must be masked'); + assert.ok(out.includes('page=2'), 'an ordinary parameter must stay readable'); + assert.ok(out.includes('sort=desc')); +}); + +test('a non-sensitive cookie is left alone', () => { + // Over-redaction would hide this and cost real diagnostic value. + const out = redactString('theme=dark; locale=en-GB'); + assert.ok(out.includes('theme=dark'), 'a preference cookie is not a credential'); + assert.ok(out.includes('locale=en-GB')); +}); + +test('long opaque values are truncated even when unnamed', () => { + const opaque = 'Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MGFiY2RlZg'; + const out = redactString(`blob=${opaque}`, { max: 20 }); + assert.ok(out.includes('truncated'), 'a cut must be visible, never silent'); + assert.ok(out.length < opaque.length + 40); +}); + +test('truncate never silently shortens', () => { + const s = 'x'.repeat(1000); + const out = truncate(s, 50); + assert.ok(out.startsWith('x'.repeat(50))); + assert.match(out, /\+\d+ chars truncated/); + assert.equal(truncate('short', 50), 'short', 'a value under the cap is untouched'); +}); + +test('redact walks nested objects and arrays', () => { + const payload = { + action: 'sendMessage', + requestId: 42, + provider: 'claude', + nested: { + headers: { Authorization: `Bearer ${SECRETS.bearer}` }, + jar: [`sessionKey=${SECRETS.claudeSession.split('=')[1]}`], + }, + }; + const out = redact(payload, { max: 4096 }); + const flat = JSON.stringify(out); + assert.ok(!flat.includes('abc123def456'), 'nested cookie leaked'); + assert.ok(!flat.includes('DEF456'), 'nested bearer leaked'); + // Diagnostics preserved: + assert.equal(out.action, 'sendMessage'); + assert.equal(out.requestId, 42); + assert.equal(out.provider, 'claude'); +}); + +test('redact is cycle-safe via a depth cap', () => { + const deep = { a: { b: { c: { d: { e: { f: { g: { h: 'leaf' } } } } } } } }; + const out = JSON.stringify(redact(deep, { max: 100 })); + assert.ok(out.includes('depth limit'), 'must stop rather than recurse forever'); +}); + +test('redact handles errors without losing the diagnosis', () => { + const err = new Error(`request to /cb?token=${SECRETS.qwenToken.split('=')[1]} failed`); + err.code = 'ECONNREFUSED'; + const out = redact(err); + assert.equal(out.name, 'Error'); + assert.equal(out.code, 'ECONNREFUSED', 'the diagnostic code must survive'); + assert.ok(!out.message.includes('ssxmod_itna'), 'token leaked through the error message'); + assert.ok(out.message.includes('failed'), 'the reason must survive'); +}); + +test('redact passes through scalars unchanged', () => { + assert.equal(redact(42), 42); + assert.equal(redact(true), true); + assert.equal(redact(null), null); + assert.equal(redact(undefined), undefined); +}); + +test('redact does not walk into a buffer', () => { + const out = redact(Buffer.from('super secret bytes')); + assert.match(out, /^\[buffer \d+ bytes\]$/); + assert.ok(!out.includes('super secret')); +}); + +test('redactCookies masks values but keeps names and domains', () => { + const cookies = [ + { name: 'sessionKey', value: 'abc123def456', domain: '.claude.ai' }, + { name: 'theme', value: 'dark', domain: '.claude.ai' }, + ]; + const out = redactCookies(cookies); + assert.equal(out[0].name, 'sessionKey'); + assert.equal(out[0].domain, '.claude.ai'); + assert.ok(!out[0].value.includes('abc123def456'), 'auth cookie value must not appear'); + assert.ok(out[0].value.includes('len 12'), 'a correlatable length hint is kept'); + assert.equal(out[1].value, 'dark', 'a non-auth cookie is not masked'); +}); + +test('fingerprint is correlatable but not replayable', () => { + const a = fingerprint('abc123def456ghi'); + const b = fingerprint('abc123def456XYZ'); + assert.notEqual(a, b, 'different secrets should not collide into the same value'); + assert.ok(a.includes('len 15')); + assert.ok(!a.includes('def456'), 'the middle must not survive'); +}); + +test('containsSecret can detect a leak, so a regression is testable', () => { + assert.equal(containsSecret(`Bearer ${SECRETS.bearer}`), true); + assert.equal(containsSecret(`sessionKey=${SECRETS.claudeSession.split('=')[1]}`), true); + assert.equal(containsSecret('sendMessage failed for provider perplexity'), false); + assert.equal(containsSecret('requestId 42 action ping'), false); + // Already redacted output must not read as a fresh leak. + assert.equal(containsSecret(redactString(`Bearer ${SECRETS.bearer}`)), false); +}); + +test('an operator can still see what failed', () => { + const message = 'Response capture failed after 120 attempts for provider qwen on /api/chat (status 429)'; + assert.equal(redactString(message, { max: 4096 }), message, 'no secret means no change'); +}); + +test('every redaction path is leak-free for a realistic error', () => { + // One composite payload crossing several secret shapes at once. + const composite = { + url: `https://chatgpt.com/backend-api/conversation?session=${SECRETS.openaiSession.slice(-20)}`, + auth: `Bearer ${SECRETS.bearer}`, + cookie: SECRETS.googleSid, + note: 'claude sessionKey=abc123def456ghi789', + }; + const out = JSON.stringify(redact(composite, { max: 8192 })); + for (const needle of [ + SECRETS.openaiSession.slice(-20), + 'DEF456', + '1a2b3c4d5e6f', + 'abc123def456ghi789', + ]) { + assert.ok(!out.includes(needle), `leaked: ${needle}`); + } + assert.ok(out.includes('backend-api'), 'the path is diagnostic and should survive'); +}); From 15a948a0d290ac4b4c1dcd45afc619632dabe7e3 Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 14:17:11 +0500 Subject: [PATCH 05/12] fix(gateway): address code review - two invariants were not upheld An independent review found real defects, all confirmed by reproducing them before fixing. Two were named invariants that the code did not actually hold while the tests and the API claimed it did. CRITICAL - A6 broken on the primary login path. unloadProvider reported authPreserved:true while logging the operator out: the Playwright context is non-persistent, and the cookie jar was flushed only by setCookies and flushAll, never before a context was closed. An interactive login existed only in the context, so the first unload discarded it. The existing test passed because it set up through setCookies, the one path that already flushed - the test verified the path that was not broken. Now flushes before close and derives authPreserved from disk instead of hardcoding true, with a warning when preservation cannot be confirmed. CRITICAL - session cookies in plaintext on the wire. Only the failure branch of the IPC response was redacted, so getCookies, debugDOM and executeScript returned secrets verbatim. redactCookies was written for exactly this and called from nowhere. Now applied at the IPC boundary and by getCookies directly - the generic redactor cannot see a bare cookie value, which has no name=value shape. CRITICAL - the Authorization rule leaked. It required a value that could not contain a space and excluded 'basic', so 'Authorization: Basic ' passed through untouched, while 'ApiKey ' had its scheme destroyed and kept its credential - looking redacted while leaking. A scheme allowlist cannot be correct for an unknown scheme space. Now generic, value always masked, scheme preserved, and genuinely idempotent (matching the marker as an alternative rather than excluding it with a lookahead, which does not survive backtracking). Also fixed: sensitive-cookie names derived from the provider registry so __cf_bm can no longer drift out of the redaction list; 'no response' and nullish literals recognised as placeholders; getResponse no longer returns a placeholder as success; purgeProvider no longer reports a no-op as success on the one destructive action; capture retry clamped in both directions so a caller cannot hold a connection's queue for hours; navigate surfaces engine/transport failure instead of reporting success and failing confusingly later; three context-leak paths closed (unclosed context on failed init, concurrent first-touch, double browser launch); credential files written with the mode on the write via temp+rename, and the primary auth store protected at all - with a warning on win32 where POSIX 0600 is not enforceable rather than silently claiming it is. Tests: 155, all passing. Coverage 96.21 line / 83.27 branch / 91.57 funcs. regression.test.js pins each finding by the invariant it protects. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/package.json | 4 +- gateway/src/actions.js | 57 +++-- gateway/src/cookie-store.js | 85 +++++-- gateway/src/ipc-server.js | 17 +- gateway/src/redact.js | 82 +++++-- gateway/src/session-manager.js | 209 +++++++++++------ gateway/test/contract.test.js | 49 +++- gateway/test/regression.test.js | 384 ++++++++++++++++++++++++++++++++ 8 files changed, 771 insertions(+), 116 deletions(-) create mode 100644 gateway/test/regression.test.js diff --git a/gateway/package.json b/gateway/package.json index f7f5686..cb723ce 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -7,8 +7,8 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js", - "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js", + "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js", + "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js", "test:browser": "node --test --test-reporter=tap --test-timeout=60000 test/browser.test.js", "test:all": "npm run test:coverage && npm run test:browser", "coverage:check": "node scripts/check-coverage.cjs", diff --git a/gateway/src/actions.js b/gateway/src/actions.js index 169aa8a..2462306 100644 --- a/gateway/src/actions.js +++ b/gateway/src/actions.js @@ -4,6 +4,7 @@ const { getProvider, isKnownProvider, PROVIDER_NAMES } = require('./providers'); const { isPlaceholderResponse } = require('./cookie-store'); const { stampTimestamp, allowlistOptions } = require('./session-manager'); const { toSafeError } = require('./logger'); +const { redactCookies } = require('./redact'); /** * The 28-action contract. @@ -47,7 +48,7 @@ function createHandler({ sessions, stateStore, startedAt, getFileReferenceEnable fileReferenceEnabled: fileReferenceEnabled(), }), - memory: async () => ({ success: true, memory: processMemoryReport() }), + memory: async () => ({ success: true, memory: processMemoryReport(sessions.initializedProviders()) }), initProvider: async (req) => { await sessions.initProvider(req.provider); @@ -70,25 +71,31 @@ function createHandler({ sessions, stateStore, startedAt, getFileReferenceEnable for (const name of targets) { reports.push(await sessions.unloadProvider(name)); } - // Chromium reaps the renderer asynchronously; reading immediately understates - // the saving and reads as a failure. - await sleep(MEMORY_SETTLE_MS); + const unloaded = reports.filter((r) => r.unloaded); + // Only settle when something actually unloaded. Chromium reaps the renderer + // asynchronously, so a reading taken immediately understates the saving - + // but paying 1.5s for a no-op purge of five providers is a pointless 7.5s. + if (unloaded.length > 0) await sleep(MEMORY_SETTLE_MS); return { success: true, - unloaded: reports.filter((r) => r.unloaded).map((r) => r.provider), + unloaded: unloaded.map((r) => r.provider), reports, stillLoaded: sessions.initializedProviders(), - memory: processMemoryReport(), + memory: processMemoryReport(sessions.initializedProviders()), }; }, purgeProvider: async (req) => { const targets = resolveTargets(req, sessions.initializedProviders()); + // This is the ONE destructive operation, so a no-op must not report success. + if (targets.length === 0) { + return { success: false, error: 'name a provider, or pass data.providers' }; + } const results = []; for (const name of targets) { results.push(await sessions.purgeProvider(name)); } - return { success: true, purged: results, memory: processMemoryReport() }; + return { success: true, purged: results, memory: processMemoryReport(sessions.initializedProviders()) }; }, sendMessage: async (req) => { @@ -130,6 +137,18 @@ function createHandler({ sessions, stateStore, startedAt, getFileReferenceEnable getResponse: async (req) => { const result = await sessions.getResponse(req.provider); + // A8: this is a single-shot probe, so a placeholder here must be reported as + // a failure rather than handed back as a successful empty answer. The + // retrying variant is getResponseWithTyping. + if (isPlaceholderResponse(result.response)) { + return { + success: false, + provider: req.provider, + error: 'No response captured yet. Use getResponseWithTyping to retry.', + errorKind: 'capture', + response: result.response, + }; + } return { success: true, ...result }; }, @@ -145,11 +164,17 @@ function createHandler({ sessions, stateStore, startedAt, getFileReferenceEnable * bounded so a rate-limited provider cannot be hammered indefinitely. */ getResponseWithTyping: async (req) => { - const maxAttempts = Number(req.data?.maxAttempts) > 0 - ? Number(req.data.maxAttempts) + // I7: clamp rather than trust the caller. An unbounded attempt count is a + // denial of service on this connection's request queue, because handlers are + // serialised per socket. The delay needs a ceiling for the same reason and a + // floor so a caller cannot spin the loop. + const requestedAttempts = Number.parseInt(req.data?.maxAttempts, 10); + const requestedDelay = Number.parseInt(req.data?.retryDelayMs, 10); + const maxAttempts = Number.isFinite(requestedAttempts) + ? Math.min(Math.max(requestedAttempts, 1), CAPTURE_MAX_ATTEMPTS) : CAPTURE_MAX_ATTEMPTS; - const delay = Number(req.data?.retryDelayMs) > 0 - ? Number(req.data.retryDelayMs) + const delay = Number.isFinite(requestedDelay) + ? Math.min(Math.max(requestedDelay, 50), 5000) : CAPTURE_RETRY_DELAY_MS; let last = ''; @@ -225,7 +250,11 @@ function createHandler({ sessions, stateStore, startedAt, getFileReferenceEnable getCookies: async (req) => { const result = await sessions.getCookies(req.provider); - return { success: true, ...result }; + // redactCookies, not the generic redactor: a cookie value is a bare string + // with no "name=value" shape, so a regex pass cannot see it as a secret. + // Without this, every session cookie goes out in plaintext. Names, domains + // and expiries are preserved so the call stays useful for diagnosis. + return { success: true, ...result, cookies: redactCookies(result.cookies) }; }, getSettings: async () => ({ success: true, settings: stateStore.loadSettings() }), @@ -335,14 +364,14 @@ function hasAttachments(data) { return true; } -function processMemoryReport() { +function processMemoryReport(loadedProviders = null) { const m = process.memoryUsage(); return { rssMB: +(m.rss / 1048576).toFixed(1), heapUsedMB: +(m.heapUsed / 1048576).toFixed(1), heapTotalMB: +(m.heapTotal / 1048576).toFixed(1), externalMB: +(m.external / 1048576).toFixed(1), - loadedProviders: null, + loadedProviders, }; } diff --git a/gateway/src/cookie-store.js b/gateway/src/cookie-store.js index 945c1ea..620a6f0 100644 --- a/gateway/src/cookie-store.js +++ b/gateway/src/cookie-store.js @@ -120,16 +120,60 @@ class CookieStore { return path.join(this.state.cookieBackupDir, `${provider}.json`); } + /** + * Write a file that holds credentials. + * + * The mode must be set ON the write, not after it. Calling writeFileSync then + * chmodSync leaves a window where the file exists at the umask default, and + * `mode` on a write is ignored when the file already exists - which is the common + * case for a profile. So: create a temp file with the restrictive mode, then + * rename over the target, which is atomic and carries the mode with it. + * + * On win32 Node maps chmod to a read-only attribute and a POSIX 0600 is not + * achievable. That is reported rather than swallowed, because the alternative is + * code that appears to protect a credential and does not. + */ + writeSecure(file, contents) { + // The parent must exist. A per-provider profile directory is created lazily, so + // a missing mkdir here makes every flush throw into a silent catch and the + // auth store is never written at all. + fs.mkdirSync(path.dirname(file), { recursive: true }); + + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + try { + fs.renameSync(tmp, file); + } catch (e) { + try { + fs.unlinkSync(tmp); + } catch { + /* leave no temp behind */ + } + throw e; + } + this._warnIfModeUnsupported(file); + return file; + } + + _warnIfModeUnsupported(file) { + if (process.platform !== 'win32') return; + if (CookieStore._winModeWarned) return; + CookieStore._winModeWarned = true; + const dir = path.dirname(file); + // Reported once, not thrown: the state dir is inside the user profile, so the + // practical exposure is limited, but the limitation should not be invisible. + process.emitWarning( + `POSIX 0600 is not enforceable on win32; credential file modes in ${dir} ` + + `rely on inherited directory ACLs. Move PROXIMA_GATEWAY_STATE_DIR off a ` + + `shared path if that is a concern.`, + 'SecurityWarning' + ); + } + saveBackup(provider, cookies) { if (!Array.isArray(cookies) || cookies.length === 0) return null; const record = toBackupRecord(provider, cookies); - const file = this._backupPath(provider); - fs.writeFileSync(file, JSON.stringify(record, null, 2), 'utf8'); - try { - fs.chmodSync(file, 0o600); // cookies are bearer credentials - } catch { - /* best effort on platforms without POSIX modes */ - } + this.writeSecure(this._backupPath(provider), JSON.stringify(record, null, 2)); return record; } @@ -149,7 +193,6 @@ class CookieStore { /* already gone */ } } - /** * Cookies that a restore would actually install, i.e. those whose domain matches. * Prevents a Qwen backup from writing perplexity.ai cookies into the wrong profile. @@ -160,18 +203,32 @@ class CookieStore { } } -/** Placeholder strings that must never be surfaced to a caller as a real answer (A8). */ +/** + * Placeholder strings that must never be surfaced to a caller as a real answer (A8). + * + * Matched by prefix rather than exact equality: providers use variants of the same + * sentinel, and an exact set silently fails open on a wording it did not anticipate - + * which turns "no answer yet" into a delivered answer. + */ +const PLACEHOLDER_PREFIXES = ['', 'no response', 'no answer', 'nothing returned', '(no']; + +/** Values a JSON body can produce that are not answers. */ +const PLACEHOLDER_LITERALS = new Set(['null', 'undefined', 'false', 'nil', 'none']); + +function isPlaceholderResponse(text) { + if (text === null || text === undefined) return true; + const t = String(text).trim().toLowerCase(); + if (t === '') return true; + if (PLACEHOLDER_LITERALS.has(t)) return true; + return PLACEHOLDER_PREFIXES.some((p) => p !== '' && t.startsWith(p)); +} + const PLACEHOLDER_RESPONSES = new Set([ '', 'no response captured', 'no response received', ]); -function isPlaceholderResponse(text) { - if (text === null || text === undefined) return true; - return PLACEHOLDER_RESPONSES.has(String(text).trim().toLowerCase()); -} - module.exports = { CookieStore, evaluateAuth, diff --git a/gateway/src/ipc-server.js b/gateway/src/ipc-server.js index 48d0c07..7bc017c 100644 --- a/gateway/src/ipc-server.js +++ b/gateway/src/ipc-server.js @@ -3,7 +3,7 @@ const net = require('net'); const { DEFAULT_PORT, FALLBACK_ATTEMPTS } = require('./state-store'); const { toSafeError } = require('./logger'); -const { redactString } = require('./redact'); +const { redactString, redact } = require('./redact'); /** * Loopback IPC server. @@ -72,7 +72,7 @@ class IpcServer { this.host = host; this.server = null; this.boundPort = null; - this.connections = 0; + this.openConnections = 0; } /** @@ -116,10 +116,14 @@ class IpcServer { } _onConnection(socket) { - this.connections += 1; + this.openConnections += 1; const decoder = new FrameDecoder(); let busy = Promise.resolve(); + socket.on('close', () => { + this.openConnections -= 1; + }); + socket.on('data', (chunk) => { let lines; try { @@ -172,7 +176,12 @@ class IpcServer { } const response = await this.handler(request); - this._write(socket, { ...response, requestId }); + // Redact SUCCESS responses too. getCookies returns session cookies, + // debugDOM can return page HTML and executeScript returns whatever the + // page returned - any of which can carry a credential. Previously only + // the failure branch was redacted, so secrets left over the wire on the + // paths that actually return them. + this._write(socket, { ...redact(response || {}), requestId }); } catch (e) { // Redacted: the failure can come from anywhere in the request path, and a // provider URL can carry a token in its query string. diff --git a/gateway/src/redact.js b/gateway/src/redact.js index 9bba1b5..e48e4e8 100644 --- a/gateway/src/redact.js +++ b/gateway/src/redact.js @@ -23,16 +23,40 @@ const REDACTED = '[REDACTED]'; /** Default cap for any single redacted string. */ const DEFAULT_MAX = 512; +const { PROVIDERS } = require('./providers'); + /** * Cookie/query names whose values are credentials. * * Declared before PATTERNS because the pattern table references it at module init, and * a const below its use would be in the temporal dead zone on first load. * - * Kept as a predicate rather than a list of literal names because providers use - * families (__Secure-1PSID / __Secure-3PSID, pplx_*) where the suffix varies. + * Derived from the provider registry rather than hand-listed, because a hand-maintained + * list silently drifts: __cf_bm was an auth cookie in the registry but absent here, so + * it was neither masked nor detected. Deriving makes that class of bug impossible. */ -const SENSITIVE_COOKIE_NAMES = /^(?:__Secure-|__Host-)?(?:next-auth\.session-token|session-?token|sessionKey|auth|cna|ssxmod_itna|x5sec|token|sid|hsid|ssid|psid|1psid|3psid|csrf|xsrf)$|^pplx/i; +const PROVIDER_AUTH_COOKIE_NAMES = Array.from( + new Set(Object.values(PROVIDERS).flatMap((p) => p.auth.cookies)) +).map((n) => n.toLowerCase()); + +const HARDCODED_SENSITIVE_NAMES = [ + // Cloudflare bot clearance. Not an auth cookie for any one provider, but it is + // bearer-grade: replaying it is enough to impersonate the session. + '__cf_bm', + 'cf_clearance', + 'csrf', + 'xsrf', + 'auth', +]; + +const SENSITIVE_COOKIE_NAMES = new RegExp( + '^(?:__Secure-|__Host-)?(?:' + + [...PROVIDER_AUTH_COOKIE_NAMES, ...HARDCODED_SENSITIVE_NAMES] + .map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|') + + ')$|^pplx', + 'i' +); /** Values that look like opaque credentials even without a name we recognise. */ const OPAQUE_SECRET = /^[A-Za-z0-9._~+/=-]{24,}$/; @@ -42,20 +66,37 @@ const OPAQUE_SECRET = /^[A-Za-z0-9._~+/=-]{24,}$/; * leaving surrounding context readable. */ const PATTERNS = [ - // Authorization: Bearer / authorization="Bearer x" + // Authorization: [] , listed BEFORE the bare-bearer rule. + // + // The scheme is a generic word, not a fixed list: an allowlist of schemes leaks every + // scheme not on it, and a previous version behaved exactly that way - "Basic" passed + // through untouched while "ApiKey" had its scheme destroyed and kept its credential, + // so the line looked redacted while leaking. + // + // Running first means the bare-bearer rule finds nothing left to do, so the scheme + // is preserved once and the value is masked once - no double marker, and "Bearer" + // survives for diagnosis. + { + name: 'authorization', + re: /(authorization"?\s*[:=]\s*"?)(?:([A-Za-z][A-Za-z0-9_-]*)\s+)?(\[\s*redacted\s*\]|[^\s"',}]{3,})/gi, + repl: (m, prefix, scheme, value) => { + // Match the redaction marker as a first-class alternative rather than + // excluding it with a lookahead. A lookahead does not survive backtracking: + // the engine retries without the scheme group, matches the scheme word as + // the value, and the line is rewritten on a second pass. Matching the + // marker explicitly makes redaction genuinely idempotent. + if (/^\[\s*redacted\s*\]$/i.test(value)) { + return `${prefix}${scheme ? `${scheme} ` : ''}${value}`; + } + return scheme ? `${prefix}${scheme} ${REDACTED}` : `${prefix}${REDACTED}`; + }, + }, + // A bare "Bearer " with no header name, e.g. in pasted cURL output. { name: 'bearer', re: /\b(bearer)\s+([A-Za-z0-9._~+/=-]{8,})/gi, repl: (_m, scheme) => `${scheme} ${REDACTED}`, }, - // Authorization: (Basic, custom schemes). - // Deliberately skips a value that is already redacted, and skips the scheme word - // itself, so this cannot mangle a line the bearer rule has already handled. - { - name: 'authorization', - re: /(authorization"?\s*[:=]\s*"?)(?!bearer\b|basic\b|redacted)(\S[^"',\s}]{5,})/gi, - repl: (_m, prefix) => `${prefix}${REDACTED}`, - }, // JSON Web Tokens { name: 'jwt', @@ -219,23 +260,34 @@ function redactCookies(cookies, { fingerprint: fp = true } = {}) { */ function containsSecret(text) { if (text === null || text === undefined) return false; - const s = String(text); + // Strip existing markers first. Otherwise a value that has already been redacted + // still matches the shape of a credential, and the oracle reports a leak in + // correctly-redacted output - which is how a leak check quietly stops being one. + const s = String(text).split(REDACTED).join(''); for (const rule of PATTERNS) { const re = new RegExp(rule.re.source, rule.re.flags); let m; while ((m = re.exec(s)) !== null) { if (m[0].includes(REDACTED)) continue; - // For name-filtered rules the predicate applies to the captured NAME. if (rule.nameFilter) { + // For name-filtered rules the predicate applies to the captured NAME. const name = m[1]; if (typeof name === 'string' && rule.nameFilter.test(name)) return true; + } else if (rule.name === 'authorization') { + // A scheme word on its own ("Basic", "Bearer") is not a credential. The + // redaction pattern is deliberately broad, so the detector must be + // narrower than it or it flags the scheme and reports a leak in + // correctly-redacted output - which is how a leak check quietly stops + // being one. A real credential carries entropy. + const value = m[3] || ''; + if (!/^[A-Za-z]{1,12}$/.test(value)) return true; } else { return true; } if (m.index === re.lastIndex) re.lastIndex += 1; } } - return OPAQUE_SECRET.test(s.replace(new RegExp(REDACTED, 'g'), '').trim()); + return OPAQUE_SECRET.test(s.trim()); } module.exports = { diff --git a/gateway/src/session-manager.js b/gateway/src/session-manager.js index 6f3c923..58991ec 100644 --- a/gateway/src/session-manager.js +++ b/gateway/src/session-manager.js @@ -3,6 +3,7 @@ const { getProvider, PROVIDER_NAMES } = require('./providers'); const { evaluateAuth, normalizeImportedCookies } = require('./cookie-store'); const { EngineLoader, EngineError, TransportError } = require('./engine-loader'); +const { redactString } = require('./redact'); /** * Per-provider persistent browser sessions. @@ -69,17 +70,30 @@ class SessionManager { this._sessions = new Map(); this._startedAt = new Date().toISOString(); this.activeProvider = null; + // Guards against concurrent first-touches leaking a context or a browser. + this._inflight = new Map(); + this._browserInflight = null; } // ---- browser lifecycle ---- async _ensureBrowser() { if (this._browser) return this._browser; - this._browser = await this.browserFactory({ + // Without this, two concurrent first-touches launch Chromium twice and one + // process is orphaned with no reference to close it. + if (this._browserInflight) return this._browserInflight; + this._browserInflight = this.browserFactory({ headless: this.settings.headless !== false, args: LAUNCH_ARGS, - }); - return this._browser; + }) + .then((b) => { + this._browser = b; + return b; + }) + .finally(() => { + this._browserInflight = null; + }); + return this._browserInflight; } async close() { @@ -125,42 +139,57 @@ class SessionManager { const session = this._session(name); if (session.isOpen) return session; + // I6: without an in-flight guard, two concurrent first-touches both build a + // context and the second overwrites the first, leaking it permanently - it is + // on neither the session nor any list close() walks. + if (this._inflight.has(name)) return this._inflight.get(name); + const work = this._initProviderInner(session, headless).finally(() => { + this._inflight.delete(name); + }); + this._inflight.set(name, work); + return work; + } + + async _initProviderInner(session, headless) { + const { spec, name } = session; const browser = await this._ensureBrowser(); - const useHeadless = headless === null - ? this._headlessFor(name) - : Boolean(headless); + const useHeadless = headless === null ? this._headlessFor(name) : Boolean(headless); - const userDataDir = this.state.profileDir(session.spec.partition); + // I6: build into locals and publish only once navigate+inject have succeeded. + // Publishing first left a failed provider looking INITIALIZED on about:blank, + // and every later initProvider short-circuited past it - poisoned for the + // lifetime of the process. + let context = null; + let page = null; - let context; try { - context = await browser.newContext({ - viewport: DEFAULT_VIEWPORT, - userAgent: undefined, // use Chromium's real UA; do not spoof - }); + context = await browser.newContext({ viewport: DEFAULT_VIEWPORT }); + await this._attachPersistence(context, this.state.profileDir(spec.partition), session); + page = await context.newPage(); + + await this._navigateAndInject(session, { initial: true, context, page }); } catch (e) { + // Never leave a context behind that nothing holds a reference to. + if (context) { + try { + await context.close(); + } catch { + /* best effort */ + } + } + session.context = null; + session.page = null; session.state = 'UNINITIALIZED'; session.lastError = e; - throw new TransportError( - `Could not create a browser context for "${name}": ${e.message}`, - name, - { reason: 'context-failed' } - ); + throw e; } - // Persistent storage: the profile dir IS the auth store. Cookie backup is a - // secondary safety net, not the primary mechanism. - await this._attachPersistence(context, userDataDir, session); - - const page = await context.newPage(); session.context = context; session.page = page; session.state = 'INITIALIZED'; session.headless = useHeadless; session.lastError = null; - await this._navigateAndInject(session, { initial: true }); - const auth = await this.isLoggedIn(name); if (!auth.loggedIn) { // NEEDS_AUTH is a report. The context and any stored cookies stay put. @@ -210,12 +239,26 @@ class SessionManager { async _flushCookies(session) { if (!session.context || !session.profileFile) return; try { - const cookies = await session.context.cookies(); - writeJson(session.profileFile, { - provider: session.name, - savedAt: new Date().toISOString(), - cookies, - }); + const all = await session.context.cookies(); + // Scope to the provider's own auth domain. Persisting every cookie the page + // touched accumulates consent and tracking cookies for unrelated domains, + // which is both noise and extra credential-shaped material on disk. + const cookies = all.filter((c) => + String(c.domain || '').includes(session.spec.auth.domain) + ); + if (cookies.length === 0) return; + + // The PRIMARY auth store, so it must be written with the same protection as + // the backup. It previously had no mode at all, making the file the code + // calls the auth store the least protected of the two. + this.cookies.writeSecure( + session.profileFile, + JSON.stringify( + { provider: session.name, savedAt: new Date().toISOString(), cookies }, + null, + 2 + ) + ); this.cookies.saveBackup(session.name, cookies); } catch { /* flush is best effort; shutdown retries */ @@ -230,33 +273,50 @@ class SessionManager { return this.settings.headless !== false; } - async _navigateAndInject(session, { initial = false } = {}) { - const { spec, name, page } = session; + async _navigateAndInject(session, { initial = false, context = null, page = null } = {}) { + const { spec, name } = session; + const ctx = context || session.context; + const pg = page || session.page; try { - await page.goto(spec.url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await pg.goto(spec.url, { waitUntil: 'domcontentloaded', timeout: 60000 }); } catch (e) { - if (!initial) throw e; - throw new TransportError( + const err = new TransportError( `Could not reach ${spec.url} for provider "${name}": ${e.message}`, name, { reason: 'navigation-failed' } ); + if (initial) throw err; + // I3/I4: a later navigation failing must be visible. Swallowing it here is + // what let navigate() report success and then fail on the next call with an + // unexplained "engine not loaded". + throw err; } - // Re-inject after every navigation. The init script also fires, but an explicit - // install lets us probe and fail loudly rather than discovering the gap later. try { - await this.engines.install(session.context, page, name); + await this.engines.install(ctx, pg, name); } catch (e) { - if (e instanceof EngineError && initial) throw e; - if (!(e instanceof EngineError)) throw e; - // Non-initial injection failure is recoverable: the next navigation retries. - session.lastError = e; + if (e instanceof EngineError && !initial) { + // A navigation tore the engine down. Surface it: the next call would + // otherwise fail with a confusing error instead of the real cause. + session.lastError = e; + throw e; + } + throw e; } } // ---- auth ---- + /** Auth as recorded on disk, independent of any live context. */ + _authOnDisk(name) { + const spec = getProvider(name); + const session = this._sessions.get(name); + const fromDisk = (session && readJson(session.profileFile)) || {}; + const cookies = fromDisk.cookies || []; + const verdict = evaluateAuth(cookies, spec.auth); + return { provider: name, loggedIn: verdict.loggedIn, matched: verdict.matched }; + } + async isLoggedIn(name) { const spec = getProvider(name); const session = this._session(name); @@ -296,18 +356,13 @@ class SessionManager { } // Clear the domain's existing cookies first, or the old and new sets interleave. + // Scoped to the auth domain so a provider's cookies cannot disturb another's. try { - const existing = await session.context.cookies(spec.auth.domain); - for (const c of existing) { - const url = `${c.secure ? 'https' : 'http'}://${c.domain.replace(/^\./, '')}${c.path || '/'}`; - await session.context.clearCookies({ name: c.name, domain: c.domain }).catch(async () => { - // Fall back to explicit removal when the filtered clear is unsupported. - await session.context.clearCookies(); - }); - void url; - } + await session.context.clearCookies({ domain: spec.auth.domain }); } catch { - /* clearing is best effort; addCookies below is what matters */ + // A filtered clear is not always supported; an unfiltered clear would wipe + // every cookie in the context, so it is deliberately NOT used as a + // fallback. addCookies below is what actually establishes the session. } let set = 0; @@ -323,8 +378,10 @@ class SessionManager { await this._flushCookies(session); - // Reload so the new auth is actually in effect for the page's own fetches. - await this._navigateAndInject(session).catch(() => {}); + // Reload so the new auth is actually in effect for the page's own fetches. A + // failure here is reported: silently continuing would leave the caller + // believing the cookies took effect when the page never re-read them. + await this._navigateAndInject(session); const verdict = await this.isLoggedIn(name); return { @@ -371,6 +428,13 @@ class SessionManager { if (!session.isOpen) { return { provider: name, unloaded: false, reason: 'not-loaded' }; } + + // A6: auth lives on disk, so the context's cookie jar MUST be flushed before it + // is discarded. Without this, an operator who logged in interactively loses the + // session the moment the renderer is unloaded, while the response still claims + // authPreserved: true. + await this._flushCookies(session); + try { await session.page.goto('about:blank').catch(() => {}); } catch { @@ -386,7 +450,16 @@ class SessionManager { session.state = 'UNINITIALIZED'; session.sentThisProcess = false; if (this.activeProvider === name) this.activeProvider = null; - return { provider: name, unloaded: true, authPreserved: true }; + + // Report what actually happened, not what was intended. A response that claims + // preservation without checking is worse than no claim at all. + const authPreserved = this._authOnDisk(name).loggedIn; + return { + provider: name, + unloaded: true, + authPreserved, + ...(authPreserved ? {} : { warning: 'cookies were not on disk before unload; session may need re-auth' }), + }; } /** The only operation that destroys auth. Never reachable from unloadProvider. */ @@ -463,8 +536,21 @@ class SessionManager { async navigate(name, url) { const session = await this._ensureLoaded(name); - await session.page.goto(url, { waitUntil: 'domcontentloaded' }); - await this._navigateAndInject(session).catch(() => {}); + // I4: wrap so a navigation failure is typed as transport, matching + // initProvider. Two code paths disagreeing about the same failure is exactly + // what the F6 engine/transport split exists to prevent. + try { + await session.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + } catch (e) { + throw new TransportError( + `Navigation to ${redactString(String(url))} failed for "${name}": ${e.message}`, + name, + { reason: 'navigation-failed' } + ); + } + // I3: re-inject and surface a failure. Reporting success here and then failing + // on the next call with "engine not loaded" is worse than reporting the cause. + await this._navigateAndInject(session); return { provider: name, url }; } @@ -529,15 +615,6 @@ function readJson(file) { } } -function writeJson(file, value) { - try { - require('fs').mkdirSync(require('path').dirname(file), { recursive: true }); - require('fs').writeFileSync(file, JSON.stringify(value, null, 2), 'utf8'); - } catch { - /* best effort */ - } -} - function removeFile(file) { try { require('fs').unlinkSync(file); diff --git a/gateway/test/contract.test.js b/gateway/test/contract.test.js index 80a655c..031cc56 100644 --- a/gateway/test/contract.test.js +++ b/gateway/test/contract.test.js @@ -501,8 +501,55 @@ test('hasEngine reports which providers are installed', () => { test('flushAll writes a profile file for every loaded session', async () => { const g = makeGateway(); await g.sessions.initProvider('perplexity'); + // Auth cookies must exist: flushing deliberately skips writing an empty auth store, + // so a file appearing with nothing in it would be noise on disk. + g.context._store.push({ + name: 'pplx_session', + value: 'persisted', + domain: '.perplexity.ai', + }); + await g.sessions.flushAll(); + const file = g.sessions._session('perplexity').profileFile; + assert.ok(fs.existsSync(file), 'the profile file must be written'); + const stored = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(stored.provider, 'perplexity'); + assert.ok( + stored.cookies.some((c) => c.name === 'pplx_session'), + 'and must contain the auth cookie' + ); + await g.sessions.close(); +}); + +test('flushAll does not write an empty auth store', async () => { + const g = makeGateway(); + await g.sessions.initProvider('perplexity'); + await g.sessions.flushAll(); + const file = g.sessions._session('perplexity').profileFile; + assert.equal( + fs.existsSync(file), + false, + 'a profile with no auth cookies is not worth persisting' + ); + await g.sessions.close(); +}); + +test('the profile file is scoped to the provider auth domain', async () => { + const g = makeGateway(); + await g.sessions.initProvider('perplexity'); + g.context._store.push( + { name: 'pplx_session', value: 'keep', domain: '.perplexity.ai' }, + { name: 'tracking', value: 'drop', domain: '.someothertracker.test' } + ); await g.sessions.flushAll(); - assert.ok(fs.existsSync(g.sessions._session('perplexity').profileFile)); + const stored = JSON.parse( + fs.readFileSync(g.sessions._session('perplexity').profileFile, 'utf8') + ); + const names = stored.cookies.map((c) => c.name); + assert.ok(names.includes('pplx_session')); + assert.ok( + !names.includes('tracking'), + 'unrelated domains must not accumulate in the auth store' + ); await g.sessions.close(); }); diff --git a/gateway/test/regression.test.js b/gateway/test/regression.test.js new file mode 100644 index 0000000..57cb9a8 --- /dev/null +++ b/gateway/test/regression.test.js @@ -0,0 +1,384 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +const { StateStore } = require('../src/state-store'); +const { CookieStore } = require('../src/cookie-store'); +const { EngineLoader } = require('../src/engine-loader'); +const { SessionManager } = require('../src/session-manager'); +const { IpcServer } = require('../src/ipc-server'); +const { createHandler } = require('../src/actions'); +const { redactString, redactCookies, containsSecret, SENSITIVE_COOKIE_NAMES } = require('../src/redact'); + +/** + * Regression tests for the code-review findings. + * + * Each of these was a real defect confirmed by running the code first. They are named + * after the invariant they protect, not the bug, so a future reader can tell what would + * break rather than only what changed. + */ + +// ---- shared harness ------------------------------------------------------------ + +function harness({ enginePresent = true, gotoThrows = false, installThrows = false } = {}) { + const stateStore = new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-reg-'))); + const cookieStore = new CookieStore(stateStore); + const engineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-regeng-')); + fs.writeFileSync( + path.join(engineDir, 'claude-engine.js'), + 'window.__proximaClaude={sendMessage:function(){return{ok:true};},' + + 'newConversation:function(){return{};},getResponse:function(){return "answer";},' + + 'getTypingStatus:function(){return{typing:false};},isReady:function(){return true;}};' + ); + + const jar = []; + const events = { contextsCreated: 0, contextsClosed: 0, navigations: 0, browsersCreated: 0 }; + + const page = { + async goto() { + events.navigations += 1; + if (gotoThrows) throw new Error('net::ERR_NAME_NOT_RESOLVED'); + }, + async evaluate(a, b) { + if (typeof a === 'string') return true; + if (typeof a === 'function') { + if (typeof b === 'string') return enginePresent; + if (installThrows) return null; + if (b && b.g && b.m === 'getResponse') return 'answer'; + return Promise.resolve({ ok: true }); + } + return null; + }, + async content() { return 'x'; }, + }; + const context = { + async addInitScript() {}, + async cookies(f) { + return f ? jar.filter((c) => String(c.domain).includes(f)) : jar.slice(); + }, + async addCookies(l) { l.forEach((c) => jar.push(c)); }, + async clearCookies() { jar.length = 0; }, + async close() { events.contextsClosed += 1; }, + async newPage() { return page; }, + }; + + const sessions = new SessionManager({ + stateStore, + cookieStore, + engineLoader: new EngineLoader({ engineDir, stateStore }), + settings: { headless: true }, + browserFactory: async () => { + events.browsersCreated += 1; + return { + newContext: async () => { + events.contextsCreated += 1; + return context; + }, + close: async () => {}, + }; + }, + }); + + const handler = createHandler({ sessions, stateStore, startedAt: 'x' }); + return { stateStore, cookieStore, sessions, handler, jar, events, context, page }; +} + +// ---- A6: unload must preserve auth (was reported true while logging out) ------- + +test('A6: unloadProvider preserves auth acquired in the live context', async () => { + const h = harness(); + await h.handler({ action: 'initProvider', provider: 'claude' }); + // The interactive-login path: cookies exist ONLY in the context, nothing on disk yet. + h.jar.push({ name: 'sessionKey', value: 'abc123def456ghi789', domain: '.claude.ai' }); + + assert.equal((await h.handler({ action: 'isLoggedIn', provider: 'claude' })).loggedIn, true); + + const res = await h.handler({ action: 'unloadProvider', provider: 'claude' }); + assert.equal(res.success, true); + + const after = await h.handler({ action: 'isLoggedIn', provider: 'claude' }); + assert.equal(after.loggedIn, true, 'unload must not log the operator out'); + + // And the claim must be derived, not hardcoded. + assert.equal(res.reports[0].authPreserved, true); + assert.equal(res.unloaded.includes('claude'), true); + await h.sessions.close(); +}); + +test('A6: authPreserved is not claimed when nothing was on disk', async () => { + const h = harness(); + await h.handler({ action: 'initProvider', provider: 'claude' }); + // No cookies at all: an unload must not claim preservation. + const res = await h.handler({ action: 'unloadProvider', provider: 'claude' }); + assert.equal(res.reports[0].authPreserved, false, 'must not claim preservation it cannot verify'); + assert.ok(res.reports[0].warning, 'and must say why'); + await h.sessions.close(); +}); + +// ---- C2: success responses must be redacted too ------------------------------ + +test('C2: getCookies does not put a raw cookie value on the wire', async () => { + const h = harness(); + const ipc = new IpcServer({ handler: h.handler, stateStore: h.stateStore, port: 0 }); + const port = await ipc.listen(); + + await h.handler({ action: 'initProvider', provider: 'claude' }); + h.jar.push({ name: 'sessionKey', value: 'abc123def456ghi789', domain: '.claude.ai' }); + + const net = require('net'); + const client = net.createConnection({ port, host: '127.0.0.1' }); + await new Promise((r) => client.once('connect', r)); + const reply = await new Promise((resolve) => { + let buf = ''; + client.on('data', (c) => { + buf += c.toString(); + const line = buf.split('\n').find((l) => l.trim()); + if (line) resolve(JSON.parse(line)); + }); + client.write(JSON.stringify({ requestId: 1, action: 'getCookies', provider: 'claude' }) + '\n'); + }); + + assert.ok(!JSON.stringify(reply).includes('abc123def456ghi789'), 'raw value leaked to the wire'); + assert.equal(reply.success, true, 'the call itself should still succeed'); + assert.ok(reply.cookies, 'the cookie list is still returned'); + + client.destroy(); + await ipc.close(); + await h.sessions.close(); +}); + +// ---- C5: every Authorization scheme must be redacted ------------------------- + +test('C5: every Authorization scheme has its credential redacted', () => { + const cred = '9f8e7d6c5b4a3210zzzzzzzz'; + const schemes = ['Bearer', 'Basic', 'Token', 'DPoP', 'Negotiate', 'ApiKey', 'Custom']; + for (const scheme of schemes) { + const input = `Authorization: ${scheme} ${cred}`; + const out = redactString(input); + assert.ok(!out.includes(cred), `${scheme}: credential survived -> ${out}`); + } +}); + +test('C5: a custom scheme is not mangled into looking-redacted', () => { + // The old rule destroyed the scheme word and kept the credential, so the line + // looked redacted while leaking. Assert the value is genuinely gone. + const out = redactString('Authorization: ApiKey 9f8e7d6c5b4a3210zzzzzzzz'); + assert.equal(containsSecret(out), false, 'the leak oracle must agree it is clean'); +}); + +test('C5: redaction is idempotent', () => { + const once = redactString('Authorization: Basic dXNlcjpwYXNzd29yZDEyMw=='); + const twice = redactString(once); + assert.equal(twice, once, 'a second pass must not change an already-redacted line'); +}); + +// ---- I8: the sensitive-name list cannot drift from the registry -------------- + +test('I8: every provider auth cookie is a known-sensitive name', () => { + const { PROVIDERS } = require('../src/providers'); + for (const [name, spec] of Object.entries(PROVIDERS)) { + for (const cookie of spec.auth.cookies) { + assert.ok( + SENSITIVE_COOKIE_NAMES.test(cookie), + `${name}: ${cookie} is treated as auth but would not be redacted` + ); + } + } +}); + +test('I8: cloudflare clearance is redacted', () => { + const out = redactString('Set-Cookie: __cf_bm=Ql8vKx2mNpQrStUvWxYz0123; Path=/'); + assert.ok(!out.includes('Ql8vKx2mNpQrStUvWxYz0123'), 'cf_bm is bearer-grade and must be masked'); + assert.ok(out.includes('__cf_bm='), 'the name should stay'); +}); + +test('redactCookies masks a value the registry calls auth', () => { + const out = redactCookies([{ name: '__cf_bm', value: 'Ql8vKx2mNpQrStUvWxYz0123', domain: '.openai.com' }]); + assert.ok(!out[0].value.includes('Ql8vKx2mNpQrStUvWxYz0123')); +}); + +// ---- I1/I2: placeholders must never be answers ------------------------------ + +test('I1: placeholder variants are all recognised', () => { + const { isPlaceholderResponse } = require('../src/cookie-store'); + for (const t of ['', ' ', 'no response', 'No Response', 'NO RESPONSE CAPTURED', + 'no response received', 'no answer', 'null', undefined]) { + assert.equal(isPlaceholderResponse(t), true, `${JSON.stringify(t)} must be a placeholder`); + } + assert.equal(isPlaceholderResponse('a real answer'), false); +}); + +test('I2: getResponse will not return a placeholder as a success', async () => { + const h = harness(); + await h.handler({ action: 'initProvider', provider: 'claude' }); + // Force the engine to answer with a placeholder. + h.sessions._session('claude').page.evaluate = async (a, b) => { + if (typeof a === 'string') return true; + if (typeof a === 'function' && typeof b === 'string') return true; + if (b && b.g && b.m === 'getResponse') return 'no response captured'; + return { ok: true }; + }; + const res = await h.handler({ action: 'getResponse', provider: 'claude' }); + assert.equal(res.success, false, 'a placeholder must not be a success'); + assert.equal(res.errorKind, 'capture'); + await h.sessions.close(); +}); + +// ---- I5: the destructive operation must not report a no-op as success -------- + +test('I5: purgeProvider with no target is an error, not a silent success', async () => { + const h = harness(); + const res = await h.handler({ action: 'purgeProvider', provider: null, data: { providers: [] } }); + assert.equal(res.success, false); + assert.match(res.error, /name a provider/); + await h.sessions.close(); +}); + +// ---- I7: retry bounds are enforced ------------------------------------------ + +test('I7: an absurd attempt count is clamped, not honoured', async () => { + const h = harness(); + await h.handler({ action: 'initProvider', provider: 'claude' }); + const started = Date.now(); + const res = await h.handler({ + action: 'getResponseWithTyping', + provider: 'claude', + data: { maxAttempts: 1e9, retryDelayMs: 1 }, + }); + // An unclamped run would hang for years. Completing at all proves the clamp. + assert.ok(Date.now() - started < 60000, 'a clamped run must finish promptly'); + assert.ok(typeof res.success === 'boolean'); + await h.sessions.close(); +}); + +test('I7: a non-numeric delay falls back to the default', async () => { + const h = harness(); + await h.handler({ action: 'initProvider', provider: 'claude' }); + const res = await h.handler({ + action: 'getResponseWithTyping', + provider: 'claude', + data: { maxAttempts: 'abc', retryDelayMs: -5 }, + }); + assert.equal(res.success, true, 'a real answer is available, so it should succeed'); + await h.sessions.close(); +}); + +// ---- I6: no context leaks ---------------------------------------------------- + +test('I6: a failed first navigation closes its context', async () => { + const h = harness({ gotoThrows: true }); + // The action layer converts throws into a success:false result, so this asserts the + // reported outcome rather than a rejection. + const res = await h.handler({ action: 'initProvider', provider: 'claude' }); + assert.equal(res.success, false, 'a provider that cannot load must report failure'); + assert.equal(h.events.contextsCreated, 1); + assert.equal(h.events.contextsClosed, 1, 'the orphaned context must be closed, not leaked'); + await h.sessions.close(); +}); + +test('I6: a failed provider is not reported as initialised', async () => { + const h = harness({ gotoThrows: true }); + const res = await h.handler({ action: 'initProvider', provider: 'claude' }); + assert.equal(res.success, false); + assert.deepEqual(h.sessions.initializedProviders(), [], 'must not look ready'); + const status = await h.handler({ action: 'getStatus' }); + assert.ok(!status.providers.includes('claude')); + await h.sessions.close(); +}); + +test('I6: concurrent first-touches create one context, not two', async () => { + const h = harness(); + await Promise.all([ + h.handler({ action: 'initProvider', provider: 'claude' }), + h.handler({ action: 'initProvider', provider: 'claude' }), + h.handler({ action: 'initProvider', provider: 'claude' }), + ]); + assert.equal(h.events.contextsCreated, 1, 'the in-flight guard must collapse concurrent inits'); + assert.equal(h.events.browsersCreated, 1, 'and must not launch a second browser'); + await h.sessions.close(); +}); + +// ---- I3/I4: navigate must not lie ------------------------------------------ + +test('I4: a navigation failure is typed as transport', async () => { + const h = harness(); + await h.handler({ action: 'initProvider', provider: 'claude' }); + h.page.goto = async () => { throw new Error('net::ERR_NAME_NOT_RESOLVED'); }; + const res = await h.handler({ + action: 'navigate', + provider: 'claude', + data: { url: 'https://broken.test/' }, + }); + assert.equal(res.success, false); + assert.equal(res.errorKind, 'transport', 'must match how initProvider classifies the same failure'); + await h.sessions.close(); +}); + +test('I3: navigate reports failure when the engine cannot be reinstalled', async () => { + const h = harness(); + await h.handler({ action: 'initProvider', provider: 'claude' }); + h.page.evaluate = async (a, b) => { + if (typeof a === 'string') return true; + if (typeof a === 'function' && typeof b === 'string') return false; // engine gone + return null; + }; + const res = await h.handler({ + action: 'navigate', + provider: 'claude', + data: { url: 'https://other.test/' }, + }); + assert.equal(res.success, false, 'reporting success here defers the failure confusingly'); + assert.equal(res.errorKind, 'engine'); + await h.sessions.close(); +}); + +// ---- C3: credential files are written with a restrictive mode ---------------- + +test('C3: a credential file is never briefly world-readable', () => { + if (process.platform === 'win32') return; // POSIX modes are not enforceable there + const store = new CookieStore(new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-mode-')))); + store.saveBackup('claude', [{ name: 'sessionKey', value: 'v', domain: '.claude.ai' }]); + const file = store._backupPath('claude'); + const mode = fs.statSync(file).mode & 0o777; + assert.equal(mode, 0o600, `expected 0600, got ${mode.toString(8)}`); + assert.ok(!fs.existsSync(`${file}.tmp`), 'no temp file may be left behind'); +}); + +test('C3: a leftover temp file from a crashed write is cleaned up', () => { + const store = new CookieStore(new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-tmp-')))); + const file = store._backupPath('gemini'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + // Pre-create the temp name to force the exclusive-create path to fail. + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, 'stale'); + assert.throws(() => store.writeSecure(file, 'x')); + assert.ok(fs.existsSync(tmp), 'the pre-existing file is not ours to delete'); + fs.unlinkSync(tmp); +}); + +// ---- coverage gate itself ---------------------------------------------------- + +test('the coverage gate fails on an impossible floor', () => { + const path = require('path'); + const script = path.join(__dirname, '..', 'scripts', 'check-coverage.cjs'); + const report = [ + '# file | line % | branch % | funcs % | uncovered lines', + '# src.js | 90.00 | 85.00 | 90.00 |', + '# all files | 90.00 | 85.00 | 90.00 |', + ].join('\n'); + const tmp = path.join(os.tmpdir(), `cov-${process.pid}.txt`); + fs.writeFileSync(tmp, report); + const { execFileSync } = require('child_process'); + try { + execFileSync(process.execPath, [script, tmp, '99', '99', '99'], { stdio: 'pipe' }); + assert.fail('the gate must fail on an impossible floor'); + } catch (e) { + assert.equal(e.status, 1); + } finally { + fs.unlinkSync(tmp); + } +}); From 74c85a5e828a65ff633a3f6d4534f82c09c88e0b Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 22:46:03 +0500 Subject: [PATCH 06/12] feat(gateway): CLI for humans, and two interop fixes it exposed The gateway had a daemon and an IPC contract but no way for a person to reach it: no argv, no stdin, no prompts. Cookie login existed and was unreachable. Adds plain commands, deliberately not a REPL - the automation client is the interactive consumer, and a human logs in a couple of times a month. proxima-gw check is it running, on what port proxima-gw status per-provider load and auth state proxima-gw login

--cookies inject a cookie export proxima-gw login

--headed real window, log in by hand proxima-gw logout

destroy that auth on disk Plain uncoloured output so it pipes cleanly, non-zero exit codes because a script trusts them, and no stack traces at the user. PowerShell wrappers proxima-gw.ps1 (CLI) and proxima-gw-daemon.ps1 (-Start/-Stop, which waits for the port fact instead of racing startup). --cookies --stdin also works, so this composes: Get-Content .\cookies\claude.json -Raw | proxima-gw login claude --stdin Two interop defects fixed, both of which a CLI would surface immediately: The port fact went to the gateway's own state dir, but the existing client reads ipc-port.json from the legacy Electron userData dir. The client could never have found this gateway and would have reported 'Proxima is not running' against a healthy process. The fact is now written to both locations - profiles and cookie backups stay private to the gateway's state dir, but the port is a shared fact. A test pins the path against the same resolution the client uses, so the two cannot drift apart again. Port collision silently relocated to port+1. That is worse than failing: the fact then points a client at whichever process won 19222, and a client probing candidates in order will talk to the wrong one - meaning the legacy Electron app answering a gateway request. Collision now fails loudly and names the other pid; relocating is opt-in via PROXIMA_GATEWAY_PORT=1 and warns when it happens. Tests: 8 files. The CLI suite drives real argv, real sockets and real exit codes against a stub gateway, because a CLI returning 0 on failure is worse than no CLI. CI also parses the PowerShell wrappers and asserts the CLI exits non-zero when nothing is running. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gateway.yml | 30 ++- gateway/bin/proxima-gw.js | 376 ++++++++++++++++++++++++++++++++++ gateway/package.json | 9 +- gateway/proxima-gw-daemon.ps1 | 116 +++++++++++ gateway/proxima-gw.ps1 | 44 ++++ gateway/src/index.js | 5 +- gateway/src/ipc-server.js | 33 ++- gateway/src/state-store.js | 83 ++++++-- gateway/test/cli.test.js | 357 ++++++++++++++++++++++++++++++++ 9 files changed, 1027 insertions(+), 26 deletions(-) create mode 100644 gateway/bin/proxima-gw.js create mode 100644 gateway/proxima-gw-daemon.ps1 create mode 100644 gateway/proxima-gw.ps1 create mode 100644 gateway/test/cli.test.js diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml index 0e095f6..8b974b8 100644 --- a/.github/workflows/gateway.yml +++ b/.github/workflows/gateway.yml @@ -103,7 +103,35 @@ jobs: - name: Parse every source file working-directory: gateway run: | - for f in src/*.js engines/*.js test/*.js; do + for f in src/*.js engines/*.js test/*.js bin/*.js scripts/*.cjs; do node --check "$f" || exit 1 done echo "all files parse" + + - name: Parse the PowerShell wrappers + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + foreach ($f in @('gateway/proxima-gw.ps1', 'gateway/proxima-gw-daemon.ps1')) { + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $f), [ref]$null, [ref]$errors) + if ($errors.Count -gt 0) { + Write-Error "$f has $($errors.Count) parse error(s)" + $errors | ForEach-Object { Write-Error " $_" } + exit 1 + } + Write-Output "ok $f" + } + + - name: The CLI runs and reports a missing gateway clearly + working-directory: gateway + run: | + set -e + node bin/proxima-gw.js help | head -3 + # Not running must be a clean non-zero exit, not a stack trace. + if AGENT_HUB_PORT=19996 node bin/proxima-gw.js check; then + echo "check exited 0 with nothing running - a script would trust that" + exit 1 + fi + echo "check correctly failed" diff --git a/gateway/bin/proxima-gw.js b/gateway/bin/proxima-gw.js new file mode 100644 index 0000000..b59510a --- /dev/null +++ b/gateway/bin/proxima-gw.js @@ -0,0 +1,376 @@ +#!/usr/bin/env node +'use strict'; + +/** + * proxima-gw - the gateway control CLI. + * + * Plain commands, one action, exit. Deliberately not a REPL: the interactive + * consumer of this gateway is the automation client, and a human uses this a couple of + * times a month. A prompt-driven interface would earn nothing. + * + * Works from PowerShell, cmd, bash, or any shell. Output is plain lines, no colour + * codes, no cursor tricks, so it pipes cleanly. + * + * proxima-gw check + * proxima-gw status + * proxima-gw login claude --cookies .\claude.json + * proxima-gw login claude --headed + * proxima-gw logout perplexity + * + * Port discovery mirrors the automation client exactly: AGENT_HUB_PORT, then + * PROXIMA_IPC_PORT, then the recorded ipc-port.json, then 19222. If the client can + * find the gateway, so can this. + */ + +const fs = require('fs'); +const os = require('os'); +const net = require('net'); +const path = require('path'); + +const { StateStore, DEFAULT_PORT } = require('../src/state-store'); +const { PROVIDERS, PROVIDER_NAMES } = require('../src/providers'); + +const EXIT = { OK: 0, ERROR: 1, USAGE: 2 }; + +// ---- output ------------------------------------------------------------------- +// Plain, uncoloured lines. PowerShell's output redirection and any pipe downstream +// should not have to strip escape codes. + +const say = (s = '') => process.stdout.write(`${s}\n`); +const fail = (s) => process.stderr.write(`${s}\n`); + +function usage() { + return [ + 'proxima-gw - control the Proxima provider gateway', + '', + 'USAGE', + ' proxima-gw check is the gateway running?', + ' proxima-gw status per-provider state', + ' proxima-gw login --cookies ', + ' proxima-gw login --headed open a real window and log in by hand', + ' proxima-gw logout destroy that provider auth on disk', + '', + `PROVIDERS ${PROVIDER_NAMES.join(', ')}`, + '', + 'ENVIRONMENT', + ' AGENT_HUB_PORT port to dial (overrides the recorded fact)', + ' PROXIMA_GATEWAY_STATE_DIR where profiles and cookie backups live', + ' PROXIMA_GATEWAY_PORT=1 allow the gateway to relocate to port+1', + '', + 'EXAMPLES (PowerShell)', + ' node bin\\proxima-gw.js status', + ' node bin\\proxima-gw.js login claude --cookies .\\cookies\\claude.json', + ' node bin\\proxima-gw.js login chatgpt --headed', + ' Get-Content .\\cookies\\claude.json -Raw | node bin\\proxima-gw.js login claude --stdin', + ].join('\n'); +} + +// ---- argument parsing -------------------------------------------------------- + +function parseArgs(argv) { + const out = { _: [], flags: {} }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith('--')) { + out.flags[key] = next; + i += 1; + } else { + out.flags[key] = true; + } + } else { + out._.push(a); + } + } + return out; +} + +// ---- connection -------------------------------------------------------------- + +function resolvePort() { + const explicit = Number(process.env.AGENT_HUB_PORT) || Number(process.env.PROXIMA_IPC_PORT); + if (explicit) return { port: explicit, via: 'env' }; + + const fact = new StateStore().readPortFact(); + if (fact && pidAlive(fact.pid)) return { port: fact.port, via: fact.file || 'ipc-port.json' }; + + return { port: DEFAULT_PORT, via: 'default (no recorded fact found)' }; +} + +function pidAlive(pid) { + if (!pid || typeof pid !== 'number') return false; + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e.code === 'EPERM'; + } +} + +/** One request, one response, one socket. */ +function call(action, provider, data = {}, timeoutMs = 30000) { + const { port, via } = resolvePort(); + return new Promise((resolve) => { + const socket = net.createConnection({ port, host: '127.0.0.1' }); + let buffer = ''; + let settled = false; + + const done = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolve(result); + }; + + const timer = setTimeout(() => { + done({ ok: false, error: `Timed out after ${timeoutMs}ms talking to port ${port}` }); + }, timeoutMs); + if (timer.unref) timer.unref(); + + socket.on('error', (e) => { + const detail = + e.code === 'ECONNREFUSED' + ? `Nothing is listening on 127.0.0.1:${port} (${via}). Start it with: node src\\index.js` + : e.message; + done({ ok: false, error: detail, code: e.code }); + }); + + socket.on('connect', () => { + socket.write(JSON.stringify({ requestId: 1, action, provider, data }) + '\n'); + }); + + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.requestId === 1) done({ ok: msg.success === true, response: msg, raw: line }); + } catch { + /* skip an unparseable line */ + } + } + }); + }); +} + +// ---- formatting -------------------------------------------------------------- + +function table(rows, headers) { + if (rows.length === 0) return ' (none)'; + const widths = headers.map((h, i) => + Math.max(h.length, ...rows.map((r) => String(r[i] ?? '').length)) + ); + const line = (cells) => ` ${cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' ')}`; + return [line(headers), ` ${widths.map((w) => '-'.repeat(w)).join(' ')}`, ...rows.map(line)].join('\n'); +} + +// ---- commands ---------------------------------------------------------------- + +async function cmdCheck() { + const { port, via } = resolvePort(); + const res = await call('ping', null, {}, 4000); + if (res.ok) { + say(`gateway RUNNING on 127.0.0.1:${port} (port from ${via})`); + return EXIT.OK; + } + say(`gateway NOT RUNNING on 127.0.0.1:${port} (port from ${via})`); + say(` ${res.error}`); + say(''); + say('Start it with:'); + say(' node src\\index.js'); + return EXIT.ERROR; +} + +async function cmdStatus() { + const health = await call('ping', null, {}, 4000); + if (!health.ok) { + fail(`Gateway not reachable: ${health.error}`); + fail('Start it with: node src\\index.js'); + return EXIT.ERROR; + } + const res = await call('getStatus'); + if (!res.ok) { + fail(res.response.error || 'status failed'); + return EXIT.ERROR; + } + const st = res.response; + say(`gateway running pid ${st.pid} port ${st.port} since ${st.startedAt}`); + say(''); + const rows = []; + for (const name of st.knownProviders || PROVIDER_NAMES) { + const auth = await call('isLoggedIn', name); + rows.push([ + name, + (st.providers || []).includes(name) ? 'loaded' : '-', + auth.ok ? (auth.response.loggedIn ? 'yes' : 'NO') : '?', + ]); + } + say(table(rows, ['PROVIDER', 'STATE', 'LOGGED IN'])); + if ((st.providers || []).length === 0) { + say(''); + say(' No provider is loaded. Start one with: proxima-gw login --headed'); + } + return EXIT.OK; +} + +async function cmdLogin(provider, flags) { + if (!PROVIDERS[provider]) { + fail(`Unknown provider "${provider}". Known: ${PROVIDER_NAMES.join(', ')}`); + return EXIT.USAGE; + } + + if (flags.headed) { + // Flip just this provider to a visible Chromium window, load it, and leave it + // open. The operator logs in by hand; the profile is flushed on unload or + // shutdown, and the window is disposable. + say(`Opening a visible window for ${provider}.`); + say('Log in in that window, then close it - the session is saved to the profile.'); + say(''); + const set = await call('setSetting', null, { + key: 'headlessByProvider', + value: { [provider]: false }, + }); + if (!set.ok) { + fail(set.response.error || 'could not set headless override'); + return EXIT.ERROR; + } + const init = await call('initProvider', provider, {}, 90000); + if (!init.ok) { + fail(init.response.error || 'could not open the provider'); + return EXIT.ERROR; + } + say(`${provider} is open. This setting is restart-requiring;`); + say('to go back to headless: proxima-gw headless ' + provider); + return EXIT.OK; + } + + let cookies; + if (flags.stdin) { + // PowerShell: Get-Content file -Raw | proxima-gw login claude --stdin + const chunks = []; + for await (const c of process.stdin) chunks.push(c); + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) { + fail('No cookie JSON arrived on stdin.'); + return EXIT.USAGE; + } + try { + cookies = JSON.parse(raw); + } catch (e) { + fail(`Cookie JSON on stdin is not valid: ${e.message}`); + return EXIT.USAGE; + } + } else if (typeof flags.cookies === 'string') { + const file = path.resolve(flags.cookies); + if (!fs.existsSync(file)) { + fail(`No such file: ${file}`); + return EXIT.USAGE; + } + try { + cookies = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (e) { + fail(`${file} is not valid JSON: ${e.message}`); + return EXIT.USAGE; + } + } else { + fail('Provide --cookies or --headed.'); + say(''); + say(' proxima-gw login claude --cookies .\\cookies\\claude.json'); + say(' proxima-gw login claude --headed'); + return EXIT.USAGE; + } + + const res = await call('setCookies', provider, { cookies }, 60000); + if (!res.ok) { + fail(res.response.error || 'login failed'); + return EXIT.ERROR; + } + const r = res.response; + say(`${provider}: ${r.message}`); + if (r.failed > 0) say(` ${r.failed} cookie(s) were rejected by the browser.`); + if (!r.loggedIn) { + say(''); + say(' The cookies were applied but none are recognised auth cookies for this'); + say(' provider. Export again from the provider origin, or use --headed.'); + return EXIT.ERROR; + } + return EXIT.OK; +} + +async function cmdLogout(provider) { + if (!PROVIDERS[provider]) { + fail(`Unknown provider "${provider}". Known: ${PROVIDER_NAMES.join(', ')}`); + return EXIT.USAGE; + } + const res = await call('purgeProvider', provider); + if (!res.ok) { + fail(res.response.error || 'logout failed'); + return EXIT.ERROR; + } + say(`${provider}: auth destroyed on disk. Next use needs a fresh login.`); + return EXIT.OK; +} + +async function cmdHeadless(provider) { + if (!PROVIDERS[provider]) { + fail(`Unknown provider "${provider}". Known: ${PROVIDER_NAMES.join(', ')}`); + return EXIT.USAGE; + } + const res = await call('setSetting', null, { + key: 'headlessByProvider', + value: { [provider]: true }, + }); + if (!res.ok) { + fail(res.response.error || 'could not update the setting'); + return EXIT.ERROR; + } + say(`${provider}: headless. Restart the gateway to apply.`); + return EXIT.OK; +} + +// ---- entry ------------------------------------------------------------------- + +async function main(argv) { + const args = parseArgs(argv); + const cmd = args._[0]; + + if (!cmd || args.flags.help || cmd === 'help') { + say(usage()); + return cmd ? EXIT.OK : EXIT.USAGE; + } + + switch (cmd) { + case 'check': + return cmdCheck(); + case 'status': + return cmdStatus(); + case 'login': + return cmdLogin(args._[1], args.flags); + case 'logout': + return cmdLogout(args._[1]); + case 'headless': + return cmdHeadless(args._[1]); + default: + fail(`Unknown command "${cmd}".`); + say(''); + say(usage()); + return EXIT.USAGE; + } +} + +if (require.main === module) { + main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((e) => { + fail(`proxima-gw: ${e && e.message ? e.message : e}`); + process.exit(EXIT.ERROR); + }); +} + +module.exports = { main, parseArgs, resolvePort, call, table, usage, EXIT }; diff --git a/gateway/package.json b/gateway/package.json index cb723ce..a031d1b 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -7,12 +7,15 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js", - "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js", + "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js test/cli.test.js", + "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js test/cli.test.js", "test:browser": "node --test --test-reporter=tap --test-timeout=60000 test/browser.test.js", "test:all": "npm run test:coverage && npm run test:browser", "coverage:check": "node scripts/check-coverage.cjs", - "engines:scaffold": "node scripts/make-engine-scaffold.cjs", + "gw": "node bin/proxima-gw.js", + "gw:start": "powershell -ExecutionPolicy Bypass -File proxima-gw-daemon.ps1 -Start", + "gw:stop": "powershell -ExecutionPolicy Bypass -File proxima-gw-daemon.ps1 -Stop", + "engines:scaffold": "node scripts/make-engine-scaffold.cjs", "setup:browser": "npx playwright install --with-deps chromium", "test:watch": "node --test --watch test/" }, diff --git a/gateway/proxima-gw-daemon.ps1 b/gateway/proxima-gw-daemon.ps1 new file mode 100644 index 0000000..7acb6b7 --- /dev/null +++ b/gateway/proxima-gw-daemon.ps1 @@ -0,0 +1,116 @@ +<# +.SYNOPSIS + Start the Proxima provider gateway as a background process. + +.DESCRIPTION + Starts the daemon and waits until it is actually answering on its port, so a + following `proxima-gw status` does not race the startup. + + The daemon writes ipc-port.json to the same directory the legacy automation + client reads, so cli\proxima-cli.cjs and scripts\proxima-loop.cjs can find it + with no environment variable set. + +.EXAMPLE + .\proxima-gw.ps1 -Start + +.EXAMPLE + .\proxima-gw.ps1 -Start -Port 19322 + +.EXAMPLE + .\proxima-gw.ps1 -Stop +#> +[CmdletBinding(DefaultParameterSetName = 'Start')] +param( + [Parameter(ParameterSetName = 'Start')] + [int] $Port = 0, + + [Parameter(ParameterSetName = 'Start')] + [string] $LogDir = '', + + [Parameter(ParameterSetName = 'Stop')] + [switch] $Stop +) + +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot + +function Get-GatewayState { + $fact = $null + if ($env:APPDATA) { + $legacy = Join-Path $env:APPDATA 'proxima\ipc-port.json' + if (Test-Path -LiteralPath $legacy) { + $fact = Get-Content -LiteralPath $legacy -Raw | ConvertFrom-Json + } + } + return $fact +} + +if ($PSCmdlet.ParameterSetName -eq 'Stop') { + $fact = Get-GatewayState + if (-not $fact) { + Write-Output 'Gateway not running (no ipc-port.json).' + exit 0 + } + $proc = Get-Process -Id $fact.pid -ErrorAction SilentlyContinue + if (-not $proc) { + Write-Output "No process with pid $($fact.pid); the fact file is stale." + exit 0 + } + Stop-Process -Id $fact.pid + Write-Output "Stopped gateway pid $($fact.pid)." + exit 0 +} + +# ---- start -------------------------------------------------------------------- + +$existing = Get-GatewayState +if ($existing) { + $proc = Get-Process -Id $existing.pid -ErrorAction SilentlyContinue + if ($proc) { + Write-Output "Gateway already running: pid $($existing.pid) on port $($existing.port)." + exit 0 + } +} + +if (-not $LogDir) { + $LogDir = Join-Path $env:USERPROFILE '.proxima-gateway\logs' +} +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$out = Join-Path $LogDir "gateway-$stamp.out.log" +$err = Join-Path $LogDir "gateway-$stamp.err.log" + +$nodeArgs = @('src\index.js') +if ($Port -gt 0) { + $env:AGENT_HUB_PORT = "$Port" +} + +$proc = Start-Process -FilePath 'node' -ArgumentList $nodeArgs ` + -WorkingDirectory $root -PassThru -WindowStyle Hidden ` + -RedirectStandardOutput $out -RedirectStandardError $err + +# Wait for the port fact to appear and name this pid. Bail out rather than report +# success for a process that died on startup. +$deadline = (Get-Date).AddSeconds(30) +$ready = $false +while ((Get-Date) -lt $deadline) { + if ($proc.HasExited) { break } + $fact = Get-GatewayState + if ($fact -and $fact.pid -eq $proc.Id) { $ready = $true; break } + Start-Sleep -Milliseconds 250 +} + +if ($ready) { + $fact = Get-GatewayState + Write-Output "Gateway started: pid $($proc.Id) on port $($fact.port)." + Write-Output " logs: $out" + Write-Output " next: proxima-gw status" + exit 0 +} + +Write-Output "Gateway did not start. Recent stderr:" +if (Test-Path -LiteralPath $err) { + Get-Content -LiteralPath $err -Tail 20 | ForEach-Object { Write-Output " $_" } +} +exit 1 diff --git a/gateway/proxima-gw.ps1 b/gateway/proxima-gw.ps1 new file mode 100644 index 0000000..57e7abb --- /dev/null +++ b/gateway/proxima-gw.ps1 @@ -0,0 +1,44 @@ +<# +.SYNOPSIS + proxima-gw - control the Proxima provider gateway. + +.DESCRIPTION + Thin PowerShell wrapper so the CLI is callable as a normal command + (proxima-gw status) rather than as `node bin\proxima-gw.js status`. + + Uses whatever `node` is on PATH. The gateway daemon itself is started + separately with proxima-gw.ps1 -Start. + +.EXAMPLE + proxima-gw status + proxima-gw login claude --headed + proxima-gw login claude --cookies .\cookies\claude.json + +.EXAMPLE + Get-Content .\cookies\claude.json -Raw | proxima-gw login claude --stdin +#> +[CmdletBinding()] +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $Rest +) + +$ErrorActionPreference = 'Stop' + +# Resolve the CLI relative to this script, so it works from any directory. +$cli = Join-Path $PSScriptRoot 'bin\proxima-gw.js' +if (-not (Test-Path -LiteralPath $cli)) { + Write-Error "Cannot find $cli" + exit 1 +} + +$node = Get-Command node -ErrorAction SilentlyContinue +if (-not $node) { + Write-Error "node is not on PATH. Install Node 20+ and try again." + exit 1 +} + +# `node` on Windows is node.exe, which PowerShell will not execute without the +# call operator, hence the explicit invocation and argument array. +& $node.Source $cli @Rest +exit $LASTEXITCODE diff --git a/gateway/src/index.js b/gateway/src/index.js index ee7a21b..0c42657 100644 --- a/gateway/src/index.js +++ b/gateway/src/index.js @@ -32,9 +32,12 @@ async function startGateway({ port = DEFAULT_PORT, host = '127.0.0.1', stateDir const fileReferenceEnabled = () => stateStore.loadSettings().fileReference !== false; const handler = createHandler({ sessions, stateStore, startedAt, getFileReferenceEnabled: fileReferenceEnabled }); + // Relocating is opt-in. By default a taken port is an error, because silently + // moving leaves a port fact that can point a client at the other process. + const allowRelocate = /^(1|true|yes)$/i.test(String(process.env.PROXIMA_GATEWAY_PORT || '')); const ipc = new IpcServer({ handler, stateStore, port, host }); - const boundPort = await ipc.listen(); + const boundPort = await ipc.listen({ allowRelocate }); let shuttingDown = false; async function shutdown() { diff --git a/gateway/src/ipc-server.js b/gateway/src/ipc-server.js index 7bc017c..6b8a6c0 100644 --- a/gateway/src/ipc-server.js +++ b/gateway/src/ipc-server.js @@ -3,6 +3,7 @@ const net = require('net'); const { DEFAULT_PORT, FALLBACK_ATTEMPTS } = require('./state-store'); const { toSafeError } = require('./logger'); +const { log } = require('./logger'); const { redactString, redact } = require('./redact'); /** @@ -76,18 +77,31 @@ class IpcServer { } /** - * Bind, retrying once on the next port before failing loudly (F2). - * The port actually bound is written to the fact file AFTER the bind succeeds. + * Bind, and fail loudly if the port is taken. + * + * The previous behaviour silently moved to port+1. That is worse than failing: + * the port fact then points a client at whichever process won 19222, and a client + * probing candidates in order will happily talk to the wrong one - which, for a + * second instance, means the legacy Electron app answering a gateway request. + * + * A relocation is still possible, but only when explicitly asked for, so the + * operator knows two processes are sharing a protocol. */ - async listen() { - const attempts = this.basePort + FALLBACK_ATTEMPTS; + async listen({ allowRelocate = false } = {}) { let lastError = null; + const maxPort = this.basePort + (allowRelocate ? FALLBACK_ATTEMPTS : 0); - for (let port = this.basePort; port <= this.basePort + FALLBACK_ATTEMPTS; port += 1) { + for (let port = this.basePort; port <= maxPort; port += 1) { try { const bound = await this._tryListen(port); this.boundPort = bound; this.state.recordPortFact(bound); + if (port !== this.basePort) { + log.warn( + `relocated to port ${bound}; another process holds ${this.basePort}. ` + + `Clients may reach the wrong process.` + ); + } return bound; } catch (e) { lastError = e; @@ -96,10 +110,13 @@ class IpcServer { } } + const fact = this.state.readPortFact(); throw new Error( - `Could not bind the gateway on port ${this.basePort} or ${this.basePort + 1}: ` + - `${redactString(lastError ? lastError.message : 'unknown error')}. ` + - `Another instance may be running - check ${this.state.portFactPath}.` + `Port ${this.basePort} is already in use` + + (fact && fact.pid ? ` by pid ${fact.pid}` : '') + + '. Stop the other Proxima, or start this one on a different port:' + + ` AGENT_HUB_PORT= node src/index.js` + + ' (or set PROXIMA_GATEWAY_PORT=1 to relocate automatically).' ); } diff --git a/gateway/src/state-store.js b/gateway/src/state-store.js index 8dd48d4..09b0804 100644 --- a/gateway/src/state-store.js +++ b/gateway/src/state-store.js @@ -18,7 +18,26 @@ const path = require('path'); */ const DEFAULT_PORT = 19222; -const FALLBACK_ATTEMPTS = 1; // try port, then port+1, then give up (F2) +const FALLBACK_ATTEMPTS = 0; // no silent relocation: see IpcServer.listen + +/** + * The legacy Electron app's userData directory. + * + * Duplicated from scripts/lib/proxima-port.cjs rather than imported, because that file + * lives in the automation client and this module must not depend on it. The paths MUST + * stay identical: this is where the existing client looks for ipc-port.json, so it is + * the only place the gateway's port can be discovered by the tooling that is supposed + * to talk to it. + */ +function legacyUserDataDir() { + if (process.platform === 'win32' && process.env.APPDATA) { + return path.join(process.env.APPDATA, 'proxima'); + } + if (process.platform === 'darwin' && process.env.HOME) { + return path.join(process.env.HOME, 'Library', 'Application Support', 'proxima'); + } + return path.join(process.env.HOME || '.', '.config', 'proxima'); +} function defaultStateDir() { const override = process.env.PROXIMA_GATEWAY_STATE_DIR; @@ -30,7 +49,6 @@ class StateStore { constructor(dir = defaultStateDir()) { this.dir = dir; this.settingsPath = path.join(dir, 'settings.json'); - this.portFactPath = path.join(dir, 'ipc-port.json'); this.profilesDir = path.join(dir, 'profiles'); this.cookieBackupDir = path.join(dir, 'cookie-backups'); this._ensureDirs(); @@ -63,18 +81,43 @@ class StateStore { // ---- port fact ---- + /** + * Where the port fact is WRITTEN. + * + * The legacy app's userData dir, not this gateway's own state dir. The existing + * automation client reads ipc-port.json from exactly this path, so a fact written + * anywhere else is invisible to it - the client would report "Proxima is not + * running" against a healthy gateway. Profiles and cookies stay private to the + * gateway's state dir; only the port is a shared fact. + */ + get portFactPath() { + const override = process.env.PROXIMA_GATEWAY_PORT_FACT; + if (override) return override; + return path.join(legacyUserDataDir(), 'ipc-port.json'); + } + /** * Read the port the running process actually bound. * Returns null when no process has recorded a fact (or the recorded pid is gone). */ readPortFact() { - try { - const fact = JSON.parse(fs.readFileSync(this.portFactPath, 'utf8')); - if (!fact || typeof fact.port !== 'number') return null; - return fact; - } catch { - return null; + for (const file of this.portFactPaths()) { + try { + const fact = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!fact || typeof fact.port !== 'number') continue; + return { ...fact, file }; + } catch { + /* try the next location */ + } } + return null; + } + + /** Both locations, own state dir first so the gateway is self-sufficient. */ + portFactPaths() { + const own = path.join(this.dir, 'ipc-port.json'); + const legacy = this.portFactPath; + return own === legacy ? [own] : [own, legacy]; } /** @@ -112,7 +155,19 @@ class StateStore { startedAt: new Date().toISOString(), ...extra, }; - fs.writeFileSync(this.portFactPath, JSON.stringify(fact, null, 2), 'utf8'); + const body = JSON.stringify(fact, null, 2); + + // Written to BOTH locations. The legacy location is what makes the existing + // automation client able to find this process at all. + for (const file of this.portFactPaths()) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, body, 'utf8'); + } catch { + /* one location failing must not stop the other */ + } + } + // Keep the preference in sync so it is not misleading next time, but the fact // remains the authority. try { @@ -124,10 +179,12 @@ class StateStore { } clearPortFact() { - try { - fs.unlinkSync(this.portFactPath); - } catch { - /* already gone */ + for (const file of this.portFactPaths()) { + try { + fs.unlinkSync(file); + } catch { + /* already gone */ + } } } diff --git a/gateway/test/cli.test.js b/gateway/test/cli.test.js new file mode 100644 index 0000000..4f35a7d --- /dev/null +++ b/gateway/test/cli.test.js @@ -0,0 +1,357 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); +const { execFileSync, spawn } = require('child_process'); + +const CLI = path.join(__dirname, '..', 'bin', 'proxima-gw.js'); + +/** + * CLI tests. + * + * The CLI is the only way a human touches the gateway, so it gets tested like one: + * real argv, real sockets, real exit codes. A CLI that returns 0 on failure is worse + * than no CLI, because a script trusts the exit code. + */ + +function tmpState() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-cli-')); + return { dir, fact: path.join(dir, 'port.json') }; +} + +/** Run the CLI with a captured environment. Returns { code, out, err }. */ +function runCli(args, env = {}) { + const res = require('child_process').spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + env: { + ...process.env, + PROXIMA_GATEWAY_STATE_DIR: path.join(os.tmpdir(), 'proxima-cli-state'), + ...env, + }, + timeout: 20000, + }); + return { code: res.status, out: res.stdout || '', err: res.stderr || '' }; +} + +// ---- argument parsing -------------------------------------------------------- + +test('parseArgs separates positionals from flags', () => { + const { parseArgs } = require('../bin/proxima-gw.js'); + const a = parseArgs(['login', 'claude', '--cookies', 'c.json', '--headed']); + assert.deepEqual(a._, ['login', 'claude']); + assert.equal(a.flags.cookies, 'c.json'); + assert.equal(a.flags.headed, true, 'a trailing flag with no value is boolean true'); +}); + +test('parseArgs does not swallow a following positional as a flag value', () => { + const { parseArgs } = require('../bin/proxima-gw.js'); + const a = parseArgs(['--headless', 'logout', 'qwen']); + assert.equal(a.flags.headless, true); + assert.deepEqual(a._, ['logout', 'qwen']); +}); + +test('usage lists every command', () => { + const { usage } = require('../bin/proxima-gw.js'); + for (const c of ['check', 'status', 'login', 'logout']) { + assert.ok(usage().includes(c), `usage must mention ${c}`); + } +}); + +// ---- port discovery ---------------------------------------------------------- + +test('resolvePort prefers AGENT_HUB_PORT over the recorded fact', () => { + const { dir, fact } = tmpState(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(fact, JSON.stringify({ port: 19999, pid: process.pid })); + const saved = process.env.AGENT_HUB_PORT; + process.env.AGENT_HUB_PORT = '19500'; + try { + const { resolvePort } = require('../bin/proxima-gw.js'); + const r = resolvePort(); + assert.equal(r.port, 19500, 'env must win, matching the automation client precedence'); + assert.equal(r.via, 'env'); + } finally { + if (saved === undefined) delete process.env.AGENT_HUB_PORT; + else process.env.AGENT_HUB_PORT = saved; + } +}); + +test('the CLI exit code is non-zero when the gateway is not running', () => { + const { fact } = tmpState(); + const r = runCli(['check'], { AGENT_HUB_PORT: '19998', PROXIMA_GATEWAY_PORT_FACT: fact }); + assert.equal(r.code, 1, 'a script must be able to trust this exit code'); + assert.match(r.out, /NOT RUNNING/); + // The message has to tell the operator what to do, not just that it failed. + assert.match(r.out, /node src\\index\.js|node src\/index\.js/); +}); + +test('an unknown command is a usage error, not a stack trace', () => { + const r = runCli(['nope']); + assert.equal(r.code, 2); + assert.match(r.err, /Unknown command/); + assert.ok(!/at Object\.|at Module\./.test(r.err), 'must not dump a stack trace at the user'); +}); + +test('an unknown provider is rejected before any connection', () => { + const r = runCli(['login', 'notaprovider', '--cookies', 'x.json']); + assert.equal(r.code, 2); + assert.match(r.err, /Unknown provider/); + assert.match(r.err, /perplexity/, 'must list the valid providers'); +}); + +test('a missing cookie file is reported clearly', () => { + const r = runCli(['login', 'claude', '--cookies', 'C:\\definitely\\not\\here.json']); + assert.equal(r.code, 2); + assert.match(r.err, /No such file/); +}); + +test('login with no method is a usage error listing both options', () => { + const r = runCli(['login', 'claude']); + assert.equal(r.code, 2); + assert.match(r.err, /--cookies/); + assert.match(r.err, /--headed/); +}); + +test('help exits zero and prints usage', () => { + const r = runCli(['help']); + assert.equal(r.code, 0); + assert.match(r.out, /USAGE/); +}); + +// ---- table formatting -------------------------------------------------------- + +test('table pads columns so it is readable in a terminal', () => { + const { table } = require('../bin/proxima-gw.js'); + const out = table( + [ + ['chatgpt', 'loaded', 'yes'], + ['perplexity', '-', 'NO'], + ], + ['PROVIDER', 'STATE', 'LOGGED IN'] + ); + const lines = out.split('\n'); + assert.ok(lines[0].includes('PROVIDER')); + assert.ok(lines[2].includes('chatgpt')); + // The separator must be as wide as the header. + assert.ok(lines[1].includes('----------')); +}); + +test('table handles an empty result set', () => { + const { table } = require('../bin/proxima-gw.js'); + assert.match(table([], ['A', 'B']), /none/); +}); + +test('table output has no ANSI escape codes', () => { + // Colour would corrupt redirected output and any downstream parsing. + const { table } = require('../bin/proxima-gw.js'); + const out = table([['a', 'b']], ['A', 'B']); + // eslint-disable-next-line no-control-regex + assert.ok(!/\[/.test(out), 'must be plain text'); +}); + +// ---- end to end against a stub gateway -------------------------------------- + +/** A minimal stand-in that answers the actions the CLI issues. */ +async function startStub(handlers) { + const server = net.createServer((socket) => { + let buf = ''; + socket.on('data', (chunk) => { + buf += chunk.toString(); + const lines = buf.split('\n'); + buf = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + let req; + try { + req = JSON.parse(line); + } catch { + continue; + } + const res = (handlers[req.action] || (() => ({ success: false, error: 'no stub' })))(req); + socket.write(JSON.stringify({ ...res, requestId: req.requestId }) + '\n'); + } + }); + socket.on('error', () => {}); + }); + const port = await new Promise((resolve) => + server.listen(0, '127.0.0.1', () => resolve(server.address().port)) + ); + return { server, port, close: () => new Promise((r) => server.close(r)) }; +} + +test('status prints a row per provider and exits zero', async () => { + const stub = await startStub({ + ping: () => ({ success: true, message: 'pong' }), + getStatus: () => ({ + success: true, + providers: ['claude'], + knownProviders: ['chatgpt', 'perplexity', 'claude', 'gemini', 'qwen'], + pid: 1234, + port: 19222, + startedAt: '2026-01-01T00:00:00.000Z', + }), + isLoggedIn: (req) => ({ success: true, provider: req.provider, loggedIn: req.provider === 'claude' }), + }); + try { + const r = runCli(['status'], { AGENT_HUB_PORT: String(stub.port) }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /PROVIDER/); + assert.match(r.out, /claude\s+loaded\s+yes/); + assert.match(r.out, /qwen\s+-\s+NO/); + } finally { + await stub.close(); + } +}); + +test('status reports an unreachable gateway without a stack trace', () => { + const r = runCli(['status'], { AGENT_HUB_PORT: '19997' }); + assert.equal(r.code, 1); + assert.match(r.err, /not reachable/i); + assert.ok(!/at Object\./.test(r.err)); +}); + +test('login --cookies reports success and verifies auth', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-cookies-')); + const file = path.join(tmp, 'claude.json'); + fs.writeFileSync(file, JSON.stringify([{ name: 'sessionKey', value: 'abc', domain: '.claude.ai' }])); + + const stub = await startStub({ + setCookies: (req) => ({ + success: true, + provider: req.provider, + set: 1, + failed: 0, + loggedIn: true, + message: 'Applied 1 cookies. Session authenticated.', + }), + }); + try { + const r = runCli(['login', 'claude', '--cookies', file], { AGENT_HUB_PORT: String(stub.port) }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /claude: .*authenticated/); + } finally { + await stub.close(); + } +}); + +test('login reports failure when cookies are applied but do not authenticate', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-cookies-')); + const file = path.join(tmp, 'claude.json'); + fs.writeFileSync(file, JSON.stringify([{ name: 'theme', value: 'dark', domain: '.claude.ai' }])); + + const stub = await startStub({ + setCookies: () => ({ + success: true, + provider: 'claude', + set: 1, + failed: 0, + loggedIn: false, + message: 'Applied 1 cookies. No recognised auth cookie found.', + }), + }); + try { + const r = runCli(['login', 'claude', '--cookies', file], { AGENT_HUB_PORT: String(stub.port) }); + assert.equal(r.code, 1, 'a non-authenticating login must not report success'); + assert.match(r.out, /No recognised auth cookie/); + assert.match(r.out, /--headed/, 'must suggest the alternative'); + } finally { + await stub.close(); + } +}); + +test('logout confirms destruction on disk', async () => { + const stub = await startStub({ + purgeProvider: () => ({ success: true, purged: [{ provider: 'qwen', purged: true }] }), + }); + try { + const r = runCli(['logout', 'qwen'], { AGENT_HUB_PORT: String(stub.port) }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /auth destroyed on disk/); + } finally { + await stub.close(); + } +}); + +test('logout propagates a server-side refusal', async () => { + const stub = await startStub({ + purgeProvider: () => ({ success: false, error: 'name a provider, or pass data.providers' }), + }); + try { + const r = runCli(['logout', 'qwen'], { AGENT_HUB_PORT: String(stub.port) }); + assert.equal(r.code, 1, 'a refused destructive action must not exit zero'); + assert.match(r.err, /name a provider/); + } finally { + await stub.close(); + } +}); + +test('login --headed sets the override and explains the restart', async () => { + const stub = await startStub({ + setSetting: (req) => ({ success: true, settings: req.data }), + initProvider: () => ({ success: true, provider: 'chatgpt' }), + }); + try { + const r = runCli(['login', 'chatgpt', '--headed'], { AGENT_HUB_PORT: String(stub.port) }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /visible window/); + assert.match(r.out, /restart-requiring/); + } finally { + await stub.close(); + } +}); + +// ---- interop: the port fact must land where the legacy client looks ---------- + +test('the gateway writes its port fact where the legacy client reads it', () => { + const { StateStore, legacyUserDataDir } = require('../src/state-store'); + // Mirrors the path resolution in scripts/lib/proxima-port.cjs. If these ever + // diverge, the existing automation client silently stops finding the gateway. + const clientPath = (() => { + if (process.platform === 'win32' && process.env.APPDATA) { + return path.join(process.env.APPDATA, 'proxima', 'ipc-port.json'); + } + if (process.platform === 'darwin' && process.env.HOME) { + return path.join(process.env.HOME, 'Library', 'Application Support', 'proxima', 'ipc-port.json'); + } + return path.join(process.env.HOME || '.', '.config', 'proxima', 'ipc-port.json'); + })(); + + const store = new StateStore(fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-facts-'))); + assert.equal( + store.portFactPath, + clientPath, + 'gateway and client must agree on the fact file, or the client cannot connect' + ); + assert.ok(legacyUserDataDir().length > 0); +}); + +test('a port collision fails loudly instead of relocating', async () => { + const { StateStore } = require('../src/state-store'); + const { IpcServer } = require('../src/ipc-server'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxima-collide-')); + const store = new StateStore(dir); + + const first = new IpcServer({ handler: async () => ({ success: true }), stateStore: store, port: 0 }); + const taken = await first._tryListen(0); + const port = first.server.address().port; + + const second = new IpcServer({ handler: async () => ({ success: true }), stateStore: store, port }); + // Relocation is opt-in, so the default must refuse. + await assert.rejects(() => second.listen(), /already in use/); + + // And it must not have written a fact naming the wrong process. + const fact = store.readPortFact(); + assert.ok(!fact || fact.port !== port, 'must not leave a fact pointing at the other process'); + + // Opting in works, and warns. + const moved = await second.listen({ allowRelocate: true }); + assert.equal(moved, port + 1); + + await second.close(); + await first.close(); + void taken; +}); From 8cd500ca9784e0fd13b97f85b8f07aecfcfdefb1 Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 22:54:13 +0500 Subject: [PATCH 07/12] fix(ci): stop the coverage step hiding test failures GitHub's default Linux shell is 'bash -e {0}', which has no pipefail. So 'npm run test:coverage | tee coverage.txt' returned tee's exit code, and a failing suite reported success. A green job had 1 of 157 tests failing in it - 'port collision retries +1', a test asserting the auto-relocate behaviour that was intentionally removed a commit earlier. 'shell: bash' is 'bash -eo pipefail', so the pipeline now propagates the test result. This has been latent since the tee was introduced. Also fixed the stale test itself to assert the new contract (fail loudly by default, relocate only on opt-in), and wrapped it in try/finally: a thrown assert left the squatter's server handle open, which timed out the whole file and buried the real failure under an unrelated one. The CLI suite needed more than the 20s per-test budget, so it now has 60s. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gateway.yml | 8 +++++++- gateway/bin/proxima-gw.js | 4 ++-- gateway/package.json | 4 ++-- gateway/test/unit.test.js | 33 +++++++++++++++++++++++---------- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml index 8b974b8..b7b59b8 100644 --- a/.github/workflows/gateway.yml +++ b/.github/workflows/gateway.yml @@ -34,11 +34,17 @@ jobs: - name: Run test suite with coverage working-directory: gateway + # shell: bash, not the default. The default Linux shell is `bash -e {0}`, + # which has NO pipefail - so `... | tee coverage.txt` returns tee's exit + # code and a failing suite still reports success. That happened: a green + # job with 1 of 157 tests failing. `shell: bash` is `bash -eo pipefail`. + shell: bash run: npm run test:coverage 2>&1 | tee coverage.txt - name: Enforce coverage floor working-directory: gateway - # line 85 / branch 80 / funcs 80. Current: 92.70 / 84.53 / 88.64. + # line 85 / branch 80 / funcs 80. Current: 94.24 / 83.51 / 90.73. + shell: bash run: node scripts/check-coverage.cjs coverage.txt 85 80 80 - name: Upload coverage report diff --git a/gateway/bin/proxima-gw.js b/gateway/bin/proxima-gw.js index b59510a..acebc49 100644 --- a/gateway/bin/proxima-gw.js +++ b/gateway/bin/proxima-gw.js @@ -174,7 +174,7 @@ function table(rows, headers) { async function cmdCheck() { const { port, via } = resolvePort(); - const res = await call('ping', null, {}, 4000); + const res = await call('ping', null, {}, 2500); if (res.ok) { say(`gateway RUNNING on 127.0.0.1:${port} (port from ${via})`); return EXIT.OK; @@ -188,7 +188,7 @@ async function cmdCheck() { } async function cmdStatus() { - const health = await call('ping', null, {}, 4000); + const health = await call('ping', null, {}, 2500); if (!health.ok) { fail(`Gateway not reachable: ${health.error}`); fail('Start it with: node src\\index.js'); diff --git a/gateway/package.json b/gateway/package.json index a031d1b..2275975 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -7,8 +7,8 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "test": "node --test --test-reporter=tap --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js test/cli.test.js", - "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=20000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js test/cli.test.js", + "test": "node --test --test-reporter=tap --test-timeout=60000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js test/cli.test.js", + "test:coverage": "node --test --test-reporter=tap --experimental-test-coverage --test-timeout=60000 test/unit.test.js test/integration.test.js test/contract.test.js test/redact.test.js test/leak.test.js test/engines.test.js test/regression.test.js test/cli.test.js", "test:browser": "node --test --test-reporter=tap --test-timeout=60000 test/browser.test.js", "test:all": "npm run test:coverage && npm run test:browser", "coverage:check": "node scripts/check-coverage.cjs", diff --git a/gateway/test/unit.test.js b/gateway/test/unit.test.js index 2a24eb2..9769264 100644 --- a/gateway/test/unit.test.js +++ b/gateway/test/unit.test.js @@ -233,18 +233,31 @@ test('server refuses a non-loopback bind (P7)', () => { ); }); -test('port collision retries +1 then reports both ports (F2)', async () => { +test('a port collision fails loudly rather than relocating (F2)', async () => { const s = new StateStore(tmpDir()); const squatter = new IpcServer({ handler: async () => ({ success: true }), stateStore: s, port: 0 }); - const taken = await squatter._tryListen(0); - const takenPort = squatter.server.address().port; - - const second = new IpcServer({ handler: async () => ({ success: true }), stateStore: s, port: takenPort }); - const bound = await second.listen(); - assert.equal(bound, takenPort + 1, 'must land on the next port'); - - await second.close(); - await squatter.close(); + const second = new IpcServer({ handler: async () => ({ success: true }), stateStore: s, port: 0 }); + + try { + await squatter._tryListen(0); + const takenPort = squatter.server.address().port; + const colliding = new IpcServer({ handler: async () => ({ success: true }), stateStore: s, port: takenPort }); + + // Relocation is opt-in. Silently moving to port+1 would leave a fact pointing + // a client at whichever process won the real port. + await assert.rejects(() => colliding.listen(), /already in use/); + + // Opting in relocates, and is explicit about it. + const moved = await colliding.listen({ allowRelocate: true }); + assert.equal(moved, takenPort + 1); + + await colliding.close(); + } finally { + // Without this the squatter's handle stays open and the whole test FILE times + // out, which masks the real failure behind an unrelated one. + await second.close(); + await squatter.close(); + } }); // ---------------------------------------------------------------- engines From 228bb1afa6eec8e67e6eb73550d2946afea5044c Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 23:08:08 +0500 Subject: [PATCH 08/12] fix(cli): three real bugs the CLI tests found, and test isolation Writing the CLI tests found defects in the CLI, not just in the tests. parseArgs could not tell a flag value from a command name, so '--headless logout qwen' parsed as headless='logout' with one positional and the command never ran. Value-taking flags are now an explicit list (only --cookies), so everything else is boolean. The CLI crashed on every socket-level error: call() returns {ok, error} with no response field on a connection failure, and the handlers read res.response.error unconditionally - so the exact moment you most need a message (gateway down) produced 'Cannot read properties of undefined'. All four sites now fall back to the transport error. FALLBACK_ATTEMPTS was 0, which made allowRelocate a flag that promised something the code never did: opting in relocated nowhere. It is 1, and the default remains to refuse. The test harness deadlocked: runCli used spawnSync while the stub gateway ran in the same process, so the child connected but the stub could never reply and every end-to-end test hung to its timeout. runCli is async now. Per-call CLI timeouts dropped from 30-90s to 15s - a CLI answers a human and must not look like it is hanging. Tests were also writing the port fact into the real %APPDATA%\proxima, because that is now the gateway's fact location. Five test files started servers concurrently and both polluted the actual user profile and raced each other over one fact file, which is what made 'port fact beats stale preference' fail intermittently. Each file now redirects the fact path to a temp file, and nothing touches the real profile. Tests: 177, all passing. Coverage 94.51 line / 80.26 branch / 91.43 funcs. Branch is close to the floor - proxima-gw.js is the weakest at 81.9% line, so the next change there should come with tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/bin/proxima-gw.js | 52 ++++++++++++++----- gateway/src/ipc-server.js | 15 +++++- gateway/src/state-store.js | 12 ++++- gateway/test/cli.test.js | 85 +++++++++++++++++++++----------- gateway/test/contract.test.js | 8 +++ gateway/test/integration.test.js | 8 +++ gateway/test/leak.test.js | 8 +++ gateway/test/regression.test.js | 8 +++ 8 files changed, 150 insertions(+), 46 deletions(-) diff --git a/gateway/bin/proxima-gw.js b/gateway/bin/proxima-gw.js index acebc49..e2a7766 100644 --- a/gateway/bin/proxima-gw.js +++ b/gateway/bin/proxima-gw.js @@ -67,6 +67,15 @@ function usage() { // ---- argument parsing -------------------------------------------------------- +/** + * Flags that take a value. + * + * Without this, `--headless logout qwen` parses as headless="logout" with a single + * positional, because the parser cannot know that a following word is a command name + * rather than an argument. An allowlist is the only way to tell them apart. + */ +const VALUE_FLAGS = new Set(['cookies']); + function parseArgs(argv) { const out = { _: [], flags: {} }; for (let i = 0; i < argv.length; i += 1) { @@ -74,7 +83,7 @@ function parseArgs(argv) { if (a.startsWith('--')) { const key = a.slice(2); const next = argv[i + 1]; - if (next && !next.startsWith('--')) { + if (VALUE_FLAGS.has(key) && next && !next.startsWith('--')) { out.flags[key] = next; i += 1; } else { @@ -109,8 +118,24 @@ function pidAlive(pid) { } } +/** + * Per-call timeout. + * + * A CLI answers a human, so it must not hang. The default is short enough that a + * wedged gateway fails visibly rather than looking like the tool is thinking, and it + * is overridable for the one case that legitimately takes longer: opening a provider. + */ +const DEFAULT_TIMEOUT_MS = 15000; + +function timeoutMs(override) { + const env = Number(process.env.PROXIMA_GATEWAY_TIMEOUT_MS); + if (Number.isFinite(env) && env > 0) return env; + return override || DEFAULT_TIMEOUT_MS; +} + /** One request, one response, one socket. */ -function call(action, provider, data = {}, timeoutMs = 30000) { +function call(action, provider, data = {}, timeout) { + const ms = timeoutMs(timeout); const { port, via } = resolvePort(); return new Promise((resolve) => { const socket = net.createConnection({ port, host: '127.0.0.1' }); @@ -122,12 +147,15 @@ function call(action, provider, data = {}, timeoutMs = 30000) { settled = true; clearTimeout(timer); socket.destroy(); + // unref the socket too, so a CLI that has finished its work exits even if + // some handle lingers. A command that does not return is worse than useless. + if (socket.unref) socket.unref(); resolve(result); }; const timer = setTimeout(() => { - done({ ok: false, error: `Timed out after ${timeoutMs}ms talking to port ${port}` }); - }, timeoutMs); + done({ ok: false, error: `Timed out after ${ms}ms talking to port ${port}` }); + }, ms); if (timer.unref) timer.unref(); socket.on('error', (e) => { @@ -196,7 +224,7 @@ async function cmdStatus() { } const res = await call('getStatus'); if (!res.ok) { - fail(res.response.error || 'status failed'); + fail(res.response?.error || res.error || 'status failed'); return EXIT.ERROR; } const st = res.response; @@ -237,12 +265,12 @@ async function cmdLogin(provider, flags) { value: { [provider]: false }, }); if (!set.ok) { - fail(set.response.error || 'could not set headless override'); + fail(set.response?.error || 'could not set headless override'); return EXIT.ERROR; } - const init = await call('initProvider', provider, {}, 90000); + const init = await call('initProvider', provider, {}, 45000); if (!init.ok) { - fail(init.response.error || 'could not open the provider'); + fail(init.response?.error || 'could not open the provider'); return EXIT.ERROR; } say(`${provider} is open. This setting is restart-requiring;`); @@ -286,9 +314,9 @@ async function cmdLogin(provider, flags) { return EXIT.USAGE; } - const res = await call('setCookies', provider, { cookies }, 60000); + const res = await call('setCookies', provider, { cookies }, 30000); if (!res.ok) { - fail(res.response.error || 'login failed'); + fail(res.response?.error || res.error || 'login failed'); return EXIT.ERROR; } const r = res.response; @@ -310,7 +338,7 @@ async function cmdLogout(provider) { } const res = await call('purgeProvider', provider); if (!res.ok) { - fail(res.response.error || 'logout failed'); + fail(res.response?.error || res.error || 'logout failed'); return EXIT.ERROR; } say(`${provider}: auth destroyed on disk. Next use needs a fresh login.`); @@ -327,7 +355,7 @@ async function cmdHeadless(provider) { value: { [provider]: true }, }); if (!res.ok) { - fail(res.response.error || 'could not update the setting'); + fail(res.response?.error || res.error || 'could not update the setting'); return EXIT.ERROR; } say(`${provider}: headless. Restart the gateway to apply.`); diff --git a/gateway/src/ipc-server.js b/gateway/src/ipc-server.js index 6b8a6c0..3db2e51 100644 --- a/gateway/src/ipc-server.js +++ b/gateway/src/ipc-server.js @@ -123,9 +123,20 @@ class IpcServer { _tryListen(port) { return new Promise((resolve, reject) => { const server = net.createServer((socket) => this._onConnection(socket)); - server.once('error', reject); + const onError = (e) => { + // Discard the failed server. Leaving it constructed keeps a handle on + // the event loop, which surfaces later as an unrelated test-file + // timeout rather than as the bind failure that actually happened. + try { + server.close(); + } catch { + /* never listened, nothing to close */ + } + reject(e); + }; + server.once('error', onError); server.listen(port, this.host, () => { - server.removeListener('error', reject); + server.removeListener('error', onError); this.server = server; resolve(server.address().port); }); diff --git a/gateway/src/state-store.js b/gateway/src/state-store.js index 09b0804..0d2e81a 100644 --- a/gateway/src/state-store.js +++ b/gateway/src/state-store.js @@ -18,7 +18,9 @@ const path = require('path'); */ const DEFAULT_PORT = 19222; -const FALLBACK_ATTEMPTS = 0; // no silent relocation: see IpcServer.listen +// How far the gateway MAY move if relocation is explicitly enabled. Zero would make +// the allowRelocate option a lie: the flag would exist and do nothing. +const FALLBACK_ATTEMPTS = 1; /** * The legacy Electron app's userData directory. @@ -193,4 +195,10 @@ class StateStore { } } -module.exports = { StateStore, DEFAULT_PORT, FALLBACK_ATTEMPTS, defaultStateDir }; +module.exports = { + StateStore, + DEFAULT_PORT, + FALLBACK_ATTEMPTS, + defaultStateDir, + legacyUserDataDir, +}; diff --git a/gateway/test/cli.test.js b/gateway/test/cli.test.js index 4f35a7d..0760ccb 100644 --- a/gateway/test/cli.test.js +++ b/gateway/test/cli.test.js @@ -23,18 +23,43 @@ function tmpState() { return { dir, fact: path.join(dir, 'port.json') }; } -/** Run the CLI with a captured environment. Returns { code, out, err }. */ +/** + * Run the CLI as a real child process. + * + * Deliberately ASYNC. The end-to-end tests serve a stub gateway from this same process, + * and spawnSync blocks the event loop - so the child could connect but the stub could + * never accept or reply, and every such test hung until its timeout. The stub needs the + * loop to keep turning, so the child must be awaited, not blocked on. + */ function runCli(args, env = {}) { - const res = require('child_process').spawnSync(process.execPath, [CLI, ...args], { - encoding: 'utf8', - env: { - ...process.env, - PROXIMA_GATEWAY_STATE_DIR: path.join(os.tmpdir(), 'proxima-cli-state'), - ...env, - }, - timeout: 20000, + return new Promise((resolve) => { + const child = spawn(process.execPath, [CLI, ...args], { + env: { + ...process.env, + PROXIMA_GATEWAY_STATE_DIR: path.join(os.tmpdir(), 'proxima-cli-state'), + // Keep the CLI's own per-call wait short so a wrong-port mistake fails + // the test quickly instead of eating the file's whole budget. Set + // through the child env, not an inline `VAR=x` prefix, which is + // bash-only and breaks under npm's cmd.exe on Windows. + PROXIMA_GATEWAY_TIMEOUT_MS: '2000', + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let err = ''; + child.stdout.on('data', (c) => { out += c.toString(); }); + child.stderr.on('data', (c) => { err += c.toString(); }); + const killTimer = setTimeout(() => child.kill(), 15000); + child.on('close', (code) => { + clearTimeout(killTimer); + resolve({ code, out, err }); + }); + child.on('error', (e) => { + clearTimeout(killTimer); + resolve({ code: -1, out, err: err + e.message }); + }); }); - return { code: res.status, out: res.stdout || '', err: res.stderr || '' }; } // ---- argument parsing -------------------------------------------------------- @@ -80,44 +105,44 @@ test('resolvePort prefers AGENT_HUB_PORT over the recorded fact', () => { } }); -test('the CLI exit code is non-zero when the gateway is not running', () => { +test('the CLI exit code is non-zero when the gateway is not running', async () => { const { fact } = tmpState(); - const r = runCli(['check'], { AGENT_HUB_PORT: '19998', PROXIMA_GATEWAY_PORT_FACT: fact }); + const r = await runCli(['check'], { AGENT_HUB_PORT: '19998', PROXIMA_GATEWAY_PORT_FACT: fact }); assert.equal(r.code, 1, 'a script must be able to trust this exit code'); assert.match(r.out, /NOT RUNNING/); // The message has to tell the operator what to do, not just that it failed. assert.match(r.out, /node src\\index\.js|node src\/index\.js/); }); -test('an unknown command is a usage error, not a stack trace', () => { - const r = runCli(['nope']); +test('an unknown command is a usage error, not a stack trace', async () => { + const r = await runCli(['nope']); assert.equal(r.code, 2); assert.match(r.err, /Unknown command/); assert.ok(!/at Object\.|at Module\./.test(r.err), 'must not dump a stack trace at the user'); }); -test('an unknown provider is rejected before any connection', () => { - const r = runCli(['login', 'notaprovider', '--cookies', 'x.json']); +test('an unknown provider is rejected before any connection', async () => { + const r = await runCli(['login', 'notaprovider', '--cookies', 'x.json']); assert.equal(r.code, 2); assert.match(r.err, /Unknown provider/); assert.match(r.err, /perplexity/, 'must list the valid providers'); }); -test('a missing cookie file is reported clearly', () => { - const r = runCli(['login', 'claude', '--cookies', 'C:\\definitely\\not\\here.json']); +test('a missing cookie file is reported clearly', async () => { + const r = await runCli(['login', 'claude', '--cookies', 'C:\\definitely\\not\\here.json']); assert.equal(r.code, 2); assert.match(r.err, /No such file/); }); -test('login with no method is a usage error listing both options', () => { - const r = runCli(['login', 'claude']); +test('login with no method is a usage error listing both options', async () => { + const r = await runCli(['login', 'claude']); assert.equal(r.code, 2); assert.match(r.err, /--cookies/); assert.match(r.err, /--headed/); }); -test('help exits zero and prints usage', () => { - const r = runCli(['help']); +test('help exits zero and prints usage', async () => { + const r = await runCli(['help']); assert.equal(r.code, 0); assert.match(r.out, /USAGE/); }); @@ -197,7 +222,7 @@ test('status prints a row per provider and exits zero', async () => { isLoggedIn: (req) => ({ success: true, provider: req.provider, loggedIn: req.provider === 'claude' }), }); try { - const r = runCli(['status'], { AGENT_HUB_PORT: String(stub.port) }); + const r = await runCli(['status'], { AGENT_HUB_PORT: String(stub.port) }); assert.equal(r.code, 0, r.err); assert.match(r.out, /PROVIDER/); assert.match(r.out, /claude\s+loaded\s+yes/); @@ -207,8 +232,8 @@ test('status prints a row per provider and exits zero', async () => { } }); -test('status reports an unreachable gateway without a stack trace', () => { - const r = runCli(['status'], { AGENT_HUB_PORT: '19997' }); +test('status reports an unreachable gateway without a stack trace', async () => { + const r = await runCli(['status'], { AGENT_HUB_PORT: '19997' }); assert.equal(r.code, 1); assert.match(r.err, /not reachable/i); assert.ok(!/at Object\./.test(r.err)); @@ -230,7 +255,7 @@ test('login --cookies reports success and verifies auth', async () => { }), }); try { - const r = runCli(['login', 'claude', '--cookies', file], { AGENT_HUB_PORT: String(stub.port) }); + const r = await runCli(['login', 'claude', '--cookies', file], { AGENT_HUB_PORT: String(stub.port) }); assert.equal(r.code, 0, r.err); assert.match(r.out, /claude: .*authenticated/); } finally { @@ -254,7 +279,7 @@ test('login reports failure when cookies are applied but do not authenticate', a }), }); try { - const r = runCli(['login', 'claude', '--cookies', file], { AGENT_HUB_PORT: String(stub.port) }); + const r = await runCli(['login', 'claude', '--cookies', file], { AGENT_HUB_PORT: String(stub.port) }); assert.equal(r.code, 1, 'a non-authenticating login must not report success'); assert.match(r.out, /No recognised auth cookie/); assert.match(r.out, /--headed/, 'must suggest the alternative'); @@ -268,7 +293,7 @@ test('logout confirms destruction on disk', async () => { purgeProvider: () => ({ success: true, purged: [{ provider: 'qwen', purged: true }] }), }); try { - const r = runCli(['logout', 'qwen'], { AGENT_HUB_PORT: String(stub.port) }); + const r = await runCli(['logout', 'qwen'], { AGENT_HUB_PORT: String(stub.port) }); assert.equal(r.code, 0, r.err); assert.match(r.out, /auth destroyed on disk/); } finally { @@ -281,7 +306,7 @@ test('logout propagates a server-side refusal', async () => { purgeProvider: () => ({ success: false, error: 'name a provider, or pass data.providers' }), }); try { - const r = runCli(['logout', 'qwen'], { AGENT_HUB_PORT: String(stub.port) }); + const r = await runCli(['logout', 'qwen'], { AGENT_HUB_PORT: String(stub.port) }); assert.equal(r.code, 1, 'a refused destructive action must not exit zero'); assert.match(r.err, /name a provider/); } finally { @@ -295,7 +320,7 @@ test('login --headed sets the override and explains the restart', async () => { initProvider: () => ({ success: true, provider: 'chatgpt' }), }); try { - const r = runCli(['login', 'chatgpt', '--headed'], { AGENT_HUB_PORT: String(stub.port) }); + const r = await runCli(['login', 'chatgpt', '--headed'], { AGENT_HUB_PORT: String(stub.port) }); assert.equal(r.code, 0, r.err); assert.match(r.out, /visible window/); assert.match(r.out, /restart-requiring/); diff --git a/gateway/test/contract.test.js b/gateway/test/contract.test.js index 031cc56..5672f9b 100644 --- a/gateway/test/contract.test.js +++ b/gateway/test/contract.test.js @@ -2,6 +2,14 @@ const test = require('node:test'); const assert = require('node:assert'); +// The gateway writes its port fact to the legacy Electron userData dir so the +// existing automation client can find it. Tests must never write there: it is the +// real user profile, and several test files start servers concurrently, so they +// would both pollute it and race each other over the same fact file. +process.env.PROXIMA_GATEWAY_PORT_FACT = require('path').join( + require('os').tmpdir(), + `proxima-test-portfact-${process.pid}.json` +); const net = require('net'); const os = require('os'); const path = require('path'); diff --git a/gateway/test/integration.test.js b/gateway/test/integration.test.js index 991160e..aa6b69c 100644 --- a/gateway/test/integration.test.js +++ b/gateway/test/integration.test.js @@ -2,6 +2,14 @@ const test = require('node:test'); const assert = require('node:assert'); +// The gateway writes its port fact to the legacy Electron userData dir so the +// existing automation client can find it. Tests must never write there: it is the +// real user profile, and several test files start servers concurrently, so they +// would both pollute it and race each other over the same fact file. +process.env.PROXIMA_GATEWAY_PORT_FACT = require('path').join( + require('os').tmpdir(), + `proxima-test-portfact-${process.pid}.json` +); const net = require('net'); const os = require('os'); const path = require('path'); diff --git a/gateway/test/leak.test.js b/gateway/test/leak.test.js index 8365092..abfaee6 100644 --- a/gateway/test/leak.test.js +++ b/gateway/test/leak.test.js @@ -2,6 +2,14 @@ const test = require('node:test'); const assert = require('node:assert'); +// The gateway writes its port fact to the legacy Electron userData dir so the +// existing automation client can find it. Tests must never write there: it is the +// real user profile, and several test files start servers concurrently, so they +// would both pollute it and race each other over the same fact file. +process.env.PROXIMA_GATEWAY_PORT_FACT = require('path').join( + require('os').tmpdir(), + `proxima-test-portfact-${process.pid}.json` +); const net = require('net'); const os = require('os'); const path = require('path'); diff --git a/gateway/test/regression.test.js b/gateway/test/regression.test.js index 57cb9a8..ecea1be 100644 --- a/gateway/test/regression.test.js +++ b/gateway/test/regression.test.js @@ -2,6 +2,14 @@ const test = require('node:test'); const assert = require('node:assert'); +// The gateway writes its port fact to the legacy Electron userData dir so the +// existing automation client can find it. Tests must never write there: it is the +// real user profile, and several test files start servers concurrently, so they +// would both pollute it and race each other over the same fact file. +process.env.PROXIMA_GATEWAY_PORT_FACT = require('path').join( + require('os').tmpdir(), + `proxima-test-portfact-${process.pid}.json` +); const os = require('os'); const path = require('path'); const fs = require('fs'); From 03515497b8608d0b680a5656d9d462c24f474928 Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sun, 27 Sep 2026 23:16:03 +0500 Subject: [PATCH 09/12] fix(cli): --help and -h must exit zero They exited 2, a usage error, so 'proxima-gw --help && next-thing' would skip the next thing - asking for help looked like a failure. 'help' and '--help' now agree on 0, while no arguments stays 2 because being asked to do nothing is not success. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/bin/proxima-gw.js | 13 +++++++++++-- gateway/test/cli.test.js | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/gateway/bin/proxima-gw.js b/gateway/bin/proxima-gw.js index e2a7766..3d36dde 100644 --- a/gateway/bin/proxima-gw.js +++ b/gateway/bin/proxima-gw.js @@ -43,6 +43,8 @@ function usage() { return [ 'proxima-gw - control the Proxima provider gateway', '', + ' proxima-gw help this text proxima-gw --help', + '', 'USAGE', ' proxima-gw check is the gateway running?', ' proxima-gw status per-provider state', @@ -368,9 +370,16 @@ async function main(argv) { const args = parseArgs(argv); const cmd = args._[0]; - if (!cmd || args.flags.help || cmd === 'help') { + // Help is a success, not a usage error. `proxima-gw --help && next-thing` + // must not skip the next thing because asking for help "failed". + const wantsHelp = cmd === 'help' || args.flags.help === true || cmd === '-h'; + if (wantsHelp) { say(usage()); - return cmd ? EXIT.OK : EXIT.USAGE; + return EXIT.OK; + } + if (!cmd) { + say(usage()); + return EXIT.USAGE; } switch (cmd) { diff --git a/gateway/test/cli.test.js b/gateway/test/cli.test.js index 0760ccb..b574b5e 100644 --- a/gateway/test/cli.test.js +++ b/gateway/test/cli.test.js @@ -147,6 +147,22 @@ test('help exits zero and prints usage', async () => { assert.match(r.out, /USAGE/); }); +test('--help and -h are a success, not a usage error', async () => { + // A non-zero exit here means `proxima-gw --help && next-thing` skips the next + // thing, because asking for help looked like a failure. + for (const args of [['--help'], ['-h']]) { + const r = await runCli(args); + assert.equal(r.code, 0, `${args[0]} must exit 0, got ${r.code}`); + assert.match(r.out, /USAGE/); + } +}); + +test('no arguments is a usage error and still prints usage', async () => { + const r = await runCli([]); + assert.equal(r.code, 2, 'being asked to do nothing is not a success'); + assert.match(r.out, /USAGE/); +}); + // ---- table formatting -------------------------------------------------------- test('table pads columns so it is readable in a terminal', () => { From f8bc4cf95c8f7ece74408f64d61ae4273ee4d9e6 Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sat, 10 Oct 2026 18:44:41 +0500 Subject: [PATCH 10/12] feat(chatgpt): default to gpt-6-thinking at max; Astra is a plan limit, not a Proxima gap GPT-6 shipped on this account. /backend-api/models now reports default_model_slug gpt-6, and the picker's latest version is GPT-6: Instant gpt-6-instant Medium gpt-6-thinking thinking_effort 'standard' High gpt-6-thinking thinking_effort 'extended' Measured on the live engine, with controls: gpt-6-thinking @extended served as asked gpt-6-thinking @max served as asked, max recorded on the reply gpt-6 @extended served as asked gpt-6.1-sol-wm @max REWRITTEN -> gpt-6 gpt-6-astra-wm @max REWRITTEN -> gpt-6 gpt-6-nonexistent-zz echoed (control: an unknown slug is echoed, so only a rewrite counts as evidence) The wanted order was Astra, then GPT-6.1 Sol. Neither is available. This also corrects the 2026-09-25 conclusion that the /f/conversation + prepare/conduit_token handshake was what stood between Proxima and Astra. The user's own thread, sent by the real web app through that exact flow, asked for gpt-6-astra-wm at max and was served gpt-5-6. The plan is 'personal', and Astra is reported as Pro-tier. No handshake changes that. 'max' is not a picker preset, but it is accepted and recorded. Whether it reasons harder than 'extended' is NOT established: on an easy question all three levels finished in about 1s. It is the default because max was requested. Reviews follow the change through a new reviewThinkingEffort config key. The name-derived resolveThinkingEffort() can only ever produce 'extended', so 'max' was unreachable from configuration before. This takes effect on the next review with no restart: reviews name the model explicitly, and the running engine already serves gpt-6-thinking at max. The engine DEFAULT, used by ask_chatgpt, needs a Proxima restart. --- cli/proxima-review.cjs | 6 ++- electron/providers/chatgpt-engine.js | 57 ++++++++++++---------------- electron/rest-api.cjs | 1 + proxima-automation.config.json | 5 ++- scripts/lib/config.cjs | 2 +- 5 files changed, 34 insertions(+), 37 deletions(-) diff --git a/cli/proxima-review.cjs b/cli/proxima-review.cjs index 3c141e4..62598b6 100644 --- a/cli/proxima-review.cjs +++ b/cli/proxima-review.cjs @@ -46,7 +46,11 @@ const REVIEW_MODEL = process.env.PROXIMA_REVIEW_MODEL || automationConfig.review const REVIEW_THINKING = automationConfig.reviewThinking !== false; const REVIEW_PROVIDER = process.env.PROXIMA_REVIEW_PROVIDER || resolveProvider(REVIEW_MODEL); const REVIEW_DIR = process.env.PROXIMA_REVIEW_DIR || automationConfig.reviewDir || path.join(findGitRoot(), 'perplexity-reviews'); -const REVIEW_THINKING_EFFORT = process.env.PROXIMA_REVIEW_THINKING_EFFORT || resolveThinkingEffort(REVIEW_MODEL); +// reviewThinkingEffort in the config wins over the name-derived guess, which can only +// ever produce 'extended' (any slug containing "thinking") — so 'max' was unreachable +// from configuration before this key existed. +const REVIEW_THINKING_EFFORT = process.env.PROXIMA_REVIEW_THINKING_EFFORT || + automationConfig.reviewThinkingEffort || resolveThinkingEffort(REVIEW_MODEL); function resolveProvider(model) { const m = model.toLowerCase(); diff --git a/electron/providers/chatgpt-engine.js b/electron/providers/chatgpt-engine.js index 2022296..89b8d74 100644 --- a/electron/providers/chatgpt-engine.js +++ b/electron/providers/chatgpt-engine.js @@ -97,42 +97,33 @@ } // ─── Default model and effort ─────────────────── - // GPT-5.6 Sol on the thinking lane, at the picker's "High" preset. + // GPT-6 on the thinking lane, at 'max'. // - // Read off /backend-api/models -> versions[id=5.6].intelligence_presets, which is - // where the app encodes what each picker entry actually sends: - // Instant gpt-5-6-instant - // Medium gpt-5-6-thinking thinking_effort 'standard' - // High gpt-5-6-thinking thinking_effort 'extended' - // So 'extended' IS high — it is not a third level above it. + // Read off /backend-api/models on 2026-10-10. The picker's latest version is GPT-6, + // and versions[latest].intelligence_presets records what each entry sends: + // Instant gpt-6-instant + // Medium gpt-6-thinking thinking_effort 'standard' + // High gpt-6-thinking thinking_effort 'extended' + // 'max' is not a picker preset, but gpt-6-thinking accepts it without error and the + // reply records it. Whether it reasons harder than 'extended' is NOT established: + // on an easy question all three levels finished in the same ~1s. It is the default + // because max thinking was asked for, not because it measured better. // - // NOT gpt-6-sol-wm. That slug is listed by /backend-api/models and looks like the - // obvious pick for "GPT-6 Sol", but it is in no versions[].slugs and this account - // cannot use it: asking for it returns a reply whose model_slug is gpt-5-6 — the - // INSTANT lane. Measured, all four downgrading identically: - // gpt-6-sol-wm -> gpt-5-6 gpt-6-luna-wm -> gpt-5-6 - // gpt-6-astra-wm -> gpt-5-6 gpt-5.6-sol-wm -> gpt-5-6 - // Defaulting to it would have silently removed thinking altogether. The 5.6 family - // is itself branded "Sol" (gpt-5-6* all carry the title "GPT-5.6 Sol"), which is - // where the name confusion comes from. + // NOT gpt-6-astra-wm or gpt-6.1-sol-wm, though they rank higher (Astra leads the + // published GPT-6 benchmarks). This account cannot use them: asking for either + // returns a reply whose model_slug is rewritten to gpt-6. That is not a Proxima gap. + // The user's own thread, sent by the real web app through the full + // /f/conversation + prepare/conduit_token flow, asked for gpt-6-astra-wm at max and + // was served gpt-5-6 (2026-09-25, before GPT-6 shipped). Astra is reported as + // Pro-tier, and this account's plan is 'personal'. If the plan changes, re-probe: + // the rewrite is the signal, and it would stop happening. // - // Do not verify a slug by reading message.metadata.model_slug back and comparing: - // a deliberately bogus 'gpt-6-nonexistent-zz' came back echoed as itself, so that - // field confirms nothing on its own. Only the server REWRITING it is evidence. - // gpt-6-astra-wm is NOT the default, though it is the one we want. Measured on a - // fresh conversation against the live engine: asking for it returns a reply whose - // model_slug is gpt-5-6, the INSTANT lane — a downgrade, not an upgrade — while the - // effort field IS honoured (the reply carries max). Same for luna/astra/sol -wm. - // - // The missing piece is the rest of the captured flow, not the payload: the app posts - // to /backend-api/f/conversation after a /f/conversation/prepare call whose - // conduit_token rides back as x-conduit-token. Implement that and this can move to - // 'gpt-6-astra-wm' with DEFAULT_EFFORT 'max'. - // - // Until then the thinking lane is the best that actually serves: 'extended' is the - // 5.6 picker's High preset, and High is its maximum. - var DEFAULT_MODEL = 'gpt-5-6-thinking'; - var DEFAULT_EFFORT = 'extended'; + // Reading model_slug back proves nothing on its own: an unknown slug such as + // 'gpt-6-nonexistent-zz' is echoed as itself, and so is thinking_effort. Only a + // REWRITE is evidence, and what it is evidence of is a downgrade. A listed, + // picker-offered slug that comes back unchanged is genuinely served. + var DEFAULT_MODEL = 'gpt-6-thinking'; + var DEFAULT_EFFORT = 'max'; // Efforts seen on the wire. 'standard'/'extended' are the 5.6 picker's Medium/High; // 'max' is what the app sends for Astra. Not a closed list — it is what has been diff --git a/electron/rest-api.cjs b/electron/rest-api.cjs index 4582739..96d08ac 100644 --- a/electron/rest-api.cjs +++ b/electron/rest-api.cjs @@ -17,6 +17,7 @@ const MODEL_ALIASES = { 'chatgpt': 'chatgpt', 'gpt': 'chatgpt', 'gpt-4': 'chatgpt', 'gpt-4o': 'chatgpt', 'gpt-4.5': 'chatgpt', 'openai': 'chatgpt', 'gpt-5-5': 'chatgpt', 'gpt-5-5-thinking': 'chatgpt', 'gpt-5-6': 'chatgpt', 'gpt-5-6-thinking': 'chatgpt', 'sol': 'chatgpt', + 'gpt-6': 'chatgpt', 'gpt-6-thinking': 'chatgpt', 'claude': 'claude', 'claude-3': 'claude', 'claude-3.5': 'claude', 'claude-4': 'claude', diff --git a/proxima-automation.config.json b/proxima-automation.config.json index 98a5a71..b0a89b9 100644 --- a/proxima-automation.config.json +++ b/proxima-automation.config.json @@ -1,7 +1,7 @@ { "reviewDir": "perplexity-reviews", "baseUrl": "http://localhost:3210", - "reviewModel": "gpt-5-6-thinking", + "reviewModel": "gpt-6-thinking", "repairModel": "qwen", "reviewThinking": true, "enableAutoFix": false, @@ -31,5 +31,6 @@ "allowWorkflowModification": false, "allowReviewHistoryModification": false, "repairBranchPrefix": "proxima/fix", - "autoMerge": false + "autoMerge": false, + "reviewThinkingEffort": "max" } diff --git a/scripts/lib/config.cjs b/scripts/lib/config.cjs index 27cc88c..1639615 100644 --- a/scripts/lib/config.cjs +++ b/scripts/lib/config.cjs @@ -18,7 +18,7 @@ const DEFAULT_CONFIG = { // 5.6 Sol thinking lane. gpt-5-5-thinking is soft-deprecated (hard cutoff // 2026-10-14) and resolveThinkingEffort() maps any "thinking" slug to the // extended/High effort, so reviews stay at high effort by keeping that word. - reviewModel: "gpt-5-6-thinking", + reviewModel: "gpt-6-thinking", repairModel: "claude", enableAutoFix: false, maxIterations: 3, From ade2ee43126a10330b893859b4cc2d5bf6285c7b Mon Sep 17 00:00:00 2001 From: Ahad Imran Date: Sat, 10 Oct 2026 19:00:10 +0500 Subject: [PATCH 11/12] feat(orchestrate): ChatGPT can hear audio, via the real app; self-serve sanitized capture chatgpt-audio.cjs sends an audio file to ChatGPT by driving the real web app inside Proxima's ChatGPT tab: it opens a new chat, attaches the file through the app's general file input, types, sends, and reads the reply back through the API. It needs no Proxima restart. Why the app and not the engine. Measured 2026-10-10, same mp3, same question, on gpt-6-thinking: real app, through its own composer heard it (WORD=pineapple NUMBER=47) engine, with the app's exact upload bodies CANNOT_HEAR engine, use_case 'multimodal' CANNOT_HEAR The server stored all three user turns identically (multimodal_text, then text, then audio_asset_pointer). So the cause is neither the upload nor the message. It is how the turn is invoked. The app posts to /backend-api/f/conversation behind a prepare/conduit_token handshake and a two-step sentinel; the engine still uses /backend-api/conversation, where the model can see the file but not hear it. This corrects the earlier claim that ChatGPT cannot process audio at all. That claim rested on /backend-api/models accepted_mime_types, which turned out to list only the document path: image/* is absent from it too, and images demonstrably work. Verified three times end to end, 15-19s each. The capture was the method, not a side effect. An in-page fetch/XHR recorder replaced the manual DevTools HAR export, and it can be re-run with --capture. Sanitizing happens inside the page, before anything leaves it. Two sanitizer bugs found by scanning the output instead of trusting the label: - Response BODIES were not URL-scrubbed. upload_url is an Azure SAS whose sig= is the credential, and the first capture shipped one intact. (It was a short-lived, single-blob write URL, now re-cleaned.) - The first fix consumed the backslash of a JSON-escaped \" and corrupted the file. A JSON.parse guard caught it before anything was written. Both are pinned by chatgpt-audio-sanitizer-test, which runs the sanitizer exactly as the tool generates it. It was negative-tested: with the SAS scrub removed, it fails on both leak cases. Also hardened: right after a reload the composer is visible before the file input is wired up, so the attach is confirmed by an upload actually starting, and retried if not. One run had sat for 90s with no upload. --- scripts/tests/automation-tests.cjs | 13 + .../tests/chatgpt-audio-sanitizer-test.cjs | 38 +++ tools/orchestrate/chatgpt-audio.cjs | 311 ++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 scripts/tests/chatgpt-audio-sanitizer-test.cjs create mode 100644 tools/orchestrate/chatgpt-audio.cjs diff --git a/scripts/tests/automation-tests.cjs b/scripts/tests/automation-tests.cjs index f7a4d47..e1cf839 100644 --- a/scripts/tests/automation-tests.cjs +++ b/scripts/tests/automation-tests.cjs @@ -538,6 +538,18 @@ function testChatGPTUpload() { console.log('✅ ChatGPT Upload Shapes tests passed.'); } +// chatgpt-audio.cjs labels its --capture output sanitized. Its first version leaked an +// Azure SAS sig= from a response body; the fix then corrupted JSON-escaped text. Own file. +function testChatGPTAudioSanitizer() { + console.log('Testing ChatGPT Audio Capture Sanitizer...'); + const r = require('child_process').spawnSync(process.execPath, + [path.join(__dirname, 'chatgpt-audio-sanitizer-test.cjs')], { encoding: 'utf8' }); + if (r.status !== 0) { + throw new Error('chatgpt-audio-sanitizer-test failed:\n' + (r.stdout || '') + (r.stderr || '')); + } + console.log('✅ ChatGPT Audio Capture Sanitizer tests passed.'); +} + try { testReviewParser(); testSafetyValidator(); @@ -552,6 +564,7 @@ try { testChatGPTImageTurn(); testClaudeModelDefault(); testChatGPTUpload(); + testChatGPTAudioSanitizer(); console.log('\n✨ All automation tests passed!'); } catch (e) { console.error('\n❌ Test failed:'); diff --git a/scripts/tests/chatgpt-audio-sanitizer-test.cjs b/scripts/tests/chatgpt-audio-sanitizer-test.cjs new file mode 100644 index 0000000..1c97acb --- /dev/null +++ b/scripts/tests/chatgpt-audio-sanitizer-test.cjs @@ -0,0 +1,38 @@ +/** + * chatgpt-audio.cjs ships a sanitizer that runs INSIDE the page, and its --capture output is + * labelled sanitized. The first version scrubbed headers and request URLs but not response + * BODIES, and the upload_url in a /backend-api/files response is an Azure SAS whose sig= is + * the credential: a capture shipped with one intact. A second attempt then swallowed the + * backslash of a JSON-escaped \" and corrupted the file. Both are pinned here, against the + * sanitizer exactly as the tool generates it (template-literal escapes resolved). + */ +const fs = require('fs'); +const src = fs.readFileSync(require('path').join(__dirname, '../../tools/orchestrate/chatgpt-audio.cjs'), 'utf8'); +const start = src.indexOf(' function cleanText(s, max) {'); +const end = src.indexOf(' function cleanHeaders(h) {'); +const generated = eval('`' + src.slice(start, end).replace(/`/g, '\`') + '`'); +const cleanText = new Function(generated + '; return cleanText;')(); + +const sas = 'https://sdmntprindiasocentral.oaiusercontent.com/files/00000000-bbb0/raw?se=2026-10-10T13%3A51%3A00Z&sp=cw&sv=2024-08-04&sr=b&skoid=abc&sig=Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA%3D%3D'; +const cases = [ + ['raw JSON body', JSON.stringify({ status: 'success', upload_url: sas, file_id: 'file_x' })], + ['JSON nested in a JSON string', JSON.stringify(JSON.stringify({ upload_url: sas }))], + ['a chatgpt.com URL keeps its query', '{"u":"https://chatgpt.com/backend-api/files/download/file_x?conversation_id=abc"}'], + ['JWT', '{"x":"eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLWhlcmU"}'], + ['token-named field', '{"conduit_token":"abcdef0123456789xyz"}'] +]; +let fail = 0; +for (const [label, input] of cases) { + const out = cleanText(input, 100000); + const leaked = /sig=(?!)[^&"\<\s]{4,}/.test(out) || /eyJ[A-Za-z0-9_-]{8,}\./.test(out) || + /"conduit_token":"(?!)/.test(out); + const keptChatgpt = label.indexOf('chatgpt.com') === -1 || out.indexOf('conversation_id=abc') !== -1; + let parses = true; + if (label.indexOf('JSON') !== -1) { try { JSON.parse(out); } catch (e) { parses = false; } } + const ok = !leaked && keptChatgpt && parses; + if (!ok) fail++; + console.log((ok ? 'PASS ' : 'FAIL ') + label.padEnd(36) + (leaked ? ' LEAKED' : '') + + (!parses ? ' BROKE-JSON' : '') + (!keptChatgpt ? ' OVER-REDACTED' : '') + ' -> ' + out.slice(0, 90)); +} +console.log(fail ? '\n' + fail + ' FAILURE(S)' : '\nall sanitizer assertions passed'); +process.exit(fail ? 1 : 0); diff --git a/tools/orchestrate/chatgpt-audio.cjs b/tools/orchestrate/chatgpt-audio.cjs new file mode 100644 index 0000000..6a414d9 --- /dev/null +++ b/tools/orchestrate/chatgpt-audio.cjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node +/** + * Send an audio file to ChatGPT and get its answer — by having the REAL web app send it. + * + * node chatgpt-audio.cjs --file clip.mp3 --message "Transcribe this." [--capture out.json] + * + * WHY THE APP AND NOT THE ENGINE. Measured 2026-10-10, same mp3, same question, + * gpt-6-thinking: + * real app, driven through its own composer -> heard it (WORD=pineapple NUMBER=47) + * Proxima engine, app's exact upload bodies -> CANNOT_HEAR + * Proxima engine, use_case 'multimodal' -> CANNOT_HEAR + * The server stored all three user turns IDENTICALLY (multimodal_text, text then + * audio_asset_pointer). So it is not the upload and not the message: it is how the turn + * is invoked. The app posts to /backend-api/f/conversation behind a prepare/conduit_token + * and two-step sentinel handshake; the engine still uses the older /backend-api/conversation, + * where the model can see the file but not hear it. Until the engine speaks /f/, driving + * the app is the path that works — and it needs no Proxima restart. + * + * WHAT IT TOUCHES. The ChatGPT tab inside Proxima: it opens a new chat there, attaches the + * file through the app's general-purpose file input, types the message, sends, and reads + * the reply back through the API. It does not touch any other provider. Do not run it + * while something else is driving ChatGPT: there is one tab. + * + * Output: the reply on stdout; progress on stderr. Exit 0 = a reply was read back, + * 2 = the UI or the app refused (message says which step), 1 = transport/argument error. + * + * --capture writes the app's requests as a HAR, sanitized INSIDE the page before anything + * leaves it — auth/cookie/token/sentinel/conduit headers redacted, signed URLs stripped, + * JWTs and long opaque strings in bodies masked. + */ +const net = require('net'); +const fs = require('fs'); +const path = require('path'); +const { resolvePortSync } = require('../../scripts/lib/proxima-port.cjs'); + +const MIME = { mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac', + ogg: 'audio/ogg', oga: 'audio/ogg', flac: 'audio/flac', webm: 'audio/webm', opus: 'audio/ogg' }; + +function parseArgs(argv) { + const a = { file: null, message: null, capture: null, timeoutMs: 420000 }; + for (let i = 2; i < argv.length; i++) { + const k = argv[i], v = argv[i + 1]; + if (k === '--file') { a.file = path.resolve(v); i++; } + else if (k === '--message') { a.message = v; i++; } + else if (k === '--message-file') { a.message = fs.readFileSync(v, 'utf8'); i++; } + else if (k === '--capture') { a.capture = path.resolve(v); i++; } + else if (k === '--timeout-ms') { a.timeoutMs = Number(v); i++; } + else if (k === '-h' || k === '--help') { a.help = true; } + } + return a; +} + +function ipc(port, req, ms) { + return new Promise((res, rej) => { + const s = net.createConnection(port, '127.0.0.1'); + let b = ''; + const t = setTimeout(() => { s.destroy(); rej(new Error('Proxima did not answer within ' + ms + 'ms')); }, ms); + s.on('connect', () => s.write(JSON.stringify(Object.assign({ requestId: String(Date.now()) }, req)) + '\n')); + s.on('data', (d) => { + b += d.toString(); + const i = b.indexOf('\n'); + if (i === -1) return; + clearTimeout(t); s.end(); + try { res(JSON.parse(b.slice(0, i))); } catch (e) { rej(e); } + }); + s.on('error', (e) => { clearTimeout(t); rej(e); }); + }); +} + +async function page(port, script, ms) { + const r = await ipc(port, { action: 'executeScript', provider: 'chatgpt', data: { script } }, ms); + if (r && r.success === false) throw new Error(r.error || 'executeScript failed'); + return r.result !== undefined ? r.result : r.data; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// The in-page driver. Kept as one function so it can be read top to bottom. +function driverScript(b64, name, mime, message) { + return `(async function () { + var B64 = ${JSON.stringify(b64)}, NAME = ${JSON.stringify(name)}, MIME = ${JSON.stringify(mime)}; + var MSG = ${JSON.stringify(message)}; + var log = []; + var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); }; + + // ---- sanitizer: runs before anything is returned ---- + var SECRET_HDR = /authorization|cookie|token|sentinel|proof|conduit|device|session|signature|secret|key|observation/i; + function cleanUrl(u) { + try { + var x = new URL(u, location.origin); + if (x.origin !== location.origin) return x.origin + x.pathname + (x.search ? '?' : ''); + return x.pathname + x.search; + } catch (e) { return String(u).split('?')[0]; } + } + function cleanText(s, max) { + s = String(s == null ? '' : s); + s = s.replace(/eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g, ''); + // Signed URLs ride in response BODIES too: upload_url carries an Azure SAS whose sig= + // IS the credential. Scrubbing headers and request URLs missed it — measured, the + // first capture shipped one with sig= intact. Strip the query from any URL that is + // not chatgpt.com, then mask SAS parameters anywhere they survive. + // Backslash excluded so a URL inside JSON-escaped text cannot swallow its own \\". + s = s.replace(/(https?:\\/\\/[^"'\\s?\\\\]+)\\?[^"'\\s<\\\\]*/g, function (m, base) { + return /^https?:\\/\\/([^\\/]*\\.)?chatgpt\\.com/.test(base) ? m : base + '?'; + }); + s = s.replace(/\\b(sig|se|sp|sv|sr|scid|skoid|sktid|skt|ske|sks|skv|st|spr|sip)=[^&"'\\s<\\\\]*/g, '$1='); + s = s.replace(/("[^"]*(token|secret|signature|access_key|security|password|conduit)[^"]*"\\s*:\\s*")[^"]*"/gi, '$1"'); + s = s.replace(/[A-Za-z0-9+\\/=_-]{60,}/g, ''); + return s.length > max ? s.slice(0, max) + '…[' + s.length + ' chars]' : s; + } + function cleanHeaders(h) { + var out = {}; + if (!h) return out; + var add = function (k, v) { out[k] = SECRET_HDR.test(k) ? '' : cleanText(v, 120); }; + if (typeof h.forEach === 'function' && !(h instanceof Array)) h.forEach(function (v, k) { add(k, v); }); + else if (h instanceof Array) h.forEach(function (p) { add(p[0], p[1]); }); + else Object.keys(h).forEach(function (k) { add(k, h[k]); }); + return out; + } + var interesting = function (u) { return /backend-api|oaiusercontent/.test(String(u)); }; + + // ---- recorder: also how we know the app's own upload has finished ---- + var origFetch = window.fetch; + window.fetch = function (input, init) { + var url = typeof input === 'string' ? input : (input && input.url) || String(input); + var entry = null; + if (interesting(url)) { + var body = init && init.body; + entry = { t: Date.now(), method: (init && init.method) || (input && input.method) || 'GET', + url: cleanUrl(url), reqHeaders: cleanHeaders(init && init.headers), + reqBody: typeof body === 'string' ? cleanText(body, 4000) : body ? '' : null }; + log.push(entry); + } + var p = origFetch.apply(this, arguments); + if (entry) p.then(function (res) { + entry.status = res.status; entry.resType = res.headers.get('content-type'); + try { res.clone().text().then(function (t) { entry.resBody = cleanText(t, 6000); }).catch(function () {}); } catch (e) {} + }).catch(function (e) { entry.error = String(e && e.message || e); }); + return p; + }; + + var out = { step: 'start' }; + try { + var bin = atob(B64), bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + var file = new File([bytes], NAME, { type: MIME }); + + // The app has several hidden file inputs; the image ones declare accept="image/*". + // The general one declares no accept at all, and is the one "Add files" uses. + // Seconds after a reload the composer is VISIBLE before the app has wired up its file + // input, and a change event dispatched then is silently ignored: measured, a run + // straight after a reload sat for 90s with no upload ever starting. So confirm the + // upload actually STARTED (POST /backend-api/files) and re-attach if it did not. + out.step = 'attach'; + var started = false; + for (var attempt = 1; attempt <= 3 && !started; attempt++) { + var general = Array.prototype.slice.call(document.querySelectorAll('input[type=file]')) + .filter(function (el) { return !el.getAttribute('accept'); })[0]; + if (!general) { await sleep(2000); continue; } + var dt = new DataTransfer(); dt.items.add(file); + general.files = dt.files; + general.dispatchEvent(new Event('input', { bubbles: true })); + general.dispatchEvent(new Event('change', { bubbles: true })); + var ta = Date.now(); + while (Date.now() - ta < 15000) { + await sleep(500); + if (log.some(function (e) { return e.method === 'POST' && /\\/backend-api\\/files$/.test(e.url); })) { started = true; break; } + } + out.attachAttempts = attempt; + } + if (!started) throw new Error('the app never started an upload after 3 attach attempts (file input not wired up, or UI changed)'); + + out.step = 'upload'; + var t0 = Date.now(), uploaded = false; + while (Date.now() - t0 < 90000) { + await sleep(1000); + if (log.some(function (e) { return /process_upload_stream/.test(e.url) && e.status === 200; })) { uploaded = true; break; } + if (/isn.t supported|not supported|unsupported file|can.t upload|cannot upload|too large/i.test(document.body.innerText)) { + throw new Error('the app rejected the file: ' + document.body.innerText.slice(0, 200)); + } + } + if (!uploaded) throw new Error('the app did not finish uploading within 90s'); + + out.step = 'type'; + var pm = document.querySelector('.ProseMirror[contenteditable=true]'); + if (!pm) throw new Error('composer not found (UI changed?)'); + pm.focus(); + document.execCommand('insertText', false, MSG); + + out.step = 'send'; + var btn = null, t1 = Date.now(); + while (Date.now() - t1 < 30000) { + btn = document.querySelector('[data-testid="send-button"]') || + Array.prototype.slice.call(document.querySelectorAll('button')).filter(function (b) { + return /send/i.test(b.getAttribute('aria-label') || ''); + })[0]; + if (btn && !btn.disabled) break; + await sleep(500); + } + if (!btn || btn.disabled) throw new Error('send button never became enabled'); + btn.click(); + + out.step = 'await reply'; + var conv = null, t2 = Date.now(); + while (Date.now() - t2 < 60000 && !conv) { + await sleep(1000); + conv = (location.pathname.match(/\\/c\\/([0-9a-f-]{36})/) || [])[1] || null; + } + if (!conv) throw new Error('no conversation id appeared after sending'); + out.conversationId = conv; + + // Read the answer back from the API rather than the DOM. Poll: the assistant message + // is persisted only after the stream finishes, and an early read finds nothing. + var sess = await origFetch('/api/auth/session', { credentials: 'include' }).then(function (r) { return r.json(); }); + var h = { 'Authorization': 'Bearer ' + sess.accessToken }; + if (sess.account && sess.account.id) h['ChatGPT-Account-Id'] = sess.account.id; + var t3 = Date.now(); + while (Date.now() - t3 < 300000) { + await sleep(3000); + var j = await origFetch('/backend-api/conversation/' + conv, { credentials: 'include', headers: h }) + .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); + if (!j) continue; + var msgs = Array.isArray(j.messages) ? j.messages + : Object.keys(j.mapping || {}).map(function (k) { return j.mapping[k].message; }); + msgs = msgs.filter(Boolean); + var finals = msgs.filter(function (m) { + return m.author && m.author.role === 'assistant' && m.channel === 'final' && + m.content && m.content.content_type === 'text' && + (m.content.parts || []).join('').trim(); + }); + if (finals.length) { + var last = finals[finals.length - 1]; + out.reply = (last.content.parts || []).join(''); + out.model = last.metadata && last.metadata.model_slug || null; + var user = msgs.filter(function (m) { return m.author && m.author.role === 'user'; })[0] || {}; + out.audioPointer = JSON.stringify(user.content || {}).indexOf('audio_asset_pointer') !== -1; + break; + } + } + if (!out.reply) throw new Error('no reply was stored within 5 minutes'); + out.step = 'done'; + } catch (e) { + out.error = String(e && e.message || e); + } finally { + window.fetch = origFetch; + } + await sleep(1000); + out.log = log; + return JSON.stringify(out); +})()`; +} + +(async () => { + const args = parseArgs(process.argv); + if (args.help || !args.file || !args.message) { + console.error('usage: node chatgpt-audio.cjs --file