From 171421439d510afebd1d9839da1b5cffd69ee8ff Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Fri, 11 Sep 2026 18:28:17 -0400 Subject: [PATCH] fix(dashboard): expose Volcano console cookie field and unwrap error objects (#13107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves are real. A quota fetcher that needs console cookies with no way to enter them is unusable on any headless or remote install, and `new Error($'{'}object{'}'}` rendering `[object Object]` in a toast is exactly what makes a route-guard rejection unreadable. --- Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the other 19 PRs of this batch. Two in-batch conflicts, both additive and resolved by keeping each side: the `ENVIRONMENT.md` table (#13035 + #13011) and the `chatHelpers.ts` import block (#12975 on the tip + #13017). - `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK; `check:docs-counts` migrations ✓ - complexity 2816 / baseline 3218 and cognitive-complexity 1271 / baseline 1437 — both under baseline - 531 of 532 focused assertions green across the batch's 46 test files - `check-file-size` rebaselined for the batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_houminxi`, landed on #13038), attributed per PR The single red is **not this batch**: `tests/unit/combo/quota-weighted-strategy.test.ts` → "A/B isolation: 7 hard-empty + 2 at 0.5% + 1 at 40%, floor=1" asserts an order between two connections of identical weight and flakes on the pure tip too — 2 failures in 4 runs at `origin/release/v3.8.51` with nothing from this batch applied. ⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, untouched here). Thanks @HouMinXi — the live evidence on these (X500 logs, `storage.sqlite` state, real `/v1/models` probes, the 36-minute outage write-up) is what let a 20-PR batch be reviewed as a unit. --- .../fixes/13107-volcengine-console-cookie.md | 1 + .../components/VolcengineConnectModal.tsx | 15 ++- .../components/modals/EditConnectionModal.tsx | 7 +- .../components/modals/QuotaScrapingFields.tsx | 29 +++++ .../modals/quotaScrapingFieldValues.ts | 10 ++ src/shared/validation/providerSpecificData.ts | 1 + .../unit/volcengine-plan-cookie-field.test.ts | 100 ++++++++++++++++++ 7 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/13107-volcengine-console-cookie.md create mode 100644 tests/unit/volcengine-plan-cookie-field.test.ts diff --git a/changelog.d/fixes/13107-volcengine-console-cookie.md b/changelog.d/fixes/13107-volcengine-console-cookie.md new file mode 100644 index 0000000000..c9a24dcbe8 --- /dev/null +++ b/changelog.d/fixes/13107-volcengine-console-cookie.md @@ -0,0 +1 @@ +- **fix(dashboard):** expose the Volcano Ark console cookie on quota scraping and unwrap connect-error objects so the dashboard shows the upstream message diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx index 7c0d11ff8b..c551838422 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Button, Input, Modal } from "@/shared/components"; import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; +import { extractErrorMessage } from "@/shared/utils/upstreamError"; /** * VolcengineConnectModal — phone/SMS-code login for the Volcano Engine console. @@ -66,6 +67,16 @@ function isTerminal(phase: SessionPhase | undefined): boolean { return !!phase && TERMINAL_PHASES.includes(phase); } +function extractModalError(data: unknown, fallback: string): string { + const record = data && typeof data === "object" ? (data as Record) : null; + return ( + extractErrorMessage(record?.error) || + (typeof record?.error === "string" ? record.error : null) || + (typeof record?.message === "string" ? record.message : null) || + fallback + ); +} + type VolcengineConnectModalProps = { isOpen: boolean; onClose: () => void; @@ -218,7 +229,7 @@ export default function VolcengineConnectModal({ }); const data = await response.json().catch(() => ({})); if (!response.ok || !data?.success || !data?.session) { - throw new Error(data?.error || "Failed to start Volcano login"); + throw new Error(extractModalError(data, "Failed to start Volcano login")); } setSession(data.session); setResendCountdown( @@ -269,7 +280,7 @@ export default function VolcengineConnectModal({ void onConnected(); } } else { - throw new Error(data?.error || "Failed to submit verification code"); + throw new Error(extractModalError(data, "Failed to submit verification code")); } } catch (error) { notify.error(error instanceof Error ? error.message : "Failed to submit verification code"); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index ede857c50a..59d7328fbd 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -379,16 +379,13 @@ export default function EditConnectionModal({ quotaPerUnit: existingQuotaPerUnit, glmOrganizationId: existingGlmOrganizationId, glmProjectId: existingGlmProjectId, - // Console-session credentials are stripped from API responses - // (sanitizeProviderSpecificDataForResponse), so there is nothing to - // round-trip: start empty and let "blank keeps the stored value" hold — - // the quota-scraping assign skips empty fields and the PUT merge - // preserves keys the payload does not carry. + // Console-session credentials stripped in responses; blank preserves stored values. ollamaCloudUsageCookie: "", alibabaConsoleCookie: "", qwenCloudCookie: "", qwenCloudSecToken: "", alibabaConsoleSecToken: "", + volcConsoleCookie: "", ccCompatibleContext1m: ccRequestDefaults.context1m, ccCompatibleRedactThinking: ccRequestDefaults.redactThinking, ccCompatibleSummarizeThinking: ccRequestDefaults.summarizeThinking, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx index 9fe727f045..a2772d8677 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx @@ -8,6 +8,7 @@ import { assignQuotaScrapingProviderData, EMPTY_QUOTA_SCRAPING_FIELDS, QWEN_TOKEN_PLAN_PROVIDERS, + VOLCENGINE_PLAN_PROVIDERS, type QuotaScrapingFieldValues, } from "./quotaScrapingFieldValues"; @@ -149,5 +150,33 @@ export default function QuotaScrapingFields({ ); } + if (VOLCENGINE_PLAN_PROVIDERS.has(provider ?? "")) { + return ( +
+ onChange({ volcConsoleCookie: e.target.value })} + placeholder="session=...; AccountID=..." + hint={providerText( + t, + "volcConsoleCookieHint", + editMode + ? "Leave blank to keep the stored cookie. To rotate, paste the updated cookie string from console.volcengine.com." + : "Required for Volcano Ark Plan quota -- the inference API key cannot read it. " + + "How to get it: log in to console.volcengine.com, open Developer Tools (F12), " + + "run document.cookie (or inspect Network headers), and paste the cookie string here. " + + "It expires with your browser session; re-paste when quota reports an expired session." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> +
+ ); + } + return null; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts index 01b48b4df9..f476d609cb 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts @@ -12,12 +12,19 @@ import { getProviderConnectionFamilyIds } from "@/shared/constants/providers"; /** Providers whose quota lives behind the Qwen/Model Studio console gateway (#9603). */ export const QWEN_TOKEN_PLAN_PROVIDERS = new Set(["qwen-cloud-token-plan", "bailian-coding-plan"]); +/** Providers whose quota lives behind the Volcano Engine console gateway. */ +export const VOLCENGINE_PLAN_PROVIDERS = new Set([ + "volcengine-coding-plan", + "volcengine-agent-plan", +]); + export type QuotaScrapingFieldValues = { ollamaCloudUsageCookie: string; alibabaConsoleCookie: string; alibabaConsoleSecToken: string; qwenCloudCookie: string; qwenCloudSecToken: string; + volcConsoleCookie: string; }; export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { @@ -26,6 +33,7 @@ export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { alibabaConsoleSecToken: "", qwenCloudCookie: "", qwenCloudSecToken: "", + volcConsoleCookie: "", }; export function assignQuotaScrapingProviderData( @@ -51,5 +59,7 @@ export function assignQuotaScrapingProviderData( if (values.qwenCloudSecToken?.trim()) { target.qwenCloudSecToken = values.qwenCloudSecToken.trim(); } + } else if (VOLCENGINE_PLAN_PROVIDERS.has(provider ?? "") && values.volcConsoleCookie?.trim()) { + target.volcConsoleCookie = values.volcConsoleCookie.trim(); } } diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 14d321ede3..9d478c1652 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -407,6 +407,7 @@ export function validateProviderSpecificData( "alibabaConsoleSecToken", "qwenCloudCookie", "qwenCloudSecToken", + "volcConsoleCookie", ] as const) { const value = data[key]; if (value !== undefined && value !== null && typeof value !== "string") { diff --git a/tests/unit/volcengine-plan-cookie-field.test.ts b/tests/unit/volcengine-plan-cookie-field.test.ts new file mode 100644 index 0000000000..d4e91fbfe6 --- /dev/null +++ b/tests/unit/volcengine-plan-cookie-field.test.ts @@ -0,0 +1,100 @@ +/** + * volcengine-plan-cookie-field.test.ts — Volcano Ark Coding/Agent Plan + * quota fetchers are cookie-authenticated (API keys cannot query the console + * quota API). Expose volcConsoleCookie in QuotaScrapingFields so users can + * paste their console cookie from the dashboard without needing local browser automation. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + EMPTY_QUOTA_SCRAPING_FIELDS, + assignQuotaScrapingProviderData, +} from "../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts"; +import { extractErrorMessage } from "../../src/shared/utils/upstreamError.ts"; +const { updateProviderConnectionSchema } = await import("../../src/shared/validation/schemas.ts"); + +test("volcengine-coding-plan and volcengine-agent-plan persist the console cookie", () => { + for (const provider of ["volcengine-coding-plan", "volcengine-agent-plan"]) { + const target: Record = {}; + + assignQuotaScrapingProviderData( + provider, + { + ...EMPTY_QUOTA_SCRAPING_FIELDS, + volcConsoleCookie: " session=volc-123; AccountID=acc-456 ", + }, + target + ); + + assert.equal( + target.volcConsoleCookie, + "session=volc-123; AccountID=acc-456", + `cookie must be stored trimmed for ${provider}` + ); + } +}); + +test("a blank volcConsoleCookie does not overwrite the stored one", () => { + for (const provider of ["volcengine-coding-plan", "volcengine-agent-plan"]) { + const target: Record = {}; + + assignQuotaScrapingProviderData( + provider, + { ...EMPTY_QUOTA_SCRAPING_FIELDS, volcConsoleCookie: " " }, + target + ); + + assert.equal( + Object.hasOwn(target, "volcConsoleCookie"), + false, + "blank input must leave the stored cookie untouched" + ); + } +}); + +test("a form object without volcConsoleCookie does not throw", () => { + const target: Record = {}; + const partial = { ...EMPTY_QUOTA_SCRAPING_FIELDS } as Record; + delete partial.volcConsoleCookie; + + for (const provider of ["volcengine-coding-plan", "volcengine-agent-plan"]) { + assert.doesNotThrow(() => + assignQuotaScrapingProviderData( + provider, + partial as unknown as typeof EMPTY_QUOTA_SCRAPING_FIELDS, + target + ) + ); + } + assert.equal(Object.hasOwn(target, "volcConsoleCookie"), false); +}); + +test("providerSpecificData validation guards the volcConsoleCookie field", () => { + const ok = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { volcConsoleCookie: "session=volc-abc" }, + }); + assert.equal(ok.success, true, JSON.stringify(ok.error?.issues)); + + const wrongType = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { volcConsoleCookie: 42 }, + }); + assert.equal(wrongType.success, false, "non-string cookie must be rejected"); + + const tooLong = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { volcConsoleCookie: "x".repeat(10_001) }, + }); + assert.equal(tooLong.success, false, "oversized cookie must be rejected"); +}); + +test("extractErrorMessage extracts message from structured error objects instead of [object Object]", () => { + const localOnlyError = { + code: "LOCAL_ONLY", + message: "This endpoint requires localhost access", + }; + const extracted = extractErrorMessage(localOnlyError); + assert.equal(extracted, "This endpoint requires localhost access"); + + const stringError = "Failed to start Volcano login"; + assert.equal(extractErrorMessage(stringError), null); +});