Skip to content

[feat] Connect a ChatGPT subscription from the UI and run agents on it - #6674

Open
mmabrouk wants to merge 82 commits into
release/v0.115.4from
spike/hosted-subscription-exploration
Open

[feat] Connect a ChatGPT subscription from the UI and run agents on it#6674
mmabrouk wants to merge 82 commits into
release/v0.115.4from
spike/hosted-subscription-exploration

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member

Context

Until now a ChatGPT subscription only worked on a self-hosted runner where an operator mounted a login folder by hand, and it did not work on Daytona at all. A cloud user had no way to use a ChatGPT plan with an agent.

This PR lets a user sign in to ChatGPT from the AI providers page with a device code, pick the subscription in the model picker like any other connection, and run any number of agents and parallel sessions on it, locally and on Daytona. The runner keeps the login fresh, publishes a renewed token back to the store, and the UI asks for a new sign-in only when the provider has really rejected the login.

Changes

API. A new vault secret kind, subscription_provider, holds the login inside the encrypted secrets row with a version, a generation, and a login state. Three routes start, poll, and cancel a device login through the runner. Two runner-facing routes accept a renewed login and a failure report; both need the runtime secret-resolve grant. A pushed login is accepted only when it is a usable token for the same account, the same generation, and a later or equal expiry, so a corrupt file can never overwrite a good login. Public create and update routes reject the server-owned login fields.

Before, a subscription had no record at all. After:

GET /secrets/{id}   ->  {"kind": "subscription_provider", "data": {"provider": "chatgpt",
                         "login_state": "ready", "login_version": 5, "login_generation": 2, ...}}

The login itself never appears in a read.

SDK. A self_managed connection may carry a slug. The resolver reads the subscription secret and delivers the login to the runner in a subscription block on modelConnection. A not-ready login fails before the run with status.failure_code = "subscription_login_required".

Runner. Device-code logins run through Pi's own OAuth library behind the runner token. A run materializes the delivered login into one directory per connection (local disk, or in-VM disk on Daytona), and Pi refreshes it under its own lock. One reconciliation loop per session publishes anything the API has not acknowledged, every 5 seconds locally and every 30 seconds on Daytona, and drains on shutdown. On an authentication failure the runner classifies it with a real refresh call: transient failures retry, a rejected refresh token reports the failure and ends with subscription_login_required, and a newer login in the store is adopted for the next turn. Every decision logs one structured line (event=subscription.materialize|publish|recovery|attempt) with ids and reasons, never a token.

Web. A ChatGPT card on the AI providers page and in the provider drawer runs the sign-in: code, copy button, "Open ChatGPT" link, countdown, cancel, and the card flips to Connected on its own. The picker shows the subscription as "ChatGPT · Subscription" with its models, disabled with "Sign in needed" when the login is dead. A mounted operator login now reads "ChatGPT (deployment login)". The chat error card offers "Sign in again" for the new code.

Hosting. The runner gets a runner-state volume for the per-connection login copy, because Pi rotates the refresh token on every exchange and the local copy is the only holder until the next publish.

Not in this PR. The Codex harness cannot use this login (Codex 0.145.0 needs an id_token that Pi never stores). Grok and other providers are a follow-up; the design folder has the research.

Tests

  • API: 313 unit tests in oss/tests/pytest/unit/secrets and unit/vault, including the attempt state machine, the push ordering rules, the start race, and the grant check on the runner-facing routes.
  • SDK: 1,272 unit tests in oss/tests/pytest/unit/agents, including the wire contract and the 422 envelope.
  • Runner: 390 unit and boundary tests for the login routes, materialize ordering, the publisher (exactly one publish for a mid-turn rewrite, retry after a failed publish, drain on shutdown), the recovery classifier with the real provider bodies, and a full /run over HTTP with a fake API.
  • Web: package suites in agenta-entities, agenta-entity-ui, agenta-chat, agenta-ui, and oss.
  • Live, real ChatGPT account, on a dev stack of this branch: UI sign-in; picker row; playground turn; three parallel sessions; a Daytona turn; two Daytona sessions renewing the same login at once; a real Pi refresh published to the store with the vault cache cleared; a dead login detected, reported, and shown as "Sign in needed"; re-login from the card; older sessions continuing after each re-login. Evidence table in docs/design/hosted-subscription-connections/status.md.
  • The release gate gained cells H1 (local) and H2 (Daytona) with parallel, refresh, and dead journeys.
  • Reviews addressed: a Codex organization review, a Codex final review, and a Fable review; the two blocking findings (token disclosure on the failure route, shared Pi prompt file between agents on one connection) are fixed in this PR.

