From ea69870796fad188862ad0e709e8cfe1d7d25108 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 18:48:56 +0000 Subject: [PATCH 1/5] feat(auth): list and disconnect omp's stored provider accounts (#197) Use omp's get_logout_accounts/logout RPC to list every stored credential for a subscription provider, mark the active one, and remove a single account. omp without those commands keeps the read-only usage-based list. Rename the connected provider's sign-in button to Add account. --- AGENTS.md | 2 +- CHANGELOG.md | 1 + app/api/auth/accounts/[provider]/route.ts | 74 ++++++++++++++ app/api/auth/logout/[provider]/route.ts | 23 ----- components/ModelsConfig.tsx | 43 ++------ components/ProviderAccounts.tsx | 114 +++++++++++++++++++--- lib/i18n/locales/en.json | 14 ++- lib/i18n/locales/ja.json | 14 ++- lib/i18n/locales/zh-CN.json | 14 ++- 9 files changed, 215 insertions(+), 84 deletions(-) create mode 100644 app/api/auth/accounts/[provider]/route.ts delete mode 100644 app/api/auth/logout/[provider]/route.ts diff --git a/AGENTS.md b/AGENTS.md index c0e46e705..c66fa590d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -495,7 +495,7 @@ during the wait. - Notifications in `AppShell` and settings cards in `SettingsConfig` present the update notification alongside copyable terminal update commands. ### Auth and model config -- Auth flows go through RPC commands (`get_login_providers`, `login`) against the omp child process; credentials live in omp's `agent.db` (SQLite) which omp-web never touches directly. +- Auth flows go through RPC commands (`get_login_providers`, `login`, `get_logout_accounts`, `logout`) against the omp child process; credentials live in omp's `agent.db` (SQLite) which omp-web never touches directly. `/api/auth/accounts/[provider]` lists stored accounts and removes one by `credentialId`; omp without those commands reports `supported: false` and the UI falls back to the read-only `omp usage` account list. - The Models panel reads and writes `models.yml` in the omp agent directory (`~/.omp/agent/models.yml`, `.yaml` fallback). - API-key status endpoints must never return the raw key. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1739611..f7307d133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Added +- List every account omp stores for a subscription provider in Settings → API Keys & Providers, marking the one omp is using, and disconnect a single account with its **Disconnect** button; the other accounts stay connected and omp keeps rotating between them. The sign-in button of a connected provider now reads **Add account**: signing in with another account adds it, and signing in with a connected account refreshes it. Requires an omp release containing [can1357/oh-my-pi#14588](https://github.com/can1357/oh-my-pi/pull/14588); older omp keeps the read-only account list from `omp usage` and explains how to disconnect from a terminal. - Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114). - Show conflicting skill names and deduplicated installations when an OMP session starts, with resolution details, a **×** button that dismisses the notice for the session until the diagnostic report changes, and a persisted **Turn off** action that disables startup notices. Inspect a running session manually in **Settings → Extensions & Tools → Skills**, even when notices are off; inspection never resumes a stopped session. Requires an OMP runtime with the skill-diagnostics RPC commands; older runtimes report live diagnostics as unavailable. - Ask a side question with `/btw `: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model. diff --git a/app/api/auth/accounts/[provider]/route.ts b/app/api/auth/accounts/[provider]/route.ts new file mode 100644 index 000000000..a37d9c46f --- /dev/null +++ b/app/api/auth/accounts/[provider]/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; +import { invalidateModelsCache } from "@/lib/models-cache"; +import { runUtilityCommand } from "@/lib/omp/rpc-utility"; + +export const dynamic = "force-dynamic"; + +/** Entry of omp's `get_logout_accounts` response. Labels never carry a raw key. */ +export interface OmpProviderAccount { + credentialId: number; + label: string; + detail: string; + type: "api_key" | "oauth"; + active: boolean; +} + +// omp releases older than the get_logout_accounts/logout RPC answer "Unknown command". +const UNSUPPORTED = "Unknown command"; + +function isAccount(value: unknown): value is OmpProviderAccount { + if (typeof value !== "object" || value === null) return false; + const a = value as Record; + return Number.isInteger(a.credentialId) && typeof a.label === "string" && typeof a.detail === "string" + && (a.type === "api_key" || a.type === "oauth") && typeof a.active === "boolean"; +} + +/** Stored credentials omp holds for one provider, active first. */ +export async function GET(_req: Request, { params }: { params: Promise<{ provider: string }> }) { + const { provider } = await params; + try { + const { accounts } = await runUtilityCommand<{ accounts?: unknown }>( + { type: "get_logout_accounts", providerId: provider }, + 30_000, + ); + const list = Array.isArray(accounts) ? accounts.filter(isAccount) : []; + return NextResponse.json({ + supported: true, + accounts: list.map(({ credentialId, label, detail, type, active }) => ({ credentialId, label, detail, type, active })), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(UNSUPPORTED)) return NextResponse.json({ supported: false, accounts: [] }); + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +/** Remove one stored credential (`?credentialId=`); omp picks the next account. */ +export async function DELETE(req: Request, { params }: { params: Promise<{ provider: string }> }) { + const raw = new URL(req.url).searchParams.get("credentialId") ?? ""; + if (!/^\d+$/.test(raw)) { + return NextResponse.json({ error: "credentialId must be an integer" }, { status: 400 }); + } + const credentialId = Number(raw); + const { provider } = await params; + try { + const { remainingSource } = await runUtilityCommand<{ remainingSource?: string }>( + { type: "logout", providerId: provider, credentialId }, + 60_000, + ); + invalidateModelsCache(); + return NextResponse.json({ remainingSource: remainingSource ?? null }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(UNSUPPORTED)) { + return NextResponse.json( + { + error: "omp-web cannot disconnect accounts with this omp version. Update omp, or run `omp` in a terminal and use /logout.", + code: "logout_unsupported", + }, + { status: 501 }, + ); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/auth/logout/[provider]/route.ts b/app/api/auth/logout/[provider]/route.ts deleted file mode 100644 index 318e33d1c..000000000 --- a/app/api/auth/logout/[provider]/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from "next/server"; - -export const dynamic = "force-dynamic"; - -// omp has no logout RPC command (modes/rpc/rpc-types.ts) and no non-interactive -// CLI logout subcommand (cli-commands.ts) — credential removal only exists as -// the interactive /logout selector in omp's own TUI, backed by the SQLite -// credential store omp-web must never write. -export async function POST( - _req: Request, - { params }: { params: Promise<{ provider: string }> } -) { - const { provider } = await params; - return NextResponse.json( - { - error: - `omp-web cannot disconnect "${provider}": omp exposes no logout command outside its own UI. ` + - "Run `omp` in a terminal and use /logout to remove the credential.", - code: "logout_unsupported", - }, - { status: 501 }, - ); -} diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx index 17f2e44bd..368da5feb 100644 --- a/components/ModelsConfig.tsx +++ b/components/ModelsConfig.tsx @@ -784,6 +784,8 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre const { t, tn } = useI18n(); const [loginState, setLoginState] = useState({ phase: "idle" }); const [inputValue, setInputValue] = useState(""); + // Bumped on login so the account list remounts and shows the new account. + const [accountsVersion, setAccountsVersion] = useState(0); const eventSourceRef = useRef(null); const inputRef = useRef(null); @@ -847,6 +849,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre } else if (data.type === "success") { es.close(); setLoginState({ phase: "success" }); + setAccountsVersion((v) => v + 1); onRefresh(); } else if (data.type === "error") { es.close(); @@ -862,22 +865,6 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre }; }, [provider.id, onRefresh, t]); - const handleLogout = useCallback(async () => { - try { - const res = await fetch(`/api/auth/logout/${encodeURIComponent(provider.id)}`, { method: "POST" }); - const d = await res.json().catch(() => ({})) as { error?: string; code?: string }; - if (!res.ok || d.error) { - // omp has no logout RPC/CLI surface; the route returns 501 with guidance. - setLoginState({ phase: "error", message: d.error || d.code ? formatApiError(d) : `HTTP ${res.status}` }); - return; - } - setLoginState({ phase: "idle" }); - onRefresh(); - } catch (e) { - setLoginState({ phase: "error", message: e instanceof Error ? e.message : String(e) }); - } - }, [provider.id, onRefresh]); - const submitCode = useCallback(async (token: string, code: string) => { if (!code.trim()) return; setLoginState({ phase: "progress", message: t("modelsConfig.verifying") }); @@ -1020,7 +1007,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre )} - + {/* Actions */}
@@ -1032,22 +1019,12 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre {t("modelsConfig.cancel")} ) : ( - <> - - {provider.loggedIn && ( - - )} - + )}
diff --git a/components/ProviderAccounts.tsx b/components/ProviderAccounts.tsx index a63a4bcbd..4b866f1eb 100644 --- a/components/ProviderAccounts.tsx +++ b/components/ProviderAccounts.tsx @@ -1,48 +1,132 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { LogOut } from "lucide-react"; import { useI18n } from "@/lib/i18n"; +import { formatApiError } from "@/lib/i18n/api-error"; import { summarizeProviderAccounts } from "@/lib/provider-accounts"; import { useProviderUsage } from "./AppShell-provider-usage"; +import { ConfirmDialog } from "./ui/field"; + +interface StoredAccount { + credentialId: number; + label: string; + detail: string; + type: "api_key" | "oauth"; + active: boolean; +} /** - * Accounts omp manages for one provider (read-only). omp stays responsible for - * credentials, account selection and rotation; this only surfaces what it - * reports, so multi-account setups are discoverable. + * Accounts omp stores for one provider. omp stays responsible for credentials, + * account selection and rotation; this lists its stored credentials + * (`get_logout_accounts`) and removes one through its `logout` RPC. omp + * releases without that RPC fall back to the read-only list `omp usage` reports. */ -export function ProviderAccounts({ providerId, enabled }: { providerId: string; enabled: boolean }) { +export function ProviderAccounts({ providerId, enabled, onChanged }: { providerId: string; enabled: boolean; onChanged: () => void }) { const { t } = useI18n(); - const { snapshot, loading } = useProviderUsage(enabled ? `provider=${encodeURIComponent(providerId)}` : null); - const accounts = useMemo( + const { snapshot, loading: usageLoading } = useProviderUsage(enabled ? `provider=${encodeURIComponent(providerId)}` : null); + const usageAccounts = useMemo( () => (snapshot ? summarizeProviderAccounts(snapshot.reports, providerId) : []), [snapshot, providerId], ); + const [stored, setStored] = useState<{ supported: boolean; accounts: StoredAccount[] } | null>(null); + const [version, setVersion] = useState(0); + const [confirm, setConfirm] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) return; + const controller = new AbortController(); + fetch(`/api/auth/accounts/${encodeURIComponent(providerId)}`, { signal: controller.signal }) + .then((res) => res.json() as Promise<{ supported?: boolean; accounts?: StoredAccount[]; error?: string }>) + // A failed listing degrades to the usage-based list rather than hiding accounts. + .then((d) => { + if (d.error) setError(d.error); + setStored({ supported: d.supported === true, accounts: d.accounts ?? [] }); + }) + .catch(() => { if (!controller.signal.aborted) setStored({ supported: false, accounts: [] }); }); + return () => controller.abort(); + }, [enabled, providerId, version]); - if (!enabled || (!loading && accounts.length === 0)) return null; + const disconnect = async (account: StoredAccount) => { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/auth/accounts/${encodeURIComponent(providerId)}?credentialId=${account.credentialId}`, { method: "DELETE" }); + const d = await res.json().catch(() => ({})) as { error?: string; code?: string }; + if (!res.ok) throw new Error(d.error || d.code ? formatApiError(d) : `HTTP ${res.status}`); + setConfirm(null); + setVersion((v) => v + 1); + onChanged(); + } catch (e) { + setConfirm(null); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + if (!enabled) return null; + const loading = stored === null || (!stored.supported && usageLoading); + const rows = stored?.supported + // omp suffixes the org (`email (org)`); usage reports the bare email. + ? stored.accounts.map((a) => ({ key: String(a.credentialId), label: a.label, title: a.detail, plan: usageAccounts.find((u) => u.label && (a.label === u.label || a.label.startsWith(`${u.label} (`)))?.plan, account: a })) + : usageAccounts.map((u) => ({ key: u.key, label: u.label ?? t("modelsConfig.accountNumber", { index: u.index ?? 1 }), title: u.label, plan: u.plan, account: null })); + if (!loading && rows.length === 0 && !error) return null; return (
- {accounts.length > 1 ? t("modelsConfig.accountsCount", { count: accounts.length }) : t("modelsConfig.accounts")} + {rows.length > 1 ? t("modelsConfig.accountsCount", { count: rows.length }) : t("modelsConfig.accounts")}
{loading ? (
{t("modelsConfig.accountsLoading")}
) : (
    - {accounts.map((account) => ( + {rows.map((row) => (
  • - {account.label ?? t("modelsConfig.accountNumber", { index: account.index ?? 1 })} - {account.plan && {account.plan}} + {row.label} + {row.plan && {row.plan}} + {row.account?.type === "api_key" && {t("modelsConfig.accountApiKey")}} + {row.account?.active && {t("modelsConfig.accountActive")}} + {row.account && ( + + )}
  • ))}
)} - {accounts.length > 1 && ( + {error &&

{error}

} + {rows.length > 1 && (

{t("modelsConfig.accountsRotationHint")}

)} + {stored && !stored.supported && !error && ( +

{t("modelsConfig.accountsLogoutUnsupported")}

+ )} + { if (!open && !busy) setConfirm(null); }} + title={t("modelsConfig.disconnectAccountTitle")} + description={confirm ? t("modelsConfig.disconnectAccountBody", { label: confirm.label }) : undefined} + confirmLabel={t("modelsConfig.disconnect")} + cancelLabel={t("modelsConfig.cancel")} + danger + busy={busy} + onConfirm={() => { if (confirm) void disconnect(confirm); }} + />
); } diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 074c3e095..e53f20b42 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -655,7 +655,7 @@ "errors.login_no_pending": "No pending login for this token", "errors.login_token_code_required": "A login token and authorization code are required", "errors.login_token_mismatch": "The login token does not match the provider", - "errors.logout_unsupported": "omp-web cannot disconnect this provider: omp exposes no logout command outside its own UI. Run `omp` in a terminal and use /logout to remove the credential.", + "errors.logout_unsupported": "omp-web cannot disconnect accounts with this omp version. Update omp, or run `omp` in a terminal and use /logout.", "errors.model_id_required": "A model ID is required", "errors.model_required": "A model config is required", "errors.model_test_unresolved": "The model did not resolve — check the API key and provider config", @@ -864,7 +864,7 @@ "modelsConfig.addFromCatalog": "from catalog", "modelsConfig.addModel": "+ model", "modelsConfig.addProvider": "+ Add provider", - "modelsConfig.alreadyConnected": "Already connected. You can re-login or disconnect.", + "modelsConfig.alreadyConnected": "Connected. Signing in with another account adds it; signing in with a connected account refreshes it.", "modelsConfig.api": "API", "modelsConfig.apiKey": "API Key", "modelsConfig.apiKeyHint": "Prefix with `!` to run a shell command, or use an env var name", @@ -940,7 +940,7 @@ "modelsConfig.providerNotConfigured": "{name} is not configured.", "modelsConfig.reasoningThinking": "Reasoning / thinking", "modelsConfig.reload": "Reload", - "modelsConfig.relogin": "Re-login", + "modelsConfig.addAccount": "Add account", "modelsConfig.remove": "Remove", "modelsConfig.rename": "Rename", "modelsConfig.resetToAuto": "reset to auto", @@ -1641,5 +1641,11 @@ "modelsConfig.accountsCount": "{count} accounts", "modelsConfig.accountsLoading": "Loading accounts…", "modelsConfig.accountNumber": "Account {index}", - "modelsConfig.accountsRotationHint": "omp manages account selection and rotation." + "modelsConfig.accountsRotationHint": "omp manages account selection and rotation.", + "modelsConfig.accountActive": "active", + "modelsConfig.accountApiKey": "API key", + "modelsConfig.accountsLogoutUnsupported": "This omp version cannot disconnect accounts from omp-web. Update omp, or run `omp` in a terminal and use /logout.", + "modelsConfig.disconnectAccount": "Disconnect {label}", + "modelsConfig.disconnectAccountTitle": "Disconnect account?", + "modelsConfig.disconnectAccountBody": "omp will delete the stored credential for {label}. Other accounts for this provider stay connected." } diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index 8408e7047..99b51cfc2 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -642,7 +642,7 @@ "errors.login_no_pending": "このトークンに対応する保留中のログインがありません", "errors.login_token_code_required": "ログイントークンと認可コードが必要です", "errors.login_token_mismatch": "ログイントークンがプロバイダーと一致しません", - "errors.logout_unsupported": "omp-web ではこのプロバイダーを切断できません。omp は自身の UI 以外にログアウトコマンドを提供していません。ターミナルで `omp` を実行し、/logout で資格情報を削除してください。", + "errors.logout_unsupported": "この omp のバージョンでは omp-web からアカウントを切断できません。omp を更新するか、ターミナルで `omp` を実行して /logout を使ってください。", "errors.model_id_required": "モデルIDが必要です", "errors.model_required": "モデル設定が必要です", "errors.model_test_unresolved": "モデルを解決できませんでした。APIキーとプロバイダー設定を確認してください", @@ -830,7 +830,7 @@ "modelsConfig.addFromCatalog": "カタログから", "modelsConfig.addModel": "+ モデル", "modelsConfig.addProvider": "+ プロバイダーを追加", - "modelsConfig.alreadyConnected": "既に接続されています。再ログインまたは切断ができます。", + "modelsConfig.alreadyConnected": "接続済みです。別のアカウントでサインインすると追加され、接続済みのアカウントでサインインすると更新されます。", "modelsConfig.api": "API", "modelsConfig.apiKey": "APIキー", "modelsConfig.apiKeyHint": "先頭に `!` を付けるとシェルコマンドを実行します。環境変数名も指定できます", @@ -906,7 +906,7 @@ "modelsConfig.providerNotConfigured": "{name} は設定されていません。", "modelsConfig.reasoningThinking": "推論 / 思考", "modelsConfig.reload": "再読み込み", - "modelsConfig.relogin": "再ログイン", + "modelsConfig.addAccount": "アカウントを追加", "modelsConfig.remove": "削除", "modelsConfig.rename": "名前を変更", "modelsConfig.resetToAuto": "自動にリセット", @@ -1604,5 +1604,11 @@ "modelsConfig.accountsCount": "{count} 件のアカウント", "modelsConfig.accountsLoading": "アカウントを読み込み中…", "modelsConfig.accountNumber": "アカウント {index}", - "modelsConfig.accountsRotationHint": "アカウントの選択とローテーションは omp が管理します。" + "modelsConfig.accountsRotationHint": "アカウントの選択とローテーションは omp が管理します。", + "modelsConfig.accountActive": "使用中", + "modelsConfig.accountApiKey": "APIキー", + "modelsConfig.accountsLogoutUnsupported": "この omp のバージョンでは omp-web からアカウントを切断できません。omp を更新するか、ターミナルで `omp` を実行して /logout を使ってください。", + "modelsConfig.disconnectAccount": "{label} を切断", + "modelsConfig.disconnectAccountTitle": "アカウントを切断しますか?", + "modelsConfig.disconnectAccountBody": "omp は {label} の保存済み資格情報を削除します。このプロバイダーの他のアカウントは接続されたままです。" } diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index b6de27b5f..3dd29d242 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -642,7 +642,7 @@ "errors.login_no_pending": "此 token 没有待处理的登录", "errors.login_token_code_required": "需要提供登录 token 和授权码", "errors.login_token_mismatch": "登录 token 与提供商不匹配", - "errors.logout_unsupported": "omp-web 无法断开此提供商的连接:omp 未在其自身 UI 之外提供登出命令。请在终端运行 `omp` 并使用 /logout 移除凭据。", + "errors.logout_unsupported": "此版本的 omp 无法从 omp-web 断开账户。请更新 omp,或在终端运行 `omp` 并使用 /logout。", "errors.model_id_required": "需要提供模型 ID", "errors.model_required": "需要提供模型配置", "errors.model_test_unresolved": "模型未能解析——请检查 API 密钥和提供商配置", @@ -830,7 +830,7 @@ "modelsConfig.addFromCatalog": "从目录添加", "modelsConfig.addModel": "+ 模型", "modelsConfig.addProvider": "+ 添加提供商", - "modelsConfig.alreadyConnected": "已连接。你可以重新登录或断开连接。", + "modelsConfig.alreadyConnected": "已连接。使用其他账户登录会添加该账户;使用已连接的账户登录会刷新其凭据。", "modelsConfig.api": "API", "modelsConfig.apiKey": "API 密钥", "modelsConfig.apiKeyHint": "以 `!` 开头可运行 shell 命令,也可使用环境变量名", @@ -906,7 +906,7 @@ "modelsConfig.providerNotConfigured": "{name} 未配置。", "modelsConfig.reasoningThinking": "推理 / 思考", "modelsConfig.reload": "重新加载", - "modelsConfig.relogin": "重新登录", + "modelsConfig.addAccount": "添加账户", "modelsConfig.remove": "移除", "modelsConfig.rename": "重命名", "modelsConfig.resetToAuto": "重置为自动", @@ -1604,5 +1604,11 @@ "modelsConfig.accountsCount": "{count} 个账户", "modelsConfig.accountsLoading": "正在加载账户…", "modelsConfig.accountNumber": "账户 {index}", - "modelsConfig.accountsRotationHint": "账户的选择和轮换由 omp 管理。" + "modelsConfig.accountsRotationHint": "账户的选择和轮换由 omp 管理。", + "modelsConfig.accountActive": "使用中", + "modelsConfig.accountApiKey": "API 密钥", + "modelsConfig.accountsLogoutUnsupported": "此版本的 omp 无法从 omp-web 断开账户。请更新 omp,或在终端运行 `omp` 并使用 /logout。", + "modelsConfig.disconnectAccount": "断开 {label}", + "modelsConfig.disconnectAccountTitle": "断开账户?", + "modelsConfig.disconnectAccountBody": "omp 将删除 {label} 的已存储凭据。此提供商的其他账户保持连接。" } From 3e587e3df9b9d77ce929f05a9f906842b4372cba Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 18:52:54 +0000 Subject: [PATCH 2/5] fix(auth): address review on provider accounts Drop a disconnected row immediately, report listing failures instead of claiming an old omp, reject unsafe credential ids, and cover the route. --- app/api/auth/accounts/[provider]/route.ts | 13 ++-- components/ProviderAccounts.tsx | 10 ++- lib/auth-accounts-route.test.mjs | 80 +++++++++++++++++++++++ lib/i18n/locales/en.json | 1 - lib/i18n/locales/ja.json | 1 - lib/i18n/locales/zh-CN.json | 1 - 6 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 lib/auth-accounts-route.test.mjs diff --git a/app/api/auth/accounts/[provider]/route.ts b/app/api/auth/accounts/[provider]/route.ts index a37d9c46f..47215464f 100644 --- a/app/api/auth/accounts/[provider]/route.ts +++ b/app/api/auth/accounts/[provider]/route.ts @@ -5,7 +5,7 @@ import { runUtilityCommand } from "@/lib/omp/rpc-utility"; export const dynamic = "force-dynamic"; /** Entry of omp's `get_logout_accounts` response. Labels never carry a raw key. */ -export interface OmpProviderAccount { +interface OmpProviderAccount { credentialId: number; label: string; detail: string; @@ -46,18 +46,15 @@ export async function GET(_req: Request, { params }: { params: Promise<{ provide /** Remove one stored credential (`?credentialId=`); omp picks the next account. */ export async function DELETE(req: Request, { params }: { params: Promise<{ provider: string }> }) { const raw = new URL(req.url).searchParams.get("credentialId") ?? ""; - if (!/^\d+$/.test(raw)) { + const credentialId = Number(raw); + if (!/^\d+$/.test(raw) || !Number.isSafeInteger(credentialId)) { return NextResponse.json({ error: "credentialId must be an integer" }, { status: 400 }); } - const credentialId = Number(raw); const { provider } = await params; try { - const { remainingSource } = await runUtilityCommand<{ remainingSource?: string }>( - { type: "logout", providerId: provider, credentialId }, - 60_000, - ); + await runUtilityCommand({ type: "logout", providerId: provider, credentialId }, 60_000); invalidateModelsCache(); - return NextResponse.json({ remainingSource: remainingSource ?? null }); + return NextResponse.json({ ok: true }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes(UNSUPPORTED)) { diff --git a/components/ProviderAccounts.tsx b/components/ProviderAccounts.tsx index 4b866f1eb..94fb086ad 100644 --- a/components/ProviderAccounts.tsx +++ b/components/ProviderAccounts.tsx @@ -45,7 +45,11 @@ export function ProviderAccounts({ providerId, enabled, onChanged }: { providerI if (d.error) setError(d.error); setStored({ supported: d.supported === true, accounts: d.accounts ?? [] }); }) - .catch(() => { if (!controller.signal.aborted) setStored({ supported: false, accounts: [] }); }); + .catch((e: unknown) => { + if (controller.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + setStored({ supported: false, accounts: [] }); + }); return () => controller.abort(); }, [enabled, providerId, version]); @@ -57,6 +61,8 @@ export function ProviderAccounts({ providerId, enabled, onChanged }: { providerI const d = await res.json().catch(() => ({})) as { error?: string; code?: string }; if (!res.ok) throw new Error(d.error || d.code ? formatApiError(d) : `HTTP ${res.status}`); setConfirm(null); + // Drop the row now; the refetch only reconciles which account is active. + setStored((s) => s && { ...s, accounts: s.accounts.filter((a) => a.credentialId !== account.credentialId) }); setVersion((v) => v + 1); onChanged(); } catch (e) { @@ -114,7 +120,7 @@ export function ProviderAccounts({ providerId, enabled, onChanged }: { providerI

{t("modelsConfig.accountsRotationHint")}

)} {stored && !stored.supported && !error && ( -

{t("modelsConfig.accountsLogoutUnsupported")}

+

{t("errors.logout_unsupported")}

)} { + sent.length = 0; + globalThis.__ompUtilityRpcState = { + proc: { + isAlive: true, + sendCommand: async (command) => { sent.push(command); return reply(command); }, + dispose: async () => {}, + }, + idleTimer: null, + queue: Promise.resolve(), + }; +}); + +const params = { params: Promise.resolve({ provider: "anthropic" }) }; +const url = "http://localhost/api/auth/accounts/anthropic"; +const unknownCommand = (command) => { throw new Error(`Unknown command: ${command.type}`); }; + +test("GET lists only well-formed accounts and only their known fields", async () => { + reply = () => ({ + accounts: [ + { credentialId: 7, provider: "anthropic", label: "a@x.test", detail: "oauth #7", type: "oauth", active: true, token: "secret" }, + { credentialId: "8", label: "bad id", detail: "", type: "oauth", active: false }, + ], + }); + const body = await (await route.GET(new Request(url), params)).json(); + assert.deepEqual(sent, [{ type: "get_logout_accounts", providerId: "anthropic" }]); + assert.deepEqual(body, { + supported: true, + accounts: [{ credentialId: 7, label: "a@x.test", detail: "oauth #7", type: "oauth", active: true }], + }); +}); + +test("older omp without the RPC is reported as unsupported, not as an error", async () => { + reply = unknownCommand; + const list = await route.GET(new Request(url), params); + assert.equal(list.status, 200); + assert.deepEqual(await list.json(), { supported: false, accounts: [] }); + + const del = await route.DELETE(new Request(`${url}?credentialId=7`, { method: "DELETE" }), params); + assert.equal(del.status, 501); + assert.equal((await del.json()).code, "logout_unsupported"); +}); + +test("DELETE removes exactly the requested credential", async () => { + reply = () => ({}); + const res = await route.DELETE(new Request(`${url}?credentialId=7`, { method: "DELETE" }), params); + assert.equal(res.status, 200); + assert.deepEqual(sent, [{ type: "logout", providerId: "anthropic", credentialId: 7 }]); +}); + +test("DELETE rejects ids omp could misread without contacting it", async () => { + reply = () => ({}); + for (const id of ["", "abc", "-1", "1.5", "9007199254740993"]) { + const res = await route.DELETE(new Request(`${url}?credentialId=${encodeURIComponent(id)}`, { method: "DELETE" }), params); + assert.equal(res.status, 400, `credentialId=${JSON.stringify(id)}`); + } + assert.deepEqual(sent, []); +}); + +test("other omp failures surface as server errors", async () => { + reply = () => { throw new Error("Credential 7 is not stored for anthropic"); }; + const res = await route.DELETE(new Request(`${url}?credentialId=7`, { method: "DELETE" }), params); + assert.equal(res.status, 500); + assert.match((await res.json()).error, /not stored/); +}); diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index e53f20b42..0570b9c7f 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -1644,7 +1644,6 @@ "modelsConfig.accountsRotationHint": "omp manages account selection and rotation.", "modelsConfig.accountActive": "active", "modelsConfig.accountApiKey": "API key", - "modelsConfig.accountsLogoutUnsupported": "This omp version cannot disconnect accounts from omp-web. Update omp, or run `omp` in a terminal and use /logout.", "modelsConfig.disconnectAccount": "Disconnect {label}", "modelsConfig.disconnectAccountTitle": "Disconnect account?", "modelsConfig.disconnectAccountBody": "omp will delete the stored credential for {label}. Other accounts for this provider stay connected." diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index 99b51cfc2..48ae6c141 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -1607,7 +1607,6 @@ "modelsConfig.accountsRotationHint": "アカウントの選択とローテーションは omp が管理します。", "modelsConfig.accountActive": "使用中", "modelsConfig.accountApiKey": "APIキー", - "modelsConfig.accountsLogoutUnsupported": "この omp のバージョンでは omp-web からアカウントを切断できません。omp を更新するか、ターミナルで `omp` を実行して /logout を使ってください。", "modelsConfig.disconnectAccount": "{label} を切断", "modelsConfig.disconnectAccountTitle": "アカウントを切断しますか?", "modelsConfig.disconnectAccountBody": "omp は {label} の保存済み資格情報を削除します。このプロバイダーの他のアカウントは接続されたままです。" diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 3dd29d242..d69267dd5 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -1607,7 +1607,6 @@ "modelsConfig.accountsRotationHint": "账户的选择和轮换由 omp 管理。", "modelsConfig.accountActive": "使用中", "modelsConfig.accountApiKey": "API 密钥", - "modelsConfig.accountsLogoutUnsupported": "此版本的 omp 无法从 omp-web 断开账户。请更新 omp,或在终端运行 `omp` 并使用 /logout。", "modelsConfig.disconnectAccount": "断开 {label}", "modelsConfig.disconnectAccountTitle": "断开账户?", "modelsConfig.disconnectAccountBody": "omp 将删除 {label} 的已存储凭据。此提供商的其他账户保持连接。" From c1e3aa566af5813e2247d78a42e0147fe9940a5e Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 19:32:34 +0000 Subject: [PATCH 3/5] fix(auth): drop the session-relative active-account badge omp computes 'active' against the settings utility process's throwaway session, so it does not say which account any chat uses. --- CHANGELOG.md | 2 +- app/api/auth/accounts/[provider]/route.ts | 9 +++++---- components/ProviderAccounts.tsx | 4 +--- lib/auth-accounts-route.test.mjs | 2 +- lib/i18n/locales/en.json | 1 - lib/i18n/locales/ja.json | 1 - lib/i18n/locales/zh-CN.json | 1 - 7 files changed, 8 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7307d133..daa7b5e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Added -- List every account omp stores for a subscription provider in Settings → API Keys & Providers, marking the one omp is using, and disconnect a single account with its **Disconnect** button; the other accounts stay connected and omp keeps rotating between them. The sign-in button of a connected provider now reads **Add account**: signing in with another account adds it, and signing in with a connected account refreshes it. Requires an omp release containing [can1357/oh-my-pi#14588](https://github.com/can1357/oh-my-pi/pull/14588); older omp keeps the read-only account list from `omp usage` and explains how to disconnect from a terminal. +- List every account omp stores for a subscription provider in Settings → API Keys & Providers and disconnect a single account with its **Disconnect** button; the other accounts stay connected and omp keeps choosing and rotating between them. The sign-in button of a connected provider now reads **Add account**: signing in with another account adds it, and signing in with a connected account refreshes it. Requires an omp release containing [can1357/oh-my-pi#14588](https://github.com/can1357/oh-my-pi/pull/14588); older omp keeps the read-only account list from `omp usage` and explains how to disconnect from a terminal. - Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114). - Show conflicting skill names and deduplicated installations when an OMP session starts, with resolution details, a **×** button that dismisses the notice for the session until the diagnostic report changes, and a persisted **Turn off** action that disables startup notices. Inspect a running session manually in **Settings → Extensions & Tools → Skills**, even when notices are off; inspection never resumes a stopped session. Requires an OMP runtime with the skill-diagnostics RPC commands; older runtimes report live diagnostics as unavailable. - Ask a side question with `/btw `: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model. diff --git a/app/api/auth/accounts/[provider]/route.ts b/app/api/auth/accounts/[provider]/route.ts index 47215464f..c297aa8a0 100644 --- a/app/api/auth/accounts/[provider]/route.ts +++ b/app/api/auth/accounts/[provider]/route.ts @@ -10,7 +10,6 @@ interface OmpProviderAccount { label: string; detail: string; type: "api_key" | "oauth"; - active: boolean; } // omp releases older than the get_logout_accounts/logout RPC answer "Unknown command". @@ -20,10 +19,12 @@ function isAccount(value: unknown): value is OmpProviderAccount { if (typeof value !== "object" || value === null) return false; const a = value as Record; return Number.isInteger(a.credentialId) && typeof a.label === "string" && typeof a.detail === "string" - && (a.type === "api_key" || a.type === "oauth") && typeof a.active === "boolean"; + && (a.type === "api_key" || a.type === "oauth"); } -/** Stored credentials omp holds for one provider, active first. */ +// omp's `active` flag is relative to the utility process's own throwaway +// session, not to any chat, so it is deliberately not passed on. +/** Stored credentials omp holds for one provider. */ export async function GET(_req: Request, { params }: { params: Promise<{ provider: string }> }) { const { provider } = await params; try { @@ -34,7 +35,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ provide const list = Array.isArray(accounts) ? accounts.filter(isAccount) : []; return NextResponse.json({ supported: true, - accounts: list.map(({ credentialId, label, detail, type, active }) => ({ credentialId, label, detail, type, active })), + accounts: list.map(({ credentialId, label, detail, type }) => ({ credentialId, label, detail, type })), }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/components/ProviderAccounts.tsx b/components/ProviderAccounts.tsx index 94fb086ad..fbbb34745 100644 --- a/components/ProviderAccounts.tsx +++ b/components/ProviderAccounts.tsx @@ -13,7 +13,6 @@ interface StoredAccount { label: string; detail: string; type: "api_key" | "oauth"; - active: boolean; } /** @@ -61,7 +60,7 @@ export function ProviderAccounts({ providerId, enabled, onChanged }: { providerI const d = await res.json().catch(() => ({})) as { error?: string; code?: string }; if (!res.ok) throw new Error(d.error || d.code ? formatApiError(d) : `HTTP ${res.status}`); setConfirm(null); - // Drop the row now; the refetch only reconciles which account is active. + // Drop the row now; the refetch reconciles with omp's store. setStored((s) => s && { ...s, accounts: s.accounts.filter((a) => a.credentialId !== account.credentialId) }); setVersion((v) => v + 1); onChanged(); @@ -98,7 +97,6 @@ export function ProviderAccounts({ providerId, enabled, onChanged }: { providerI {row.label} {row.plan && {row.plan}} {row.account?.type === "api_key" && {t("modelsConfig.accountApiKey")}} - {row.account?.active && {t("modelsConfig.accountActive")}} {row.account && (