Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 91 additions & 5 deletions gui/src/components/provider-workspace/ProviderAuthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export default function ProviderAuthPanel({
const [addingKey, setAddingKey] = useState(false);
const [newKey, setNewKey] = useState("");
const [keyBusy, setKeyBusy] = useState(false);
const [importingJson, setImportingJson] = useState(false);
const [importJsonText, setImportJsonText] = useState("");
const [importBusy, setImportBusy] = useState(false);
const [importResult, setImportResult] = useState<{ imported: number; failed: number } | null>(null);
const [reserveQuotaSlots, setReserveQuotaSlots] = useState(false);
const deviceCodeCopy = useCopyFeedback<string>();

Expand Down Expand Up @@ -278,11 +282,93 @@ export default function ProviderAuthPanel({
{accountLoadState === "ready" && loggedIn && accounts.length === 0 && (
<div className="pwi-auth-state pwi-auth-state--empty">{t("pws.noAccounts")}</div>
)}
{loggedIn && (
<button type="button" className="btn btn-ghost btn-sm" style={{ marginTop: 8 }}
onClick={() => void authHandlers.onLogin(item.name, true)} disabled={busy || Boolean(switchingAccountId)}>
{t("pws.addAccount")}
</button>
{importingJson ? (
<div className="pwi-auth-add-key" style={{ marginTop: 8, flexDirection: "column", alignItems: "stretch" }}>
<textarea
className="input"
rows={4}
value={importJsonText}
onChange={e => setImportJsonText(e.target.value)}
placeholder='[{"email":"user@gmail.com","refresh_token":"1//..."}]'
disabled={importBusy}
style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 12 }}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +287 to +295

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add an accessible label to the import textarea

The new JSON paste textarea has no associated label or aria-label; the placeholder is only an example and is not a durable accessible name, so screen-reader users cannot tell what the field is for once focused or populated. Add a localized visible label or aria-label for the Cockpit JSON input.

AGENTS.md reference: gui/AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

{importResult && (
<div className="muted faint" style={{ fontSize: 12, marginTop: 4 }}>
{t("pws.importResultSummary", { imported: importResult.imported, failed: importResult.failed })}
</div>
)}
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={importBusy || !importJsonText.trim()}
onClick={async () => {
const text = importJsonText.trim();
if (!text) return;
setImportBusy(true);
setImportResult(null);
try {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
void err;
setImportResult({ imported: 0, failed: 1 });
return;
Comment on lines +315 to +318

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Show why JSON imports fail

When the pasted JSON is malformed, this catch reduces the error to the same count-only summary used for any other failure, and the later non-OK response path does the same. In those cases users only see “0 imported, 1 failed” and cannot tell whether to fix JSON syntax, choose a different provider, or retry a server-side failure; store and render a localized validation/error message from JSON.parse or the response body instead.

AGENTS.md reference: gui/AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

}
const res = await fetch(`${apiBase}/api/oauth/accounts/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: item.name,
accounts: Array.isArray(parsed) ? parsed : (parsed as { accounts?: unknown })?.accounts ?? parsed,
}),
Comment on lines +320 to +326

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate Cockpit imports with the OAuth risk warning

When a user imports Google Antigravity JSON from the Accounts tab, this direct POST stores a high-risk OAuth refresh token without going through the existing dashboard warning path (requestLoginOAuth checks oauthTosRisk(provider), and google-antigravity is classified as high risk). Route this import action through the same acknowledgement modal before sending/saving the tokens so users cannot bypass the warning just by using the new import path.

Useful? React with 👍 / 👎.

});
if (res.ok) {
const data = (await res.json()) as { importedCount?: number; failedCount?: number };
setImportResult({ imported: data.importedCount ?? 0, failed: data.failedCount ?? 0 });
if ((data.importedCount ?? 0) > 0) {
setImportJsonText("");
void authHandlers.onRetryAccounts?.(item.name);
}
} else {
setImportResult({ imported: 0, failed: 1 });
}
} catch (err) {
void err;
setImportResult({ imported: 0, failed: 1 });
} finally {
setImportBusy(false);
}
}}
>
{importBusy ? t("pws.saving") : t("pws.importJson")}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => { setImportingJson(false); setImportJsonText(""); setImportResult(null); }}
>
{t("common.cancel")}
</button>
</div>
</div>
) : (
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
{loggedIn && (
<button type="button" className="btn btn-ghost btn-sm"
onClick={() => void authHandlers.onLogin(item.name, true)} disabled={busy || Boolean(switchingAccountId)}>
{t("pws.addAccount")}
</button>
)}
{item.name === "google-antigravity" && (
<button type="button" className="btn btn-ghost btn-sm"
onClick={() => setImportingJson(true)} disabled={busy || Boolean(switchingAccountId)}>
{t("pws.importJsonCockpit")}
</button>
)}
</div>
)}
</>
)}
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,9 @@ export const de: Record<TKey, string> = {
"pws.noModelMatch": "Keine Modelle entsprechen dem Filter.",
"pws.adapterBaseRequired": "Adapter und Basis-URL sind erforderlich.",
"pws.addAccount": "Konto hinzufügen",
"pws.importJson": "JSON importieren",
"pws.importJsonCockpit": "JSON importieren (Cockpit)",
"pws.importResultSummary": "{imported} Konto/en importiert, {failed} fehlgeschlagen.",
"pws.addKey": "API-Schlüssel hinzufügen",
"pws.apiKeys": "API-Schlüssel",
"pws.authMode": "Auth-Modus",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,9 @@ export const en = {
"pws.noModelMatch": "No models match the filter.",
"pws.adapterBaseRequired": "Adapter and base URL are required.",
"pws.addAccount": "Add account",
"pws.importJson": "Import JSON",
"pws.importJsonCockpit": "Import JSON (Cockpit)",
"pws.importResultSummary": "Imported {imported} account(s), {failed} failed.",
"pws.addKey": "Add API key",
"pws.apiKeys": "API Keys",
"pws.authMode": "Auth mode",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,9 @@ export const ja: Record<TKey, string> = {
"pws.noModelMatch": "フィルタに一致するモデルがありません。",
"pws.adapterBaseRequired": "アダプターとベース URL は必須です。",
"pws.addAccount": "アカウントを追加",
"pws.importJson": "JSON をインポート",
"pws.importJsonCockpit": "JSON をインポート (Cockpit)",
"pws.importResultSummary": "{imported} 件のアカウントをインポートしました(失敗: {failed} 件)。",
"pws.addKey": "API キーを追加",
"pws.apiKeys": "API キー",
"pws.authMode": "認証モード",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,9 @@ export const ko: Record<TKey, string> = {
"pws.noModelMatch": "필터와 일치하는 모델이 없습니다.",
"pws.adapterBaseRequired": "어댑터와 기본 URL은 필수입니다.",
"pws.addAccount": "계정 추가",
"pws.importJson": "JSON 가져오기",
"pws.importJsonCockpit": "JSON 가져오기 (Cockpit)",
"pws.importResultSummary": "{imported}개 계정을 가져왔습니다 ({failed}개 실패).",
"pws.addKey": "API 키 추가",
"pws.apiKeys": "API 키",
"pws.authMode": "인증 방식",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,9 @@ export const ru: Record<TKey, string> = {
"pws.noModelMatch": "Нет моделей, соответствующих фильтру.",
"pws.adapterBaseRequired": "Укажите адаптер и базовый URL.",
"pws.addAccount": "Добавить аккаунт",
"pws.importJson": "Импортировать JSON",
"pws.importJsonCockpit": "Импорт JSON (Cockpit)",
"pws.importResultSummary": "Импортировано аккаунтов: {imported}, ошибок: {failed}.",
"pws.addKey": "Добавить API-ключ",
"pws.apiKeys": "API-ключи",
"pws.authMode": "Режим аутентификации",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,9 @@ export const zh: Record<TKey, string> = {
"pws.noModelMatch": "没有匹配筛选的模型。",
"pws.adapterBaseRequired": "适配器和基本 URL 为必填项。",
"pws.addAccount": "添加账户",
"pws.importJson": "导入 JSON",
"pws.importJsonCockpit": "导入 JSON (Cockpit)",
"pws.importResultSummary": "已导入 {imported} 个账户,{failed} 个失败。",
"pws.addKey": "添加 API 密钥",
"pws.apiKeys": "API 密钥",
"pws.authMode": "认证方式",
Expand Down
63 changes: 62 additions & 1 deletion src/cli/account-extended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const EXTENDED_USAGE = `Usage:
ocx account alias <provider> <id|main> <display-name|-> [--json]
ocx account remove <provider> <id|main> --yes [--json]
ocx account clear-cooldown <provider> <id|main> [--json]
ocx account add-key <provider> [--label <label>] [--json]`;
ocx account add-key <provider> [--label <label>] [--json]
ocx account import <provider> <file-or-json> [--json]`;
const PIPE_GUIDANCE = `Pipe the API key on stdin, for example:
ocx account add-key <provider> <<< "$MY_KEY"
security find-generic-password -w <item> | ocx account add-key <provider>`;
Expand Down Expand Up @@ -348,3 +349,63 @@ export async function cmdAlias(args: string[], deps: AccountDeps): Promise<numbe
else console.log(alias ? `${name}: ${requestedId} is now “${alias}”` : `${name}: cleared alias for ${requestedId}`);
return 0;
}


export async function cmdImport(args: string[], deps: AccountDeps): Promise<number> {
const wantsJson = flag(args, "--json");
const name = args.shift();
const input = args.shift();
if (!name || !input || args.length) return usage();

const classified = configAndType(deps, name);
if ("error" in classified) return usage(`Error: ${classified.error}`);
if (classified.type !== "oauth") {
return usage("Error: account import only applies to OAuth providers (such as google-antigravity)");
}

let jsonText = input;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid accepting credential JSON on the command line

When input is not an existing file, the command parses that argv value as JSON; for the advertised <file-or-json> path this means a refresh_token can be supplied directly on the command line, exposing it via shell history and process listings. This is a new credential-handling path, so require a file or stdin (-) instead of accepting raw secret JSON in argv.

AGENTS.md reference: AGENTS.md:L218-L224

Useful? React with 👍 / 👎.

const { existsSync, readFileSync } = await import("node:fs");
if (existsSync(input)) {
try {
jsonText = readFileSync(input, "utf8");
} catch (err) {
return usage(`Error reading file "${input}": ${err instanceof Error ? err.message : String(err)}`);
}
}

let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch (err) {
return usage(`Error parsing JSON: ${err instanceof Error ? err.message : String(err)}`);
}

const baseUrl = await resolveBaseUrl(deps);
if (!baseUrl) return proxyUnreachable();

const response = await apiJson(deps, baseUrl, "POST", "/api/oauth/accounts/import", {
provider: name,
accounts: Array.isArray(parsed) ? parsed : (parsed as { accounts?: unknown })?.accounts ?? parsed,
});

if (response.status === 0) return proxyUnreachable();
if (response.status !== 200) return apiError(response.json, `failed to import accounts for ${name}`);

if (wantsJson) {
console.log(JSON.stringify(response.json, null, 2));
} else {
const imp = response.json.importedCount ?? 0;
const fail = response.json.failedCount ?? 0;
console.log(`${name}: imported ${imp} account(s) successfully (${fail} failed)`);
if (Array.isArray(response.json.results)) {
for (const res of response.json.results) {
if (res.status === "imported") {
console.log(` ✓ ${res.email ?? res.accountId}`);
} else {
console.log(` ✗ ${res.email ?? "account"}: ${res.error}`);
}
}
}
}
return 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return failure when every CLI import entry fails

When the import API returns HTTP 200 with { ok: false, importedCount: 0, failedCount: ... } for revoked or invalid refresh tokens, the CLI still prints the result and exits 0 here. Scripts and users will treat a completely failed import as successful; check the response ok/counts and return a non-zero status when no account was imported.

Useful? React with 👍 / 👎.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
4 changes: 3 additions & 1 deletion src/cli/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { loadConfig } from "../config";
import { providerCodexAccountMode } from "../providers/registry";
import type { OcxConfig } from "../types";
import { cmdAddKey, cmdAlias, cmdAutoSwitch, cmdClearCooldown, cmdRefresh, cmdRemove } from "./account-extended";
import { cmdAddKey, cmdAlias, cmdAutoSwitch, cmdClearCooldown, cmdImport, cmdRefresh, cmdRemove } from "./account-extended";
import { apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
from "./account-api";

Expand All @@ -25,6 +25,7 @@ const ACCOUNT_USAGE = `Usage:
ocx account remove <provider> <account-or-key-id|main> --yes [--json]
ocx account clear-cooldown <provider> <account-id|main> [--json]
ocx account add-key <provider> [--label <label>] [--json]
ocx account import <provider> <file-or-json> [--json]
ocx account login <provider> [--id <account-id>] [--reauth] [--code -] [--no-wait] [--json]
ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
ocx account cancel <provider> [--flow <flow-id>] [--json]
Expand Down Expand Up @@ -263,6 +264,7 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
if (sub === "remove") return await cmdRemove(rest, deps);
if (sub === "clear-cooldown") return await cmdClearCooldown(rest, deps);
if (sub === "add-key") return await cmdAddKey(rest, deps);
if (sub === "import") return await cmdImport(rest, deps);
if (sub === "main") {
const { cmdNativeMainAccount } = await import("./account-main");
return await cmdNativeMainAccount(rest, deps);
Expand Down
87 changes: 87 additions & 0 deletions src/server/management/oauth-account-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,93 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
return jsonResponse({ ok: true, cleared });
}

if (url.pathname === "/api/oauth/accounts/import" && req.method === "POST") {
const rawBody = await readManagementJsonBodyOr(req, null);
let provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
let accountEntriesRaw: unknown[] = [];

if (Array.isArray(rawBody)) {
accountEntriesRaw = rawBody;
} else if (isPlainRecord(rawBody)) {
if (typeof rawBody.provider === "string" && rawBody.provider.trim()) {
provider = rawBody.provider.trim().toLowerCase();
}
if (Array.isArray(rawBody.accounts)) {
accountEntriesRaw = rawBody.accounts;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (!provider) provider = "google-antigravity";
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
if (provider !== "google-antigravity") {
return jsonResponse({ error: "account import is currently only supported for google-antigravity" }, 400);
}
if (accountEntriesRaw.length === 0) return jsonResponse({ error: "no valid accounts array provided" }, 400);
if (accountEntriesRaw.length > 100) return jsonResponse({ error: "batch import exceeds maximum limit of 100 accounts" }, 400);

const { saveCredential, getAccountSet } = await import("../../oauth/store");
const { refreshAntigravityToken } = await import("../../oauth/google-antigravity");
const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");

const results: Array<{ email?: string; accountId?: string; status: "imported" | "failed"; error?: string }> = [];
let importedCount = 0;
let failedCount = 0;

for (const rawItem of accountEntriesRaw) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound account import batches

For a large Cockpit export or accidental JSON array with many objects, this unbounded loop calls refreshAntigravityToken once per entry, and each call can spend up to the OAuth request timeout before moving to the next. A single management request can therefore consume proxy resources and issue hundreds or thousands of Google token requests; reject or cap batches before entering the refresh loop.

Useful? React with 👍 / 👎.

if (!isPlainRecord(rawItem)) {
results.push({ status: "failed", error: "invalid account object" });
failedCount++;
continue;
}

const entry = rawItem as Record<string, unknown>;
const refreshToken = (
(typeof entry.refresh_token === "string" ? entry.refresh_token : "")
|| (typeof entry.refreshToken === "string" ? entry.refreshToken : "")
|| (typeof entry.refresh === "string" ? entry.refresh : "")
).trim();
const inputEmail = typeof entry.email === "string" ? entry.email.trim().toLowerCase() : undefined;

if (!refreshToken) {
results.push({ email: inputEmail, status: "failed", error: "missing refresh token" });
failedCount++;
continue;
}

try {
const creds = await refreshAntigravityToken(refreshToken);
if (inputEmail && !creds.email) creds.email = inputEmail;
const identity = creds.accountId ?? creds.email;
if (!identity) {
results.push({ email: inputEmail, status: "failed", error: "account identity required (email or accountId)" });
failedCount++;
continue;
}
await saveCredential(provider, creds, { preserveIdentityless: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Upgrade legacy identityless accounts on import

Because imports now require an account identity, passing preserveIdentityless: true disables the store’s legacy-upgrade path for an existing active Google Antigravity credential that was saved without email/accountId. In that case importing the same Cockpit account appends a second active row instead of upgrading the stale identityless slot, leaving a duplicate, selectable credential behind; omit this option for identity-bearing imports.

Useful? React with 👍 / 👎.

const set = getAccountSet(provider);
const activeAcc = set?.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity);
results.push({ email: creds.email ?? inputEmail, accountId: activeAcc?.id, status: "imported" });
importedCount++;
} catch (err) {
results.push({ email: inputEmail, status: "failed", error: err instanceof Error ? err.message : String(err) });
failedCount++;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

reconcileLiveStateStores();
clearProviderQuotaCache();
clearAccountQuotaCache(provider);

return jsonResponse({
ok: importedCount > 0,
provider,
importedCount,
failedCount,
totalCount: accountEntriesRaw.length,
results,
});
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown; alias?: unknown };
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
Expand Down
Loading
Loading