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, + // 没有外部