CI: every job is green except the web acceptance job, which fails the same way on the other open PRs against this release branch (#6664, #6643) and is unrelated to this change.

Known limits: a pending device login lives in the runner process and dies with a runner restart; with several runner replicas behind one URL a poll can miss the attempt and the card asks to try again. Both are recorded in status.md.

What to QA

  • Settings, AI providers: click Connect ChatGPT, open the link, enter the code, approve. The card flips to Connected by itself within a few seconds.
  • New agent, Model: the "ChatGPT · Subscription" row lists the seven models. Pick one, send a message. The reply arrives. Send two messages from two agents at the same time; both answer.
  • Advanced, Sandbox: daytona. Send a message. The reply arrives.
  • Settings, AI providers, Remove: the confirm dialog appears; after Remove the picker row is gone and an agent still pointing at it gets the "not ready" error with a Sign in again button.
  • Regression: an OpenAI API key connection and a mounted "ChatGPT (deployment login)" row still work as before.

mmabrouk and others added 30 commits September 7, 2026 14:40
…dence

Adds the Codex and Pi authentication research, the product code map, the shared
implementation contract with the amendments from the Codex review, the real-provider
refresh probe scripts, and the log entries for the first day of the exploration.

Claude-Session: https://claude.ai/code/session_01DHWJ9gGjwuYe4d4wnf29DC
A self_managed connection may now carry a slug. The vault resolver selects the
subscription_provider secret with that slug, requires a ready login, and emits a
runtime_provided connection with a subscription block on the wire. A not-ready
login fails before the run with failure_code subscription_login_required, on the
invoke error envelope and on the in-stream error frame alike. Login values are
masked in dumps and seeded into the per-run redactor.

Claude-Session: https://claude.ai/code/session_01DHWJ9gGjwuYe4d4wnf29DC
…n attempts

Adds a project-scoped vault secret kind that holds a hosted ChatGPT login, with
login state, version, and generation counters. Login attempts start, poll, and
cancel a device-code login through the runner. Two runner-facing routes accept a
refreshed login (generation, account, refresh token, then expiry) and a failure
report that answers stale with the current login when a newer one exists.
The login is redacted on every browser read.

Claude-Session: https://claude.ai/code/session_01DHWJ9gGjwuYe4d4wnf29DC
…he picker

Adds the ChatGPT card on the AI providers page and in the provider drawer, with a
device-code login panel (code, copy, open link, countdown, cancel) driven by an
attempt poll that stops on a terminal state. A ready subscription secret appears
in the model picker as a Subscription row with its own models and slug; a
subscription that needs a sign-in shows a disabled row with the reason. The chat
error card offers Sign in again for subscription_login_required, read from the
422 envelope and from the in-stream frame, and Try again for
subscription_login_refreshed.

Claude-Session: https://claude.ai/code/session_018bgL2qRpTRn5raKuEN2fuh
The run request may carry a subscription block with a Pi-format login. The runner
keeps one directory per connection on local disk, or on in-VM disk on Daytona,
materializes the login under Pi's own lock with a generation-then-expiry rule, and
points the Pi subscription branch at that directory. A file watch, or a polling
fallback when inotify is exhausted, publishes a refreshed login back to the API
as soon as Pi writes it, with a turn-end and session-end push as well.

On an authentication failure the runner exchanges the local refresh token through
the official client to classify the failure: a transient answer writes the new
pair and retries, a stale answer from the API adopts the newer login and replays
the turn once when no output was emitted, and a provider refusal reports the
failure and ends with subscription_login_required. Device-code login attempts run
in the runner behind the runner token and hand the login to the API until it
acknowledges with a delete.

Codex is out of this slice: Codex 0.145.0 refuses a login file without id_token,
which Pi never stores.

Claude-Session: https://claude.ai/code/session_018bgL2qRpTRn5raKuEN2fuh
A dev runner that mounts an operator login folder next to a hosted ChatGPT
connection showed two identical "ChatGPT" rows in the model picker. The mounted
row now reads "ChatGPT (deployment login)"; the hosted row keeps the connection
name.

Claude-Session: https://claude.ai/code/session_018bgL2qRpTRn5raKuEN2fuh
A poll response that arrives after the user cancelled and started a new attempt
could install the cancelled login or clear the new attempt. The store and clear
writes now recheck the attempt id inside the row lock and become a no-op when it
no longer matches.

Claude-Session: https://claude.ai/code/session_018bgL2qRpTRn5raKuEN2fuh
…x reads

Two defects from the live cells. The runner published whatever the local login
file held, so a corrupt file overwrote the good stored login. Every publish path
now checks that the access token is a JWT carrying the account claim that matches
the login, before any push. The failure report quotes the version and generation
delivered to the run, so a newer stored login is still recognized as stale.
Sandbox file reads return bytes, not text; the read-back path decodes them, so a
refresh inside a Daytona sandbox is published instead of dropped.

Claude-Session: https://claude.ai/code/session_018bgL2qRpTRn5raKuEN2fuh
…oken

A pushed login must carry an access token that decodes as a JWT with the account
claim of the stored connection, a refresh token, and a future expiry. Anything
else answers updated false with reason invalid_login and leaves the row alone. A
completed device attempt that hands back an unusable login ends as failed.

Claude-Session: https://claude.ai/code/session_018bgL2qRpTRn5raKuEN2fuh
The publisher started with a turn and stopped in its finally, so a refresh that
Pi persisted around the end of a turn, or during a park, went unpublished. It now
runs from environment acquire to teardown. Every skipped publish logs the moment
and the three expiries that decided it.

Claude-Session: https://claude.ai/code/session_018bgL2qRpTRn5raKuEN2fuh
… states

The provider answers refresh_token_reused first and invalid_refresh_token later
for the same dead token. Both now classify as refresh_token_rejected, from an
explicit code list rather than a prefix, so the stored login_error is stable.

Claude-Session: https://claude.ai/code/session_018bgL2qRpTRn5raKuEN2fuh
mmabrouk and others added 10 commits September 8, 2026 21:20
The subscription kind keeps its credential on the data object, so it lives in
DATA_CREDENTIAL_FIELDS rather than in the container map, and the probe suite's coverage
assertion read only the container map. CREDENTIAL_FIELD_KINDS is the union the assertion
should have been reading, and the two maps must stay disjoint so one credential never has
two locations.
The push answered updated:false, stale:false both when the row already held the pushed
credential and when it refused one, so the runner could not tell whether the version it was
told describes its own login. The reject bucket is now one decision per outcome, and each
puts a slug on the wire: same_login, invalid_login, older_login, other_account,
wrong_generation, no_login. An accepted push and a stale answer still carry none, because
updated and stale say it. A login for another account now logs at warning.
accountId is optional in the credential shape, and both the runner's gate and the API's own
shape check accept a login that omits it. The push then compared the field against the
stored one, so every such refresh was refused as another account's and the connection could
never be refreshed again. Both sides now read the token's claim, with the field as a
fallback, and a stored login that names no account is not compared at all.
The locked update returns nothing when the row is gone, so the callback that fills in each
caller's answer never runs. A push and a failure report then validated an empty result into
a 500, and a device login poll reported a sign-in as stored when nothing was written. The
row can disappear between the load and the write: a user disconnects the connection while a
run is pushing.
The web rule is at most one short line per in-code comment. These files narrated the why in
prose blocks. Every invariant is kept, in one line each; only the module headers keep a
second line for the Design pointer, which is the convention across the packages.
@mmabrouk

mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

All nine threads are answered above and the head is 0275f3e. Posted by the AI agent (Fable) on behalf of Mahmoud.

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@mmabrouk I will review the changes at head 0275f3eb20.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Corrects the inverted mount-rejection sentence, drops two leftover editing
fragments and the empty open-questions heading, points the validator citation at
validate.ts, names the Grok vendor xAI, repairs one malformed prompt line, and
replaces the routable dev address with a placeholder.
…rsor

The subscriptions section can no longer render a border and a label over
nothing. A disabled provider row now shows the default cursor, which
cursor-pointer on the shared row class was winning by stylesheet order.
@mmabrouk

mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

Head 500cb06805. Of the 18 minor and 15 nitpick notes: fixed the malformed attempt id (404, not 500), Cache-Control no-store on the login route, the publisher shutdown race, the two order-dependent sleeps in tests, the shared wire golden for the subscription block, the explicit attempt response projection, the cursor override, the required hosted card prop, the gitleaks ignore comment, the coverage rules and the vault trigger, and the docs typos and the dev address. Skipped: a PostgreSQL version floor in the migration (three earlier migrations already ADD VALUE on the same enum; a repo-wide decision) and the two ruff notes (both files pass ruff 0.15.12 exactly as CI runs it).

Posted by the AI agent (Fable) on behalf of Mahmoud.

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@mmabrouk I will review the changes at head 500cb06805.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/runner/src/engines/sandbox_agent/subscription-login/publisher.ts (1)

329-329: 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win

Sensitive Data Exposure

Reachability: External
Exploitability: Moderate
CWE: CWE-319 — Cleartext Transmission of Sensitive Information

Reject non-HTTPS API bases before sending credentials.

apiBase() accepts http:// values from environment variables and request-derived telemetry endpoints. pushLogin then sends deps.authorization to that base. Validate credential-bearing API bases as HTTPS and block credential-bearing redirects.

🧹 Nitpick comments (1)
docs/design/hosted-subscription-connections/research/experiments/reuse_window_probe.py (1)

71-80: 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🔵 Trivial | 💤 Low value

Sensitive Data Exposure

Reachability: Internal
Exploitability: Difficult
CWE: CWE-732 — Incorrect Permission Assignment for Critical Resource

Set restrictive permissions before writing token files.

Under a permissive umask, the stash and both write-back files can contain tokens before os.chmod runs. Use an opener that applies 0o600 before json.dump, and use it for all three writes.

🔒 Proposed fix
def secure_open(path, flags):
    fd = os.open(path, flags, 0o600)
    os.fchmod(fd, 0o600)
    return fd

Pass opener=secure_open to each write-mode open() call and write through a context manager. Remove the post-write os.chmod calls.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: e84e32c1-6d47-4db7-a0c1-6f8844b1e132

📥 Commits

Reviewing files that changed from the base of the PR and between b8e224b and 0ee8d55.

📒 Files selected for processing (49)
  • .agents/skills/agent-release-gate/resources/coverage.md
  • .agents/skills/agent-release-gate/resources/path_triggers.py
  • .gitleaksignore
  • api/oss/src/apis/fastapi/vault/models.py
  • api/oss/src/apis/fastapi/vault/router.py
  • api/oss/src/core/secrets/redaction.py
  • api/oss/src/core/secrets/subscription_rules.py
  • api/oss/src/core/secrets/subscription_service.py
  • api/oss/tests/pytest/unit/providers/test_provider_probe.py
  • api/oss/tests/pytest/unit/secrets/test_subscription_login_service.py
  • api/oss/tests/pytest/unit/secrets/test_write_only.py
  • api/oss/tests/pytest/unit/vault/test_subscription_login_routes.py
  • docs/design/hosted-subscription-connections/communication-log.md
  • docs/design/hosted-subscription-connections/fable-prompt.md
  • docs/design/hosted-subscription-connections/research/experiments/hosted_subscription_cells.py
  • docs/design/hosted-subscription-connections/research/experiments/refresh_rotation_probe.py
  • docs/design/hosted-subscription-connections/research/experiments/reuse_window_probe.py
  • docs/design/hosted-subscription-connections/research/other-providers.md
  • docs/design/hosted-subscription-connections/research/storage-mounts.md
  • docs/design/hosted-subscription-connections/status.md
  • docs/design/hosted-subscription-connections/v0/plan.md
  • docs/design/hosted-subscription-connections/v0/research.md
  • docs/design/hosted-subscription-connections/v0/status.md
  • sdks/python/agenta/sdk/agents/wire_models.py
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.subscription_connection.json
  • sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py
  • services/runner/src/engines/sandbox_agent/pi-assets.ts
  • services/runner/src/engines/sandbox_agent/run-plan.ts
  • services/runner/src/engines/sandbox_agent/run-turn.ts
  • services/runner/src/engines/sandbox_agent/subscription-login/publisher.ts
  • services/runner/src/server.ts
  • services/runner/tests/unit/sandbox-agent-pi-assets.test.ts
  • services/runner/tests/unit/subscription-login-files.test.ts
  • services/runner/tests/unit/subscription-login-publish.test.ts
  • services/runner/tests/unit/subscription-login-routes.test.ts
  • services/runner/tests/unit/subscription-login-run-boundary.test.ts
  • services/runner/tests/unit/subscription-recovery-throws.test.ts
  • services/runner/tests/unit/subscription-recovery.test.ts
  • services/runner/tests/unit/subscription-run-plan.test.ts
  • services/runner/tests/unit/wire-contract.test.ts
  • web/packages/agenta-entities/src/secret/api/loginAttempts.ts
  • web/packages/agenta-entities/src/secret/core/subscriptionConnections.ts
  • web/packages/agenta-entities/src/secret/state/subscriptionLogin.ts
  • web/packages/agenta-entities/tests/unit/subscription-login-attempt.test.ts
  • web/packages/agenta-entity-ui/src/secretProvider/PlaygroundProviderSections.tsx
  • web/packages/agenta-entity-ui/src/secretProvider/ProviderDrawer.tsx
  • web/packages/agenta-entity-ui/src/secretProvider/SubscriptionConnectionCard.tsx
  • web/packages/agenta-entity-ui/tests/unit/subscriptionPickerRow.test.ts
  • web/packages/agenta-ui/src/SelectLLMProvider/SelectLLMProviderBase.tsx
🚧 Files skipped from review as they are similar to previous changes (17)
  • docs/design/hosted-subscription-connections/status.md
  • docs/design/hosted-subscription-connections/v0/status.md
  • api/oss/src/apis/fastapi/vault/models.py
  • web/packages/agenta-entities/tests/unit/subscription-login-attempt.test.ts
  • web/packages/agenta-entities/src/secret/state/subscriptionLogin.ts
  • docs/design/hosted-subscription-connections/research/experiments/refresh_rotation_probe.py
  • docs/design/hosted-subscription-connections/fable-prompt.md
  • services/runner/tests/unit/subscription-login-run-boundary.test.ts
  • web/packages/agenta-entity-ui/src/secretProvider/SubscriptionConnectionCard.tsx
  • web/packages/agenta-entity-ui/tests/unit/subscriptionPickerRow.test.ts
  • web/packages/agenta-entity-ui/src/secretProvider/ProviderDrawer.tsx
  • docs/design/hosted-subscription-connections/v0/research.md
  • web/packages/agenta-entities/src/secret/core/subscriptionConnections.ts
  • docs/design/hosted-subscription-connections/research/storage-mounts.md
  • web/packages/agenta-ui/src/SelectLLMProvider/SelectLLMProviderBase.tsx
  • web/packages/agenta-entities/src/secret/api/loginAttempts.ts
  • docs/design/hosted-subscription-connections/v0/plan.md

Included review availability: Your plan provides up to 8 included reviews per hour; 0 remain after this review.

Comment thread .agents/skills/agent-release-gate/resources/coverage.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants