Skip to content

feat(gateway): headless provider gateway replacing the Electron runtime - #1

Open
Ahad690 wants to merge 12 commits into
ahad/provider-workfrom
feat/cleanroom-gateway
Open

Ahad690 wants to merge 12 commits into
ahad/provider-workfrom
feat/cleanroom-gateway

Conversation

@Ahad690

@Ahad690 Ahad690 commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Clean-room rebuild of the provider access layer as a headless Playwright-Chromium service. No GUI, no REST, no WebSocket server, no Electron import.

Why

The upstream runtime is an Electron app, but the real dependency is a persistent per-provider Chromium profile plus an authenticated same-origin fetch(). Nothing in the browser layer needs Electron - the 12 distinct Electron APIs in use each have a first-class Playwright equivalent.

The gateway speaks the loopback contract the existing automation client already uses, so that client attaches unchanged: TCP 127.0.0.1:19222, newline-delimited JSON.

Layout

Path Role
src/state-store.js settings (preference) vs port fact (authority)
src/cookie-store.js domain+name auth detection, backup, injection
src/session-manager.js per-provider persistent contexts, auth-preserving unload
src/engine-loader.js init-script injection with presence probing
src/ipc-server.js framing, dispatch, loopback enforcement
src/actions.js the action contract
engines/README.md the engine interface, for porting

Engines carry all provider protocol knowledge; the gateway carries none.

Tests

  • 53 unit + integration (no browser required)
  • 10 real-Chromium tests, skipped cleanly when Playwright is absent

CI runs unit, real-browser and a syntax check in parallel.

Decisions made without asking

  • Engines are not copied. A clean-room reference engine for Perplexity is included to prove the contract is sufficient. The other four are left to be written against engines/README.md.
  • Headless is the default but overridable per provider, so a headed fallback is a config change rather than a rewrite.
  • Gemini is login + message only - the only provider with no upload path.
  • setHeadlessMode persists and reports that a restart is required rather than pretending to flip in-process.

Known gaps

  • Headless bot-detection is unverified. Headless Chromium is more detectable than headed. Needs an empirical check against a real authenticated profile before the session manager is trusted. Not answerable in CI, which has no provider credentials.
  • Four of five provider engines are unwritten.
  • Attachment upload is gated behind a setting but the upload path is not yet implemented.

Rebuilds the provider access layer as a headless Playwright-Chromium
service. No GUI, no REST, no WebSocket server, no Electron import.

The existing automation client attaches unchanged over the documented
loopback contract: TCP 127.0.0.1:19222, newline-delimited JSON,
{requestId, action, provider, data} in and {requestId, success, ...} out.

Components:
  state-store      settings (preference) vs port fact (authority)
  cookie-store     domain+name auth detection, backup, injection
  session-manager  per-provider persistent contexts, A6-preserving unload
  engine-loader    init-script injection with presence probing
  ipc-server       framing, dispatch, loopback enforcement
  actions          the action contract

Engines carry all provider protocol knowledge; the gateway carries none.

Tests: 53 unit + integration, plus 10 real-Chromium tests that skip
cleanly when Playwright is absent. CI runs unit, real-browser and a
syntax check in parallel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 20e583d3-7ae8-43e0-8d21-bbf131a7a242


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ahad690 and others added 11 commits September 27, 2026 12:54
All three were test bugs, not product bugs:

- browser: a re-injection test never registered its init script, so the
  global was genuinely undefined after reload rather than stale
- browser: Playwright's cookies() filter needs a full URL, not a bare
  domain ('claude.ai' -> 'https://claude.ai')
- integration: the A8 placeholder-retry test raced a setTimeout, so it
  passed locally and failed on a slow runner. Replaced with a queued
  response list, which also pins the exact attempt count.

Adds a second A8 case asserting a run of placeholders reports failure
rather than surfacing a placeholder as an answer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coverage was 86.18 line / 77.85 branch / 76.38 funcs - branch and funcs
under the 80 bar. Now 92.70 / 84.53 / 88.64 across 88 tests.

Adds contract.test.js: 34 tests covering the actions and error paths the
report showed as untouched - executeScript, navigate, debugDOM,
getTypingStatus, waitForSendButton, getCookies, setSetting, multi-provider
unload/purge, attachment gating, engine error classification, and the
session lifecycle.

Product fix found while covering engine-loader: call() returned the
in-page __error sentinel to the caller as if it were a legitimate result,
so a missing engine method would look like a successful call returning
nonsense. It now raises a typed EngineError. Regression test added.

Adds scripts/check-coverage.cjs, a real gate: it parses the report, fails
below 85/80/80, names the weakest files, and refuses to pass when the
report is unparseable or the totals row is missing. CI runs it after the
suite, so coverage cannot silently regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gateway holds session cookies and provider bearer tokens, and those
values reach error paths: a Playwright failure can quote the URL it was
fetching, and a provider can echo request headers back in an error body.

src/redact.js - regex for known shapes (bearer, JWT, provider cookies,
sensitive query params) plus truncation for opaque values. Diagnostics are
preserved: action names, requestIds, provider names, status codes and
error text survive; only secret-shaped values are masked. Cookie names are
kept so a stale session is still diagnosable. containsSecret() makes a
regression detectable rather than merely improbable.

src/logger.js - redaction at the sink, not the source, because call sites
should not have to know which values are secret. There is deliberately no
raw console path. toSafeError() is the single normaliser for internal
errors crossing to the wire.

Applied to: action dispatch, IPC framing, bind failures, startup and
uncaught-exception stacks. Also removed the reference engine's practice of
reading a failure body into the error, since a provider can echo the
credential there.

Two product bugs found and fixed while testing this:
- toSafeError did not set success:false, so the IPC catch path returned a
  response with no success field at all - a caller would read the missing
  field as 'probably fine'.
- The engines README-per-provider set shipped Perplexity looking ready
  while the other four refused. Perplexity's endpoint was equally
  unverified, so it now carries the same guard. Having one engine that
  looks finished would misrepresent the state of the work.

Engine scaffolds for chatgpt, claude, gemini and qwen via
scripts/make-engine-scaffold.cjs. Each is contract-complete - state
handling, failure classification, empty-response discipline - and each
REFUSES to issue a request while verified=false, so a guessed endpoint
cannot masquerade as a working one.

Tests: 131, all passing. Coverage 94.83 line / 83.26 branch / 87.58 funcs.
redact/leak/engines suites prove secrets do not survive any boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An independent review found real defects, all confirmed by reproducing
them before fixing. Two were named invariants that the code did not
actually hold while the tests and the API claimed it did.

CRITICAL - A6 broken on the primary login path. unloadProvider reported
authPreserved:true while logging the operator out: the Playwright context
is non-persistent, and the cookie jar was flushed only by setCookies and
flushAll, never before a context was closed. An interactive login existed
only in the context, so the first unload discarded it. The existing test
passed because it set up through setCookies, the one path that already
flushed - the test verified the path that was not broken. Now flushes
before close and derives authPreserved from disk instead of hardcoding
true, with a warning when preservation cannot be confirmed.

CRITICAL - session cookies in plaintext on the wire. Only the failure
branch of the IPC response was redacted, so getCookies, debugDOM and
executeScript returned secrets verbatim. redactCookies was written for
exactly this and called from nowhere. Now applied at the IPC boundary
and by getCookies directly - the generic redactor cannot see a bare cookie
value, which has no name=value shape.

CRITICAL - the Authorization rule leaked. It required a value that could
not contain a space and excluded 'basic', so 'Authorization: Basic
<creds>' passed through untouched, while 'ApiKey <creds>' had its scheme
destroyed and kept its credential - looking redacted while leaking. A
scheme allowlist cannot be correct for an unknown scheme space. Now
generic, value always masked, scheme preserved, and genuinely idempotent
(matching the marker as an alternative rather than excluding it with a
lookahead, which does not survive backtracking).

Also fixed: sensitive-cookie names derived from the provider registry so
__cf_bm can no longer drift out of the redaction list; 'no response' and
nullish literals recognised as placeholders; getResponse no longer returns
a placeholder as success; purgeProvider no longer reports a no-op as
success on the one destructive action; capture retry clamped in both
directions so a caller cannot hold a connection's queue for hours;
navigate surfaces engine/transport failure instead of reporting success
and failing confusingly later; three context-leak paths closed (unclosed
context on failed init, concurrent first-touch, double browser launch);
credential files written with the mode on the write via temp+rename, and
the primary auth store protected at all - with a warning on win32 where
POSIX 0600 is not enforceable rather than silently claiming it is.

Tests: 155, all passing. Coverage 96.21 line / 83.27 branch / 91.57 funcs.
regression.test.js pins each finding by the invariant it protects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gateway had a daemon and an IPC contract but no way for a person to
reach it: no argv, no stdin, no prompts. Cookie login existed and was
unreachable. Adds plain commands, deliberately not a REPL - the automation
client is the interactive consumer, and a human logs in a couple of times
a month.

  proxima-gw check                    is it running, on what port
  proxima-gw status                   per-provider load and auth state
  proxima-gw login <p> --cookies <f>  inject a cookie export
  proxima-gw login <p> --headed       real window, log in by hand
  proxima-gw logout <p>               destroy that auth on disk

Plain uncoloured output so it pipes cleanly, non-zero exit codes because a
script trusts them, and no stack traces at the user. PowerShell wrappers
proxima-gw.ps1 (CLI) and proxima-gw-daemon.ps1 (-Start/-Stop, which waits
for the port fact instead of racing startup).

--cookies --stdin also works, so this composes:
  Get-Content .\cookies\claude.json -Raw | proxima-gw login claude --stdin

Two interop defects fixed, both of which a CLI would surface immediately:

The port fact went to the gateway's own state dir, but the existing client
reads ipc-port.json from the legacy Electron userData dir. The client could
never have found this gateway and would have reported 'Proxima is not
running' against a healthy process. The fact is now written to both
locations - profiles and cookie backups stay private to the gateway's state
dir, but the port is a shared fact. A test pins the path against the same
resolution the client uses, so the two cannot drift apart again.

Port collision silently relocated to port+1. That is worse than failing:
the fact then points a client at whichever process won 19222, and a client
probing candidates in order will talk to the wrong one - meaning the legacy
Electron app answering a gateway request. Collision now fails loudly and
names the other pid; relocating is opt-in via PROXIMA_GATEWAY_PORT=1 and
warns when it happens.

Tests: 8 files. The CLI suite drives real argv, real sockets and real exit
codes against a stub gateway, because a CLI returning 0 on failure is worse
than no CLI. CI also parses the PowerShell wrappers and asserts the CLI
exits non-zero when nothing is running.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitHub's default Linux shell is 'bash -e {0}', which has no pipefail. So
'npm run test:coverage | tee coverage.txt' returned tee's exit code, and a
failing suite reported success. A green job had 1 of 157 tests failing in
it - 'port collision retries +1', a test asserting the auto-relocate
behaviour that was intentionally removed a commit earlier.

'shell: bash' is 'bash -eo pipefail', so the pipeline now propagates the
test result. This has been latent since the tee was introduced.

Also fixed the stale test itself to assert the new contract (fail loudly by
default, relocate only on opt-in), and wrapped it in try/finally: a thrown
assert left the squatter's server handle open, which timed out the whole
file and buried the real failure under an unrelated one.

The CLI suite needed more than the 20s per-test budget, so it now has 60s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Writing the CLI tests found defects in the CLI, not just in the tests.

parseArgs could not tell a flag value from a command name, so
'--headless logout qwen' parsed as headless='logout' with one positional
and the command never ran. Value-taking flags are now an explicit list
(only --cookies), so everything else is boolean.

The CLI crashed on every socket-level error: call() returns {ok, error}
with no response field on a connection failure, and the handlers read
res.response.error unconditionally - so the exact moment you most need a
message (gateway down) produced 'Cannot read properties of undefined'.
All four sites now fall back to the transport error.

FALLBACK_ATTEMPTS was 0, which made allowRelocate a flag that promised
something the code never did: opting in relocated nowhere. It is 1, and
the default remains to refuse.

The test harness deadlocked: runCli used spawnSync while the stub gateway
ran in the same process, so the child connected but the stub could never
reply and every end-to-end test hung to its timeout. runCli is async now.
Per-call CLI timeouts dropped from 30-90s to 15s - a CLI answers a human
and must not look like it is hanging.

Tests were also writing the port fact into the real %APPDATA%\proxima,
because that is now the gateway's fact location. Five test files started
servers concurrently and both polluted the actual user profile and raced
each other over one fact file, which is what made 'port fact beats stale
preference' fail intermittently. Each file now redirects the fact path to
a temp file, and nothing touches the real profile.

Tests: 177, all passing. Coverage 94.51 line / 80.26 branch / 91.43 funcs.
Branch is close to the floor - proxima-gw.js is the weakest at 81.9% line,
so the next change there should come with tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
They exited 2, a usage error, so 'proxima-gw --help && next-thing' would
skip the next thing - asking for help looked like a failure. 'help' and
'--help' now agree on 0, while no arguments stays 2 because being asked to
do nothing is not success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t, not a Proxima gap

GPT-6 shipped on this account. /backend-api/models now reports default_model_slug
gpt-6, and the picker's latest version is GPT-6:
  Instant  gpt-6-instant
  Medium   gpt-6-thinking  thinking_effort 'standard'
  High     gpt-6-thinking  thinking_effort 'extended'

Measured on the live engine, with controls:
  gpt-6-thinking  @Extended  served as asked
  gpt-6-thinking  @max       served as asked, max recorded on the reply
  gpt-6           @Extended  served as asked
  gpt-6.1-sol-wm  @max       REWRITTEN -> gpt-6
  gpt-6-astra-wm  @max       REWRITTEN -> gpt-6
  gpt-6-nonexistent-zz       echoed (control: an unknown slug is echoed, so only a
                             rewrite counts as evidence)

The wanted order was Astra, then GPT-6.1 Sol. Neither is available. This also
corrects the 2026-09-25 conclusion that the /f/conversation + prepare/conduit_token
handshake was what stood between Proxima and Astra. The user's own thread, sent by
the real web app through that exact flow, asked for gpt-6-astra-wm at max and was
served gpt-5-6. The plan is 'personal', and Astra is reported as Pro-tier. No
handshake changes that.

'max' is not a picker preset, but it is accepted and recorded. Whether it reasons
harder than 'extended' is NOT established: on an easy question all three levels
finished in about 1s. It is the default because max was requested.

Reviews follow the change through a new reviewThinkingEffort config key. The
name-derived resolveThinkingEffort() can only ever produce 'extended', so 'max' was
unreachable from configuration before. This takes effect on the next review with
no restart: reviews name the model explicitly, and the running engine already
serves gpt-6-thinking at max. The engine DEFAULT, used by ask_chatgpt, needs a
Proxima restart.
…ve sanitized capture

chatgpt-audio.cjs sends an audio file to ChatGPT by driving the real web app inside
Proxima's ChatGPT tab: it opens a new chat, attaches the file through the app's
general file input, types, sends, and reads the reply back through the API. It
needs no Proxima restart.

Why the app and not the engine. Measured 2026-10-10, same mp3, same question, on
gpt-6-thinking:
  real app, through its own composer             heard it (WORD=pineapple NUMBER=47)
  engine, with the app's exact upload bodies     CANNOT_HEAR
  engine, use_case 'multimodal'                  CANNOT_HEAR
The server stored all three user turns identically (multimodal_text, then text,
then audio_asset_pointer). So the cause is neither the upload nor the message. It
is how the turn is invoked. The app posts to /backend-api/f/conversation behind a
prepare/conduit_token handshake and a two-step sentinel; the engine still uses
/backend-api/conversation, where the model can see the file but not hear it. This
corrects the earlier claim that ChatGPT cannot process audio at all. That claim
rested on /backend-api/models accepted_mime_types, which turned out to list only
the document path: image/* is absent from it too, and images demonstrably work.
Verified three times end to end, 15-19s each.

The capture was the method, not a side effect. An in-page fetch/XHR recorder
replaced the manual DevTools HAR export, and it can be re-run with --capture.
Sanitizing happens inside the page, before anything leaves it.

Two sanitizer bugs found by scanning the output instead of trusting the label:
  - Response BODIES were not URL-scrubbed. upload_url is an Azure SAS whose sig= is
    the credential, and the first capture shipped one intact. (It was a short-lived,
    single-blob write URL, now re-cleaned.)
  - The first fix consumed the backslash of a JSON-escaped \" and corrupted the
    file. A JSON.parse guard caught it before anything was written.
Both are pinned by chatgpt-audio-sanitizer-test, which runs the sanitizer exactly
as the tool generates it. It was negative-tested: with the SAS scrub removed, it
fails on both leak cases.

Also hardened: right after a reload the composer is visible before the file input
is wired up, so the attach is confirmed by an upload actually starting, and retried
if not. One run had sat for 90s with no upload.
capture.cjs records what a site's web app actually sends, from inside any loaded
Proxima tab (chatgpt, claude, qwen, gemini, perplexity), and writes it as a
sanitized HAR. This replaces "open DevTools, reproduce the flow, Save all as HAR,
send the file over". No part of it is specific to one site: the page's own origin
decides what is first-party, and fetch and XHR are wrapped identically everywhere.

  --seconds N | --stop-file F   drains to disk every 2s and reinstalls after a reload
  --start / --stop              single-page flows only; a page reload ends it

There is ONE sanitizer: lib/page-capture.cjs. Its functions are injected into the
page via Function.toString, and the same functions run again in Node as a second
pass. chatgpt-audio.cjs now uses it as well, in place of the copy it carried. A
copied sanitizer drifts from its original, and the drifted ChatGPT-only version is
the one that leaked an Azure SAS sig= from a response body.

Every write goes through the same gate: second-pass scrub, then a JSON.parse guard,
then a leak scan across 7 checks. If anything still looks like a credential or an
email address, nothing is written and the exit code is 3.

Verified live on chatgpt, claude and qwen. The test traffic was read-only but
deliberately secret-heavy: session tokens, account emails, auth headers. All three
files scanned clean. Then came the step the scanner cannot do: listing every
unmasked string of 16+ characters by key name. That found personal fields the
patterns did not know, Claude's full_name and display_name and ChatGPT's deviceId
and region. They are now masked as <pii>. Opaque ids are kept, because protocol
work needs them. Gemini and perplexity are supported through --init, which loads
the tab first, but they are untested: neither was loaded.

chatgpt-audio.cjs was re-verified after the refactor: 4/4 heard, and its capture
of 23 requests scanned clean.

page-capture-test pins 21 assertions: Azure SAS raw and JSON-in-JSON, Alibaba OSS
signatures, Gemini at= and f.sid, JWTs, secret-named fields, emails, personal
fields, sensitive headers, a scanner that must see raw leaks, and the things that
must survive (own-origin queries, model names, words like "format"). It replaces
chatgpt-audio-sanitizer-test, which tested the now-removed copy.

docs/ORCHESTRATION_LOOP.md is the orchestrator-facing loop description, now listing
both scripts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant