Skip to content

Sign in with ChatGPT: serve a person's ChatGPT plan models to Workshop - #35

Draft
sublimator wants to merge 6 commits into
cppalliance:masterfrom
sublimator:sign-in-with-chatgpt
Draft

sublimator wants to merge 6 commits into
cppalliance:masterfrom
sublimator:sign-in-with-chatgpt

Conversation

@sublimator

@sublimator sublimator commented Nov 14, 2026 •

Copy link
Copy Markdown

Draft, for reference. One of three drafts: cppalliance/runvil-harness#14, this one and
cppalliance/runvil-workshop#22. Use it or not.

It lets a person sign in with their ChatGPT account and use their plan's
models from Workshop, through OpenAI's documented flow for open-source tools
(https://developers.openai.com/siwc/token-sharing-open-source). The
Gateway is the only process that ever holds the tokens.

Read these three things first

  • One existing route changes (commit 5). PUT /admin/config must now
    name the revision it was read from. With none it returns
    428 config_revision_required, and with a stale one 409. Every client
    that saves the config has to change. The settings page is changed here.
    It is its own commit, so it can be judged alone.
  • A sign-in is saved on Unix only. On Windows the store refuses to save
    a credential, so a sign-in does not persist there. Only macOS was run.
  • The plan's custody is not fully closed. The new /admin/chatgpt
    routes need the owner proof. The older admin routes do not. So a caller
    that may save the config can add or remove the plan's entries. It cannot
    point them elsewhere, and it cannot use them without the plan proof.

What it adds

Six commits on fe136b4. The first five each stand alone.

  1. A clippy fix for macOS in tray/macos.rs. fe136b4 fails
    large_enum_variant there. CI lints on Linux, so it did not show. It is
    independent of the rest.
  2. gateway-protocol. A Responses upstream, because the plan endpoint
    speaks only the Responses API, and a bearer source for a credential that
    has a lifetime.
  3. gateway-api-discovery. gateway-private.json, a private file for
    two proofs the Gateway mints at each start.
  4. gateway-chatgpt-plan, a new crate. Sign-in (PKCE, a one-shot
    loopback callback), ID-token checks, an owner-only credential file,
    refresh with rotation, sign-out with revocation. Each module names the
    OpenAI page and section it follows.
  5. A revision on config saves. The plan's model setup makes the Gateway
    a second writer of gateway.toml. Without the check, a settings page
    left open could overwrite that result with a later save.
  6. The routes and the gate.
    • Owner-only routes under /admin/chatgpt/ (status, sign-in,
      sign-out, forget, reset, models/enable, models/forget). They
      need the owner proof on top of ordinary authentication. Sign-in exists
      only on a Gateway that listens on loopback.
    • Plan models are listed and served only to a caller that presents
      X-Runvil-Plan-Proof. Anyone else gets 403 chatgpt_plan_runvil_only.
      OpenAI's terms restrict a plan to the application its user connected
      (https://openai.com/policies/sign-in-with-chatgpt-terms/, section 3.1):
      "Use the user's plan only for the application they connected. Do not
      provide general-purpose API access for other tools or unrelated
      requests." The plan proof is how the Gateway keeps to that.
    • The settings page shows a plan endpoint without a URL or a key. It
      makes no /admin/chatgpt call, so the owner proof never reaches a
      page. Sign-in is driven from Workshop.

The contracts are in crates/gateway/README.md under "The ChatGPT plan" and
"Saving the configuration". The note is
vibe/2026-11-14-1-sign-in-with-chatgpt.md.

How it was checked

On macOS arm64 only.

  • Everything CI runs: fmt, clippy, tests (2,157 of 2,157), the headless
    check, tidy, docs, cargo deny, and the settings page's typecheck and
    tests (218 of 218). Also cargo audit.
  • Each of the first four commits passes format, clippy, tidy and its
    crate's tests alone. Commit 5 passes the whole list alone.
  • 71 hand-written mutations, each of one named rule and each killed by a
    named test. The rules are in the gate, the ID-token checks, the fences
    around sign-in and refresh, the store, the config revision and the
    Responses translation. The runner is outside this repo.
  • Against the real service, with my own plan. Sign-in through the browser
    and sign-out with confirmed revocation both work. So do the model list
    with and without the proof, a streamed tool round and a token refresh.
    The sign-in survives a Gateway restart.

Not done

  • Windows was not run at all.
  • No route forces a token refresh, so the failure cases of refresh are
    covered against a fake issuer only.
  • The owner proof on the older admin routes, as said above.

@coderabbitai

coderabbitai Bot commented Nov 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

On macOS, `cargo clippy --locked --workspace --all-targets --all-features` fails with `clippy::large_enum_variant` on `TraySlot`: `Armed` holds a box, and `Headless` held a `GatewayHandle` inline. `Headless` now holds a box too.

- CI lints on Linux, where `tray/macos.rs` is not compiled, so the job did not see it.
- `run_headless` takes the handle by value as before. The one caller dereferences the box.
A caller always speaks chat completions to the Gateway, and every backend spoke chat completions too. `ResponsesUpstream` is a second implementation of `Upstream`: it rewrites the request as an OpenAI Responses request, always asks the backend to stream, and translates the events back to chat chunks. A caller that did not ask for a stream gets the same chunks folded into one completion. No endpoint selects it yet. The config vocabulary for it comes with the ChatGPT plan routes.

- `wire::to_responses_request` builds the request. Its module doc holds the field mapping. A field that only tunes the reply and has no Responses equivalent is dropped. A request whose meaning would change is refused with the new `ProtocolError::UnsupportedRequest` before anything is sent: `n` above 1, a `function` message, or a tool that is not a function.
- The request never asks the provider to store the response.
- `upstream/responses/` holds the SSE reader and the translator. Text and tool-call arguments are relayed as their deltas arrive. Each `output_item.done` is checked against what was streamed: argument text that never arrived is sent then, and arguments that contradict what was sent fail the response.
- A failure the backend reports inside its event stream becomes a `ProtocolError::UpstreamStatus`. It arrives as a `response.failed` event, or as an `error` event with its details as its own fields or nested under `error`. A `code` that names a limit, a quota or a rate maps to 429, an `invalid_request_error` to 400, anything else to 502.
- `BearerSource` supplies the credential for each request, and `rejected` tells it that the backend answered one with a 401. `StaticBearer` wraps a fixed secret. `ProtocolError::CredentialUnavailable` is what a source returns when it has none, with a stable `code`.
- `http_util::hardened_client` builds a client that follows no redirect, with the timeouts it is given. It returns `None` when the client cannot be built and never falls back to `reqwest::Client::default()`. `http_util::no_redirect_streaming_client` is that client for a streaming backend, and `ResponsesUpstream` refuses each request when it holds `None`.
- One change reaches the existing backends: the display of `ProtocolError::UpstreamStatus` now ends with the backend's error `code` when the body has one that is a plain identifier of at most 64 characters, as in `upstream returned 400 (context_length_exceeded)`. The backend's message text is still never shown.
- `upstream/openai.rs` shares `check_status` and `escape_control` with the new module.

Plan: vibe/2026-11-14-1-sign-in-with-chatgpt.md
`gateway.json` is the discovery contract: any client reads it to attach, and an operator hands its key to other tools. The ChatGPT plan routes need two values that only Runvil's own programs may read. `GatewayPrivateFile` is `gateway-private.json`, written beside the discovery file and named by no public contract.

- The file holds `pid`, `epoch`, `plan_proof`, and `owner_proof`. `pid` and `epoch` are the discovery file's, and `names_boot_of` pairs the two files only when both agree, so a proof of an earlier boot is not sent to a later one.
- `write_to` uses the discovery file's atomic, owner-only writer. `remove_private_if_mine` withdraws the file at shutdown when the `pid` is this process's.
- `read` returns `None` for a missing file and an error for one it cannot read, parse, or validate. `validation_error` refuses a `pid` of 0, an empty proof, a proof that is not printable ASCII without spaces, and two proofs that are equal.
- `Debug` prints `[REDACTED]` for both proofs.
- Nothing writes the file yet. The Gateway app mints the proofs and writes it with the ChatGPT plan routes.

Plan: vibe/2026-11-14-1-sign-in-with-chatgpt.md
The crate holds a person's Sign in with ChatGPT credential inside the Gateway process. It follows OpenAI's documented flow for open-source tools, <https://developers.openai.com/siwc/token-sharing-open-source>, and each module names the page and section it follows. No crate depends on it yet.

- `callback` is a one-shot listener on `127.0.0.1:0` for `GET /auth/callback`. Its page is a fixed sentence, and nothing from the request is written to the page or to a log.
- `id_token` verifies RS256 against the issuer's JWKS, and then checks `iss`, `aud`, `exp` with 60 seconds of skew, `nonce`, `at_hash`, and `sub`, in that order.
- `oauth` makes the dynamic registration, the PKCE authorization URL, the code exchange, the refresh, and the revocation, over bounded requests that follow no redirect.
- `plan` is the broker, `ChatGptPlan`. It serializes sign-in, completion, refresh, sign-out, forget, and reset, caches the access bearer until 120 seconds before it expires, and implements `BearerSource`.
- The broker's status reports a sign-in as pending from the start of an attempt until that attempt's own work is finished, the exchange, the activation, and the revocation of a token it will not keep. So a client that polls does not read "nothing pending" in between and stop waiting. A sign-out, a reset, or a newer attempt ends the pending state at once, and an attempt whose callback never arrives stops being pending when it expires.
- `store` is the `chatgpt-plan.json` record. It is written through a fresh `0600` temp file and renamed, in a directory that must be private to the user. A corrupt record is reported and never guessed at. On Windows the store refuses to save.
- `fake_issuer`, which exists in tests and behind the `test-fixtures` feature, is an issuer the tests sign in to. It serves the discovery document, JWKS, the token endpoint, and revocation.
- `aws-lc-rs` becomes a workspace dependency, for the RS256 check and for random bytes. It is the crypto backend the Gateway already links, and `cargo tree -i ring` still prints nothing.
- `Cargo.lock` gains the crate and `untrusted` 0.7.1, which `aws-lc-rs` needs.

Plan: vibe/2026-11-14-1-sign-in-with-chatgpt.md
`PUT /admin/config` replaced the configuration with the document it was given, whatever the file held by then. A settings page that was left open could so overwrite a file that had changed since the page read it. A save now names the revision it was read from, and the Gateway refuses one that is missing or stale.

- The revision is the lowercase hex SHA-256 of the file's bytes. `GET /admin/config` returns it in `ETag` and in `x-config-revision` when the configuration came from a file. `GET /admin/config-pending` returns it in both headers and in a `revision` field.
- `PUT /admin/config` takes it as `revision` in the body, as `x-config-revision`, or as `If-Match`. With none it returns `428 config_revision_required` and stages nothing. With one that does not match the bytes it would replace it returns `409 config_conflict`. A malformed one, or two carriers that disagree, returns `422`.
- A save is compared with the staged draft when there is one, and with the file otherwise.
- This changes an existing route. Every client that saves the configuration has to send the revision back.
- The settings page does. `config-store.ts` keeps the revision it loaded and sends it with each save. On a `409` it tells the person to reload the page.
- The check covers writers that go through the Gateway. It is not a lock against another program that edits the file between the check and the rename.
- The second writer in the new tests is a ChatGPT plan entry written to the file. That is the writer the next commit adds: its model setup changes the configuration under the apply lock, and this check stops a page that was open from overwriting the result.
- `crates/gateway/README.md` states the contract under "Saving the configuration".

Plan: vibe/2026-11-14-1-sign-in-with-chatgpt.md
An endpoint's credential was an API key from the configuration. `auth = "chatgpt-plan"` is a second kind: the Gateway signs a person in to their ChatGPT account, keeps the credential to itself, and serves the plan's models to a caller that presents the plan proof of this boot on top of its ordinary authentication. Every other endpoint keeps `auth = "api-key"`, and a client that names no plan model sees the routes it saw before. A person authorizes one tool to use their plan, so the Gateway serves it to Runvil's own client and does not become a general relay for it. `crates/gateway/README.md` states the contract under "The ChatGPT plan".

- `gateway-config`: the vocabulary gains `protocol = "openai-responses"` and `auth = "chatgpt-plan"`. The second requires the first and allows neither `base_url` nor `api_key`.
- `gateway-routing`: an endpoint records where its credential comes from, as `CredentialSource`. A request is authorized by that, never by a name.
- At start the app mints the owner proof and the plan proof and writes `gateway-private.json`. It withdraws the file at shutdown. A Gateway that shuts down to restart keeps its discovery file for its supervisor and still removes its proofs file: the next child mints its own.
- `/admin/chatgpt/sign-in`, `status`, `sign-out`, `forget`, and `reset` take `OwnerCaller`: a loopback peer, ordinary authentication, and the owner proof. No route returns a token. The sign-in exists only on a Gateway that listens on loopback.
- The routing table's entry for a plan model holds an upstream that refuses. `ChatGpt::authorize_plan` hands out the one that spends the plan, after it checks the listener, the proof, the broker, and the sign-in. `resolve_routed_model` applies it for chat, embeddings, rerank, and speech. A request without the proof gets `403 chatgpt_plan_runvil_only`, and `GET /v1/models` omits the plan's rows for that caller.
- `/admin/chatgpt/models/enable` and `models/forget` add the plan's endpoint and models to `gateway.toml` and to live routing, and remove them. An enable commits only if the sign-in it started under still holds.
- The settings page shows a plan endpoint with a badge and without `base_url` and `api_key`, and skips it in a cloud merge. It makes no `/admin/chatgpt` request: the owner proof never reaches a page.
- `RUNVIL_CHATGPT_AGENT_NAME` changes the name the consent screen shows.
- Not done: the store refuses to save a credential on Windows, so a sign-in does not persist there. The older admin routes do not require the owner proof, so a caller that may save the configuration can still add or remove the plan's entries. It cannot give them a `base_url` or a key, and it cannot use them without the plan proof.

Plan: vibe/2026-11-14-1-sign-in-with-chatgpt.md
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