mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
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:
parent
d8f9118853
commit
394482f97f
44 changed files with 1704 additions and 125 deletions
|
|
@ -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.
|
||||
|
|
|
|||
93
dev-docs/PROVIDER_ACCOUNTS_UX_PLAN.md
Normal file
93
dev-docs/PROVIDER_ACCOUNTS_UX_PLAN.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
166
packages/server/src/provider-accounts/service.test.ts
Normal file
166
packages/server/src/provider-accounts/service.test.ts
Normal 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() }
|
||||
})
|
||||
142
packages/server/src/provider-accounts/service.ts
Normal file
142
packages/server/src/provider-accounts/service.ts
Normal 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"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
})
|
||||
|
|
@ -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)\/[^/]+$/],
|
||||
|
|
|
|||
81
packages/server/src/server/routes/provider-accounts.test.ts
Normal file
81
packages/server/src/server/routes/provider-accounts.test.ts
Normal 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() }
|
||||
})
|
||||
65
packages/server/src/server/routes/provider-accounts.ts
Normal file
65
packages/server/src/server/routes/provider-accounts.ts
Normal 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) }
|
||||
},
|
||||
})
|
||||
}
|
||||
27
packages/server/src/usage/codex-credential.test.ts
Normal file
27
packages/server/src/usage/codex-credential.test.ts
Normal 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)
|
||||
})
|
||||
25
packages/server/src/usage/codex-credential.ts
Normal file
25
packages/server/src/usage/codex-credential.ts
Normal 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 }
|
||||
}
|
||||
20
packages/server/src/usage/codex-selection-quota.test.ts
Normal file
20
packages/server/src/usage/codex-selection-quota.test.ts
Normal 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)
|
||||
})
|
||||
30
packages/server/src/usage/codex-selection-quota.ts
Normal file
30
packages/server/src/usage/codex-selection-quota.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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(() => {})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
170
packages/ui/src/components/provider-auth/provider-accounts.tsx
Normal file
170
packages/ui/src/components/provider-auth/provider-accounts.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
}>
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "בחרו את ספק החיפוש שבו הסוכנים משתמשים למציאת מידע ברשת. השינויים נשמרים אוטומטית.",
|
||||
|
|
|
|||
|
|
@ -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": "エージェントがウェブで情報を検索する際のプロバイダーを選択します。変更は自動的に保存されます。",
|
||||
|
|
|
|||
|
|
@ -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": "एजेन्टहरूले वेबमा जानकारी खोज्न प्रयोग गर्ने प्रदायक छान्नुहोस्। परिवर्तनहरू स्वतः सुरक्षित हुन्छन्।",
|
||||
|
|
|
|||
|
|
@ -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": "Выберите поисковый сервис для агентов. Изменения сохраняются автоматически.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "选择智能体在网上查找信息时使用的搜索服务。更改会自动保存。",
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
64
packages/ui/src/styles/components/provider-accounts.css
Normal file
64
packages/ui/src/styles/components/provider-accounts.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
101
packages/ui/tests/browser/fixtures/provider-accounts-preview.tsx
Normal file
101
packages/ui/tests/browser/fixtures/provider-accounts-preview.tsx
Normal 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 }
|
||||
47
packages/ui/tests/browser/fixtures/provider-accounts.tsx
Normal file
47
packages/ui/tests/browser/fixtures/provider-accounts.tsx
Normal 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: {} } }) }
|
||||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
186
packages/ui/tests/browser/provider-accounts.test.ts
Normal file
186
packages/ui/tests/browser/provider-accounts.test.ts
Normal 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() }
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
60
scripts/test-provider-account-selection-native.mjs
Normal file
60
scripts/test-provider-account-selection-native.mjs
Normal 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")
|
||||
})
|
||||
32
scripts/test-provider-accounts-native.mjs
Normal file
32
scripts/test-provider-accounts-native.mjs
Normal 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")
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue