diff --git a/apps/server/src/modules/auth-settings/instances-routes.ts b/apps/server/src/modules/auth-settings/instances-routes.ts
index b41fb73e..caedbd2b 100644
--- a/apps/server/src/modules/auth-settings/instances-routes.ts
+++ b/apps/server/src/modules/auth-settings/instances-routes.ts
@@ -19,6 +19,7 @@ import {
PathMappingCreate,
PathMappingUpdate,
type SecretPurpose,
+ secretInput,
} from "@umt/contracts";
import { z } from "zod";
import { ApiException } from "../../http/errors.ts";
@@ -172,6 +173,14 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
return await ctx.secrets.create({ purpose, plaintext });
};
+ /** 审计里的字段名:凭证只记"设置 / 清除",不记值;空串(不变)不记 */
+ const auditFields = (body: Record) =>
+ Object.keys(body).flatMap((k) => {
+ if (k !== "credential") return [k];
+ const c = secretInput(body.credential as string | null | undefined);
+ return c === undefined ? [] : [c === null ? "credential(cleared)" : "credential(set)"];
+ });
+
// ── library_root ──
const sentinel = async (localPath: string, file: string | null | undefined) => {
const name = file || DEFAULT_ROOT_SENTINEL;
@@ -254,7 +263,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
return { ...curSettings, ...(next.settings ?? {}), authKind };
};
endpoint(app, DL.create, async ({ body, actor }) => {
- const { credential, authKind, ...rest } = body;
+ const { credential: rawCredential, authKind, ...rest } = body;
+ const credential = secretInput(rawCredential) ?? undefined;
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
const keep = authKind !== "none" && credential;
const ref = keep ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
@@ -277,13 +287,18 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
}
});
endpoint(app, DL.update, async ({ params, body, actor }) => {
- const { credential, authKind, ...rest } = body;
+ // 只写凭证统一语义:省略或空串 = 不变,null = 清除
+ const { credential: rawCredential, authKind, ...rest } = body;
+ const credential = secretInput(rawCredential);
const cur = await getRow("downloader_instance", params.id);
+ const curAuth = ((cur.settings ?? {}) as Record).authKind;
+ if (credential === null && (authKind ?? curAuth) === "api_key")
+ throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证");
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
let dropRef: string | null = null;
- if (authKind === "none") {
- // 无认证:不再保留密码 / Key
+ if (authKind === "none" || credential === null) {
+ // 无认证或显式清除:不再保留密码 / Key
if (cur.secret_ref) {
values.secret_ref = null;
dropRef = cur.secret_ref;
@@ -299,7 +314,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
"downloader_instance.update",
{ type: "downloader_instance", id: row.id },
{
- fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
+ fields: auditFields(body),
},
);
return toApi(row);
@@ -354,7 +369,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
list("media_server_instance", "media-server-instances", MS.list);
get("media_server_instance", "media-server-instances", MS.get);
endpoint(app, MS.create, async ({ body, actor }) => {
- const { credential, ...rest } = body;
+ const { credential: rawCredential, ...rest } = body;
+ const credential = secretInput(rawCredential) ?? undefined;
const ref = credential
? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential })
: null;
@@ -374,20 +390,27 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
}
});
endpoint(app, MS.update, async ({ params, body, actor }) => {
- const { credential, ...rest } = body;
+ // 只写凭证统一语义:省略或空串 = 不变,null = 清除
+ const { credential: rawCredential, ...rest } = body;
+ const credential = secretInput(rawCredential);
const values = toDb(rest, JSON_COLS);
- if (credential) {
+ let dropRef: string | null = null;
+ if (credential !== undefined) {
const cur = await getRow("media_server_instance", params.id);
- values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
+ if (credential === null) {
+ values.secret_ref = null;
+ dropRef = cur.secret_ref ?? null;
+ } else values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
}
const row = await update("media_server_instance", params.id, values);
+ if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
await audit(
ctx,
actor,
"media_server_instance.update",
{ type: "media_server_instance", id: row.id },
{
- fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
+ fields: auditFields(body),
},
);
return toApi(row);
diff --git a/apps/server/src/modules/auth-settings/settings-routes.ts b/apps/server/src/modules/auth-settings/settings-routes.ts
index 2db8cffb..b4a0e9cd 100644
--- a/apps/server/src/modules/auth-settings/settings-routes.ts
+++ b/apps/server/src/modules/auth-settings/settings-routes.ts
@@ -5,6 +5,7 @@ import {
mergeSettings,
type SecretPurpose,
type SettingsKey,
+ secretInput,
} from "@umt/contracts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
@@ -75,11 +76,14 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
// 审计里不留明文密钥
const logged: Record = { ...patch };
+ const mark = (v: string | null | undefined) => {
+ const x = secretInput(v);
+ return x === undefined ? undefined : x === null ? "(cleared)" : "(set)";
+ };
if (patch.metadata && "tmdbApiKey" in patch.metadata)
- logged.metadata = { ...patch.metadata, tmdbApiKey: patch.metadata.tmdbApiKey === null ? "(cleared)" : "(set)" };
+ logged.metadata = { ...patch.metadata, tmdbApiKey: mark(patch.metadata.tmdbApiKey) };
const ws = patch.agent?.webSearch;
- if (ws && "apiKey" in ws)
- logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: ws.apiKey === null ? "(cleared)" : "(set)" } };
+ if (ws && "apiKey" in ws) logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: mark(ws.apiKey) } };
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
return next;
});
@@ -96,7 +100,8 @@ interface SecretSlot {
/** PATCH 里出现的只写密钥字段(undefined = 不改;null = 清除;字符串 = 设置) */
function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
const out: SecretSlot[] = [];
- const tmdb = patch.metadata?.tmdbApiKey;
+ // 只写凭证统一语义:省略或空串 = 不变,null = 清除
+ const tmdb = secretInput(patch.metadata?.tmdbApiKey);
if (tmdb !== undefined)
out.push({
plaintext: tmdb,
@@ -107,7 +112,7 @@ function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
s.metadata.tmdbApiKeySecretRef = ref;
},
});
- const ws = patch.agent?.webSearch?.apiKey;
+ const ws = secretInput(patch.agent?.webSearch?.apiKey);
if (ws !== undefined)
out.push({
plaintext: ws,
diff --git a/apps/server/src/modules/browser/__tests__/browser-provider-config.test.ts b/apps/server/src/modules/browser/__tests__/browser-provider-config.test.ts
index dd52ff8a..6e8bb688 100644
--- a/apps/server/src/modules/browser/__tests__/browser-provider-config.test.ts
+++ b/apps/server/src/modules/browser/__tests__/browser-provider-config.test.ts
@@ -80,6 +80,9 @@ describe.skipIf(!up)("浏览器服务登记", () => {
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
expect(p2.status).toBe(200);
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
+ // 空串 = 保持原凭据(FIX-8 只写凭证统一语义)
+ const kept = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: "", enabled: true } });
+ expect(kept.body.data).toMatchObject({ hasAccessKey: true, secretRef: u.body.data.secretRef });
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts
index e56242d5..524e5c61 100644
--- a/apps/server/src/modules/browser/service.ts
+++ b/apps/server/src/modules/browser/service.ts
@@ -39,6 +39,7 @@ import type {
DownloadArtifact,
NavigationBudget,
} from "@umt/contracts";
+import { secretInput } from "@umt/contracts";
import {
type BrowserControlLeaseRow,
type BrowserProviderRow,
@@ -354,8 +355,9 @@ export class BrowserService {
.where("name", "=", body.name)
.executeTakeFirst();
if (exists) throw new BrowserServiceError("conflict", `浏览器服务「${body.name}」已存在`);
- const secretRef = body.accessKey
- ? await this.ctx.secrets.create({ purpose: "other", plaintext: body.accessKey, label: `browser:${body.name}` })
+ const accessKey = secretInput(body.accessKey);
+ const secretRef = accessKey
+ ? await this.ctx.secrets.create({ purpose: "other", plaintext: accessKey, label: `browser:${body.name}` })
: null;
const row = await this.ctx.db
.insertInto("browser_provider")
@@ -374,8 +376,10 @@ export class BrowserService {
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据。camofox-env 的地址与凭据归环境变量管,只能改启用 */
async updateProvider(
id: string,
- body: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
+ input: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
): Promise {
+ // 只写凭证统一语义:省略或空串 = 不变,null = 清除
+ const body = { ...input, accessKey: secretInput(input.accessKey) };
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
diff --git a/apps/server/src/modules/pi/service.ts b/apps/server/src/modules/pi/service.ts
index 3ffd541b..a17e2dcf 100644
--- a/apps/server/src/modules/pi/service.ts
+++ b/apps/server/src/modules/pi/service.ts
@@ -7,7 +7,7 @@ import {
type UpstreamCredentialStore,
type UpstreamSpec,
} from "@umt/agent";
-import type { Actor, PiUpstream, PiUpstreamCreate, PiUpstreamUpdate } from "@umt/contracts";
+import { type Actor, type PiUpstream, type PiUpstreamCreate, type PiUpstreamUpdate, secretInput } from "@umt/contracts";
import type { PiUpstreamRow } from "@umt/db";
import { ApiException } from "../../http/errors.ts";
import type { ServerContext } from "../types.ts";
@@ -91,8 +91,10 @@ export async function listUpstreams(
export async function createUpstream(
ctx: ServerContext,
actor: Actor | undefined,
- body: PiUpstreamCreate,
+ input: PiUpstreamCreate,
): Promise {
+ // 只写凭证统一语义:空串 = 不设置
+ const body = { ...input, apiKey: secretInput(input.apiKey) ?? undefined };
validateShape(body);
const id = randomUUID();
let secretRef: string | null = null;
@@ -132,9 +134,13 @@ export async function updateUpstream(
ctx: ServerContext,
actor: Actor | undefined,
row: PiUpstreamRow,
- body: PiUpstreamUpdate,
+ input: PiUpstreamUpdate,
onCredentialDropped: () => void,
): Promise {
+ // 只写凭证统一语义:省略或空串 = 不变,null = 清除
+ const { apiKey: rawKey, ...rest } = input;
+ const apiKey = secretInput(rawKey);
+ const body: PiUpstreamUpdate = apiKey === undefined ? rest : { ...rest, apiKey };
const authKind = body.authKind ?? row.auth_kind;
const baseUrl = body.baseUrl !== undefined ? body.baseUrl : authKind === "oauth" ? null : row.base_url;
const defaultModel = body.defaultModel !== undefined ? body.defaultModel : row.default_model;
diff --git a/apps/server/src/modules/sources-identity/service/account.ts b/apps/server/src/modules/sources-identity/service/account.ts
index 973b8fef..1d3f6497 100644
--- a/apps/server/src/modules/sources-identity/service/account.ts
+++ b/apps/server/src/modules/sources-identity/service/account.ts
@@ -21,7 +21,9 @@ import type {
SiteLoginResult,
SiteUserDataPoint,
} from "@umt/contracts";
+import { secretInput } from "@umt/contracts";
import { type SiteAccountRow, type SiteRow, sql } from "@umt/db";
+import { ApiException } from "../../../http/errors.ts";
import { toApi } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
@@ -92,24 +94,40 @@ const PURPOSE: Record = {
password: "site_password",
};
+const MISSING_SECRET: Record = {
+ password: "填写密码",
+ cookie: "粘贴 Cookie",
+ api_key: "填写 API Key",
+};
+
type NormalizedCredentials =
| {
kind: "password";
label: string;
username: string;
- password: string;
- totpSecret?: string;
+ /** undefined = 保持已存密码 */
+ password?: string | undefined;
+ /** undefined = 保持;null = 清除 */
+ totpSecret?: string | null | undefined;
saveCookieOnly?: boolean;
userAgent?: string;
enabled?: boolean;
}
- | { kind: "cookie"; label: string; cookie: string; userAgent?: string; enabled?: boolean }
- | { kind: "api_key"; label: string; apiKey: string; enabled?: boolean }
+ | { kind: "cookie"; label: string; cookie?: string | undefined; userAgent?: string; enabled?: boolean }
+ | { kind: "api_key"; label: string; apiKey?: string | undefined; enabled?: boolean }
| { kind: "passkey"; label: string; passkey: string; enabled?: boolean };
/** 旧格式 {authKind, credential, username?} → 新格式 */
export function normalizeCredentials(body: SiteCredentialsPut): NormalizedCredentials {
- if ("kind" in body) return body as NormalizedCredentials;
+ if ("kind" in body) {
+ // 只写凭证统一语义:省略或空串 = 保持已存值;TOTP 的 null = 清除
+ const b = body as NormalizedCredentials;
+ if (b.kind === "password")
+ return { ...b, password: secretInput(b.password) ?? undefined, totpSecret: secretInput(b.totpSecret) };
+ if (b.kind === "cookie") return { ...b, cookie: secretInput(b.cookie) ?? undefined };
+ if (b.kind === "api_key") return { ...b, apiKey: secretInput(b.apiKey) ?? undefined };
+ return b;
+ }
const label = body.label;
const enabled = body.enabled;
const opt = enabled === undefined ? {} : { enabled };
@@ -148,7 +166,7 @@ export async function setSiteCredentials(
// 已有账号的同类凭证原地覆盖(replace),不新建 secret
const pre = await db
.selectFrom("site_account")
- .select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref"])
+ .select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref", "session_secret_ref", "login_name"])
.where("site_id", "=", site.id)
.where("label", "=", label)
.executeTakeFirst();
@@ -213,11 +231,35 @@ export async function setSiteCredentials(
});
} else {
const plaintext = body.kind === "password" ? body.password : body.kind === "cookie" ? body.cookie : body.apiKey;
- const ref = await put(pre?.auth_kind === body.kind ? pre.secret_ref : null, PURPOSE[body.kind]!, plaintext);
+ const sameKind = pre?.auth_kind === body.kind;
+ // 留空 = 保持已存的主凭证:只在同类账号且已存时可以
+ const keepMain = plaintext === undefined;
+ if (keepMain && !(sameKind && pre?.secret_ref))
+ throw new ApiException("validation_failed", MISSING_SECRET[body.kind] ?? "填写凭证");
+ const ref =
+ plaintext === undefined
+ ? pre!.secret_ref!
+ : await put(sameKind ? pre?.secret_ref : null, PURPOSE[body.kind]!, plaintext);
+ if (keepMain) reused.add(ref);
+ // TOTP:新值替换;省略 = 同类账号保持;null 或换类型 = 清除
+ const keepTotp = body.kind === "password" && body.totpSecret === undefined && sameKind && pre?.totp_secret_ref;
const totpRef =
body.kind === "password" && body.totpSecret
? await put(pre?.totp_secret_ref, "site_password", body.totpSecret, ":totp")
+ : keepTotp
+ ? pre!.totp_secret_ref
+ : null;
+ if (totpRef) reused.add(totpRef);
+ // 只改 UA / 启用等、密码与登录名都没变:会话 Cookie 仍有效,不丢
+ const keepSession =
+ keepMain &&
+ body.kind === "password" &&
+ pre?.session_secret_ref &&
+ pre.login_name === body.username &&
+ totpRef === pre.totp_secret_ref
+ ? pre.session_secret_ref
: null;
+ if (keepSession) reused.add(keepSession);
result = await db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
@@ -236,7 +278,7 @@ export async function setSiteCredentials(
secret_ref: ref,
status: "unknown" as const,
last_error: null,
- session_secret_ref: null,
+ session_secret_ref: keepSession,
totp_secret_ref: totpRef,
login_name: body.kind === "password" ? body.username : null,
user_agent: "userAgent" in body ? (body.userAgent ?? null) : null,
diff --git a/apps/server/test/auth-settings/secret-inputs.test.ts b/apps/server/test/auth-settings/secret-inputs.test.ts
new file mode 100644
index 00000000..a92998f7
--- /dev/null
+++ b/apps/server/test/auth-settings/secret-inputs.test.ts
@@ -0,0 +1,144 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { DEV_KEY, json } from "../helpers.ts";
+import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
+
+// FIX-8 只写凭证统一语义:省略或空串 = 保持原值,null = 清除,非空 = 替换;明文不出 API。
+
+const hasPg = await pgAvailable();
+
+describe.skipIf(!hasPg)("只写凭证:留空不变 / 显式清除(真实 PG)", () => {
+ let h: Harness;
+ let admin: ReturnType;
+
+ beforeAll(async () => {
+ h = await createHarness({ engine: false });
+ admin = bearer(h.app, DEV_KEY);
+ }, 60_000);
+ afterAll(async () => {
+ await h?.close();
+ });
+
+ const secretOf = (ref: string) => h.ctx.secrets.resolve(ref);
+
+ it("下载器(账密):空串保持;null 清除;新值原地替换", async () => {
+ const c = (
+ await json(
+ await admin("POST", "/downloader-instances", {
+ name: "qb-keep",
+ baseUrl: "http://127.0.0.1:1",
+ authKind: "password",
+ username: "u",
+ credential: "pw-original",
+ }),
+ )
+ ).data;
+ const ref = c.secretRef as string;
+ expect(ref).toBeTruthy();
+
+ const kept = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "", name: "qb-k2" })))
+ .data;
+ expect(kept.secretRef).toBe(ref);
+ expect(await secretOf(ref)).toBe("pw-original");
+
+ const ws = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: " " }))).data;
+ expect(ws.secretRef).toBe(ref);
+
+ const replaced = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "pw-new" }))).data;
+ expect(replaced.secretRef).toBe(ref);
+ expect(await secretOf(ref)).toBe("pw-new");
+
+ const cleared = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: null }))).data;
+ expect(cleared.secretRef).toBeNull();
+ await expect(secretOf(ref)).rejects.toThrow(/不存在/);
+ expect(JSON.stringify(cleared)).not.toContain("pw-");
+ });
+
+ it("下载器(API Key):不能清除 Key(422),留空保持", async () => {
+ const c = (
+ await json(
+ await admin("POST", "/downloader-instances", {
+ name: "qb-key",
+ baseUrl: "http://127.0.0.1:1",
+ authKind: "api_key",
+ credential: "qbt_key_1",
+ }),
+ )
+ ).data;
+ expect((await admin("PATCH", `/downloader-instances/${c.id}`, { credential: null })).status).toBe(422);
+ const kept = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "" }))).data;
+ expect(kept.secretRef).toBe(c.secretRef);
+ // 新建时空串 = 没填:API Key 认证必须有 Key
+ expect(
+ (
+ await admin("POST", "/downloader-instances", {
+ name: "qb-empty",
+ baseUrl: "http://127.0.0.1:1",
+ authKind: "api_key",
+ credential: "",
+ })
+ ).status,
+ ).toBe(422);
+ });
+
+ it("媒体服务器:空串保持;null 清除", async () => {
+ const c = (
+ await json(
+ await admin("POST", "/media-server-instances", {
+ name: "emby-keep",
+ baseUrl: "http://127.0.0.1:1",
+ credential: "emby-key-1",
+ }),
+ )
+ ).data;
+ const ref = c.secretRef as string;
+ const kept = (await json(await admin("PATCH", `/media-server-instances/${c.id}`, { credential: "" }))).data;
+ expect(kept.secretRef).toBe(ref);
+ expect(await secretOf(ref)).toBe("emby-key-1");
+ const cleared = (await json(await admin("PATCH", `/media-server-instances/${c.id}`, { credential: null }))).data;
+ expect(cleared.secretRef).toBeNull();
+ await expect(secretOf(ref)).rejects.toThrow(/不存在/);
+ // 空串新建 = 不存 key
+ const n = (
+ await json(
+ await admin("POST", "/media-server-instances", {
+ name: "emby-nokey",
+ baseUrl: "http://127.0.0.1:1",
+ credential: "",
+ }),
+ )
+ ).data;
+ expect(n.secretRef).toBeNull();
+ });
+
+ it("设置(TMDB / 网页搜索 Key):空串保持;null 清除;审计不留明文", async () => {
+ const set = (await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: "tmdb-secret-1" } }))).data;
+ const ref = set.metadata.tmdbApiKeySecretRef as string;
+ expect(ref).toBeTruthy();
+ const kept = (
+ await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: "", tmdbBaseUrl: "https://t.local" } }))
+ ).data;
+ expect(kept.metadata.tmdbApiKeySecretRef).toBe(ref);
+ expect(kept.metadata.tmdbBaseUrl).toBe("https://t.local");
+ expect(await secretOf(ref)).toBe("tmdb-secret-1");
+ const cleared = (await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: null } }))).data;
+ expect(cleared.metadata.tmdbApiKeySecretRef).toBeNull();
+
+ const ws = (
+ await json(await admin("PATCH", "/settings", { agent: { webSearch: { provider: "brave", apiKey: "brave-1" } } }))
+ ).data;
+ const wref = ws.agent.webSearch.apiKeySecretRef as string;
+ const wkept = (await json(await admin("PATCH", "/settings", { agent: { webSearch: { apiKey: "" } } }))).data;
+ expect(wkept.agent.webSearch.apiKeySecretRef).toBe(wref);
+ const wcleared = (await json(await admin("PATCH", "/settings", { agent: { webSearch: { apiKey: null } } }))).data;
+ expect(wcleared.agent.webSearch.apiKeySecretRef).toBeNull();
+
+ const audits = await h.db
+ .selectFrom("audit_event")
+ .select("payload")
+ .where("action", "=", "settings.update")
+ .execute();
+ const text = JSON.stringify(audits);
+ expect(text).not.toContain("tmdb-secret-1");
+ expect(text).not.toContain("brave-1");
+ });
+});
diff --git a/apps/server/test/pi/upstreams.test.ts b/apps/server/test/pi/upstreams.test.ts
index 40dbf37d..027c3c69 100644
--- a/apps/server/test/pi/upstreams.test.ts
+++ b/apps/server/test/pi/upstreams.test.ts
@@ -148,6 +148,11 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
(await req("POST", "/pi/upstreams", { name: "OpenAI 主号", provider: "openai", authKind: "api_key" })).status,
).toBe(409);
+ // 空串 = 保持原 key(FIX-8 只写凭证统一语义);掩码只给前后 4 位
+ const kept = (await req("PATCH", `/pi/upstreams/${a.id}`, { apiKey: "", name: "OpenAI 主号" })).body.data;
+ expect(kept).toMatchObject({ hasCredential: true });
+ expect(kept.credentialMask).toMatch(/^.{4}….{4}$/);
+ expect(JSON.stringify(kept)).not.toMatch(/secret-\d/);
// 清 key → auth_required;测试报 auth
const cleared = (await req("PATCH", `/pi/upstreams/${a.id}`, { apiKey: null })).body.data;
expect(cleared).toMatchObject({ status: "auth_required", hasCredential: false });
diff --git a/apps/server/test/sources-identity/credentials-keep.test.ts b/apps/server/test/sources-identity/credentials-keep.test.ts
new file mode 100644
index 00000000..41943e4f
--- /dev/null
+++ b/apps/server/test/sources-identity/credentials-keep.test.ts
@@ -0,0 +1,87 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { createHarness, type Harness, pgAvailable } from "./harness.ts";
+
+// FIX-8 站点账号凭证:同类账号更新时密码 / Cookie / API Key 留空 = 保持,TOTP 省略保持、null 清除;
+// 新账号或换类型时必须填。明文不出 API。
+
+const up = await pgAvailable();
+
+// biome-ignore lint/suspicious/noExplicitAny: 响应体断言
+const json = async (r: Response): Promise => {
+ const body = await r.json();
+ if (!r.ok) throw new Error(`${r.status} ${JSON.stringify(body)}`);
+ return body.data;
+};
+
+describe.skipIf(!up)("站点凭证留空不变 / 显式清除", () => {
+ let h: Harness;
+ let siteId: string;
+ const put = (body: unknown) => h.req(`/sites/${siteId}/credentials`, { method: "PUT", body });
+ const account = () =>
+ h.db
+ .selectFrom("site_account")
+ .selectAll()
+ .where("site_id", "=", siteId)
+ .where("label", "=", "default")
+ .executeTakeFirstOrThrow();
+
+ beforeAll(async () => {
+ h = await createHarness("credkeep");
+ const site = await json(
+ await h.req("/sites", {
+ body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "http://127.0.0.1:1/" },
+ }),
+ );
+ siteId = site.id;
+ }, 60_000);
+ afterAll(async () => {
+ await h?.close();
+ });
+
+ it("新账号不填密码 → 422", async () => {
+ expect((await put({ kind: "password", username: "alice" })).status).toBe(422);
+ expect((await put({ kind: "password", username: "alice", password: " " })).status).toBe(422);
+ });
+
+ it("账密:改登录名不填密码 = 保持密码与 TOTP;null 清除 TOTP", async () => {
+ const r = await json(await put({ kind: "password", username: "alice", password: "pw-1", totpSecret: "JBSWY3DP" }));
+ expect(JSON.stringify(r)).not.toContain("pw-1");
+ const a1 = await account();
+ expect(h.secrets.plain.get(a1.secret_ref!)).toBe("pw-1");
+ expect(a1.totp_secret_ref).toBeTruthy();
+
+ await json(await put({ kind: "password", username: "alice2", password: "", userAgent: "UA/1" }));
+ const a2 = await account();
+ expect(a2.secret_ref).toBe(a1.secret_ref);
+ expect(h.secrets.plain.get(a2.secret_ref!)).toBe("pw-1");
+ expect(a2.totp_secret_ref).toBe(a1.totp_secret_ref);
+ expect(a2.login_name).toBe("alice2");
+ expect(a2.user_agent).toBe("UA/1");
+
+ await json(await put({ kind: "password", username: "alice2", totpSecret: null }));
+ const a3 = await account();
+ expect(a3.totp_secret_ref).toBeNull();
+ expect(h.secrets.plain.get(a3.secret_ref!)).toBe("pw-1");
+ expect(h.secrets.plain.has(a1.totp_secret_ref!)).toBe(false);
+
+ await json(await put({ kind: "password", username: "alice2", password: "pw-2" }));
+ expect(h.secrets.plain.get((await account()).secret_ref!)).toBe("pw-2");
+ });
+
+ it("换类型必须填新凭证;同类 Cookie / API Key 留空保持", async () => {
+ expect((await put({ kind: "cookie" })).status).toBe(422);
+ await json(await put({ kind: "cookie", cookie: "a=1" }));
+ const c1 = await account();
+ await json(await put({ kind: "cookie", cookie: "", userAgent: "UA/2" }));
+ const c2 = await account();
+ expect(c2.secret_ref).toBe(c1.secret_ref);
+ expect(h.secrets.plain.get(c2.secret_ref!)).toBe("a=1");
+
+ expect((await put({ kind: "api_key", apiKey: "" })).status).toBe(422);
+ await json(await put({ kind: "api_key", apiKey: "k-1" }));
+ const k1 = await account();
+ await json(await put({ kind: "api_key" }));
+ expect(h.secrets.plain.get((await account()).secret_ref!)).toBe("k-1");
+ expect((await account()).secret_ref).toBe(k1.secret_ref);
+ });
+});
diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx
index bed5752e..8c13947f 100644
--- a/apps/web/app/components/agent/agent-conversation.tsx
+++ b/apps/web/app/components/agent/agent-conversation.tsx
@@ -44,6 +44,7 @@ import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { humanizeReason } from "~/lib/reasons.ts";
+import { formatTokens } from "~/lib/tokens.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
import { splitContextGoal } from "./agent-store.ts";
@@ -653,7 +654,7 @@ export function ResultLine({
value:
u.totalTokens === undefined
? null
- : `${u.totalTokens.toLocaleString("zh-CN")}${run.budget.maxTokens ? ` / ${run.budget.maxTokens.toLocaleString("zh-CN")}` : ""}`,
+ : `${formatTokens(u.totalTokens)}${run.budget.maxTokens ? ` / ${formatTokens(run.budget.maxTokens)}` : ""}`,
},
{
label: "工具调用",
diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx
index adf89887..5543b70c 100644
--- a/apps/web/app/components/agent/agent-run-panel.tsx
+++ b/apps/web/app/components/agent/agent-run-panel.tsx
@@ -29,6 +29,7 @@ import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
+import { formatTokens } from "~/lib/tokens.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import {
AgentConversation,
@@ -217,7 +218,7 @@ export function RunOverviewBar({
? `约 ${formatUsd(u.costUsd)}${r.budget.maxCostUsd ? ` / 上限 ${formatUsd(r.budget.maxCostUsd)}` : ""}`
: null,
},
- { label: "token", value: u.totalTokens !== undefined ? u.totalTokens.toLocaleString("zh-CN") : null },
+ { label: "token", value: u.totalTokens !== undefined ? formatTokens(u.totalTokens) : null },
{
label: "工具调用",
value: `${r.toolCallCount}${r.budget.maxToolCalls ? ` / ${r.budget.maxToolCalls}` : ""}`,
diff --git a/apps/web/app/components/agent/agent-subagent-tree.tsx b/apps/web/app/components/agent/agent-subagent-tree.tsx
index bb693f2f..746c5d96 100644
--- a/apps/web/app/components/agent/agent-subagent-tree.tsx
+++ b/apps/web/app/components/agent/agent-subagent-tree.tsx
@@ -3,6 +3,7 @@ import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
import { createContext, type ReactNode, useContext, useState } from "react";
import { Link } from "react-router";
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
+import { formatTokens } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { shortGoalTitle } from "./agent-store.ts";
@@ -10,7 +11,7 @@ import { shortGoalTitle } from "./agent-store.ts";
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
-const tokens = (n: number | undefined) => (n === undefined ? "—" : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
+const tokens = (n: number | undefined) => formatTokens(n);
export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?: number }) {
const parts = [`${u.toolCalls} 次调用`, `${tokens(u.totalTokens)} token`];
diff --git a/apps/web/app/components/console/secret-input.tsx b/apps/web/app/components/console/secret-input.tsx
new file mode 100644
index 00000000..caa62c21
--- /dev/null
+++ b/apps/web/app/components/console/secret-input.tsx
@@ -0,0 +1,142 @@
+import { Undo2, X } from "lucide-react";
+import { useState } from "react";
+import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
+import { Button } from "~/components/ui/button.tsx";
+import { Input } from "~/components/ui/input.tsx";
+import { Textarea } from "~/components/ui/textarea.tsx";
+import { SECRET_CLEAR, secretView } from "~/lib/secret.ts";
+import { cn } from "~/lib/utils.ts";
+
+/**
+ * 只写凭证输入框。已保存时框里显示掩码占位(「已设置 ••••••••」,服务端给了末 4 位就带上),
+ * 聚焦后直接输入即替换;留空保存 = 保持原值;「清除」确认后把值设为 SECRET_CLEAR,保存时才生效。
+ * 值约定见 ~/lib/secret.ts。明文只在用户输入时存在于这个表单里,从不由服务端回填。
+ */
+export function SecretInput({
+ id,
+ value,
+ onChange,
+ isSet,
+ mask,
+ clearable = false,
+ label = "凭证",
+ placeholder,
+ multiline = false,
+ disabled,
+ invalid,
+ className,
+}: {
+ id?: string;
+ value: string;
+ onChange: (v: string) => void;
+ /** 服务端已保存(secretRef / hasCredential 等) */
+ isSet: boolean;
+ /** 服务端给的掩码 hint(如 credentialMask),只取末 4 位显示 */
+ mask?: string | null;
+ /** 允许清除(必填凭证不给) */
+ clearable?: boolean;
+ /** 确认弹窗里的名字:"API Key"、"密码" */
+ label?: string;
+ placeholder?: string;
+ /** Cookie 这类长串用多行 */
+ multiline?: boolean;
+ disabled?: boolean;
+ invalid?: boolean;
+ className?: string;
+}) {
+ const [confirming, setConfirming] = useState(false);
+ const view = secretView(value, { isSet, mask, clearable });
+
+ if (view.state === "cleared") {
+ return (
+
+
+
+
+ );
+ }
+
+ const shownPlaceholder = view.state === "set" ? view.placeholder : placeholder;
+ const common = {
+ id,
+ value,
+ disabled,
+ // 没有外部
-
setTokenValue(e.target.value)}
+ onChange={setTokenValue}
className="min-w-0 flex-1"
/>