feat(accounts): native account controls and opt-in Codex quota selection (#793)

Expose the approved active-account dropdown with persistent credential-keyed drafts and always-visible add/rename/remove actions. Enrich only native fallback labels with a sanitized Codex login while keeping all credential values server-side. Persist opt-in automatic selection for supported OpenAI OAuth accounts and admit one fresh-quota-verified native activation before the next prompt/custom command, with local manual/policy fences, ownership and deletion admission, no model changes, and no prompt or mutation replay. Preserve dev's Missions revert and document service-wide selection and external-client compare-and-set limitations. Validated both package typechecks, the UI build, 812 server tests, 865 CI-selected UI unit tests, isolated native 2.0.22 synthetic-account/YAML fixtures, and focused Chromium/native bridge regressions. Final admin merge explicitly requested by the user; platform/runtime/native checks are green while the general tests job is still in progress.
This commit is contained in:
Pascal André 2026-10-02 17:36:23 +02:00 • committed by GitHub
parent d8f9118853
commit 394482f97f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1704 additions and 125 deletions

View file

@ -1,6 +1,7 @@
# AGENT NOTES
## Styling Guidelines
- Provider accounts use a native dropdown like Web search defaults, without an Accounts disclosure or selectable account rows. Provider headers keep the name and text Manage models button on one row; a faint separator distinguishes the account controls below. Account actions are always visible, not hover-revealed. The `provider-account` container stacks the label above the dropdown/actions at narrow card widths. Native account ordering supplies the current account; rename/remove drafts stay keyed by credential. Environment connections are read-only. The browser never reads credential secrets. Native labels win; only `default` may display a sanitized Codex login from the owned server adapter. Opt-in Codex rotation runs before prompt/command admission, using fresh bounded quotas, local manual-change fences and no mutation/prompt replay; other providers remain manual. Account styling lives in `styles/components/provider-accounts.css`, backend policy in `provider-accounts/`, with limitations and isolated validation in `dev-docs/PROVIDER_ACCOUNTS_UX_PLAN.md`. Browser preview percentages remain simulated.
- Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed.
- Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`.
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.

View file

@ -0,0 +1,93 @@
# Provider accounts: direct selection and opt-in rotation
Implemented UX and bounded backend behavior for #793. The browser sandbox uses
real Settings and account components with synthetic credentials and quota data;
preview percentages are not native measurements. Production auto-selection is
opt-in and supports native OpenAI ChatGPT OAuth (Codex) only.
## Visible accounts
- Show an Active account dropdown below the provider name, styled like the
native Web search default selector. Opening its menu lists saved accounts;
choosing an option activates it. No Accounts disclosure or checkbox rows.
- Keep a text Manage models button on the provider header. Add/rename/remove
icons beside the dropdown target its provider/account. Add opens the exact
same native Connect flow with the provider preselected; it is not a second
connection mechanism.
- Keep all account actions visible without hover. Separate the provider header
from account controls with a faint rule, using a softer account label and
aligned dropdown/actions. At narrow card widths, place the label above the
dropdown while retaining the actions beside it.
- Use native labels as-is, including a login/email if supplied. Explicit aliases
must not be overwritten by a refresh. Environment connections are read-only.
- Preserve rename drafts across native refreshes, account switches and failed
writes. Selection remains service-wide, not session-specific.
## Login-derived names
The pinned 2.0.22 `ConnectionCredentialInfo` exposes ID, label, method and optional
auth status, not a login. Native labels are preferred; only the native fallback
`default` is enriched with a sanitized Codex OAuth email when available. The
server-only `usage/codex-credential.ts` reads native credential exports and uses
the same selected-token identity rules as the existing quota adapter. It returns
only the bounded display email through the owned account settings route, never
tokens, account IDs, claims or credential values. Claims are display hints, not
authorization. No credential is automatically renamed; explicit aliases survive.
Other providers and API keys retain their native labels. An upstream sanitized
identity field is preferable for extending provider coverage. Display metadata
is fetched only while Settings is mounted; it is not persisted as a catalog.
## Auto-select account: implemented backend behavior
- Opt-in for the OpenAI integration, persisted under CodeNomad's
`providerAccounts.autoSelect.openai` config owner. The control is visible only
with two supported, unexpired OAuth accounts and native credential export
support (2.0.20+); this feature does not raise the global technical minimum.
Selection is native and service-wide, not session-specific. Environment and
API-key connections are never automatic fallback candidates.
- At 100% of an applicable quota window, choose the next usable account of the
same provider in stable order. Exclude exhausted, expired or needs-auth
accounts; unknown/stale usage is not evidence of availability.
- If no account is demonstrably available, retain the current selection and
let the ordinary native send report its result. Do not loop, create credentials, change models or
silently fall back to another provider.
- Reuse `usage/native-codex.ts`'s bounded quota transport with each explicit
credential, without temporarily activating candidates. At most 20 candidates
are considered within a 15-second preflight. Unknown, missing, past-reset or
failed quota responses cannot authorize a fallback. No quota display cache is
used for an activation decision. Unlike the display parser, selection rejects
every malformed declared window, missing/past resets, duplicate durations,
out-of-range percentages and contradictory availability flags before any
normalization can omit or clamp them. Other providers need independent adapters.
- Persist only the opt-in policy. Keep native
exports on the server stack, redact failures, and never expose tokens through
the account catalog, browser, logs or persisted display cache.
- Apply decisions before the next prompt/custom-command admission,
independent of an open Settings window. Shell, `/btw`,
tools, autonomous native steps and already queued requests are not replayed
or intercepted. Serialize decisions across this backend's workspaces, freshly verify
connection/ownership/deletion fences and current credential before activation.
A local manual mutation or policy change racing a quota read wins, including
away-and-back selection; stale responses cannot switch it. Native selection,
credential fingerprints, session model/location and ownership are reread before
activation. An ambiguous activation failure blocks that send with a redacted
error, without retrying another candidate or the prompt.
- Do not automatically replay a failed prompt, custom command or tool operation.
In-flight OpenCode steps may retain a previously acquired account; seamless
mid-generation failover needs a proven upstream contract, not a UI promise.
- Native activation has no compare-and-set contract: a TUI/other backend can
still change selection after the last read. The local serialization fence does
not claim distributed locking or atomic selection against external clients.
- Validation: server policy/ownership/admission regressions, real Chromium
controls and `scripts/test-provider-account-selection-native.mjs` against an
isolated 2.0.22 CLI/database, with synthetic OAuth and injected quota responses.
The native fixture covers concurrency, exhaustion, alias preservation and a
fresh read of persisted YAML. No real credentials or remote generation.
## Preview variants
`/preview` is manual; `/preview?auto` starts with simulated rotation enabled;
`/preview?single` omits the option. `?light` renders the light palette.
The separate preview toolbar's Simulate 100% button exhausts the current mock
account. Production controls do not contain a simulation action.

View file

@ -55,6 +55,9 @@ file scraping or prompt-body skill injection is used.
## Web settings boundary
PR #792 gatekeeper reached zero findings at `c3b05383`:
https://github.com/NeuralNomadsAI/CodeNomad/pull/792#pullrequestreview-5326614696
OpenChamber's `/api/config/websearch` is its own backend endpoint, not a native
OpenCode 2.0.18 method. CodeNomad edits the selected authorized document via the
existing host/WSL atomic configuration machinery, preserving foreign fields and
@ -62,3 +65,12 @@ comments. Native configuration watching applies edits; no `location.reload`.
The isolated fixture validates global watching on 2.0.7/2.0.18 and project
document persistence/reset. It disables project discovery to exclude ancestor
user config; project precedence is covered by deterministic backend tests.
## Provider account contract
Native integration connections are ordered with the active credential first,
followed by other credentials and environment sources. Isolated 2.0.7 and
2.0.18 fixtures validate multiple key creation, activation, rename and individual
removal without secret material in catalog output. Global mutations use only
the explicit credential ID. Browser validation covers preserving unsaved labels
through native invalidations and failed saves without automatic write replay.

View file

@ -75,6 +75,12 @@ export interface WorkspaceDeleteResponse {
status: WorkspaceStatus
}
export interface ProviderAccountsSnapshot {
supported: boolean
enabled: boolean
logins: Record<string, string>
}
export interface ProviderUsageWindow {
usedPercent: number | null
remainingPercent: number | null

View file

@ -0,0 +1,166 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { once } from "node:events"
import test from "node:test"
import { OpenCode, type CredentialEntry } from "@opencode/client"
import type { ServiceConnection } from "../workspaces/opencode-service"
import type { SettingsService } from "../settings/service"
import { AccountSelectionFailed, ProviderAccountsService, quotaState } from "./service"
const quota = (used = 25, resetAt: number | null = Date.now() + 3600000) => ({ windows: {
"5h": { usedPercent: used, remainingPercent: 100 - used, windowSeconds: 18000, resetAt },
} })
const credential = (id: string, active = false): CredentialEntry => ({
id, integrationID: "openai", label: "default", active,
value: { type: "oauth", methodID: "chatgpt-browser", access: `secret-${id}`, refresh: "secret-refresh",
expires: Date.now() + 3600000, metadata: { accountID: `private-${id}`, email: `${id}@example.com` } },
})
export async function accountsFixture() {
const entries = [credential("one", true), credential("two"), credential("three")]
const state = { entries, order: ["one", "two", "three"], current: true, directory: "/project", provider: "openai",
activeFailure: false, needsAuth: new Set<string>(), calls: [] as string[], quotaCalls: [] as string[],
usage: { one: quota(100), two: quota(20), three: quota(30) } as Record<string, ReturnType<typeof quota>> }
let policy: Record<string, any> = {}
const settings = {
getOwner: () => policy,
mergePatchOwner: (_kind: string, _owner: string, patch: any) => { policy = { ...policy, ...patch }; return policy },
} as Pick<SettingsService, "getOwner" | "mergePatchOwner">
const server = createServer(async (req, res) => {
assert.equal(req.headers.authorization, "Bearer isolated-secret")
state.calls.push(`${req.method} ${req.url}`)
res.setHeader("Content-Type", "application/json")
if (req.url?.startsWith("/api/session/")) res.end(JSON.stringify({ data: { id: "ses_fixture", projectID: "fixture",
location: { directory: state.directory }, model: { providerID: state.provider, id: "fixture" }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } }))
else if (req.url?.startsWith("/api/provider/")) res.end(JSON.stringify({ location: { directory: state.directory }, data: {
id: state.provider, integrationID: state.provider, name: "Fixture", activation: "auto", package: "fixture" } }))
else if (req.url?.startsWith("/api/integration/")) res.end(JSON.stringify({ location: { directory: state.directory }, data: {
id: "openai", name: "Fixture", methods: [], connections: state.order.map(id => {
const entry = state.entries.find(entry => entry.id === id)!
return { type: "credential", id, label: entry.label, method: entry.value.type === "key" ? "key" : "oauth",
...(state.needsAuth.has(id) ? { status: { status: "needs_auth", message: "private-error" } } : {}) }
}),
} }))
else if (req.url === "/api/credential") res.end(JSON.stringify({ data: state.entries }))
else if (req.url?.endsWith("/activate")) {
if (state.activeFailure) { res.statusCode = 500; res.end(JSON.stringify({ error: "secret-provider-error" })); return }
const id = req.url.split("/")[3]
state.order = [id, ...state.order.filter(item => item !== id)]
for (const entry of state.entries) entry.active = entry.id === id
res.statusCode = 204; res.end()
} else { res.statusCode = 404; res.end("{}") }
})
server.listen(0, "127.0.0.1"); await once(server, "listening")
const client = OpenCode.make({ baseUrl: `http://127.0.0.1:${(server.address() as { port: number }).port}`,
headers: { authorization: "Bearer isolated-secret" } })
const connection = { client, assertCurrent: () => { if (!state.current) throw new Error("Stale fixture") } } as ServiceConnection
const service = new ProviderAccountsService(settings, async (_access, accountID, signal) => {
signal.throwIfAborted(); state.quotaCalls.push(accountID)
return state.usage[accountID.slice(8)] ?? { windows: {} }
})
return { state, settings, service, connection,
send: (validate: (directory: string) => Promise<boolean> = async directory => directory === "/project") =>
service.beforeSend(connection, "ses_fixture", AbortSignal.timeout(3000), validate),
close: () => new Promise<void>(resolve => { server.close(() => resolve()); server.closeAllConnections() }),
}
}
test("opt-in is absent by default and only exhausted supported OAuth rotates", async () => {
const f = await accountsFixture()
try {
await f.send(); assert.equal(f.state.calls.length, 0)
f.service.setEnabled(true)
f.state.usage.one = quota(99); await f.send()
assert.deepEqual(f.state.order, ["one", "two", "three"])
assert.deepEqual(f.state.quotaCalls, ["private-one"])
f.state.usage.one = quota(100); await f.send()
assert.deepEqual(f.state.order, ["two", "one", "three"])
assert.equal(f.state.calls.filter(call => call.includes("/activate")).length, 1)
assert.deepEqual(f.settings.getOwner("config", "providerAccounts").autoSelect, { openai: true })
} finally { await f.close() }
})
test("unknown/reset quotas, key/expired/needs-auth candidates and all-exhausted catalogs never switch", async () => {
const f = await accountsFixture()
try {
f.service.setEnabled(true)
f.state.usage.one = quota(100, 1); await f.send()
assert.deepEqual(f.state.quotaCalls, ["private-one"])
f.state.usage.one = quota(100)
f.state.entries[1].value = { type: "key", key: "secret-key" }
f.state.needsAuth.add("three"); await f.send()
assert.equal(f.state.quotaCalls.length, 2)
f.state.entries[1] = credential("two")
f.state.entries[2].value = { ...credential("three").value, type: "oauth", methodID: "chatgpt-browser",
access: "secret-three", refresh: "secret-refresh", expires: 1 }
f.state.usage.two = { windows: {} } as any; await f.send()
f.state.usage.two = quota(100); await f.send()
assert.equal(f.state.calls.filter(call => call.includes("/activate")).length, 0)
assert.equal(f.state.order[0], "one")
assert.equal(quotaState({ windows: {} }), "unknown")
assert.equal(quotaState(quota(100)), "exhausted")
assert.equal(quotaState(quota(20)), "available")
} finally { await f.close() }
})
test("manual away-and-back and policy changes fence a pending decision", async () => {
for (const change of ["manual", "policy"] as const) {
const f = await accountsFixture()
try {
f.service.setEnabled(true)
let checks = 0
await f.send(async () => {
if (++checks === 2) {
if (change === "manual") f.service.manual(f.connection)()
else { f.service.setEnabled(false); f.service.setEnabled(true) }
}
return true
})
assert.equal(f.state.order[0], "one")
assert.equal(f.state.calls.filter(call => call.includes("/activate")).length, 0)
} finally { await f.close() }
}
})
test("concurrent workspaces serialize; connection and location changes cannot activate", async () => {
const f = await accountsFixture()
try {
f.service.setEnabled(true)
await Promise.all([f.send(), f.send()])
assert.equal(f.state.calls.filter(call => call.includes("/activate")).length, 1)
f.state.order = ["one", "two", "three"]
for (const entry of f.state.entries) entry.active = entry.id === "one"
let checks = 0
await f.send(async () => { if (++checks === 2) f.state.directory = "/foreign"; return f.state.directory === "/project" })
assert.equal(f.state.calls.filter(call => call.includes("/activate")).length, 1)
f.state.directory = "/project"; checks = 0
await assert.rejects(f.send(async () => { if (++checks === 2) f.state.current = false; return true }), /Stale fixture/)
assert.equal(f.state.calls.filter(call => call.includes("/activate")).length, 1)
} finally { await f.close() }
})
test("failed activation is redacted and never retried with another account", async () => {
const f = await accountsFixture()
try {
f.service.setEnabled(true); f.state.activeFailure = true
await assert.rejects(f.send(), error => error instanceof AccountSelectionFailed && !String(error).includes("secret"))
assert.equal(f.state.calls.filter(call => call.includes("/activate")).length, 1)
assert.equal(f.state.order[0], "one")
} finally { await f.close() }
})
test("Settings snapshots disclose only fallback logins and preserve aliases", async () => {
const f = await accountsFixture()
try {
f.state.entries[1].label = "Team alias"
const snapshot = await f.service.snapshot(f.connection, "/project", "openai", AbortSignal.timeout(3000))
assert.deepEqual(snapshot, { supported: true, enabled: false, logins: { one: "one@example.com", three: "three@example.com" } })
const raw = JSON.stringify(snapshot)
for (const forbidden of ["secret", "private-", "access", "refresh", "expires"]) assert.equal(raw.includes(forbidden), false)
f.state.calls = []
assert.deepEqual(await f.service.snapshot(f.connection, "/project", "anthropic", AbortSignal.timeout(3000)),
{ supported: false, enabled: false, logins: {} })
assert.equal(f.state.calls.length, 0)
f.state.provider = "anthropic"; f.service.setEnabled(true); await f.send()
assert.equal(f.state.quotaCalls.length, 0)
} finally { await f.close() }
})

