diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml new file mode 100644 index 0000000..b7b59b8 --- /dev/null +++ b/.github/workflows/gateway.yml @@ -0,0 +1,143 @@ +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 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: 94.24 / 83.51 / 90.73. + shell: bash + 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 + 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 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/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/docs/ORCHESTRATION_LOOP.md b/docs/ORCHESTRATION_LOOP.md new file mode 100644 index 0000000..50ac20c --- /dev/null +++ b/docs/ORCHESTRATION_LOOP.md @@ -0,0 +1,219 @@ +# The Proxima orchestration loop + +You are the **orchestrator**: a local coding agent. You drive a long-lived Claude +conversation on claude.ai — the **supervisor** — through **Proxima**, an Electron app +that keeps logged-in browser sessions and exposes them as MCP tools. The supervisor +plans and decides. You build and verify. One supervisor turn = one command. + +Everything below is verified against the code. Paths are absolute so this works from any +project directory. + +--- + +## 1. Roles + +| Who | Runs where | Can | Cannot | +|---|---|---|---| +| **You** (orchestrator) | locally, e.g. Claude Code | run tools, read/write files, run tests, git | see the supervisor's thread except through replies | +| **Supervisor** | claude.ai, Opus 5.5 at high effort | spec, plan, choose between options, judge evidence you send | run code, read your disk, verify anything | +| **Proxima** | Electron app on this machine | relay turns, upload files, save the supervisor's files to disk | think — it is plumbing | + +The supervisor **believes whatever you tell it.** It has no way to check. That is the +single most important fact about this loop. + +--- + +## 2. Start-up (once per session) + +1. **Start Proxima** from its repo root and leave it running: + ``` + cd C:\Users\subha\Documents\PROJECTS\Proxima + npm start + ``` + First time only: log in to claude.ai in Proxima's Claude tab. + +2. **Preflight** before any work, and again before leaving anything unattended: + ``` + node C:/Users/subha/Documents/PROJECTS/Proxima/tools/orchestrate/preflight.cjs + ``` + + | Exit | Meaning | Action | + |---|---|---| + | `0` | READY | proceed | + | `1` | NOT READY — at least one FAIL | fix the listed item first | + | `2` | UNDETERMINED — a check could not be decided | treat as not ready; never act on a guess | + + Add `--json` for machine-readable output. + +--- + +## 3. One supervisor turn + +``` +node C:/Users/subha/Documents/PROJECTS/Proxima/tools/orchestrate/supervisor.cjs --message-file turn.txt --state C:/path/to/your-project/supervisor-state.json +``` + +Keep commands on one line. Line continuation differs between cmd (`^`), PowerShell +(`` ` ``) and bash (`\`), and the wrong one silently splits the command. + +What happens: the script spawns Proxima's MCP server, calls its `ask_claude` tool, which +sends your message to the claude.ai tab and waits for the full reply. + +**Output contract** + +| Channel | Contains | +|---|---| +| **stdout** | the supervisor's reply, verbatim. Last line: `_conversation_id: _` | +| **stderr** | progress lines prefixed `[supervisor]` — thread id, turn number, seconds | +| **exit code** | `0` ok · `2` the tool reported an error (read stdout for it) · `1` transport or argument failure | + +**Flags** + +| Flag | Purpose | +|---|---| +| `--message-file ` | the turn's text. Prefer this over `--message`: no shell quoting problems | +| `--message ""` | short one-line turns only | +| `--state ` | where the thread id is remembered. **Always pass an absolute path.** Default is `supervisor-state.json` in the current directory, which silently forks state if you change directory | +| `--new` | start a fresh thread and remember it | +| `--conversation-id ` | target a specific thread; beats the state file | +| `--attach ` | upload a real file; repeat for several. A missing file exits `1` before anything is sent | +| `--model ` | default `claude-opus-5-5` | +| `--effort ` | default `high` | +| `--no-tag` | drop the `[PROXIMA]` prefix (see §6) | +| `--timeout-ms ` | default `1800000` (30 minutes) | +| `--tool --arg key=value …` | call any other Proxima MCP tool instead (see §7) | + +--- + +## 4. The cycle + +``` + ┌─► 1. write turn.txt: context + EVIDENCE + one clear question + │ 2. run a supervisor turn (§3) + │ 3. read the reply; extract decisions and any saved files (§5) + │ 4. do the work + │ 5. VERIFY with something machine-checkable — tests, build, exit codes, a diff + └── 6. next turn reports the RESULT of step 5, not your opinion of it +``` + +The first turn of a project uses `--new`. Every later turn omits it and the state file +supplies the thread. + +--- + +## 5. Files in and out + +**In — attachments.** `--attach` uploads the actual file; the supervisor reads it, not a +paraphrase. Images and PDFs are referenced by id, text and code are carried as extracted +text. Attach large or exact material (test output, diffs, specs) rather than pasting it. + +**Out — artifacts.** When the supervisor writes a file, Proxima saves it to disk and the +reply gains a block like: + +``` +**2 artifact(s) saved to disk:** +- C:\Users\…\AppData\Roaming\proxima\claude-artifacts\…\spec.md (4210 bytes) +- C:\Users\…\AppData\Roaming\proxima\claude-artifacts\…\PRD.md (9120 bytes) [edited file, recovered from sandbox] +``` + +Open those paths with ordinary file tools. `[edited file, recovered from sandbox]` marks a +file the supervisor *edited* rather than created — it is the current version. To pull +files from a thread later: + +``` +node C:/Users/subha/Documents/PROJECTS/Proxima/tools/orchestrate/supervisor.cjs --tool claude_artifacts --arg conversation_id= +``` + +--- + +## 6. Things that happen to every message + +- **Prefixed `[PROXIMA]`** so a human reading the thread can tell your turns from theirs. +- **Suffixed** with `Current time: `. +- **Thread continuity:** the id is saved to the state file **only when a turn succeeds**, + so a failing turn never overwrites a good thread with a bad one. +- **A dead thread fails loudly.** If the remembered conversation no longer exists, the + turn exits `2` saying the pinned conversation is gone. It never silently starts a new, + empty thread. Recovering is your decision: run the next turn with `--new` and re-brief + the supervisor. + +--- + +## 7. Other tools (via `--tool`) + +| Tool | Use | +|---|---| +| `claude_artifacts` | download files from a thread (`conversation_id`, `limit`, `include_uploads`) | +| `claude_conversation` | inspect a thread's metadata (`conversation_id`) | +| `ask_chatgpt` | second opinion from ChatGPT (`message`) | +| `ask_qwen` | Qwen 3.8, including **video** and image review (`message`, `session`) | + +Exit codes match §3: `0` ok, `2` tool error, `1` transport. + +**Standalone scripts** (not `--tool`; run them directly): + +| Script | Use | +|---|---| +| `tools/orchestrate/chatgpt-audio.cjs --file --message ""` | ChatGPT **listens** to an audio file. `ask_chatgpt` cannot: its endpoint shows the model the file but not the sound, and it answers `CANNOT_HEAR`. This script drives the real ChatGPT tab instead, so run it alone. Only mp3 has been verified. | +| `tools/orchestrate/capture.cjs --provider --seconds 90` | Records what a site's app sends, as a **sanitized HAR**, from any loaded Proxima tab: chatgpt, claude, qwen, gemini, perplexity. Trigger the flow during the window. Use `--stop-file ` to stop it from another process. If the output still looks like it holds a secret, it writes nothing and exits `3`. | + +**Response cache — only if you call these tools directly.** If your agent has Proxima +registered as its own MCP server (tools named like `mcp__proxima__ask_chatgpt`), that +server stays running, and an identical prompt sent within **5 minutes** can come back as +the cached first answer instead of a new one. `ask_claude` skips the cache whenever a +thread id, `new_chat`, model, effort or attachment is given; `ask_chatgpt` always uses +it. Change the prompt when you need a fresh answer. Calls made through `supervisor.cjs` +are never cached: it starts a fresh MCP server for every call. + +--- + +## 8. Rules + +**MUST** + +- Run preflight before starting, and before any unattended stretch. +- Send **evidence, not claims**: test output, error text, diffs, exit codes. "It works" is + not evidence, and the supervisor cannot tell the difference. +- Verify every supervisor decision independently before building on it. Two models + agreeing is not proof. +- Keep **one state file per project**, at an absolute path, for the project's lifetime. +- Treat a reply that reads `No response received` or `No response captured` as a **failed + turn**, even when the exit code is `0`. An empty capture is returned as that text, not + as an error. +- Use `--new` on the very first turn; every later turn gets its thread from the state file. + +**MUST NOT** + +- Run two supervisor turns at the same time. Proxima drives one browser tab, so concurrent + turns interleave and both replies come back wrong while both report success. +- Change files under Proxima's `electron/` without restarting Proxima. Until the restart, + the old code is still running and the change looks exactly like a bug. Preflight's + "running code is current" check catches this. +- Delete or hand-edit the state file mid-project. +- Pass relative `--state` paths. + +--- + +## 9. When something fails + +| Symptom | Cause | Do | +|---|---|---| +| exit `1`, "MCP server exited" or a connection error | Proxima not running, or crashed | start Proxima, rerun preflight | +| exit `2`, "pinned conversation … is gone" | the remembered thread was deleted | `--new`, then re-brief the supervisor | +| exit `2`, any other error | the tool rejected the call | stdout starts with `Error:` and says why | +| preflight FAIL "running code is current" | Proxima older than its source | restart Proxima | +| preflight FAIL "Claude session" | logged out of claude.ai | log in via Proxima's Claude tab | +| preflight FAIL "free memory" | under 1 GB free (8 GB machine) | close something. Below this, failures look like a dead service when they are not | +| turn hangs until the 30-minute timeout | provider stuck or machine starved | check memory, then rerun preflight | + +--- + +## 10. Limits + +- The supervisor has **no tools**. Everything it knows came from your turns. +- **One turn at a time**, strictly serial. +- "Proxima" here means the **Electron app** started with `npm start`. The headless gateway + on the `feat/cleanroom-gateway` branch is a different runtime and cannot send messages + yet: its provider engines are unverified scaffolds. The Electron app works on that + branch too, since its code is identical there. +- Deeper history and provider-specific traps: `C:\Users\subha\Documents\PROJECTS\Proxima\ORCHESTRATION_HANDOFF.md`. 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/gateway/bin/proxima-gw.js b/gateway/bin/proxima-gw.js new file mode 100644 index 0000000..3d36dde --- /dev/null +++ b/gateway/bin/proxima-gw.js @@ -0,0 +1,413 @@ +#!/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', + '', + ' proxima-gw help this text proxima-gw --help', + '', + '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 -------------------------------------------------------- + +/** + * 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) { + const a = argv[i]; + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (VALUE_FLAGS.has(key) && 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'; + } +} + +/** + * 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 = {}, timeout) { + const ms = timeoutMs(timeout); + 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(); + // 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 ${ms}ms talking to port ${port}` }); + }, ms); + 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, {}, 2500); + 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, {}, 2500); + 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 || res.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, {}, 45000); + 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 }, 30000); + if (!res.ok) { + fail(res.response?.error || res.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 || res.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 || res.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]; + + // 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 EXIT.OK; + } + if (!cmd) { + say(usage()); + return 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/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/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 new file mode 100644 index 0000000..b14ad2a --- /dev/null +++ b/gateway/engines/perplexity-engine.js @@ -0,0 +1,194 @@ +/** + * 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. + * + * 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. + * - Must be safe to evaluate repeatedly (navigations re-run it). + */ +(function () { + 'use strict'; + + 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. + var state = { + conversationId: null, + lastResponse: '', + typing: false, + conversationStartedAt: null, + }; + + function nowIso() { + 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; + } + + 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 verified; + } + + 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. + // + // 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) { + return { kind: 'auth', retryable: false, status: status }; + } + if (status >= 500) { + return { kind: 'upstream', retryable: true, status: status }; + } + return { kind: 'unknown', retryable: false, status: status }; + } + + 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'); + } + assertVerified('sendMessage'); + + 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(ENDPOINTS.send, { + 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 failure = classifyFailure(res.status); + 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, + 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 new file mode 100644 index 0000000..2275975 --- /dev/null +++ b/gateway/package.json @@ -0,0 +1,28 @@ +{ + "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=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", + "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/" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "playwright": "^1.49.0" + } +} 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/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/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 new file mode 100644 index 0000000..2462306 --- /dev/null +++ b/gateway/src/actions.js @@ -0,0 +1,386 @@ +'use strict'; + +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. + * + * 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(sessions.initializedProviders()) }), + + 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)); + } + 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: unloaded.map((r) => r.provider), + reports, + stillLoaded: sessions.initializedProviders(), + 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(sessions.initializedProviders()) }; + }, + + 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); + // 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 }; + }, + + 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) => { + // 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.isFinite(requestedDelay) + ? Math.min(Math.max(requestedDelay, 50), 5000) + : 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); + // 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() }), + + 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) { + // 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, + ...toSafeError(e, { + ...(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(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, + }; +} + +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..620a6f0 --- /dev/null +++ b/gateway/src/cookie-store.js @@ -0,0 +1,240 @@ +'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`); + } + + /** + * 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); + this.writeSecure(this._backupPath(provider), JSON.stringify(record, null, 2)); + 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). + * + * 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', +]); + +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..8a7c11c --- /dev/null +++ b/gateway/src/engine-loader.js @@ -0,0 +1,161 @@ +'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' } + ); + } + + const result = await page.evaluate( + ({ g, m, a }) => { + const engine = window[g]; + if (!engine || typeof engine[m] !== '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; + } +} + +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..0c42657 --- /dev/null +++ b/gateway/src/index.js @@ -0,0 +1,89 @@ +'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'); +const { log } = require('./logger'); + +/** + * 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 }); + // 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({ allowRelocate }); + + 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 }); + + log.info( + `listening on 127.0.0.1:${gateway.boundPort} ` + + `state=${gateway.stateStore.dir} ` + + `headless=${gateway.stateStore.loadSettings().headless !== false}` + ); + + const stop = async (signal) => { + 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) => { + // Via the logger, so a stack quoting a token-bearing URL is redacted. + log.exception(e, { phase: 'uncaughtException' }); + await gateway.shutdown(); + process.exit(1); + }); +} + +if (require.main === module) { + main().catch((e) => { + log.exception(e, { phase: 'startup' }); + 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..3db2e51 --- /dev/null +++ b/gateway/src/ipc-server.js @@ -0,0 +1,237 @@ +'use strict'; + +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'); + +/** + * 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.openConnections = 0; + } + + /** + * 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({ allowRelocate = false } = {}) { + let lastError = null; + const maxPort = this.basePort + (allowRelocate ? FALLBACK_ATTEMPTS : 0); + + 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; + if (e.code === 'EADDRINUSE') continue; + throw e; + } + } + + const fact = this.state.readPortFact(); + throw new Error( + `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).' + ); + } + + _tryListen(port) { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => this._onConnection(socket)); + 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', onError); + this.server = server; + resolve(server.address().port); + }); + }); + } + + _onConnection(socket) { + this.openConnections += 1; + const decoder = new FrameDecoder(); + let busy = Promise.resolve(); + + socket.on('close', () => { + this.openConnections -= 1; + }); + + 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); + // 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. + this._write(socket, { requestId, ...toSafeError(e) }); + } + } + + _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/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/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/redact.js b/gateway/src/redact.js new file mode 100644 index 0000000..e48e4e8 --- /dev/null +++ b/gateway/src/redact.js @@ -0,0 +1,304 @@ +'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; + +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. + * + * 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 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,}$/; + +/** + * Patterns applied in order. Each captures the secret and rewrites only that group, + * leaving surrounding context readable. + */ +const PATTERNS = [ + // 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}`, + }, + // 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; + // 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; + 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.trim()); +} + +module.exports = { + REDACTED, + DEFAULT_MAX, + redact, + redactString, + redactCookies, + truncate, + fingerprint, + containsSecret, + PATTERNS, + SENSITIVE_COOKIE_NAMES, +}; diff --git a/gateway/src/session-manager.js b/gateway/src/session-manager.js new file mode 100644 index 0000000..58991ec --- /dev/null +++ b/gateway/src/session-manager.js @@ -0,0 +1,641 @@ +'use strict'; + +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. + * + * 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; + // 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; + // 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, + }) + .then((b) => { + this._browser = b; + return b; + }) + .finally(() => { + this._browserInflight = null; + }); + return this._browserInflight; + } + + 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; + + // 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); + + // 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; + + try { + 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 e; + } + + session.context = context; + session.page = page; + session.state = 'INITIALIZED'; + session.headless = useHeadless; + session.lastError = null; + + 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 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 */ + } + } + + _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, context = null, page = null } = {}) { + const { spec, name } = session; + const ctx = context || session.context; + const pg = page || session.page; + try { + await pg.goto(spec.url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + } catch (e) { + 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; + } + + try { + await this.engines.install(ctx, pg, name); + } catch (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); + + 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. + // Scoped to the auth domain so a provider's cookies cannot disturb another's. + try { + await session.context.clearCookies({ domain: spec.auth.domain }); + } catch { + // 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; + 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. 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 { + 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' }; + } + + // 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 { + /* 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; + + // 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. */ + 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); + // 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 }; + } + + 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 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..0d2e81a --- /dev/null +++ b/gateway/src/state-store.js @@ -0,0 +1,204 @@ +'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; +// 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. + * + * 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; + 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.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 ---- + + /** + * 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() { + 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]; + } + + /** + * 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, + }; + 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 { + this.saveSettings({ ipcPort: port }); + } catch { + /* preference sync is best-effort */ + } + return fact; + } + + clearPortFact() { + for (const file of this.portFactPaths()) { + try { + fs.unlinkSync(file); + } catch { + /* already gone */ + } + } + } + + profileDir(provider) { + return path.join(this.profilesDir, provider); + } +} + +module.exports = { + StateStore, + DEFAULT_PORT, + FALLBACK_ATTEMPTS, + defaultStateDir, + legacyUserDataDir, +}; diff --git a/gateway/test/browser.test.js b/gateway/test/browser.test.js new file mode 100644 index 0000000..b926c86 --- /dev/null +++ b/gateway/test/browser.test.js @@ -0,0 +1,300 @@ +'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(); + 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: () => ({}) }; + }); + assert.equal(await page.evaluate(() => window.__proximaTest.marker), 'stale'); + + await page.reload(); + assert.equal( + await page.evaluate(() => window.__proximaTest.marker), + 'installed', + 'a reload must re-run the init script, replacing the stale engine' + ); + + 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, + }, + ]); + + // 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(); + }); +}); + +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('https://perplexity.ai'); + 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/cli.test.js b/gateway/test/cli.test.js new file mode 100644 index 0000000..b574b5e --- /dev/null +++ b/gateway/test/cli.test.js @@ -0,0 +1,398 @@ +'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 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 = {}) { + 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 }); + }); + }); +} + +// ---- 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', async () => { + const { fact } = tmpState(); + 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', 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', 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', 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', 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', async () => { + const r = await runCli(['help']); + assert.equal(r.code, 0); + 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', () => { + 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 = 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/); + assert.match(r.out, /qwen\s+-\s+NO/); + } finally { + await stub.close(); + } +}); + +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)); +}); + +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 = 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 { + 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 = 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'); + } 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 = 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 { + 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 = 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 { + 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 = 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/); + } 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; +}); diff --git a/gateway/test/contract.test.js b/gateway/test/contract.test.js new file mode 100644 index 0000000..5672f9b --- /dev/null +++ b/gateway/test/contract.test.js @@ -0,0 +1,616 @@ +'use strict'; + +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'); +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'); + // 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(); + 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(); +}); + +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')); +}); 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/integration.test.js b/gateway/test/integration.test.js new file mode 100644 index 0000000..aa6b69c --- /dev/null +++ b/gateway/test/integration.test.js @@ -0,0 +1,567 @@ +'use strict'; + +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'); +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, 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: () => nextResponse(), + 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) => { + await client.send('initProvider', 'perplexity'); + const page = g.sessions._session('perplexity').page; + // Deterministic: two placeholders, then a real answer. No timers. + page._state.responses = ['no response captured', '', 'real answer']; + + const res = await client.send('getResponseWithTyping', 'perplexity', { + maxAttempts: 5, + retryDelayMs: 5, + }); + assert.equal(res.success, true); + assert.equal(res.response, 'real answer'); + 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); + }); +}); + +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/leak.test.js b/gateway/test/leak.test.js new file mode 100644 index 0000000..abfaee6 --- /dev/null +++ b/gateway/test/leak.test.js @@ -0,0 +1,305 @@ +'use strict'; + +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'); +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'); +}); diff --git a/gateway/test/regression.test.js b/gateway/test/regression.test.js new file mode 100644 index 0000000..ecea1be --- /dev/null +++ b/gateway/test/regression.test.js @@ -0,0 +1,392 @@ +'use strict'; + +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'); + +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); + } +}); diff --git a/gateway/test/unit.test.js b/gateway/test/unit.test.js new file mode 100644 index 0000000..9769264 --- /dev/null +++ b/gateway/test/unit.test.js @@ -0,0 +1,303 @@ +'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('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 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 + +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); +}); 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, diff --git a/scripts/tests/automation-tests.cjs b/scripts/tests/automation-tests.cjs index f7a4d47..0f8974c 100644 --- a/scripts/tests/automation-tests.cjs +++ b/scripts/tests/automation-tests.cjs @@ -538,6 +538,20 @@ function testChatGPTUpload() { console.log('✅ ChatGPT Upload Shapes tests passed.'); } +// capture.cjs and chatgpt-audio.cjs both label their output sanitized, through ONE shared +// sanitizer (tools/orchestrate/lib/page-capture.cjs). Its ChatGPT-only predecessor leaked +// an Azure SAS sig= from a response body, and its first fix corrupted JSON-escaped text; +// this pins those plus the Qwen/Gemini/Claude credential and PII shapes. Own file. +function testPageCapture() { + console.log('Testing Page Capture Sanitizer...'); + const r = require('child_process').spawnSync(process.execPath, + [path.join(__dirname, 'page-capture-test.cjs')], { encoding: 'utf8' }); + if (r.status !== 0) { + throw new Error('page-capture-test failed:\n' + (r.stdout || '') + (r.stderr || '')); + } + console.log('✅ Page Capture Sanitizer tests passed.'); +} + try { testReviewParser(); testSafetyValidator(); @@ -552,6 +566,7 @@ try { testChatGPTImageTurn(); testClaudeModelDefault(); testChatGPTUpload(); + testPageCapture(); console.log('\n✨ All automation tests passed!'); } catch (e) { console.error('\n❌ Test failed:'); diff --git a/scripts/tests/page-capture-test.cjs b/scripts/tests/page-capture-test.cjs new file mode 100644 index 0000000..e6ba1fc --- /dev/null +++ b/scripts/tests/page-capture-test.cjs @@ -0,0 +1,79 @@ +/** + * The shared capture sanitizer (tools/orchestrate/lib/page-capture.cjs), tested against + * the credential shapes each provider actually produces. capture.cjs and chatgpt-audio.cjs + * both label their output "sanitized"; this is what makes that true. + * + * History that put these cases here: an earlier ChatGPT-only capture scrubbed headers and + * request URLs but not response BODIES, and shipped an Azure SAS upload URL with sig= + * intact. Its first fix then swallowed the backslash of a JSON-escaped \" and corrupted + * the file. Both are pinned below, plus the shapes of the other providers. + */ +const cap = require('../../tools/orchestrate/lib/page-capture.cjs'); + +let fails = 0; +const ok = (c, l) => { console.log((c ? ' PASS ' : ' FAIL ') + l); if (!c) fails++; }; +const clean = (s, origin) => cap.capCleanText(s, 0, origin || 'https://chatgpt.com'); +const leaks = (s) => cap.scan(s); + +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 OSS = 'https://qwen-webui-prod.oss-accelerate.aliyuncs.com/u/f.mp3?OSSAccessKeyId=STS.NUabcd1234&Expires=1790000000&Signature=abc%2Fdef%3D&security-token=CAIS1234567890'; + +// --- ChatGPT: Azure SAS in a response body, raw and JSON-in-JSON ------------------------- +let out = clean(JSON.stringify({ status: 'success', upload_url: SAS, file_id: 'file_x' })); +ok(!/sig=Zm9v/.test(out) && leaks(out).length === 0, 'Azure SAS sig= removed from a response body'); +out = clean(JSON.stringify(JSON.stringify({ upload_url: SAS }))); +let parses = true; try { JSON.parse(out); } catch (e) { parses = false; } +ok(parses && !/sig=Zm9v/.test(out), 'JSON nested in a JSON string: SAS removed AND still valid JSON'); + +// --- Qwen: Alibaba OSS signed URL --------------------------------------------------------- +out = clean('{"url":"' + OSS + '"}', 'https://chat.qwen.ai'); +ok(!/Signature=abc|OSSAccessKeyId=STS|security-token=CAIS/.test(out), 'Alibaba OSS signature, key id and STS token removed'); + +// --- Gemini: XSRF token in a form-encoded body --------------------------------------------- +out = clean('f.req=%5B%5B%22x%22%5D%5D&at=AJvLN6M3xYzQ9kExampleXsrfToken123%3A1790000000000&', 'https://gemini.google.com'); +ok(/at=/.test(out) && /f\.req=/.test(out), 'Gemini at= XSRF token removed, f.req payload kept'); +out = clean('/_/BardChatUi/data/batchexecute?rpcids=x&f.sid=-123456789012345&bl=boq_x', 'https://gemini.google.com'); +ok(/f\.sid=/.test(out) && /rpcids=x/.test(out), 'Gemini f.sid session id removed, rpcids kept'); + +// --- generic shapes ----------------------------------------------------------------------- +out = clean('{"x":"eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLWhlcmU"}'); +ok(out === '{"x":""}', 'JWT masked'); +out = clean('{"conduit_token":"abcdef0123456789xyz","accessToken":"zzzzzzzzzzzzzzzzzz"}'); +ok(/"conduit_token":""/.test(out) && /"accessToken":""/.test(out), 'secret-named JSON fields redacted'); +out = clean('{"user":{"email":"someone.real@example.com"}}'); +ok(//.test(out) && !/example\.com/.test(out), 'email address masked'); +out = clean('{"full_name":"Real Person","display_name":"Real","statsigContext":{"deviceId":"0b2f8c1e-aaaa-bbbb-cccc-111122223333","region":"Somewhere, Earth"}}'); +ok(!/Real Person|"Real"|0b2f8c1e|Somewhere/.test(out) && (out.match(//g) || []).length === 4, + 'personal fields (full_name, display_name, deviceId, region) masked'); +out = clean('{"data":[{"id":"qwen3.8-max","name":"Qwen3.8-Max","owned_by":"qwen"}]}', 'https://chat.qwen.ai'); +ok(/"name":"Qwen3\.8-Max"/.test(out), 'plain "name" is kept, so model names survive'); +out = clean('{"blob":"' + 'A'.repeat(80) + '"}'); +ok(//.test(out), 'long opaque strings masked'); + +// --- what must NOT be destroyed ----------------------------------------------------------- +out = clean('{"u":"https://chatgpt.com/backend-api/files/download/file_x?conversation_id=abc"}'); +ok(/conversation_id=abc/.test(out), 'an own-origin URL keeps its ordinary query parameters'); +out = clean('{"format":"mp3","state":"done","first":"x"}'); +ok(out === '{"format":"mp3","state":"done","first":"x"}', 'words containing "at"/"st"/"se" are not mangled'); +ok(cap.capCleanUrl('https://claude.ai/api/organizations/abc/chat_conversations?limit=5', 'https://claude.ai') + === '/api/organizations/abc/chat_conversations?limit=5', 'own-origin request URL becomes a clean path'); +ok(cap.capCleanUrl(SAS, 'https://chatgpt.com').indexOf('?') !== -1, + 'other-origin request URL loses its query'); + +// --- headers ----------------------------------------------------------------------------- +const h = cap.capCleanHeaders({ + Authorization: 'Bearer abc', Cookie: 'a=b', 'bx-ua': 'x', 'x-xsrf-token': 'y', + 'openai-sentinel-proof-token': 'z', 'x-conduit-token': 'q', 'content-type': 'application/json' +}, 'https://chatgpt.com'); +ok(['Authorization', 'Cookie', 'bx-ua', 'x-xsrf-token', 'openai-sentinel-proof-token', 'x-conduit-token'] + .every((k) => h[k] === ''), 'auth, cookie, Qwen bx-*, XSRF, sentinel and conduit headers redacted'); +ok(h['content-type'] === 'application/json', 'ordinary headers kept'); + +// --- the scanner is the last gate: it must SEE a leak -------------------------------------- +ok(leaks(SAS).some((x) => x.check === 'signed-URL parameter'), 'scanner catches a raw SAS sig='); +ok(leaks('at=AJvLN6M3xYzQ9kExampleXsrf').some((x) => x.check === 'session/XSRF parameter'), 'scanner catches a raw at= token'); +ok(leaks('mail me at someone.real@example.com').some((x) => x.check === 'email address'), 'scanner catches an email'); +ok(leaks(clean(JSON.stringify({ upload_url: SAS, user: 'a@b.co' }))).length === 0, 'scanner is quiet on sanitized output'); + +console.log(fails ? '\n' + fails + ' FAILURE(S)' : '\nall page-capture sanitizer assertions passed'); +process.exit(fails ? 1 : 0); diff --git a/tools/orchestrate/capture.cjs b/tools/orchestrate/capture.cjs new file mode 100644 index 0000000..f1af705 --- /dev/null +++ b/tools/orchestrate/capture.cjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/** + * Record what a site's web app actually sends, from inside any Proxima provider tab, and + * write it as a sanitized HAR. The replacement for "open DevTools, reproduce, export HAR". + * + * node capture.cjs --provider --seconds 90 record for 90s, then write + * node capture.cjs --provider --stop-file record until exists + * node capture.cjs --provider --start install and return + * node capture.cjs --provider --stop collect what --start saw + * node capture.cjs --provider --status is a recorder running here? + * + * --out default ./proxima-capture--.har + * --max-body characters kept per request/response body (default 8000) + * --init load the provider's tab first if Proxima has not (costs memory) + * + * is any provider Proxima has a tab for: chatgpt, claude, qwen, gemini, perplexity. + * Nothing in here is specific to one of them — the page's own origin decides what is + * first-party, and the recorder wraps fetch and XMLHttpRequest the same way everywhere. + * + * Use --seconds or --stop-file for anything that might navigate. A full page load wipes a + * recorder that lives in the page, so those modes drain it to this process every 2s and + * reinstall it after a reload; a reload costs at most the requests in flight across it, + * and the output says when one happened. --start/--stop has no process in between and + * cannot survive a reload — fine for single-page flows, wrong for anything else. + * + * What triggers the traffic is up to you: click through the flow in Proxima's window, or + * run another tool against that tab while this records. Proxima's own engines run in the + * page's world, so their requests are captured too. + * + * Every write is scrubbed a second time in Node and then SCANNED. If the scan still finds + * something that looks like a credential or an email address, nothing is written and the + * exit code is 3. That gate exists because "sanitized" has been wrong before. + * + * Exit: 0 written · 1 argument/transport error · 2 nothing captured or provider not loaded · + * 3 refused to write: output still looked unsanitized after both passes. + */ +const net = require('net'); +const fs = require('fs'); +const path = require('path'); +const { resolvePortSync } = require('../../scripts/lib/proxima-port.cjs'); +const cap = require('./lib/page-capture.cjs'); + +function parseArgs(argv) { + const a = { provider: null, mode: null, seconds: null, stopFile: null, out: null, maxBody: 8000, init: false }; + for (let i = 2; i < argv.length; i++) { + const k = argv[i], v = argv[i + 1]; + if (k === '--provider') { a.provider = String(v || '').toLowerCase(); i++; } + else if (k === '--seconds') { a.mode = 'timed'; a.seconds = Number(v); i++; } + else if (k === '--stop-file') { a.mode = 'timed'; a.stopFile = path.resolve(v); i++; } + else if (k === '--start') a.mode = 'start'; + else if (k === '--stop') a.mode = 'stop'; + else if (k === '--status') a.mode = 'status'; + else if (k === '--out') { a.out = path.resolve(v); i++; } + else if (k === '--max-body') { a.maxBody = Number(v); i++; } + else if (k === '--init') a.init = true; + else if (k === '-h' || k === '--help') a.help = true; + } + return a; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const say = (m) => console.error('[capture] ' + m); + +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 inPage(port, provider, script, ms) { + const r = await ipc(port, { action: 'executeScript', provider, data: { script } }, ms || 30000); + if (r && r.success === false) throw new Error(r.error || 'executeScript failed'); + const v = r.result !== undefined ? r.result : r.data; + return typeof v === 'string' ? JSON.parse(v) : v; +} + +/** + * Fail fast on a provider Proxima has not loaded. Measured: an executeScript against an + * unloaded tab does not error, it hangs until the caller's timeout. + */ +async function ensureLoaded(port, provider, init) { + const st = await ipc(port, { action: 'getStatus' }, 15000); + const loaded = (st && st.providers) || []; + if (loaded.indexOf(provider) !== -1) return true; + if (!init) { + say(provider + ' is not loaded in Proxima (loaded: ' + (loaded.join(', ') || 'none') + ').'); + say('Pass --init to load it (it costs memory), or open it in Proxima first.'); + return false; + } + say('loading ' + provider + ' in Proxima...'); + await ipc(port, { action: 'initProvider', provider }, 30000); + for (let i = 0; i < 40; i++) { + await sleep(1000); + const ok = await inPage(port, provider, 'JSON.stringify(document.readyState === "complete" && !!location.origin)', 5000) + .catch(() => false); + if (ok === true) return true; + } + say(provider + ' tab never finished loading'); + return false; +} + +function defaultOut(provider) { + const ts = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15); + return path.resolve('proxima-capture-' + provider + '-' + ts + '.har'); +} + +/** Second-pass scrub, parse guard, leak scan, then — and only then — write. */ +function writeHar(entries, meta, out) { + const har = cap.toHar(entries, meta); + let text = cap.scrub(JSON.stringify(har, null, 2), meta.origin); + JSON.parse(text); // a scrub that broke the JSON must not be written + const hits = cap.scan(text); + if (hits.length) { + say('REFUSED TO WRITE: the output still looks like it contains secrets after both passes:'); + hits.forEach((h) => say(' ' + h.check + ' x' + h.count + ' (e.g. ' + h.sample + ')')); + say('Nothing was written. Tighten capCleanText in lib/page-capture.cjs for this shape.'); + return 3; + } + fs.writeFileSync(out, text); + const counts = {}; + entries.forEach((e) => { + const k = e.method + ' ' + String(e.url).split('?')[0].replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/g, '{uuid}').slice(0, 70); + counts[k] = (counts[k] || 0) + 1; + }); + say('wrote ' + entries.length + ' request(s) from ' + meta.origin + ' -> ' + out); + say('scan: clean on all ' + Object.keys(cap.LEAK_CHECKS).length + ' checks'); + Object.keys(counts).sort((x, y) => counts[y] - counts[x]).slice(0, 15) + .forEach((k) => console.error(' ' + String(counts[k]).padStart(3) + ' ' + k)); + return 0; +} + +(async () => { + const a = parseArgs(process.argv); + if (a.help || !a.provider || !a.mode) { + console.error('usage: node capture.cjs --provider (--seconds N | --stop-file F | --start | --stop | --status) [--out file.har]'); + process.exit(a.help ? 0 : 1); + } + if (a.mode === 'timed' && !(a.seconds > 0) && !a.stopFile) { console.error('--seconds needs a positive number'); process.exit(1); } + const port = resolvePortSync(); + if (!(await ensureLoaded(port, a.provider, a.init))) process.exit(2); + const out = a.out || defaultOut(a.provider); + const install = cap.pageScript('capInstall(location.origin, ' + Number(a.maxBody) + ')'); + + if (a.mode === 'status') { + const s = await inPage(port, a.provider, + 'JSON.stringify(window.__proximaCapture ? { running: true, origin: window.__proximaCapture.origin, ' + + 'held: window.__proximaCapture.log.length, sinceSec: Math.round((Date.now() - window.__proximaCapture.startedAt) / 1000) } : { running: false })'); + console.log(JSON.stringify(s)); + process.exit(0); + } + + if (a.mode === 'start') { + const r = await inPage(port, a.provider, install); + say((r.state === 'already' ? 'already recording' : 'recording') + ' in ' + r.origin + + '. Note: a page reload ends it; use --seconds or --stop-file for flows that navigate.'); + process.exit(0); + } + + if (a.mode === 'stop') { + const d = await inPage(port, a.provider, cap.pageDrainScript(true, 5000), 30000); + if (d.missing) { + say('no recorder in the ' + a.provider + ' tab: never started, or the page reloaded since --start.'); + process.exit(2); + } + await inPage(port, a.provider, cap.pageScript('capUninstall()')).catch(() => {}); + if (!d.entries.length) { say('the recorder ran but saw no requests'); process.exit(2); } + process.exit(writeHar(d.entries, { origin: d.origin, startedAt: d.startedAt }, out)); + } + + // timed: install, drain every 2s, survive reloads, stop on time or on the stop file. + let meta = null; + const all = []; + const reloads = []; + const deadline = a.seconds ? Date.now() + a.seconds * 1000 : Infinity; + const r0 = await inPage(port, a.provider, install); + meta = { origin: r0.origin, startedAt: Date.now() }; + say('recording in ' + r0.origin + (a.seconds ? ' for ' + a.seconds + 's' : ' until ' + a.stopFile + ' exists') + + ' — trigger the flow in Proxima\'s ' + a.provider + ' tab now'); + let lastTick = 0; + while (Date.now() < deadline && !(a.stopFile && fs.existsSync(a.stopFile))) { + await sleep(2000); + let d; + try { d = await inPage(port, a.provider, cap.pageDrainScript(false, 0), 15000); } + catch (e) { say('drain failed (' + e.message + '), retrying'); continue; } + if (d.missing) { + // The page reloaded: the recorder went with it. Put it back. + reloads.push(new Date().toISOString()); + say('page reloaded — recorder reinstalled; requests in flight across the reload were missed'); + await inPage(port, a.provider, install).catch(() => {}); + continue; + } + all.push.apply(all, d.entries); + if (Date.now() - lastTick > 10000) { say(all.length + ' request(s) so far'); lastTick = Date.now(); } + } + const d = await inPage(port, a.provider, cap.pageDrainScript(true, 5000), 30000).catch(() => ({ entries: [] })); + if (d && d.entries) all.push.apply(all, d.entries); + await inPage(port, a.provider, cap.pageScript('capUninstall()')).catch(() => {}); + if (a.stopFile && fs.existsSync(a.stopFile)) { try { fs.unlinkSync(a.stopFile); } catch (e) { /* not ours to keep */ } } + if (!all.length) { say('no requests seen'); process.exit(2); } + all.sort((x, y) => x.t - y.t); + if (reloads.length) meta.note = 'Page reloaded ' + reloads.length + ' time(s) during capture: ' + reloads.join(', ') + '.'; + process.exit(writeHar(all, meta, out)); +})().catch((e) => { say(e.message); process.exit(1); }); diff --git a/tools/orchestrate/chatgpt-audio.cjs b/tools/orchestrate/chatgpt-audio.cjs new file mode 100644 index 0000000..a353c2e --- /dev/null +++ b/tools/orchestrate/chatgpt-audio.cjs @@ -0,0 +1,273 @@ +#!/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.har] + * + * 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 and what was seen), + * 1 = transport/argument error, 3 = --capture refused to write (output looked unsanitized). + * + * Recording uses the same in-page recorder and sanitizer as capture.cjs + * (lib/page-capture.cjs) — one copy, so the two cannot drift apart. + */ +const net = require('net'); +const fs = require('fs'); +const path = require('path'); +const { resolvePortSync } = require('../../scripts/lib/proxima-port.cjs'); +const cap = require('./lib/page-capture.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. It assumes the shared recorder is already installed, and reads its +// log to know when the app's own upload has started and finished. +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 C = window.__proximaCapture; + if (!C) return JSON.stringify({ step: 'start', error: 'recorder not installed' }); + var origFetch = C.origFetch; + var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); }; + var seen = function (re, status) { + return C.log.some(function (e) { return re.test(e.url) && (status === undefined || e.status === status); }); + }; + + 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 (C.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 (seen(/process_upload_stream/, 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. + // origFetch, so the read-back is not itself recorded. + 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); + } + return JSON.stringify(out); +})()`; +} + +/** Same gate as capture.cjs: second-pass scrub, parse guard, scan, then write or refuse. */ +function writeCapture(entries, origin, out) { + let text = cap.scrub(JSON.stringify(cap.toHar(entries, { origin, startedAt: Date.now() }), null, 2), origin); + JSON.parse(text); + const hits = cap.scan(text); + if (hits.length) { + console.error('[chatgpt-audio] REFUSED to write the capture — it still looks unsanitized:'); + hits.forEach((x) => console.error(' ' + x.check + ' x' + x.count)); + return false; + } + fs.writeFileSync(out, text); + console.error('[chatgpt-audio] sanitized capture (' + entries.length + ' requests, scan clean): ' + out); + return true; +} + +(async () => { + const args = parseArgs(process.argv); + if (args.help || !args.file || !args.message) { + console.error('usage: node chatgpt-audio.cjs --file