Merge branch 'fix8'

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-30 00:10:57 +08:00
commit 9ef2e8554c
39 files changed
+1175 -232

No files matched your search

@@ -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<string, unknown>) =>
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<string, unknown>).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);
@@ -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<string, unknown> = { ...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,
@@ -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);
+7 -3
View File
@@ -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<BrowserProviderView> {
// 只写凭证统一语义:省略或空串 = 不变,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;
+9 -3
View File
@@ -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<PiUpstream> {
// 只写凭证统一语义:空串 = 不设置
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<PiUpstream> {
// 只写凭证统一语义:省略或空串 = 不变,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;
@@ -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<string, SecretPurpose> = {
password: "site_password",
};
const MISSING_SECRET: Record<string, string> = {
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,