View file

@ -0,0 +1,142 @@
import type { ServiceConnection } from "../workspaces/opencode-service"
import type { SettingsService } from "../settings/service"
import { codexCredential } from "../usage/codex-credential"
import { fetchCodexSelectionQuota } from "../usage/native-codex"
import type { ProviderUsage } from "../usage/types"
import type { ProviderAccountsSnapshot } from "../api-types"
import { readLocationRef } from "../opencode/compatibility/location"
export class AccountSelectionFailed extends Error {
constructor() { super("Provider account selection failed") }
}
export class ProviderAccountsService {
private policyRevision = 0
private readonly states = new WeakMap<ServiceConnection, { epoch: number; manual: number; pending?: Promise<void> }>()
constructor(private readonly settings: Pick<SettingsService, "getOwner" | "mergePatchOwner">,
private readonly quota = fetchCodexSelectionQuota) {}
private state(connection: ServiceConnection) {
let state = this.states.get(connection)
if (!state) { state = { epoch: 0, manual: 0 }; this.states.set(connection, state) }
return state
}
// Fence local explicit mutations immediately, including switches away and back.
manual(connection: ServiceConnection) {
const state = this.state(connection)
state.epoch++; state.manual++
let released = false
return () => { if (!released) { released = true; state.manual--; state.epoch++ } }
}
enabled() {
const policy = this.settings.getOwner("config", "providerAccounts")?.autoSelect
return Boolean(policy && typeof policy === "object" && "openai" in policy && policy.openai === true)
}
setEnabled(enabled: boolean) {
this.settings.mergePatchOwner("config", "providerAccounts", { autoSelect: { openai: enabled } })
this.policyRevision++
}
async snapshot(connection: ServiceConnection, directory: string, integrationID: string, signal: AbortSignal): Promise<ProviderAccountsSnapshot> {
if (integrationID !== "openai") return { supported: false, enabled: false, logins: {} }
const integration = await this.integration(connection, directory, signal)
const entries = await connection.client.credential.list({ signal })
const logins: Record<string, string> = {}
let eligible = 0
for (const item of integration.connections.slice(0, 128)) {
if (item.type !== "credential" || item.method !== "oauth") continue
const entry = entries.find(entry => entry.id === item.id && entry.integrationID === "openai")
const credential = entry && codexCredential(entry)
if (!credential) continue
eligible++
// Preserve all explicit aliases; only native's fallback label is enriched.
if (item.label === "default" && credential.login) logins[item.id] = credential.login
}
connection.assertCurrent(); signal.throwIfAborted()
return { supported: eligible >= 2, enabled: this.enabled(), logins }
}
async beforeSend(connection: ServiceConnection, sessionID: string, signal: AbortSignal, validate: (directory: string) => Promise<boolean>) {
if (!this.enabled()) return
const state = this.state(connection)
// Serialize decisions across this backend's workspaces, without retaining exports.
const previous = state.pending
const task = (async () => {
await previous?.catch(() => {})
signal.throwIfAborted(); connection.assertCurrent()
if (!this.enabled() || state.manual) return
const epoch = state.epoch
const policyRevision = this.policyRevision
const session = await connection.client.session.get({ sessionID }, { signal })
if (!session.model || readLocationRef(session.location).workspaceID !== undefined) return
const directory = session.location.directory
if (!await validate(directory)) return
const provider = await connection.client.provider.get({ providerID: session.model.providerID, location: { directory } }, { signal })
if (provider.location.directory !== directory || provider.data.id !== session.model.providerID
|| provider.data.integrationID !== "openai") return
const integration = await this.integration(connection, directory, signal)
const selected = integration.connections[0]
if (selected?.type !== "credential" || selected.method !== "oauth" || selected.status?.status === "needs_auth") return
const entries = await connection.client.credential.list({ signal })
const currentEntry = entries.find(entry => entry.integrationID === "openai" && entry.id === selected.id && entry.active)
const current = currentEntry && codexCredential(currentEntry)
if (!current || quotaState(await this.quota(current.access, current.accountID, signal)) !== "exhausted") return
// Stable native catalog order; never temporarily activate candidates to query them.
for (const candidate of integration.connections.slice(1, 21)) {
if (candidate.type !== "credential" || candidate.method !== "oauth" || candidate.status?.status === "needs_auth") continue
const entry = entries.find(entry => entry.integrationID === "openai" && entry.id === candidate.id)
const credential = entry && codexCredential(entry)
if (!credential) continue
let available = false
try { available = quotaState(await this.quota(credential.access, credential.accountID, signal)) === "available" }
catch { signal.throwIfAborted(); connection.assertCurrent() }
if (!available) continue
if (epoch !== state.epoch || state.manual || policyRevision !== this.policyRevision || !this.enabled() || !await validate(directory)) return
const latest = await this.integration(connection, directory, signal)
const fresh = await connection.client.credential.list({ signal })
const freshCurrent = fresh.find(entry => entry.id === selected.id && entry.active && entry.integrationID === "openai")
const freshCandidate = fresh.find(entry => entry.id === candidate.id && entry.integrationID === "openai")
const candidateConnection = latest.connections.find(item => item.type === "credential" && item.id === candidate.id)
const currentConnection = latest.connections[0]
if (currentConnection?.type !== "credential" || currentConnection.id !== selected.id
|| !candidateConnection || candidateConnection.status?.status === "needs_auth"
|| !freshCurrent || codexCredential(freshCurrent)?.identity !== current.identity
|| !freshCandidate || codexCredential(freshCandidate)?.identity !== credential.identity) return
const latestSession = await connection.client.session.get({ sessionID }, { signal })
if (latestSession.location.directory !== directory || latestSession.model?.providerID !== session.model.providerID
|| latestSession.model?.id !== session.model.id || !await validate(directory)) return
connection.assertCurrent(); signal.throwIfAborted()
if (epoch !== state.epoch || state.manual || policyRevision !== this.policyRevision || !this.enabled()) return
try { await connection.client.credential.activate({ credentialID: candidate.id }, { signal }) }
catch { throw new AccountSelectionFailed() }
return // One attempt only, including ambiguous transport failures. No replay.
}
})()
state.pending = task
try { await task }
catch (error) {
if (error instanceof AccountSelectionFailed) throw error
// Unknown usage leaves the native selection alone. Connection invalidation
// and caller cancellation still abort the send; never expose SDK errors.
connection.assertCurrent()
} finally { if (state.pending === task) state.pending = undefined }
}
private async integration(connection: ServiceConnection, directory: string, signal: AbortSignal) {
connection.assertCurrent(); signal.throwIfAborted()
const result = await connection.client.integration.get({ integrationID: "openai", location: { directory } }, { signal })
if (result.location.directory !== directory || result.data.id !== "openai") throw new Error("Account catalog changed")
connection.assertCurrent(); signal.throwIfAborted()
return result.data
}
}
// Past reset snapshots and empty/non-numeric usage are not availability evidence.
export function quotaState(usage: ProviderUsage, now = Date.now()): "unknown" | "available" | "exhausted" {
const windows = Object.values(usage.windows).filter(window => window.windowSeconds !== null && window.windowSeconds > 0)
if (!windows.length || windows.some(window => window.usedPercent === null || !Number.isFinite(window.usedPercent)
|| (window.resetAt !== null && window.resetAt <= now))) return "unknown"
return windows.some(window => window.usedPercent! >= 100) ? "exhausted" : "available"
}

View file

@ -912,6 +912,18 @@ describe("instance proxy location enforcement", () => {
assert.equal(requestCount(), 0)
})
it("allows only native credential rename, activate and remove operations with directory ownership", async () => {
const { app, requestCount } = await harness()
for (const [method, suffix, payload] of [
["PATCH", "account", { label: "Renamed" }], ["POST", "account/activate", undefined], ["DELETE", "account", undefined],
] as const) {
assert.equal((await app.inject({ method, url: `/workspaces/workspace/instance/api/credential/${suffix}`, payload })).statusCode, 200)
assert.equal((await app.inject({ method, url: `/workspaces/workspace/instance/api/credential/${suffix}?location[directory]=/other`, payload })).statusCode, 403)
}
assert.equal(requestCount(), 3)
assert.equal((await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/credential/account/export" })).statusCode, 403)
})
it("validates prompt file ownership before translating root, worktree, and Windows URIs", async () => {
const mappings = {
"/repo/notes.txt": "/home/dev/repo/notes.txt",

View file

@ -0,0 +1,70 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import type { Logger } from "../../logger"
import type { OpenCodeClient } from "@opencode/client"
import { AccountSelectionFailed, ProviderAccountsService } from "../../provider-accounts/service"
import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server"
import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
import { createRuntimeFetch } from "../../opencode/compatibility/transport"
async function fixture() {
const upstream = Fastify(), app = Fastify()
const events: string[] = []
const state = { fail: false, owned: true }
upstream.all("/*", async () => { events.push("forward"); return {} })
await upstream.listen({ host: "127.0.0.1", port: 0 })
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
const workspace = { id: "w", path: "/project" }
const client = { session: {
get: async () => ({ id: "ses_fixture", location: { directory: "/project" } }),
environment: async () => { events.push("environment") },
} } as unknown as OpenCodeClient
const connection = { endpoint, client, fetch: createRuntimeFetch(endpoint), assertCurrent: () => {},
invalidate: () => {}, profile: async () => "modern" }
const manager: InstanceProxyWorkspaceManager = {
get: () => workspace as never, getSharedServiceConnection: async () => connection as never,
getSharedServiceEndpoint: async () => endpoint as never, getInstanceAuthorizationHeader: () => "Basic private",
getSharedServiceClient: async () => client, getServiceDirectory: () => "/project",
getServiceDirectoryForPath: async (_id, directory) => directory === "/project" ? directory : undefined,
getWorktreeIdentityForPath: async () => "/project", getSessionEnvironment: async () => ({}),
ownsLocation: async () => state.owned, ownsDirectory: async () => true, ownsPath: async () => true,
}
const accounts = { beforeSend: async (_connection: unknown, _sessionID: string, _signal: AbortSignal, validate: (directory: string) => Promise<boolean>) => {
events.push("selection"); assert.equal(await validate("/project"), true)
if (state.fail) throw new AccountSelectionFailed()
}, manual: () => { events.push("manual"); return () => { events.push("release") } } } as unknown as ProviderAccountsService
registerInstanceProxyRoutes(app, { workspaceManager: manager, accounts, worktreeDeletionFence: new WorktreeDeletionFence(),
logger: { debug() {}, error() {}, isLevelEnabled: () => false } as unknown as Logger })
return { app, state, events, close: async () => { await app.close(); await upstream.close() } }
}
test("quota selection precedes prompt and command forwarding, never shell or foreign requests", async () => {
const f = await fixture()
try {
for (const action of ["prompt", "command", "shell"]) {
f.events.length = 0
const response = await f.app.inject({ method: "POST", url: `/workspaces/w/instance/api/session/ses_fixture/${action}`,
payload: action === "prompt" ? { text: "fixture" } : action === "command" ? { name: "fixture" } : { command: "fixture" } })
assert.equal(response.statusCode, 200)
assert.deepEqual(f.events, action === "shell" ? ["environment", "forward"] : ["environment", "selection", "forward"])
}
f.events.length = 0; f.state.owned = false
assert.equal((await f.app.inject({ method: "POST", url: "/workspaces/w/instance/api/session/ses_fixture/prompt", payload: { text: "fixture" } })).statusCode, 403)
assert.deepEqual(f.events, [])
} finally { await f.close() }
})
test("ambiguous activation failure blocks forwarding and manual switches fence their native write", async () => {
const f = await fixture()
try {
f.state.fail = true
const response = await f.app.inject({ method: "POST", url: "/workspaces/w/instance/api/session/ses_fixture/prompt", payload: { text: "fixture" } })
assert.equal(response.statusCode, 502)
assert.equal(response.json().error, "PROVIDER_ACCOUNT_SELECTION_FAILED")
assert.deepEqual(f.events, ["environment", "selection"])
f.events.length = 0
assert.equal((await f.app.inject({ method: "POST", url: "/workspaces/w/instance/api/credential/cre_fixture/activate" })).statusCode, 200)
assert.deepEqual(f.events, ["manual", "forward", "release"])
} finally { await f.close() }
})

View file

@ -61,6 +61,8 @@ import { isAutomationPluginRequest, registerAutomationPluginRoute } from "./rout
import { DeveloperCdp } from "../developer-cdp"
import { formatHostForUrl, isLoopbackHost, isWildcardHost, stripHostBrackets } from "./network-host"
import { validatePromptAttachmentBudget } from "./prompt-attachment-budget"
import { ProviderAccountsService, AccountSelectionFailed } from "../provider-accounts/service"
import { registerProviderAccountsRoutes } from "./routes/provider-accounts"
interface HttpServerDeps {
bindHost: string
@ -309,6 +311,8 @@ export function createHttpServer(deps: HttpServerDeps) {
registerPluginControlRoutes(app, { controls: configurationControls })
registerWebSearchSettingsRoutes(app, new WebSearchSettings(configurationControls))
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
const accounts = new ProviderAccountsService(deps.settings)
registerProviderAccountsRoutes(app, { accounts, workspaceManager: deps.workspaceManager, worktreeDeletionFence })
registerOpenCodeUpdateRoutes(app, {
service: createOpenCodeUpdateService(deps.settings, deps.workspaceManager),
logger: apiLogger,
@ -357,7 +361,7 @@ export function createHttpServer(deps: HttpServerDeps) {
})
registerYoloRoutes(app, { yoloManager: deps.yoloManager })
registerSessionPruningRoutes(app, { workspaceManager: deps.workspaceManager, worktreeDeletionFence })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger, worktreeDeletionFence })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger, worktreeDeletionFence, accounts })
if (deps.uiDevServerUrl) {
@ -442,6 +446,7 @@ interface InstanceProxyDeps {
workspaceManager: InstanceProxyWorkspaceManager
logger: Logger
worktreeDeletionFence: WorktreeDeletionFence
accounts?: ProviderAccountsService
}
interface SideCarProxyDeps {
@ -606,6 +611,7 @@ export function registerInstanceProxyRoutes(app: FastifyInstance, deps: Instance
reply,
workspaceManager: deps.workspaceManager,
worktreeDeletionFence: deps.worktreeDeletionFence,
accounts: deps.accounts,
pathSuffix: "",
logger: deps.logger,
})
@ -620,6 +626,7 @@ export function registerInstanceProxyRoutes(app: FastifyInstance, deps: Instance
reply,
workspaceManager: deps.workspaceManager,
worktreeDeletionFence: deps.worktreeDeletionFence,
accounts: deps.accounts,
pathSuffix: request.params["*"] ?? "",
logger: deps.logger,
})
@ -634,6 +641,7 @@ export function registerInstanceProxyRoutes(app: FastifyInstance, deps: Instance
reply,
workspaceManager: deps.workspaceManager,
worktreeDeletionFence: deps.worktreeDeletionFence,
accounts: deps.accounts,
pathSuffix: `api/session/${encodeURIComponent(request.params.sessionId)}/prompt`,
logger: deps.logger,
})
@ -654,6 +662,7 @@ interface InstanceProxyRequestArgs {
reply: FastifyReply
workspaceManager: InstanceProxyWorkspaceManager
worktreeDeletionFence: WorktreeDeletionFence
accounts?: ProviderAccountsService
logger: Logger
pathSuffix?: string
}
@ -954,6 +963,7 @@ async function proxyWorkspaceAdmission(args: InstanceProxyRequestArgs, admission
}
const sessionId = getSessionRouteId(pathname)
let authorizedSessionDirectory: string | undefined
if (sessionId && !isGlobalFormAction(pathname, request.method)) {
let session
try {
@ -975,6 +985,7 @@ async function proxyWorkspaceAdmission(args: InstanceProxyRequestArgs, admission
reply.code(403).send({ error: "Session does not belong to workspace" })
return
}
authorizedSessionDirectory = session.location.directory
if (request.method !== "GET" && request.method !== "HEAD") {
const sessionWorktree = await wait(workspaceManager.getWorktreeIdentityForPath(workspaceId, session.location.directory))
if (!sessionWorktree) {
@ -988,14 +999,17 @@ async function proxyWorkspaceAdmission(args: InstanceProxyRequestArgs, admission
const body = applyDefaultWorkspaceLocation(targetUrl, promptBody, request.method, serviceDirectory, requestLocations.directories.length > 0 || sessionListHasScope, Boolean(sessionId) && !isGlobalFormAction(pathname, request.method))
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
signal.throwIfAborted()
const releaseMutation = request.method === "GET" || request.method === "HEAD"
const releaseFence = request.method === "GET" || request.method === "HEAD"
? undefined
: worktreeDeletionFence.enter([...mutationIdentities])
if (request.method !== "GET" && request.method !== "HEAD" && !releaseMutation) {
if (request.method !== "GET" && request.method !== "HEAD" && !releaseFence) {
reply.code(409).send({ error: "Worktree deletion is in progress" })
return
}
logger.debug({ workspaceId, method: request.method, targetUrl: targetUrl.toString() }, "Proxying request to instance")
const releaseAccount = connection && request.method !== "GET" && request.method !== "HEAD"
&& /^\/api\/(?:credential|integration)(?:\/|$)/.test(pathname) ? args.accounts?.manual(connection) : undefined
const releaseMutation = releaseFence || releaseAccount ? () => { releaseAccount?.(); releaseFence?.() } : undefined
try {
connection?.assertCurrent()
@ -1028,6 +1042,20 @@ async function proxyWorkspaceAdmission(args: InstanceProxyRequestArgs, admission
}
signal.throwIfAborted()
connection?.assertCurrent()
if (connection && !pathname.replace(/\/$/, "").endsWith("/shell") && args.accounts) {
try {
await wait(args.accounts.beforeSend(connection, sessionId!, AbortSignal.any([signal, AbortSignal.timeout(15_000)]), async directory => {
const current = await connection.client.session.get({ sessionID: sessionId! }, { signal })
return workspaceManager.get(workspaceId) === workspace
&& directory === authorizedSessionDirectory && current.location.directory === authorizedSessionDirectory
&& await workspaceManager.ownsLocation(workspaceId, current.location, connection.client, signal)
}))
} catch (error) {
releaseMutation?.()
if (error instanceof AccountSelectionFailed) return reply.code(502).send({ error: "PROVIDER_ACCOUNT_SELECTION_FAILED" })
throw error
}
}
}
if (connection || workspaceManager.getSharedServiceFetch) {
const headers = sanitizeInstanceProxyRequestHeaders(request.headers, instanceAuthHeader)
@ -1367,6 +1395,8 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean {
["DELETE", /^\/api\/shell\/[^/]+$/],
["POST", /^\/api\/experimental\/mcp\/[^/]+\/(?:connect|disconnect)$/],
["DELETE", /^\/api\/credential\/[^/]+$/],
["PATCH", /^\/api\/credential\/[^/]+$/],
["POST", /^\/api\/credential\/[^/]+\/activate$/],
["POST", /^\/api\/integration\/[^/]+\/connect\/(?:key|oauth|command)$/],
["GET", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/],
["DELETE", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/],

View file

@ -0,0 +1,81 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import { registerProviderAccountsRoutes } from "./provider-accounts"
import { ProviderAccountsService } from "../../provider-accounts/service"
import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
async function fixture() {
const app = Fastify()
const workspace = { id: "w", path: "D:/project" }
const state = { current: true, reads: 0, owned: true, available: true, supported: true, enabled: false, fail: false }
const connection = { client: {}, assertCurrent: () => { if (!state.current) throw new Error("private-native-detail") } }
const accounts = {
snapshot: async (_connection: unknown, directory: string) => {
state.reads++; assert.equal(directory, "/project")
if (state.fail) throw new Error("secret-access-token")
return { supported: state.supported, enabled: state.enabled, logins: { one: "one@example.com" } }
},
setEnabled: (enabled: boolean) => { state.enabled = enabled },
} as unknown as ProviderAccountsService
const fence = new WorktreeDeletionFence()
registerProviderAccountsRoutes(app, { accounts, worktreeDeletionFence: fence, workspaceManager: {
get: id => id === "w" && state.available ? workspace as never : undefined,
getSharedServiceConnection: async () => connection as never,
getServiceDirectoryForPath: async (_id, directory) => directory === "D:/project" ? "/project" : undefined,
ownsLocation: async () => state.owned,
getWorktreeIdentityForPath: async () => "owned-root",
} })
return { app, state, fence, url: "/api/workspaces/w/provider-accounts/openai" }
}
test("account settings require owned exact location before accessing native credentials", async () => {
const f = await fixture()
try {
assert.equal((await f.app.inject({ url: `${f.url}?directory=/foreign` })).statusCode, 403)
assert.equal(f.state.reads, 0)
const read = await f.app.inject({ url: `${f.url}?directory=D:/project` })
assert.equal(read.statusCode, 200)
assert.equal(read.headers["cache-control"], "no-store")
assert.deepEqual(read.json().logins, { one: "one@example.com" })
const invalid = await f.app.inject({ method: "PUT", url: f.url, payload: { directory: "D:/project", enabled: "yes" } })
assert.equal(invalid.statusCode, 400)
assert.equal(f.state.enabled, false)
} finally { await f.app.close() }
})
test("opt-in writes are explicit, supported-only and fenced against deletion", async () => {
const f = await fixture()
try {
f.state.supported = false
const write = () => f.app.inject({ method: "PUT", url: f.url, payload: { directory: "D:/project", enabled: true } })
assert.equal((await write()).statusCode, 409)
assert.equal(f.state.enabled, false)
f.state.supported = true
assert.equal((await write()).json().enabled, true)
let release!: () => void
const deleting = f.fence.run("owned-root", ["owned-root"], () => new Promise<void>(resolve => { release = resolve }))
await Promise.resolve()
assert.equal((await write()).statusCode, 409)
release()
await deleting
f.state.supported = false
const disabled = await f.app.inject({ method: "PUT", url: f.url, payload: { directory: "D:/project", enabled: false } })
assert.equal(disabled.statusCode, 200)
assert.equal(f.state.enabled, false)
} finally { await f.app.close() }
})
test("reconnect and credential failures disclose no upstream details or mutate policy", async () => {
const f = await fixture()
try {
for (const failure of ["fail", "current"] as const) {
f.state.fail = failure === "fail"; f.state.current = failure !== "current"
const response = await f.app.inject({ method: "PUT", url: f.url, payload: { directory: "D:/project", enabled: true } })
assert.equal(response.statusCode, 503)
assert.equal(response.body.includes("secret"), false)
assert.equal(response.body.includes("private"), false)
assert.equal(f.state.enabled, false)
}
} finally { await f.app.close() }
})

View file

@ -0,0 +1,65 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { WorkspaceManager } from "../../workspaces/manager"
import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
import type { ProviderAccountsService } from "../../provider-accounts/service"
type Deps = {
workspaceManager: Pick<WorkspaceManager, "get" | "getSharedServiceConnection" | "getServiceDirectoryForPath" | "ownsLocation" | "getWorktreeIdentityForPath">
worktreeDeletionFence: WorktreeDeletionFence
accounts: ProviderAccountsService
}
const paramsSchema = z.object({ id: z.string().min(1).max(256), integrationID: z.string().min(1).max(256) }).strict()
const readSchema = z.object({ directory: z.string().min(1).max(4096) }).strict()
const writeSchema = readSchema.extend({ enabled: z.boolean() }).strict()
export function registerProviderAccountsRoutes(app: FastifyInstance, deps: Deps) {
for (const method of ["GET", "PUT"] as const) app.route({
method, url: "/api/workspaces/:id/provider-accounts/:integrationID",
handler: async (request, reply) => {
reply.header("Cache-Control", "no-store")
const params = paramsSchema.safeParse(request.params)
const input = method === "GET" ? readSchema.safeParse(request.query) : writeSchema.safeParse(request.body)
if (!params.success || !input.success) return reply.code(400).send({ error: "Invalid provider accounts request" })
const { id, integrationID } = params.data
const manager = deps.workspaceManager, workspace = manager.get(id)
if (!workspace) return reply.code(404).send({ error: "Workspace not found" })
const controller = new AbortController()
const close = () => controller.abort()
reply.raw.once("close", close)
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)])
let release: (() => void) | undefined
try {
const connection = await manager.getSharedServiceConnection(id)
if (!connection) return reply.code(503).send({ error: "Provider accounts unavailable" })
const directory = await manager.getServiceDirectoryForPath(id, input.data.directory)
if (!directory || !await manager.ownsLocation(id, { directory }, connection.client, signal)) {
return reply.code(403).send({ error: "Location does not belong to workspace" })
}
if (method === "PUT") {
const identity = await manager.getWorktreeIdentityForPath(id, input.data.directory)
if (!identity) return reply.code(403).send({ error: "Location does not belong to workspace" })
release = deps.worktreeDeletionFence.enter([identity])
if (!release) return reply.code(409).send({ error: "Worktree deletion in progress" })
}
const snapshot = await deps.accounts.snapshot(connection, directory, integrationID, signal)
if (manager.get(id) !== workspace || !await manager.ownsLocation(id, { directory }, connection.client, signal)) {
return reply.code(403).send({ error: "Location does not belong to workspace" })
}
signal.throwIfAborted(); connection.assertCurrent()
if (method === "PUT") {
if (integrationID !== "openai" || ((input.data as z.infer<typeof writeSchema>).enabled && !snapshot.supported)) {
return reply.code(409).send({ error: "Automatic account selection unavailable" })
}
const enabled = (input.data as z.infer<typeof writeSchema>).enabled
deps.accounts.setEnabled(enabled)
return { ...snapshot, enabled }
}
return snapshot
} catch {
// Credential exports are server-only. No raw errors or request details.
return reply.code(503).send({ error: "Provider accounts unavailable" })
} finally { release?.(); reply.raw.off("close", close) }
},
})
}

View file

@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import test from "node:test"
import type { CredentialEntry } from "@opencode/client"
import { codexCredential } from "./codex-credential"
const token = (claims: object) => `header.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.signature`
const entry = (metadata: Record<string, any> = {}, claims: object = {}): CredentialEntry => ({
id: "fixture", integrationID: "openai", label: "default", active: true, value: {
type: "oauth", methodID: "chatgpt-browser", access: token(claims), refresh: "private-refresh",
expires: Date.now() + 3600000, metadata: { accountID: "fixture-account", ...metadata },
},
})
test("Codex login uses bounded email hints, never arbitrary token claims", () => {
assert.equal(codexCredential(entry({ email: "native@example.com" }, { email: "jwt@example.com" }))?.login, "native@example.com")
assert.equal(codexCredential(entry({}, { "https://api.openai.com/profile": { email: "profile@example.com" } }))?.login, "profile@example.com")
for (const email of ["secret-access-token", "bad\n@example.com", "\x00@example.com", `${"x".repeat(255)}@example.com`]) {
assert.equal(codexCredential(entry({ email }))?.login, undefined)
}
assert.equal(codexCredential(entry({}, { name: "Never disclose this" }))?.login, undefined)
})
test("expiry, integration and method remain hard credential boundaries", () => {
const expired = entry(); expired.value = { ...expired.value, type: "oauth", methodID: "chatgpt-browser", access: "fixture", refresh: "fixture", expires: 1 }
assert.equal(codexCredential(expired), null)
assert.equal(codexCredential({ ...entry(), integrationID: "other" }), null)
assert.equal(codexCredential(entry({}, { exp: 1 })), null)
assert.equal(codexCredential({ ...entry(), value: { type: "key", key: "fixture" } }), null)
})

View file

@ -0,0 +1,25 @@
import { createHash } from "node:crypto"
import type { CredentialEntry } from "@opencode/client"
import { decodeJwtClaims, getString } from "./shared"
// Server-only. This object must never be logged, cached or returned over HTTP.
export function codexCredential(entry: CredentialEntry, now = Date.now()) {
const value = entry.value
if (entry.integrationID !== "openai" || value.type !== "oauth"
|| !["chatgpt-browser", "chatgpt-headless"].includes(value.methodID)
|| !Number.isFinite(value.expires) || value.expires <= now + 120_000 || !getString(value.access)) return null
const claims = decodeJwtClaims(value.access)
const expiry = Number(claims?.exp) * 1000
if (Number.isFinite(expiry) && expiry <= now + 120_000) return null
const accountID = getString(value.metadata?.accountID)
?? getString(claims?.["https://api.openai.com/auth"]?.chatgpt_account_id)
if (!accountID) return null
const identity = createHash("sha256").update(JSON.stringify([
entry.integrationID, entry.id, value.methodID, accountID, value.expires, value.access,
])).digest("hex")
const email = getString(value.metadata?.email)
?? getString(claims?.["https://api.openai.com/profile"]?.email) ?? getString(claims?.email)
// Display only, not authentication or authorization. Do not return arbitrary claims.
const login = email && email.length <= 254 && /^[^\s@\x00-\x1f\x7f]+@[^\s@\x00-\x1f\x7f]+\.[^\s@\x00-\x1f\x7f]+$/.test(email) ? email : undefined
return { identity, access: value.access, accountID, login }
}

View file

@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import test from "node:test"
import { parseCodexSelectionQuota } from "./codex-selection-quota"
const window = (used = 20, seconds = 18000) => ({ used_percent: used, limit_window_seconds: seconds, reset_at: (Date.now() + 3600000) / 1000 })
test("activation evidence validates every declared quota window before display normalization", () => {
const primary = window()
for (const secondary of [window(-1, 604800), window(101, 604800), { ...window(20, 604800), used_percent: "invalid" },
{ ...window(20, 604800), reset_at: 1 }, { ...window(20, 604800), reset_at: null },
{ ...window(20, 604800), limit_window_seconds: 0 }, window(20), "unknown"]) {
assert.throws(() => parseCodexSelectionQuota({ rate_limit: { primary_window: primary, secondary_window: secondary } }), /Quota unavailable/)
}
for (const rate of [{}, { primary_window: primary, allowed: false }, { primary_window: primary, limit_reached: true },
{ primary_window: primary, allowed: "true" }]) {
assert.throws(() => parseCodexSelectionQuota({ rate_limit: rate }), /Quota unavailable/)
}
const usage = parseCodexSelectionQuota({ rate_limit: { primary_window: primary, secondary_window: window(100, 604800) } })
assert.equal(usage.windows.weekly.usedPercent, 100)
assert.equal(usage.windows["5h"].usedPercent, 20)
})

View file

@ -0,0 +1,30 @@
import { parseCodexUsage } from "./providers/oauth"
import { asObject, toNumber, toTimestamp } from "./shared"
// Display parsers may omit malformed windows and clamp percentages. A mutation
// decision must instead reject incomplete or contradictory availability evidence.
export function parseCodexSelectionQuota(payload: unknown) {
const rate = asObject(asObject(payload)?.rate_limit)
if (!rate) throw new Error("Quota unavailable")
if ([rate.allowed, rate.limit_reached].some(flag => flag !== undefined && typeof flag !== "boolean")) {
throw new Error("Quota unavailable")
}
const sources = [rate.primary_window, rate.secondary_window].filter(source => source !== null && source !== undefined)
if (!sources.length) throw new Error("Quota unavailable")
const durations = new Set<number>()
let exhausted = false
for (const source of sources) {
const window = asObject(source)
const seconds = toNumber(window?.limit_window_seconds)
const used = toNumber(window?.used_percent)
const reset = toTimestamp(window?.reset_at)
if (seconds === null || seconds <= 0 || durations.has(seconds)
|| used === null || used < 0 || used > 100 || reset === null || reset <= Date.now()) {
throw new Error("Quota unavailable")
}
durations.add(seconds)
exhausted ||= used === 100
}
if (!exhausted && (rate.allowed === false || rate.limit_reached === true)) throw new Error("Quota unavailable")
return parseCodexUsage(payload)
}

View file

@ -1,10 +1,10 @@
import { createHash } from "node:crypto"
import { ClientError } from "@opencode/client"
import type { ServiceConnection } from "../workspaces/opencode-service"
import type { ProviderUsageResponse } from "../api-types"
import type { ProviderUsage } from "./types"
import { decodeJwtClaims, getString } from "./shared"
import { codexCredential } from "./codex-credential"
import { parseCodexUsage } from "./providers/oauth"
import { parseCodexSelectionQuota } from "./codex-selection-quota"
interface UsageScope {
instanceId: string
@ -61,24 +61,11 @@ export function createNativeCodexUsage() {
throw error
})
const entry = entries.find(entry => entry.id === selected.id && entry.integrationID === integrationID && entry.active)
const value = entry?.value
if (value?.type !== "oauth" || !["chatgpt-browser", "chatgpt-headless"].includes(value.methodID)
|| !Number.isFinite(value.expires) || value.expires <= Date.now() + 120_000 || !getString(value.access)) {
throw new Error("Selected subscription credential unavailable")
}
const claims = decodeJwtClaims(value.access)
const jwtExpiry = Number(claims?.exp) * 1000
if (Number.isFinite(jwtExpiry) && jwtExpiry <= Date.now() + 120_000) throw new Error("Expired subscription credential")
const accountID = getString(value.metadata?.accountID)
?? getString(claims?.["https://api.openai.com/auth"]?.chatgpt_account_id)
// Require a same-selected-credential account identity, never another store.
if (!accountID) throw new Error("Subscription account unavailable")
const identity = createHash("sha256").update(JSON.stringify([
integrationID, entry!.id, value.methodID, accountID, value.expires, value.access,
])).digest("hex")
const credential = entry && codexCredential(entry)
if (!credential) throw new Error("Selected subscription credential unavailable")
connection.assertCurrent()
signal.throwIfAborted()
return { identity, access: value.access, accountID }
return credential
}
try {
@ -97,7 +84,7 @@ export function createNativeCodexUsage() {
requestKey = JSON.stringify([key, selected.identity])
request = requests.get(requestKey)
if (!request) {
request = fetchQuota(selected.access, selected.accountID, signal)
request = fetchCodexQuota(selected.access, selected.accountID, signal)
requests.set(requestKey, request)
}
snapshot = { identity: selected.identity, usage: await request, fetchedAt: Date.now() }
@ -125,7 +112,15 @@ export function createNativeCodexUsage() {
}
}
async function fetchQuota(access: string, accountID: string, signal: AbortSignal): Promise<ProviderUsage> {
export async function fetchCodexQuota(access: string, accountID: string, signal: AbortSignal): Promise<ProviderUsage> {
return fetchQuota(access, accountID, signal, false)
}
export async function fetchCodexSelectionQuota(access: string, accountID: string, signal: AbortSignal): Promise<ProviderUsage> {
return fetchQuota(access, accountID, signal, true)
}
async function fetchQuota(access: string, accountID: string, signal: AbortSignal, selection: boolean): Promise<ProviderUsage> {
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
signal, redirect: "error", headers: { Authorization: `Bearer ${access}`, "ChatGPT-Account-Id": accountID, "Content-Type": "application/json" },
})
@ -148,7 +143,8 @@ async function fetchQuota(access: string, accountID: string, signal: AbortSignal
chunks.push(chunk.value)
}
signal.throwIfAborted()
return parseCodexUsage(JSON.parse(Buffer.concat(chunks).toString("utf8")))
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"))
return selection ? parseCodexSelectionQuota(payload) : parseCodexUsage(payload)
} finally {
signal.removeEventListener("abort", abort)
await reader.cancel().catch(() => {})

View file

@ -7,7 +7,7 @@ import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { getFavoritesOnlyPreference, getProviderModelVisibilityPreference, setFavoritesOnlyPreference, uiState, toggleFavoriteModelPreference } from "../stores/preferences"
import { ProviderManagerModal } from "./provider-auth/provider-manager-modal"
import { openSettings } from "../stores/settings-screen"
import { isModelVisible, resolvePickerValue } from "../lib/model-visibility"
const log = getLogger("session")
@ -65,7 +65,6 @@ export default function ModelSelector(props: ModelSelectorProps) {
const [isOpen, setIsOpen] = createSignal(false)
const [inputValue, setInputValue] = createSignal("")
const [openComboboxValue, setOpenComboboxValue] = createSignal<FlatModel | undefined>()
const [providersModalOpen, setProvidersModalOpen] = createSignal(false)
let searchInputRef!: HTMLInputElement
let suppressNextClose = false
@ -433,7 +432,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
event.preventDefault()
event.stopPropagation()
closePicker()
setProvidersModalOpen(true)
void openSettings("providers")
}}
>
<PlugZap class="w-4 h-4" />
@ -443,7 +442,6 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Content>
</Combobox.Portal>
</Combobox>
<ProviderManagerModal instanceId={props.instanceId} open={providersModalOpen()} onOpenChange={setProvidersModalOpen} />
</div>
)
}

View file

@ -0,0 +1,170 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { ConnectionInfo, LocationRef, OpenCodeClient } from "@opencode/client"
import { Plus, Pencil, Trash2, LockKeyhole } from "lucide-solid"
import { useI18n } from "../../lib/i18n"
import { serverEvents } from "../../lib/server-events"
import { requestLocationOptions, toRequestLocation } from "../../stores/request-locations"
import { serverApi } from "../../lib/api-client"
import type { ProviderAccountsSnapshot } from "../../../../server/src/api-types"
export function ProviderAccounts(props: {
instanceId: string
integrationId: string
client: OpenCodeClient
location: LocationRef
disabled?: boolean
initialConnections?: ConnectionInfo[]
onChanged?: () => Promise<void>
onAdd?: () => void
}) {
const { t } = useI18n()
const [connections, setConnections] = createSignal<ConnectionInfo[]>([])
const [busy, setBusy] = createSignal(false)
const [error, setError] = createSignal(false)
const emptySnapshot = (): ProviderAccountsSnapshot => ({ supported: false, enabled: false, logins: {} })
const [selection, setSelection] = createSignal(emptySnapshot())
let setAutomatic: (enabled: boolean) => Promise<void> = async () => {}
let refresh = () => {}
let run: (id: string, action: "activate" | "remove" | "rename", label?: string) => Promise<boolean> = async () => false
createEffect(() => {
const client = props.client, instanceId = props.instanceId, integrationID = props.integrationId, location = { ...props.location }
let disposed = false, reading = false, writing = false, trailing = false
// Cache-first: keep the catalog snapshot while the authoritative read
// revalidates behind it.
setConnections(props.initialConnections ?? []); setError(false); setBusy(false); setSelection(emptySnapshot())
const read = async () => {
if (disposed) return
if (reading || writing) { trailing = true; return }
reading = true; setBusy(true)
do {
trailing = false
try {
const catalog = await client.integration.list({ location: toRequestLocation(location) }, requestLocationOptions(location))
const snapshot = integrationID === "openai"
? await serverApi.getProviderAccounts(instanceId, integrationID, location.directory).catch(() => emptySnapshot())
: emptySnapshot()
if (!disposed && !trailing) {
setConnections(catalog.data.find(item => item.id === integrationID)?.connections ?? [])
setSelection(snapshot)
setError(false)
}
} catch { if (!disposed && !trailing) setError(true) }
} while (trailing && !disposed)
reading = false
if (!disposed) setBusy(false)
}
refresh = () => { void read() }
setAutomatic = async enabled => {
if (disposed || reading || writing || props.disabled) return
writing = true; setBusy(true); setError(false)
let failed = false
try {
const snapshot = await serverApi.setProviderAccountSelection(instanceId, integrationID, location.directory, enabled)
if (!disposed) setSelection(snapshot)
} catch { failed = true }
finally { writing = false }
if (disposed) return
await read()
if (failed && !disposed) setError(true)
}
run = async (credentialID, action, label) => {
if (disposed || reading || writing || props.disabled) return false
if (!connections().some(item => item.type === "credential" && item.id === credentialID)) return false
writing = true; setBusy(true); setError(false)
let failed = false
try {
const options = requestLocationOptions(location, { includeDirectory: true })
if (action === "activate") await client.credential.activate({ credentialID }, options)
else if (action === "remove") await client.credential.remove({ credentialID }, options)
else await client.credential.update({ credentialID, label: label!.trim() }, options)
} catch { failed = true }
finally { writing = false }
if (disposed) return false
await read()
if (failed) { if (!disposed) setError(true); return false }
if (!disposed) await props.onChanged?.()
return !disposed
}
const events = serverEvents.on("instance.event", payload => {
if (payload.type === "instance.event" && payload.instanceId === instanceId
&& ["integration.updated", "credential.updated", "credential.switched"].includes(payload.event.type)) refresh()
})
const status = serverEvents.on("instance.eventStatus", payload => {
if (payload.type === "instance.eventStatus" && payload.instanceId === instanceId && payload.status === "connected") refresh()
})
const reconnect = serverEvents.onOpen(() => refresh())
const policy = serverEvents.on("storage.configChanged", payload => {
if (payload.type === "storage.configChanged" && ["providerAccounts", "*"].includes(payload.owner)) refresh()
})
onCleanup(() => { disposed = true; events(); status(); reconnect(); policy() })
void read()
})
const ids = () => connections().map(item => item.type === "credential" ? `credential:${item.id}` : `env:${item.name}`)
const connectionLabel = (item: ConnectionInfo) => item.type === "credential"
? (item.label === "default" ? selection().logins?.[item.id] ?? item.label : item.label) : item.name
const addAccountControl = () => <Show when={props.onAdd}>
<button type="button" class="icon-button-compact" disabled={busy() || props.disabled}
title={t("settings.accounts.add")} aria-label={t("settings.accounts.add")} onClick={() => props.onAdd?.()}><Plus size={14} /></button>
</Show>
return <div class="provider-accounts" role="group" aria-label={t("settings.accounts.title")}>
<Show when={error()}><p role="alert">{t("settings.accounts.error")}</p></Show>
<label class="provider-account-control">
<span class="settings-toggle-title">{t("settings.accounts.current")}</span>
<select class="selector-trigger" aria-label={t("settings.accounts.current")} title={t("settings.accounts.global")}
disabled={busy() || props.disabled || !connections().some(item => item.type === "credential")}
value={ids()[0] ?? ""} onChange={event => {
const id = event.currentTarget.value
if (id.startsWith("credential:") && id !== ids()[0]) void run(id.slice(11), "activate")
}}>
<For each={connections()}>{item => {
const id = item.type === "credential" ? `credential:${item.id}` : `env:${item.name}`
return <option value={id} selected={id === ids()[0]} disabled={item.type === "env"}>{connectionLabel(item)}</option>
}}</For>
</select>
</label>
<For each={ids()}>{(id, index) => {
const connection = () => connections().find(item => id === (item.type === "credential" ? `credential:${item.id}` : `env:${item.name}`))!
const [label, setLabel] = createSignal("")
const [dirty, setDirty] = createSignal(false)
const [editing, setEditing] = createSignal(false)
let input: HTMLInputElement | undefined
let editRevision = 0
createEffect(() => { const item = connection(); if (!dirty()) setLabel(item.type === "credential" ? item.label : item.name) })
return <Show when={index() === 0}><div class="provider-account" data-account-id={id}>
<Show when={connection().type === "credential"} fallback={<div class="provider-account-actions">{addAccountControl()}<span title={t("settings.providers.source.env")} aria-label={t("settings.providers.source.env")}><LockKeyhole size={14} /></span></div>}>
<div class="provider-account-actions">
{addAccountControl()}
<button type="button" class="icon-button-compact" disabled={busy() || props.disabled}
title={t("settings.accounts.rename")} aria-label={t("settings.accounts.rename")}
onClick={() => { setEditing(true); queueMicrotask(() => { input?.focus(); input?.select() }) }}><Pencil size={14} /></button>
<button type="button" class="icon-button-compact" disabled={busy() || props.disabled}
title={t("settings.accounts.remove")} aria-label={t("settings.accounts.remove")}
onClick={() => void run(id.slice(11), "remove")}><Trash2 size={14} /></button>
</div>
<Show when={editing()}><form class="provider-account-edit" onSubmit={event => {
event.preventDefault()
if (busy() || props.disabled || !label().trim() || !dirty()) return
const revision = editRevision
void run(id.slice(11), "rename", label()).then(saved => {
if (saved && revision === editRevision) { setDirty(false); setEditing(false) }
})
}}>
<input ref={input} class="providers-input" aria-label={t("settings.accounts.label")} value={label()} maxlength={256} disabled={busy() || props.disabled}
onInput={event => { editRevision++; setDirty(true); setLabel(event.currentTarget.value) }} />
<button type="submit" class="selector-button" disabled={busy() || props.disabled || !label().trim() || !dirty()}
>{t("settings.configFiles.actions.save")}</button>
<button type="button" class="selector-button" disabled={busy() || props.disabled}
onClick={() => { setDirty(false); setEditing(false); setLabel(connectionLabel(connection())) }}>{t("alertDialog.actions.cancel")}</button>
</form></Show>
</Show>
</div></Show>
}}</For>
<Show when={selection().supported && connections().filter(item => item.type === "credential").length > 1}>
<label class="provider-account-auto" title={t("settings.accounts.autoSelectHint")}>
<input type="checkbox" checked={selection().enabled} disabled={busy() || props.disabled}
onChange={event => { const enabled = event.currentTarget.checked; event.currentTarget.checked = selection().enabled; void setAutomatic(enabled) }} />
<span>{t("settings.accounts.autoSelect")}</span>
</label>
</Show>
</div>
}

View file

@ -1,10 +1,11 @@
import { Dialog } from "@kobalte/core/dialog"
import { Select } from "@kobalte/core/select"
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Component } from "solid-js"
import { Check, ChevronDown, ExternalLink, KeyRound, Loader2, PlugZap, RefreshCw, X } from "lucide-solid"
import type { FormAnswer, FormValue, IntegrationMethod, LocationRef, ModelInfo, OpenCodeClient, ProviderInfo } from "@opencode/client"
import { Check, ChevronDown, ExternalLink, KeyRound, Loader2, PlugZap, X } from "lucide-solid"
import type { ConnectionInfo, FormAnswer, FormValue, IntegrationMethod, LocationRef, ModelInfo, OpenCodeClient, ProviderInfo } from "@opencode/client"
import { openExternalUrl } from "../../lib/external-url"
import { useI18n } from "../../lib/i18n"
import { serverEvents } from "../../lib/server-events"
import { isLocalTauriHost } from "../../lib/runtime-env"
import { isFormFieldVisible, isHttpFormUrl } from "../../lib/form-schema"
import {
@ -21,6 +22,7 @@ import { fetchProviders, getActiveCatalogLocation } from "../../stores/sessions"
import { locationAuthorityKey, requestLocationOptions, toRequestLocation } from "../../stores/request-locations"
import { getRootClient } from "../../stores/opencode-client"
import { ProviderAuthForm } from "./provider-auth-form"
import { ProviderAccounts } from "./provider-accounts"
import { buildListedProviders, buildProviderVisibilityModels, type ListedProvider as ProviderOption } from "./provider-options"
import {
ProviderModelVisibilityManager,
@ -49,6 +51,7 @@ type NativeAuthMethod = Exclude<IntegrationMethod, { type: "env" }>
type NativeAuthorization = ProviderAuthAuthorization & { attemptID: string }
type ListedProvider = ProviderOption & {
models: ProviderVisibilityModel[]
connections: ConnectionInfo[]
}
interface ProviderManagerModalProps {
@ -59,6 +62,11 @@ interface ProviderManagerModalProps {
onOpenChange?: (open: boolean) => void
}
// Last successful catalog per instance/location. Displayed immediately on the
// next open and revalidated lazily, so managing providers from the composer
// never waits for three cold native reads.
const providerAuthCache = new Map<string, { providers: ListedProvider[]; methods: Record<string, NativeAuthMethod[]> }>()
export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props) => {
const { t } = useI18n()
const [methodsByProvider, setMethodsByProvider] = createSignal<Record<string, NativeAuthMethod[]>>({})
@ -114,6 +122,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
const configurableProviders = createMemo<ConfigurableProviderOption[]>(() => {
return availableProviders()
.filter(provider => provider.source === "unknown" && provider.canConnect)
.sort((left, right) => left.id.localeCompare(right.id, undefined, { sensitivity: "base" }))
.map((listed) => {
return {
@ -270,7 +279,8 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
loadedInstanceId = null
loadedClient = null
loadedCatalogLocationKey = null
resetProviderData()
resetFlow(null)
setManagedProviderId(null)
return
}
const instanceId = props.instanceId
@ -279,19 +289,58 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
loadedInstanceId = null
loadedClient = null
loadedCatalogLocationKey = null
resetProviderData()
resetFlow(null)
setManagedProviderId(null)
return
}
const catalogLocation = currentCatalogLocation()
const catalogLocationKey = locationAuthorityKey(catalogLocation)
if (loadedInstanceId === instanceId && loadedClient === authClient && loadedCatalogLocationKey === catalogLocationKey) return
resetProviderData()
resetFlow(null)
setManagedProviderId(null)
setLoadError(null)
// Cache-first: show the last snapshot immediately, revalidate behind it.
const cached = providerAuthCache.get(`${instanceId}\0${catalogLocationKey}`)
if (cached) {
setAvailableProviders(cached.providers)
setMethodsByProvider(cached.methods)
setSelectedProviderId((current) => current ?? cached.providers[0]?.id ?? null)
setLoading(true)
} else {
setAvailableProviders([])
setMethodsByProvider({})
setSelectedProviderId(null)
}
loadedInstanceId = instanceId
loadedClient = authClient
loadedCatalogLocationKey = catalogLocationKey
void loadProviderData(authClient, version, catalogLocation)
})
// Mutations and reconnects revalidate while mounted; no manual refresh.
createEffect(() => {
const instanceId = props.instanceId
if ((!props.embedded && !props.open) || !client()) return
let trailing = false
const refresh = () => {
const authClient = client()
if (!authClient || loading()) { trailing = true; return }
const catalogLocation = currentCatalogLocation()
void loadProviderData(authClient, loadVersion, catalogLocation).finally(() => {
if (trailing) { trailing = false; refresh() }
})
}
const events = serverEvents.on("instance.event", payload => {
if (payload.type === "instance.event" && payload.instanceId === instanceId
&& ["integration.updated", "credential.updated", "credential.switched", "model.updated", "config.updated"].includes(payload.event.type)) refresh()
})
const status = serverEvents.on("instance.eventStatus", payload => {
if (payload.type === "instance.eventStatus" && payload.instanceId === instanceId && payload.status === "connected") refresh()
})
const reconnect = serverEvents.onOpen(() => refresh())
onCleanup(() => { events(); status(); reconnect() })
})
createEffect(() => {
if (stage() === "code") queueMicrotask(() => oauthCodeInput?.focus())
})
@ -325,6 +374,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
.map((provider) => ({
...provider,
models: buildProviderVisibilityModels(provider.id, providerResponse.data, modelResponse.data),
connections: integrationResponse.data.find(item => item.id === provider.id)?.connections ?? [],
}))
const methods = Object.fromEntries(integrationResponse.data.map((integration) => [
integration.id,
@ -332,7 +382,8 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
]))
setAvailableProviders(listed)
setMethodsByProvider(methods)
setSelectedProviderId((current) => current ?? listed[0]?.id ?? null)
setSelectedProviderId((current) => current ?? listed[0]?.id ?? integrationResponse.data[0]?.id ?? null)
providerAuthCache.set(`${props.instanceId}\0${locationAuthorityKey(catalogLocation)}`, { providers: listed, methods })
} catch (error) {
if (!isCurrentLoad()) return
setLoadError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.loadFailed")))
@ -350,16 +401,6 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
pendingOauthPopup = null
}
function resetProviderData() {
resetFlow(null)
setMethodsByProvider({})
setAvailableProviders([])
setSelectedProviderId(null)
setManagedProviderId(null)
setLoadError(null)
setLoading(false)
}
function resetFlow(nextProviderId: string | null = null) {
if (nextProviderId && !(methodsByProvider()[nextProviderId]?.length)) return
disposePendingAuth()
@ -570,37 +611,6 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
}
}
async function disconnectProvider(providerId: string) {
const authClient = client()
const provider = availableProviders().find((item) => item.id === providerId)
if (!authClient || !provider) return
const instanceId = props.instanceId
const catalogLocation = currentCatalogLocation()
disposePendingAuth()
const operationVersion = ++authOperationVersion
setActionError(null)
setStage("authorizing")
try {
const disconnectMode = getDisconnectMode(provider)
if (disconnectMode === "not-disconnectable") {
setActionError(t("settings.providers.errors.envDisconnectUnavailable"))
setStage("error")
return
}
if (disconnectMode !== "credential-remove") return
await Promise.all(provider.credentialIds.map((credentialID) => authClient.credential.remove({
credentialID,
}, requestLocationOptions(catalogLocation, { includeDirectory: true }))))
if (!isCurrentOperation(operationVersion, instanceId, authClient)) return
await refreshAfterAuth(authClient, instanceId, operationVersion, catalogLocation)
if (isCurrentOperation(operationVersion, instanceId, authClient)) resetFlow(null)
} catch (error) {
if (!isCurrentOperation(operationVersion, instanceId, authClient)) return
setActionError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.removeFailed")))
setStage("idle")
}
}
function cancelOAuthWait() {
const providerId = activeProviderId()
const attemptID = authorization()?.attemptID
@ -662,7 +672,6 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<PlugZap class="settings-card-heading-icon" />
<div>
<Dialog.Title class="providers-manager-title">{t("settings.providers.title")}</Dialog.Title>
<p class="settings-card-subtitle">{t("settings.providers.subtitle")}</p>
</div>
</div>
<button type="button" class="selector-button selector-button-secondary settings-screen-close" onClick={() => handleModalOpenChange(false)} aria-label={t("settings.close")}>
@ -678,6 +687,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<Show when={client()}>
<div class="providers-connect-bar">
<Show when={configurableProviders().length > 0}>
<Select<ConfigurableProviderOption>
value={selectedProviderOption()}
onChange={(option) => option && setSelectedProviderId(option.id)}
@ -688,17 +698,6 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<Select.Item item={itemProps.item} class="selector-option selector-option--multiline">
<div class="selector-option-content">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Select.ItemLabel>
<div class="selector-option-description">
<span dir="ltr">{itemProps.item.rawValue.id}</span>
<span> • </span>
<span>
{itemProps.item.rawValue.modelCount === 1
? t("settings.providers.models.one", { count: itemProps.item.rawValue.modelCount })
: t("settings.providers.models.other", { count: itemProps.item.rawValue.modelCount })}
</span>
<span> • </span>
<span>{itemProps.item.rawValue.connectionSummary}</span>
</div>
</div>
</Select.Item>
)}
@ -709,11 +708,6 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
{(state) => (
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">{state.selectedOption()?.name ?? t("settings.providers.selectProvider")}</span>
<Show when={state.selectedOption()}>
<span class="selector-trigger-secondary" dir="ltr">
{state.selectedOption()?.id} • {state.selectedOption()?.connectionSummary}
</span>
</Show>
</div>
)}
</Select.Value>
@ -725,10 +719,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<button type="button" class="selector-button selector-button-primary" disabled={!selectedProviderOption()?.canConnect} onClick={() => resetFlow(selectedProviderOption()?.id ?? null)}>
{t("settings.providers.actions.connect")}
</button>
<button type="button" class="settings-pill-button" disabled={loading()} onClick={() => void refreshProviderData()}>
<RefreshCw class={loading() ? "providers-spin-icon" : "providers-button-icon"} />
{t("settings.providers.refresh")}
</button>
</Show>
</div>
<Show when={loadError()}>
@ -743,7 +734,6 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<div class="providers-panel-header">
<div>
<h3 class="settings-card-title">{t("settings.providers.auth.title", { provider: activeProviderName() })}</h3>
<p class="settings-card-subtitle">{t("settings.providers.auth.subtitle")}</p>
</div>
<button
type="button"
@ -758,7 +748,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<Show when={methodOptions().length > 1}>
<div class="settings-toggle-row settings-toggle-row-compact providers-method-row">
<div><div class="settings-toggle-title">{t("settings.providers.method.title")}</div><div class="settings-toggle-caption">{t("settings.providers.method.subtitle")}</div></div>
<div class="settings-toggle-title">{t("settings.providers.method.title")}</div>
<Select<MethodOption>
value={selectedMethodOption()}
onChange={(option) => {
@ -794,14 +784,10 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<ProviderAuthForm fields={selectedForm()} answer={formAnswer()} disabled={stage() !== "prompts" && stage() !== "error"} onAnswer={updateFormAnswer} />
</Show>
<Show when={selectedMethod().type === "oauth" && !selectedForm() && (stage() === "prompts" || stage() === "error" || stage() === "authorizing")}>
<div class="settings-card-message" role="status">{t("settings.providers.oauth.noPrompts")}</div>
</Show>
<Show when={selectedMethod().type === "command" && (stage() === "prompts" || stage() === "error" || stage() === "authorizing")}>
<div class="providers-form-stack">
<div class="settings-card-message" role="status">{t("settings.providers.command.description")}</div>
<div class="providers-command-preview" dir="ltr">{selectedCommand()?.command.join(" ")}</div>
<div class="providers-command-preview" title={t("settings.providers.command.description")} dir="ltr">{selectedCommand()?.command.join(" ")}</div>
</div>
</Show>
@ -834,38 +820,42 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
</section>
</Show>
<section class="providers-list-section">
<Show when={!props.embedded}><h3 class="settings-card-title">{t("settings.providers.configured.title")}</h3></Show>
<section class="providers-list-section providers-accounts-list">
<Show when={managedProvider()} fallback={
<>
<Show when={loading()}><div class="providers-loading-row" role="status"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
<Show when={loading() && configuredProviders().length === 0}><div class="providers-loading-row" role="status"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
<Show when={!loading() && configuredProviders().length === 0}><div class="settings-card-message" role="status">{t("settings.providers.empty.noConfiguredProviders")}</div></Show>
<div class="providers-grid">
<For each={configuredProviders()}>{(provider) => (
<For each={configuredProviders().map(provider => provider.id)}>{(providerId) => {
const provider = createMemo(() => configuredProviders().find(item => item.id === providerId)!)
return (
<article class="providers-card settings-toggle-row settings-toggle-row-compact">
<div class="providers-card-copy">
<h4 class="providers-card-title">{provider.name || provider.id}</h4>
<p class="providers-card-meta">
<Show when={provider.name && provider.name !== provider.id}>
<bdi dir="ltr">{provider.id}</bdi><span aria-hidden="true"> • </span>
</Show>
{configuredProviderSummary(provider)}
</p>
<h4 class="providers-card-title" title={configuredProviderSummary(provider())}>{provider().name || providerId}</h4>
</div>
<div class="provider-model-card-actions">
<button
ref={(element) => manageModelButtons.set(provider.id, element)}
ref={(element) => manageModelButtons.set(providerId, element)}
type="button"
class="selector-button selector-button-secondary"
title={t("settings.providers.actions.manageModels")} aria-label={t("settings.providers.actions.manageModels")}
onClick={() => {
managedProviderTriggerId = provider.id
setManagedProviderId(provider.id)
managedProviderTriggerId = providerId
setManagedProviderId(providerId)
}}
>{t("settings.providers.actions.manageModels")}</button>
<Show when={getDisconnectMode(provider) === "credential-remove"}><button type="button" class="selector-button selector-button-secondary providers-disconnect-button" disabled={stage() !== "idle"} onClick={() => void disconnectProvider(provider.id)} title={t("settings.providers.actions.disconnect")}>{t("settings.providers.actions.disconnect")}</button></Show>
</div>
<Show when={client() && (provider().credentialIds.length > 0 || provider().source === "env")}>
<ProviderAccounts instanceId={props.instanceId} integrationId={providerId} client={client()!}
initialConnections={provider().connections}
onAdd={provider().canConnect ? () => {
resetFlow(providerId)
queueMicrotask(() => document.querySelector<HTMLElement>(".providers-connect-panel")?.scrollIntoView({ block: "nearest" }))
} : undefined}
location={currentCatalogLocation()} disabled={stage() !== "idle"} onChanged={refreshProviderData} />
</Show>
</article>
)}</For>
)}}</For>
</div>
</>
}>

View file

@ -25,6 +25,7 @@ import type {
PluginControlLocation,
PluginControlsSnapshot,
ProviderUsageResponse,
ProviderAccountsSnapshot,
ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
@ -482,6 +483,14 @@ export const serverApi = {
)
},
getProviderAccounts(instanceId: string, integrationID: string, directory: string): Promise<ProviderAccountsSnapshot> {
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/provider-accounts/${encodeURIComponent(integrationID)}?${new URLSearchParams({ directory })}`)
},
setProviderAccountSelection(instanceId: string, integrationID: string, directory: string, enabled: boolean): Promise<ProviderAccountsSnapshot> {
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/provider-accounts/${encodeURIComponent(integrationID)}`, {
method: "PUT", body: JSON.stringify({ directory, enabled }),
})
},
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
},

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "Aktives Konto",
"settings.accounts.title": "Konten",
"settings.accounts.add": "Konto hinzufügen",
"settings.accounts.rename": "Konto umbenennen",
"settings.accounts.remove": "Konto entfernen",
"settings.accounts.global": "Konten werden von diesem OpenCode-Dienst global geteilt.",
"settings.accounts.active": "Aktiv",
"settings.accounts.label": "Kontobezeichnung",
"settings.accounts.activate": "Konto verwenden",
"settings.accounts.error": "Kontovorgang fehlgeschlagen. Aktualisieren Sie den aktuellen Zustand.",
"settings.accounts.autoSelect": "Konto automatisch auswählen",
"settings.accounts.autoSelectHint": "Vor dem nächsten Senden bei 100 % Codex-Nutzung ein nachweislich verfügbares OAuth-Konto wählen. Die Auswahl gilt serviceweit; fehlgeschlagene Anfragen werden nicht wiederholt.",
"settings.providers.models.title": "Modelle",
"settings.websearch.title": "Websuche",
"settings.websearch.description": "Wählen Sie den Suchanbieter, mit dem Agenten im Web recherchieren. Änderungen werden automatisch gespeichert.",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "Active account",
"settings.accounts.title": "Accounts",
"settings.accounts.add": "Add account",
"settings.accounts.rename": "Rename account",
"settings.accounts.remove": "Remove account",
"settings.accounts.global": "Accounts are shared globally by this OpenCode service.",
"settings.accounts.active": "Active",
"settings.accounts.label": "Account label",
"settings.accounts.activate": "Use account",
"settings.accounts.error": "Account operation failed. Refresh to reconcile the current state.",
"settings.accounts.autoSelect": "Auto-select account",
"settings.accounts.autoSelectHint": "Before the next send, at 100% Codex usage, select a verified available OAuth account. Selection is service-wide; failed requests are not replayed.",
"settings.providers.models.title": "Models",
"settings.websearch.title": "Web search",
"settings.websearch.description": "Choose the search provider agents use to find information on the web. Changes are saved automatically.",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "Cuenta activa",
"settings.accounts.title": "Cuentas",
"settings.accounts.add": "Añadir cuenta",
"settings.accounts.rename": "Renombrar cuenta",
"settings.accounts.remove": "Eliminar cuenta",
"settings.accounts.global": "Este servicio OpenCode comparte las cuentas globalmente.",
"settings.accounts.active": "Activa",
"settings.accounts.label": "Nombre de cuenta",
"settings.accounts.activate": "Usar cuenta",
"settings.accounts.error": "La operación falló. Actualice para consultar el estado actual.",
"settings.accounts.autoSelect": "Seleccionar cuenta automáticamente",
"settings.accounts.autoSelectHint": "Antes del próximo envío, al llegar al 100 % de uso de Codex, seleccionar una cuenta OAuth con disponibilidad verificada. La selección se aplica a todo el servicio; las solicitudes fallidas no se repiten.",
"settings.providers.models.title": "Modelos",
"settings.websearch.title": "Búsqueda web",
"settings.websearch.description": "Elija el proveedor que usan los agentes para buscar información en la web. Los cambios se guardan automáticamente.",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "Compte actif",
"settings.accounts.title": "Comptes",
"settings.accounts.add": "Ajouter un compte",
"settings.accounts.rename": "Renommer le compte",
"settings.accounts.remove": "Supprimer le compte",
"settings.accounts.global": "Les comptes sont partagés globalement par ce service OpenCode.",
"settings.accounts.active": "Actif",
"settings.accounts.label": "Nom du compte",
"settings.accounts.activate": "Utiliser ce compte",
"settings.accounts.error": "Échec de l’opération. Actualisez pour consulter l’état courant.",
"settings.accounts.autoSelect": "Sélection automatique du compte",
"settings.accounts.autoSelectHint": "Avant le prochain envoi, à 100 % d’utilisation Codex, sélectionner un compte OAuth dont la disponibilité est vérifiée. Le choix s’applique à tout le service ; les requêtes échouées ne sont pas rejouées.",
"settings.providers.models.title": "Modèles",
"settings.websearch.title": "Recherche web",
"settings.websearch.description": "Choisissez le moteur utilisé par les agents pour rechercher des informations sur le web. Les changements sont enregistrés automatiquement.",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "חשבון פעיל",
"settings.accounts.title": "חשבונות",
"settings.accounts.add": "הוספת חשבון",
"settings.accounts.rename": "שינוי שם החשבון",
"settings.accounts.remove": "הסרת חשבון",
"settings.accounts.global": "החשבונות משותפים לכל שירות OpenCode זה.",
"settings.accounts.active": "פעיל",
"settings.accounts.label": "שם החשבון",
"settings.accounts.activate": "השתמש בחשבון",
"settings.accounts.error": "פעולת החשבון נכשלה. רענן לצפייה במצב הנוכחי.",
"settings.accounts.autoSelect": "בחירת חשבון אוטומטית",
"settings.accounts.autoSelectHint": "לפני השליחה הבאה, בשימוש Codex של 100%, לבחור חשבון OAuth שזמינותו אומתה. הבחירה חלה על כל השירות; בקשות שנכשלו לא נשלחות שוב.",
"settings.providers.models.title": "מודלים",
"settings.websearch.title": "חיפוש ברשת",
"settings.websearch.description": "בחרו את ספק החיפוש שבו הסוכנים משתמשים למציאת מידע ברשת. השינויים נשמרים אוטומטית.",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "有効なアカウント",
"settings.accounts.title": "アカウント",
"settings.accounts.add": "アカウントを追加",
"settings.accounts.rename": "アカウント名を変更",
"settings.accounts.remove": "アカウントを削除",
"settings.accounts.global": "アカウントはこのOpenCodeサービス全体で共有されます。",
"settings.accounts.active": "有効",
"settings.accounts.label": "アカウント名",
"settings.accounts.activate": "このアカウントを使用",
"settings.accounts.error": "操作に失敗しました。更新して現在の状態を確認してください。",
"settings.accounts.autoSelect": "アカウントを自動選択",
"settings.accounts.autoSelectHint": "次の送信前にCodex使用率が100%なら、利用可能と確認できたOAuthアカウントを選択します。選択はサービス全体に適用され、失敗したリクエストは再送しません。",
"settings.providers.models.title": "モデル",
"settings.websearch.title": "ウェブ検索",
"settings.websearch.description": "エージェントがウェブで情報を検索する際のプロバイダーを選択します。変更は自動的に保存されます。",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "सक्रिय खाता",
"settings.accounts.title": "खाताहरू",
"settings.accounts.add": "खाता थप्नुहोस्",
"settings.accounts.rename": "खाताको नाम बदल्नुहोस्",
"settings.accounts.remove": "खाता हटाउनुहोस्",
"settings.accounts.global": "खाताहरू यस OpenCode सेवाभर साझा हुन्छन्।",
"settings.accounts.active": "सक्रिय",
"settings.accounts.label": "खाताको नाम",
"settings.accounts.activate": "यो खाता प्रयोग गर्नुहोस्",
"settings.accounts.error": "कार्य असफल भयो। हालको अवस्था हेर्न रिफ्रेस गर्नुहोस्।",
"settings.accounts.autoSelect": "खाता स्वतः चयन गर्नुहोस्",
"settings.accounts.autoSelectHint": "अर्को पठाउनु अघि Codex प्रयोग १००% भएमा उपलब्धता प्रमाणित भएको OAuth खाता छान्नुहोस्। चयन पूरै सेवामा लागू हुन्छ; असफल अनुरोधहरू फेरि पठाइँदैनन्।",
"settings.providers.models.title": "मोडेलहरू",
"settings.websearch.title": "वेब खोज",
"settings.websearch.description": "एजेन्टहरूले वेबमा जानकारी खोज्न प्रयोग गर्ने प्रदायक छान्नुहोस्। परिवर्तनहरू स्वतः सुरक्षित हुन्छन्।",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "Активный аккаунт",
"settings.accounts.title": "Аккаунты",
"settings.accounts.add": "Добавить аккаунт",
"settings.accounts.rename": "Переименовать аккаунт",
"settings.accounts.remove": "Удалить аккаунт",
"settings.accounts.global": "Аккаунты общие для всего сервиса OpenCode.",
"settings.accounts.active": "Активен",
"settings.accounts.label": "Имя аккаунта",
"settings.accounts.activate": "Использовать аккаунт",
"settings.accounts.error": "Операция не удалась. Обновите текущее состояние.",
"settings.accounts.autoSelect": "Автоматически выбирать аккаунт",
"settings.accounts.autoSelectHint": "Перед следующей отправкой при 100% использования Codex выбрать OAuth-аккаунт с проверенной доступностью. Выбор действует для всего сервиса; неудачные запросы не повторяются.",
"settings.providers.models.title": "Модели",
"settings.websearch.title": "Поиск в интернете",
"settings.websearch.description": "Выберите поисковый сервис для агентов. Изменения сохраняются автоматически.",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "Etkin hesap",
"settings.accounts.title": "Hesaplar",
"settings.accounts.add": "Hesap ekle",
"settings.accounts.rename": "Hesabı yeniden adlandır",
"settings.accounts.remove": "Hesabı kaldır",
"settings.accounts.global": "Hesaplar bu OpenCode hizmetinde küresel olarak paylaşılır.",
"settings.accounts.active": "Etkin",
"settings.accounts.label": "Hesap adı",
"settings.accounts.activate": "Hesabı kullan",
"settings.accounts.error": "Hesap işlemi başarısız. Güncel durumu yenileyin.",
"settings.accounts.autoSelect": "Hesabı otomatik seç",
"settings.accounts.autoSelectHint": "Sonraki gönderimden önce Codex kullanımı %100 ise kullanılabilirliği doğrulanmış bir OAuth hesabı seç. Seçim hizmet genelinde geçerlidir; başarısız istekler yeniden gönderilmez.",
"settings.providers.models.title": "Modeller",
"settings.websearch.title": "Web araması",
"settings.websearch.description": "Ajanların web üzerinde bilgi aramak için kullanacağı sağlayıcıyı seçin. Değişiklikler otomatik kaydedilir.",

View file

@ -1,4 +1,16 @@
export const settingsMessages = {
"settings.accounts.current": "活动账户",
"settings.accounts.title": "账户",
"settings.accounts.add": "添加账户",
"settings.accounts.rename": "重命名账户",
"settings.accounts.remove": "移除账户",
"settings.accounts.global": "账户由此 OpenCode 服务全局共享。",
"settings.accounts.active": "已激活",
"settings.accounts.label": "账户名称",
"settings.accounts.activate": "使用账户",
"settings.accounts.error": "账户操作失败。请刷新以查看当前状态。",
"settings.accounts.autoSelect": "自动选择账户",
"settings.accounts.autoSelectHint": "下次发送前,若 Codex 使用率达到 100%,选择已验证可用的 OAuth 账户。选择对整个服务生效;不会重发失败的请求。",
"settings.providers.models.title": "模型",
"settings.websearch.title": "网页搜索",
"settings.websearch.description": "选择智能体在网上查找信息时使用的搜索服务。更改会自动保存。",

View file

@ -105,7 +105,7 @@ test("model picker delegates keyboard selection to its accessible Kobalte input"
assert.doesNotMatch(kobalteBase.slice(kobalteBase.indexOf("export interface ComboboxBaseOptions"), kobalteBase.indexOf("export interface ComboboxBaseRenderProps")), /inputValue\?:/)
assert.match(source, /const closePicker = \(\) => \{\s*setIsOpen\(false\)\s*restoreSelectedInput\(\)/)
assert.match(source, /if \(!next\) restoreSelectedInput\(\)/)
assert.match(source, /closePicker\(\)\s*setProvidersModalOpen\(true\)/)
assert.match(source, /closePicker\(\)\s*void openSettings\("providers"\)/)
const footer = source.slice(source.indexOf('<div class="selector-footer">'), source.indexOf("</Combobox.Content>"))
assert.doesNotMatch(footer, /toggleFavoritesOnly/)
assert.doesNotMatch(footer, /favoritesOnly\.showAll/)

View file

@ -67,6 +67,10 @@ export function createInstanceFetch(baseUrl: string, isForeground: () => boolean
const { tGlobal } = await import("./i18n")
throw new Error(tGlobal("envEditor.applyFailed"))
}
if (body?.error === "PROVIDER_ACCOUNT_SELECTION_FAILED") {
const { tGlobal } = await import("./i18n")
throw new Error(tGlobal("settings.accounts.error"))
}
}
return response
}

View file

@ -0,0 +1,64 @@
.provider-accounts {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 320px) auto;
gap: var(--space-sm);
align-items: center;
}
.provider-accounts p {
grid-column: 1 / -1;
color: var(--text-secondary);
margin-block: var(--space-sm);
}
.provider-account { display: contents; }
.provider-account-control { display: contents; }
.provider-account-control > .settings-toggle-title {
color: var(--text-secondary);
font-weight: var(--font-weight-normal);
}
.provider-account-control .selector-trigger { width: 100%; min-width: 0; }
.provider-account-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.provider-account-auto {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.provider-account-edit { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); width: 100%; grid-column: 1 / -1; }
.provider-account input { min-width: 0; flex: 1 1 10rem; }
.provider-accounts .selector-button { width: auto; }
.providers-accounts-list .providers-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
padding-block: var(--space-lg);
column-gap: var(--space-md);
row-gap: 0;
border-block-end: 1px solid var(--border-base);
container: provider-account / inline-size;
}
.providers-accounts-list .providers-card:last-child { border-block-end: 0; }
.providers-accounts-list .providers-card-title { font-weight: var(--font-weight-semibold); }
.providers-accounts-list .provider-accounts {
grid-column: 1 / -1;
margin-block-start: var(--space-md);
padding-block-start: var(--space-md);
border-block-start: 1px solid color-mix(in oklab, var(--border-base) 40%, transparent);
}
.providers-accounts-list .provider-model-card-actions { flex-wrap: wrap; }
.providers-connect-bar:has(> button:only-child) { display: flex; justify-content: end; }
@container provider-account (max-width: 520px) {
.provider-accounts { grid-template-columns: minmax(0, 1fr) auto; }
.provider-account-control > .settings-toggle-title { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
.provider-accounts { grid-template-columns: minmax(0, 1fr) auto; }
.provider-account-control > .settings-toggle-title { grid-column: 1 / -1; }
}

View file

@ -5,6 +5,7 @@
@import "./components/session-aside.css";
@import "./components/prompt-skills.css";
@import "./components/websearch-settings.css";
@import "./components/provider-accounts.css";
@import "./components/history-search.css";
@import "./components/native-titlebar.css";
@import "./components/badges.css";

View file

@ -35,6 +35,7 @@ const client: any = {
switchAgent: async () => {}, switchModel: async () => {}, prompt: native.session.prompt,
},
model: { default: async () => model },
skill: { list: async () => ({ data: [] }) },
message: { list: async () => ({ data: [], cursor: {} }) },
}
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)

View file

@ -0,0 +1,10 @@
.accounts-preview-root { height: 100dvh; padding-block-start: 42px; }
.accounts-preview-toolbar {
position: fixed; inset: 0 0 auto; height: 42px; z-index: 100;
display: flex; align-items: center; gap: var(--space-md); padding-inline: var(--space-md);
background: var(--surface-base); border-block-end: 1px solid var(--border-base);
color: var(--text-secondary); font-size: var(--font-size-xs); overflow-x: auto; white-space: nowrap;
}
.accounts-preview-toolbar > span { margin-inline-end: auto; }
.accounts-preview-toolbar a { color: var(--text-primary); text-decoration: underline; text-underline-offset: 3px; }
.accounts-preview-toolbar .selector-button { font-size: var(--font-size-xs); min-height: 26px; width: auto; flex: 0 0 auto; }

View file

@ -0,0 +1,101 @@
// Interactive design sandbox: synthetic labels and quotas, no live credentials.
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import { SettingsScreen } from "../../../src/components/settings-screen"
import { setActiveSettingsSection } from "../../../src/stores/settings-screen"
import { ConfigProvider } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { ThemeProvider } from "../../../src/lib/theme"
import { sdkManager } from "../../../src/lib/sdk-manager"
import { serverApi } from "../../../src/lib/api-client"
import { serverEvents } from "../../../src/lib/server-events"
import { applyUiSettings } from "./ui-settings"
import "../../../src/index.css"
import "./provider-accounts-preview.css"
const params = new URLSearchParams(location.search)
const single = params.has("single")
const [automatic, setAutomatic] = createSignal(params.has("auto"))
const [used, setUsed] = createSignal<Record<string, number>>({ personal: 76, work: 24, spare: 12 })
const [rows, setRows] = createSignal<any[]>([
{ type: "credential", id: "personal", label: "default", method: "oauth" },
...single ? [] : [
{ type: "credential", id: "work", label: "alex@studio.fr", method: "oauth" },
{ type: "credential", id: "spare", label: "Compte équipe", method: "oauth" },
],
])
const integrations: any[] = [
{ id: "anthropic", name: "Anthropic", connections: [{ type: "credential", id: "claude", label: "alex@exemple.fr", method: "oauth" }] },
{ id: "openrouter", name: "OpenRouter", connections: [] },
{ id: "exa", name: "Exa", connections: [{ type: "credential", id: "exa-key", label: "Clé API", method: "key" }] },
{ id: "tavily", name: "Tavily", connections: [] },
].map(item => ({ ...item, methods: [{ type: "key", label: "API key" }] }))
const catalog = () => [{ id: "openai", name: "OpenAI", methods: [{ type: "key", label: "API key" }], connections: rows() }, ...integrations]
const notify = () => (serverEvents as any).dispatch({ type: "instance.event", instanceId: "preview", event: { type: "credential.updated", data: {} } })
const mutate = async (action: string, input: any) => {
const integration = catalog().find(item => item.connections.some((row: any) => row.id === input.credentialID))
const next = integration!.connections.map((row: any) => ({ ...row }))
const found = next.find((row: any) => row.id === input.credentialID)!
if (action === "activate") next.splice(0, next.length, found, ...next.filter((row: any) => row !== found))
if (action === "rename") found.label = input.label
if (action === "remove") next.splice(next.indexOf(found), 1)
if (integration!.id === "openai") setRows(next)
else integrations.find(item => item.id === integration!.id).connections = next
notify()
}
const client: any = {
provider: { list: async () => ({ data: ["openai", "anthropic", "openrouter"].filter(id => catalog().find(item => item.id === id)!.connections.length > 0).map(id => ({ id, name: catalog().find(item => item.id === id)!.name, integrationID: id })) }) },
model: { list: async () => ({ data: [] }) },
websearch: { providers: async () => ({ data: [{ id: "exa", name: "Exa" }, { id: "tavily", name: "Tavily" }] }) },
integration: { list: async () => ({ data: structuredClone(catalog()) }), connect: { key: async (input: any) => {
const saved = { type: "credential", id: `demo-${Date.now()}`, label: "Clé API", method: "key" }
if (input.integrationID === "openai") setRows([saved, ...rows()])
else catalog().find(item => item.id === input.integrationID)!.connections.unshift(saved)
notify()
} } },
credential: { activate: (input: any) => mutate("activate", input), update: (input: any) => mutate("rename", input), remove: (input: any) => mutate("remove", input) },
}
;(sdkManager as any).clients.set("preview:/workspaces/preview/instance", client)
let global: string | false | null = "exa", project: string | false | null = null
serverApi.getWebSearchSettings = async () => ({ location: { directory: "/preview" }, effective: project ?? global,
scopes: [{ scope: "global", path: "/preview/global.jsonc", selection: global }, { scope: "project", path: "/preview/project.jsonc", selection: project }] })
serverApi.setWebSearchSettings = async (_id, input) => { if (input.scope === "global") global = input.provider; else project = input.provider }
const policyWrites: boolean[] = []
let failPolicy = false
serverApi.getProviderAccounts = async (_id, integrationID) => ({ supported: integrationID === "openai" && rows().length > 1,
enabled: automatic(), logins: integrationID === "openai" ? { personal: "alex@exemple.fr" } : {} })
serverApi.setProviderAccountSelection = async (_id, integrationID, directory, enabled) => {
policyWrites.push(enabled)
if (failPolicy) { failPolicy = false; throw new Error("Synthetic policy failure") }
setAutomatic(enabled); notify()
return serverApi.getProviderAccounts("preview", integrationID, directory)
}
await applyUiSettings({ locale: "fr", theme: params.has("light") ? "light" : "dark" })
setActiveSettingsSection("providers")
function Simulation() {
const exhaust = () => {
const current = rows()[0]
if (!current) return
const quotas = { ...used(), [current.id]: 100 }
setUsed(quotas)
if (!automatic()) return
const next = rows().find(row => Number.isFinite(quotas[row.id]) && quotas[row.id] < 100)
if (next) void mutate("activate", { credentialID: next.id })
}
return <>
<nav class="accounts-preview-toolbar" aria-label="Aperçus">
<span>Aperçu · données fictives</span>
<a href="/preview">Manuel</a><a href="/preview?auto">Auto</a><a href="/preview?single">Un compte</a>
<a href={params.has("light") ? "/preview" : "/preview?light"}>Clair / sombre</a>
<span aria-live="polite">Usage simulé : {used()[rows()[0]?.id] ?? "—"} %</span>
<button type="button" class="selector-button" onClick={exhaust}>Simuler 100 %</button>
</nav>
</>
}
render(() => <ConfigProvider><I18nProvider><ThemeProvider><div class="accounts-preview-root">
<SettingsScreen standalone providerContext={{ instanceId: "preview", location: { directory: "/preview" } }} />
<Simulation />
</div></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).accountsPreview = { rows, used, automatic, policyWrites, failPolicy: () => { failPolicy = true },
refresh: notify }

View file

@ -0,0 +1,47 @@
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import { ProviderAccounts } from "../../../src/components/provider-auth/provider-accounts"
import { ProviderManagerModal } from "../../../src/components/provider-auth/provider-manager-modal"
import { sdkManager } from "../../../src/lib/sdk-manager"
import { ConfigProvider } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { ThemeProvider } from "../../../src/lib/theme"
import { serverEvents } from "../../../src/lib/server-events"
import { applyUiSettings } from "./ui-settings"
import "../../../src/index.css"
const [directory, setDirectory] = createSignal("/a")
let connections: any[] = [{ type: "credential", id: "one", label: "First", method: "key" }, { type: "credential", id: "two", label: "Second", method: "oauth" }, { type: "env", name: "PROVIDER_KEY" }]
const writes: any[] = []
let fail = false, reads = 0
let deferParent = false, releaseParent: (() => void) | undefined
const mutate = async (action: string, input: any) => {
writes.push({ action, ...input })
if (fail) { fail = false; throw new Error("fixture failure") }
const found = connections.find(item => item.id === input.credentialID)
if (action === "activate") connections = [found, ...connections.filter(item => item !== found)]
if (action === "rename") found.label = input.label
if (action === "remove") connections = connections.filter(item => item !== found)
}
const client: any = { provider: { list: async () => {
if (deferParent) { deferParent = false; await new Promise<void>(resolve => { releaseParent = resolve }) }
return { data: [{ id: "provider", name: "Fixture provider", activation: "enabled", package: "fixture" }] }
} },
model: { list: async () => ({ data: [] }) },
websearch: { providers: async () => ({ data: [] }) },
integration: { list: async () => { reads++; return { data: [{ id: "provider", name: "Fixture provider", methods: [{ type: "key", label: "API key" }], connections: structuredClone(connections) }] } } },
credential: { activate: (input: any) => mutate("activate", input), update: (input: any) => mutate("rename", input), remove: (input: any) => mutate("remove", input) } }
await applyUiSettings({})
;(sdkManager as any).clients.set("accounts:/workspaces/accounts/instance", client)
render(() => <ConfigProvider><I18nProvider><ThemeProvider><main style={{ width: "min(700px, 100%)" }}>
{new URLSearchParams(location.search).has("parent")
? <ProviderManagerModal instanceId="accounts" location={{ directory: directory() }} embedded />
: <ProviderAccounts instanceId="accounts" integrationId="provider" client={client} location={{ directory: directory() }} />}
</main></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).fixture = { writes, reads: () => reads, setDirectory, fail: () => { fail = true },
deferParent: () => { deferParent = true }, parentPending: () => Boolean(releaseParent),
releaseParent: () => { releaseParent?.(); releaseParent = undefined },
switchExternally: () => {
connections = [connections[1], connections[0], ...connections.slice(2)]
;(serverEvents as any).dispatch({ type: "instance.event", instanceId: "accounts", event: { type: "credential.switched", data: {} } })
},
refresh: () => (serverEvents as any).dispatch({ type: "instance.event", instanceId: "accounts", event: { type: "credential.updated", data: {} } }) }

View file

@ -6,6 +6,9 @@ import { updatePreferences, type UiSettings } from "../../../src/stores/preferen
// specific preference keep the "ui" config bucket in memory instead.
export async function applyUiSettings(settings: Partial<UiSettings>): Promise<void> {
let bucket: { settings?: Record<string, unknown> } = {}
const fetchConfigOwner = serverApi.fetchConfigOwner
serverApi.fetchConfigOwner = async <T extends Record<string, unknown>>(owner: string): Promise<T> =>
owner === "ui" ? bucket as T : fetchConfigOwner<T>(owner)
serverApi.patchConfigOwner = async <T extends Record<string, unknown>>(_owner: string, patch: unknown): Promise<T> => {
const next = (patch ?? {}) as { settings?: Record<string, unknown> }
bucket = { settings: { ...bucket.settings, ...next.settings } }

View file

@ -71,7 +71,8 @@ for (const host of ["electron", "tauri"] as const) {
proxyPath: `/workspaces/project-${i}/instance`, binaryId: "fixture", binaryLabel: "fixture",
createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(),
})) : path === "/api/storage/config/ui" ? { settings: { locale: "en" } }
: path.includes("/instance/api/") ? [] : {}
: path.includes("/instance/api/") ? { location: { directory: "D:/current-project" }, data: [] }
: path.endsWith("/websearch-settings") ? { location: { directory: "D:/current-project" }, scopes: [], effective: null } : {}
await route.fulfill({ json: body })
})
try {
@ -97,13 +98,18 @@ for (const host of ["electron", "tauri"] as const) {
assert.deepEqual(workspaceRequests, [], "reconnect/events must not hydrate unrelated projects")
await Promise.all([
page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/integration")),
page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/model")),
page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/websearch-settings")),
page.getByRole("button", { name: "Fournisseurs", exact: true }).click(),
])
assert.equal(workspaceRequests.length, 3)
assert.deepEqual(new Set(workspaceRequests.map(request => new URL(request).pathname)), new Set([
"/workspaces/current-project/instance/api/provider", "/workspaces/current-project/instance/api/model",
"/workspaces/current-project/instance/api/integration", "/workspaces/current-project/instance/api/websearch/provider",
"/api/workspaces/current-project/websearch-settings",
]))
for (const request of workspaceRequests) {
const target = new URL(request)
assert.match(target.pathname, /^\/workspaces\/current-project\/instance\/api\/(provider|model|integration)$/)
assert.equal(target.searchParams.get("location[directory]"), "D:/current-project")
assert.equal(target.searchParams.get(target.pathname.startsWith("/api/") ? "directory" : "location[directory]"), "D:/current-project")
}
assert.deepEqual(errors, [])
} catch (error) {

View file

@ -0,0 +1,186 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { chromium, type Browser } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
let server: ViteDevServer, browser: Browser, url: string
let cacheDir: string
before(async () => {
// Independent invocations must not replace each other's optimized modules.
cacheDir = await mkdtemp(join(tmpdir(), "opencode", "provider-accounts-vite-"))
server = await createServer({ configFile: false, cacheDir, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
plugins: [solid(), { name: "accounts-fixture", configureServer(s) {
for (const [route, fixture] of [["/fixture", "provider-accounts"], ["/design", "provider-accounts-preview"]]) {
s.middlewares.use(route, async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await s.transformIndexHtml(route, `<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/${fixture}.tsx"></script></body></html>`))
})
}
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, server: { host: "127.0.0.1", port: 0, hmr: false, watch: null } })
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await server?.close(); if (cacheDir) await rm(cacheDir, { recursive: true, force: true }) })
test("native account dropdown activates by identity and preserves failed rename drafts", async () => {
const page = await browser.newPage({ viewport: { width: 380, height: 900 }, locale: "en-US" })
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.goto(url)
const select = page.getByRole("combobox", { name: "Active account" })
await select.waitFor()
assert.equal(await page.locator(".provider-accounts summary").count(), 0)
assert.equal(await select.inputValue(), "credential:one")
assert.equal(await select.locator('option[value="env:PROVIDER_KEY"]').evaluate(option => (option as HTMLOptionElement).disabled), true)
await select.selectOption("credential:two")
await page.waitForFunction(() => document.querySelector<HTMLSelectElement>(".provider-accounts select")?.value === "credential:two")
assert.equal(await page.locator('[data-account-id="credential:one"]').count(), 0)
await select.selectOption("credential:one")
const first = page.locator('[data-account-id="credential:one"]')
await first.getByRole("button", { name: "Rename account", exact: true }).click()
await first.getByLabel("Account label").fill("New name")
await page.evaluate(() => (window as any).fixture.refresh())
assert.equal(await first.getByLabel("Account label").inputValue(), "New name")
await page.evaluate(() => (window as any).fixture.fail())
await first.getByRole("button", { name: "Save", exact: true }).click()
await page.getByRole("alert").waitFor()
assert.equal(await first.getByLabel("Account label").inputValue(), "New name")
assert.equal((await page.evaluate(() => (window as any).fixture.writes)).length, 3)
await first.getByRole("button", { name: "Save", exact: true }).click()
await page.waitForFunction(() => document.querySelector<HTMLSelectElement>(".provider-accounts select")?.selectedOptions[0].textContent === "New name")
assert.equal(await page.locator("main").evaluate(el => el.scrollWidth <= el.clientWidth), true)
await first.getByRole("button", { name: "Remove account", exact: true }).click()
await first.waitFor({ state: "detached" })
assert.equal(await select.inputValue(), "credential:two")
assert.deepEqual(await page.evaluate(() => (window as any).fixture.writes), [
{ action: "activate", credentialID: "two" }, { action: "activate", credentialID: "one" },
{ action: "rename", credentialID: "one", label: "New name" }, { action: "rename", credentialID: "one", label: "New name" },
{ action: "remove", credentialID: "one" },
])
assert.deepEqual(errors, [])
} finally { await page.close() }
})
test("provider manager keeps per-account drafts across dropdown and external switches", async () => {
const page = await browser.newPage({ viewport: { width: 380, height: 900 }, locale: "en-US" })
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.goto(`${url}?parent`)
const select = page.getByRole("combobox", { name: "Active account" })
await select.waitFor()
await page.getByRole("button", { name: "Add account", exact: true }).click()
await page.locator(".providers-connect-panel").getByRole("button", { name: "Close", exact: true }).click()
const first = page.locator('[data-account-id="credential:one"]'), second = page.locator('[data-account-id="credential:two"]')
await first.getByRole("button", { name: "Rename account", exact: true }).click()
await first.getByLabel("Account label").fill("Uncommitted first")
await select.selectOption("credential:two")
await second.getByRole("button", { name: "Rename account", exact: true }).click()
await second.getByLabel("Account label").fill("Uncommitted second")
await page.evaluate(() => (window as any).fixture.switchExternally())
await first.getByLabel("Account label").waitFor()
assert.equal(await first.getByLabel("Account label").inputValue(), "Uncommitted first")
await page.evaluate(() => (window as any).fixture.switchExternally())
await second.getByLabel("Account label").waitFor()
assert.equal(await second.getByLabel("Account label").inputValue(), "Uncommitted second")
await select.selectOption("credential:one")
await first.getByLabel("Account label").fill("Saved label")
await page.evaluate(() => (window as any).fixture.deferParent())
await first.getByRole("button", { name: "Save", exact: true }).click()
await page.waitForFunction(() => (window as any).fixture.parentPending())
await first.getByLabel("Account label").fill("Newer draft")
await page.evaluate(() => (window as any).fixture.releaseParent())
await page.waitForFunction(() => !document.querySelector(".providers-loading-row"))
assert.equal(await first.getByLabel("Account label").inputValue(), "Newer draft")
assert.equal(await first.getByRole("button", { name: "Save", exact: true }).isEnabled(), true)
assert.equal(await page.locator("main").evaluate(el => el.scrollWidth <= el.clientWidth), true)
assert.deepEqual(errors, [])
} finally { await page.close() }
})
test("dropdown design simulates opt-in rotation and hides the option for one account", async () => {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
const design = url.replace("/fixture", "/design")
const card = page.locator(".providers-card").filter({ has: page.getByRole("heading", { name: "OpenAI", exact: true }) })
const select = card.getByRole("combobox", { name: "Compte actif" })
try {
await page.goto(design)
await select.waitFor()
assert.equal(await select.inputValue(), "credential:personal")
assert.equal(await select.locator('option[value="credential:personal"]').textContent(), "alex@exemple.fr")
assert.equal(await select.locator('option[value="credential:spare"]').textContent(), "Compte équipe")
assert.equal(await select.locator("option").count(), 3)
await page.mouse.move(0, 0)
await select.evaluate(el => (el as HTMLElement).blur())
assert.equal(await card.locator(".provider-accounts").evaluate(el => getComputedStyle(el).borderTopWidth), "1px")
assert.equal(await card.locator(".provider-account-actions").evaluate(el => {
for (let parent: Element | null = el; parent; parent = parent.parentElement) {
if (Number(getComputedStyle(parent).opacity) < 1) return false
}
return true
}), true)
for (const width of [1280, 640, 390]) {
await page.setViewportSize({ width, height: 900 })
const positions = await card.evaluate(el => {
const name = el.querySelector("h4")!.getBoundingClientRect()
const models = el.querySelector(".provider-model-card-actions")!.getBoundingClientRect()
const select = el.querySelector("select")!.getBoundingClientRect()
const actions = el.querySelector(".provider-account-actions")!.getBoundingClientRect()
return { headerAligned: Math.abs((name.top + name.bottom) / 2 - (models.top + models.bottom) / 2) < 2,
actionsAligned: Math.abs((select.top + select.bottom) / 2 - (actions.top + actions.bottom) / 2) < 2,
separated: select.top > models.bottom, fits: el.scrollWidth <= el.clientWidth }
})
assert.deepEqual(positions, { headerAligned: true, actionsAligned: true, separated: true, fits: true })
}
await page.getByRole("button", { name: "Simuler 100 %" }).click()
assert.equal(await page.evaluate(() => (window as any).accountsPreview.used().personal), 100)
assert.equal(await select.inputValue(), "credential:personal")
await page.goto(`${design}?auto`)
await page.getByRole("checkbox", { name: "Sélection automatique du compte" }).waitFor()
for (const id of ["work", "spare", "spare"]) {
await page.getByRole("button", { name: "Simuler 100 %" }).click()
await page.waitForFunction(expected => (window as any).accountsPreview.rows()[0].id === expected, id)
await page.waitForFunction(expected => document.querySelector<HTMLSelectElement>(".providers-card .provider-accounts select")?.value === `credential:${expected}`, id)
}
await page.goto(`${design}?single`)
await select.waitFor()
assert.equal(await select.locator("option").count(), 1)
assert.equal(await page.getByRole("checkbox", { name: "Sélection automatique du compte" }).count(), 0)
await page.setViewportSize({ width: 390, height: 800 })
assert.equal(await page.locator(".providers-accounts-list").evaluate(el => el.scrollWidth <= el.clientWidth), true)
assert.deepEqual(errors, [])
} finally { await page.close() }
})
test("production auto-selection control persists opt-in and never replays a failed setting write", async () => {
const page = await browser.newPage({ viewport: { width: 390, height: 900 } })
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.goto(url.replace("/fixture", "/design"))
const toggle = page.getByRole("checkbox", { name: "Sélection automatique du compte" })
await toggle.waitFor()
assert.equal(await toggle.isChecked(), false)
await toggle.check()
await page.waitForFunction(() => (window as any).accountsPreview.automatic() === true)
await page.evaluate(() => (window as any).accountsPreview.refresh())
await page.waitForFunction(() => !document.querySelector<HTMLInputElement>(".provider-account-auto input")?.disabled)
assert.equal(await toggle.isChecked(), true)
await page.evaluate(() => (window as any).accountsPreview.failPolicy())
await toggle.click() // Failed persistence deliberately restores the checked state.
await page.getByRole("alert").waitFor()
assert.equal(await toggle.isChecked(), true)
assert.deepEqual(await page.evaluate(() => (window as any).accountsPreview.policyWrites), [true, false])
assert.equal(await page.locator(".providers-accounts-list").evaluate(el => el.scrollWidth <= el.clientWidth), true)
} finally { await page.close() }
})

View file

@ -86,7 +86,8 @@ test("Providers separates Models and Web search with shared row and input stylin
assert.equal(await page.getByRole("heading", { name: "Configured Providers", exact: true }).count(), 0)
await page.locator(".providers-card-title").filter({ hasText: "OpenAI" }).waitFor()
assert.equal(await page.getByRole("region", { name: "Models", exact: true }).getByRole("heading", { name: "Alpha", exact: true }).count(), 0)
assert.equal(await page.evaluate(() => (window as any).fixture.integrationLists()), 2)
// Models + search catalogs, then the two now-visible account lists.
assert.equal(await page.evaluate(() => (window as any).fixture.integrationLists()), 4)
const search = page.getByRole("region", { name: "Web search", exact: true })
await search.getByRole("button", { name: "Connect", exact: true }).click()
await page.getByLabel("API key", { exact: true }).waitFor()

View file

@ -0,0 +1,60 @@
import assert from "node:assert/strict"
import { withProductRuntime } from "./native-product-fixture.mjs"
import { ProviderAccountsService } from "../packages/server/src/provider-accounts/service.ts"
import { SettingsService } from "../packages/server/src/settings/service.ts"
import { resolveConfigLocation } from "../packages/server/src/config/location.ts"
import path from "node:path"
import { readFile } from "node:fs/promises"
// Run with node --import tsx and an absolute isolated 2.0.20+ CLI path.
// Native selection/storage are real; tokens and the quota transport are synthetic.
await withProductRuntime(process.argv[2], async () => {}, async ({ client, root, requests }) => {
const location = { directory: root }
await client.location.get({ location })
const first = await client.credential.create({ integrationID: "openai", label: "default", value: {
type: "oauth", methodID: "chatgpt-browser", access: "fixture-access-one", refresh: "fixture-refresh-one",
expires: Date.now() + 3600000, metadata: { accountID: "fixture-one", email: "one@example.com" },
}, activate: true })
const second = await client.credential.create({ integrationID: "openai", label: "Team alias", value: {
type: "oauth", methodID: "chatgpt-browser", access: "fixture-access-two", refresh: "fixture-refresh-two",
expires: Date.now() + 3600000, metadata: { accountID: "fixture-two", email: "two@example.com" },
}, activate: false })
const session = await client.session.create({ location, model: { providerID: "openai", id: "gpt-5" } })
const configLocation = resolveConfigLocation(path.join(root, "codenomad"))
const logger = { debug() {}, info() {}, warn() {}, error() {}, child() { return this } }
const settings = new SettingsService(configLocation, undefined, logger)
const quotas = { "fixture-one": 100, "fixture-two": 25 }
const calls = []
const service = new ProviderAccountsService(settings, async (_access, accountID) => {
calls.push(accountID)
return { windows: { "5h": { usedPercent: quotas[accountID], remainingPercent: 100 - quotas[accountID],
windowSeconds: 18000, resetAt: Date.now() + 3600000 } } }
})
const connection = { client, assertCurrent: () => {} }
const signal = () => AbortSignal.timeout(15000)
const selected = async () => (await client.integration.get({ integrationID: "openai", location })).data.connections[0]
const snapshot = await service.snapshot(connection, root, "openai", signal())
assert.deepEqual(snapshot.logins, { [first.id]: "one@example.com" })
assert.equal(snapshot.supported, true)
await service.beforeSend(connection, session.id, signal(), async directory => directory === root)
assert.equal((await selected()).id, first.id)
assert.equal(calls.length, 0)
service.setEnabled(true)
await Promise.all([1, 2].map(() => service.beforeSend(connection, session.id, signal(), async directory => directory === root)))
assert.equal((await selected()).id, second.id)
assert.equal((await selected()).label, "Team alias")
quotas["fixture-two"] = 100
await service.beforeSend(connection, session.id, signal(), async () => true)
assert.equal((await selected()).id, second.id, "All exhausted retains native selection")
service.setEnabled(false)
assert.equal(new ProviderAccountsService(new SettingsService(configLocation, undefined, logger)).enabled(), false,
"Policy survives a fresh YAML read")
const persisted = await readFile(configLocation.configYamlPath, "utf8")
assert.ok(persisted.includes("providerAccounts"))
assert.equal(persisted.includes("fixture-access"), false)
const exported = await client.credential.list()
assert.equal(exported.find(entry => entry.id === first.id).label, "default", "Derived login never renames a credential")
assert.equal(JSON.stringify(snapshot).includes("fixture-access"), false)
assert.equal(requests.length, 0, "No model generation or remote quota request")
console.log("PASS native Codex account selection, concurrent admission, alias/login boundary, exhaustion and opt-in persistence")
})

View file

@ -0,0 +1,32 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { withProductRuntime } from "./native-product-fixture.mjs"
await withProductRuntime(process.argv[2], async () => {}, async ({ client, root }) => {
const location = { directory: root }
await client.location.get({ location })
let integration
const deadline = Date.now() + 30_000
do {
integration = (await client.integration.list({ location })).data.find(item => item.id === "openai" && item.methods.some(method => method.type === "key"))
if (!integration) await delay(100)
} while (!integration && Date.now() < deadline)
assert(integration, "Fixture needs native OpenAI key registration; no remote validation or generation is requested")
const list = async () => (await client.integration.list({ location })).data.find(item => item.id === integration.id).connections.filter(item => item.type === "credential")
await client.integration.connect.key({ integrationID: integration.id, key: "fixture-only-one", label: "First", location })
await client.integration.connect.key({ integrationID: integration.id, key: "fixture-only-two", label: "Second", location })
let connections = await list()
assert.equal(connections.length, 2)
assert.equal(connections[0].label, "Second", "Native first connection is active")
const first = connections.find(item => item.label === "First")
await client.credential.activate({ credentialID: first.id })
assert.equal((await list())[0].id, first.id)
await client.credential.update({ credentialID: first.id, label: "Renamed" })
assert.equal((await list())[0].label, "Renamed")
await client.credential.remove({ credentialID: first.id })
connections = await list()
assert.equal(connections.length, 1)
assert.equal(connections[0].label, "Second", "Removing one account retains the other")
assert.equal(JSON.stringify(connections).includes("fixture-only"), false)
console.log("PASS native multiple accounts, active ordering, rename, single-account removal and secret-free catalog")
})