feat(agent): web.search / web.fetch via Brave Search (WEB-1)
- adapters/websearch: Brave client (X-Subscription-Token, error classes), result normalization with redaction/truncation, SSRF guard, HTML→text fetch (10s, 512KB, text/html|text/plain only) - modules/websearch: 10-min PG cache (region websearch), cross-process per-minute limit, web.fetch allowlist = URLs from the caller's latest web.search; POST /websearch/test - settings.agent.webSearch (key write-only → secret_ref); settings card under Agent; web.* offered to the model only when enabled, plus a system prompt rule; outputs marked untrusted - capability count 27 → 29; e2e E13; runbook docs/runbook/websearch.md Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
40dd0e69e4
commit
d53df25fc8
32 files changed
+1854
-39
No files matched your search
@@ -1,11 +1,18 @@
|
||||
import { Api, GlobalSettings, type GlobalSettingsUpdate, mergeSettings, type SettingsKey } from "@umt/contracts";
|
||||
import {
|
||||
Api,
|
||||
GlobalSettings,
|
||||
type GlobalSettingsUpdate,
|
||||
mergeSettings,
|
||||
type SecretPurpose,
|
||||
type SettingsKey,
|
||||
} from "@umt/contracts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import { audit, endpoint } from "./endpoint.ts";
|
||||
import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "./provided.ts";
|
||||
|
||||
// GET/PATCH /settings:按分组部分更新 → mergeSettings → GlobalSettings 整体校验 → 逐组 upsert;
|
||||
// TMDB 密钥(metadata.tmdbApiKey 只写)存为 secret,设置里只留 tmdbApiKeySecretRef(Agent 上游密钥在 pi_upstream,
|
||||
// TMDB 密钥(metadata.tmdbApiKey)与网页搜索 Key(agent.webSearch.apiKey)只写,存为 secret,设置里只留 secret 引用(Agent 上游密钥在 pi_upstream,
|
||||
// 旧 pi.apiKey 已弃用,PATCH 里会被 schema 丢弃);变更经 SSE 总线广播 settings.changed,
|
||||
// 各进程的 ctx.settings 缓存随之失效。自动化总开关 automation.enabled / 只读对账 automation.readOnlyReconcile 也走这里。
|
||||
|
||||
@@ -22,28 +29,28 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
const patch = body as GlobalSettingsUpdate;
|
||||
const current = isCached(ctx.settings) ? await ctx.settings.fresh() : await ctx.settings.all();
|
||||
const merged = mergeSettings(current, patch);
|
||||
// TMDB 密钥只写:存 secret(metadata_api_key),设置里只留 tmdbApiKeySecretRef
|
||||
const oldTmdbRef = current.metadata.tmdbApiKeySecretRef;
|
||||
const tmdbKey = patch.metadata?.tmdbApiKey;
|
||||
let dropTmdbRef: string | null = null;
|
||||
let newTmdbRef: string | null = null;
|
||||
if (tmdbKey === null) {
|
||||
merged.metadata.tmdbApiKeySecretRef = null;
|
||||
dropTmdbRef = oldTmdbRef;
|
||||
}
|
||||
// 只写密钥:存 secret,设置里只留 secret 引用
|
||||
const slots = secretSlots(patch);
|
||||
for (const sl of slots) if (sl.plaintext === null) sl.set(merged, null);
|
||||
const parsed = GlobalSettings.safeParse(merged);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
||||
throw new ApiException("validation_failed", msg);
|
||||
}
|
||||
const next = parsed.data;
|
||||
if (typeof tmdbKey === "string") {
|
||||
if (oldTmdbRef) {
|
||||
await ctx.secrets.replace(oldTmdbRef, tmdbKey);
|
||||
next.metadata.tmdbApiKeySecretRef = oldTmdbRef;
|
||||
const created: string[] = [];
|
||||
const dropped: string[] = [];
|
||||
for (const sl of slots) {
|
||||
const oldRef = sl.get(current);
|
||||
if (sl.plaintext === null) {
|
||||
if (oldRef) dropped.push(oldRef);
|
||||
} else if (oldRef) {
|
||||
await ctx.secrets.replace(oldRef, sl.plaintext);
|
||||
sl.set(next, oldRef);
|
||||
} else {
|
||||
newTmdbRef = await ctx.secrets.create({ purpose: "metadata_api_key", plaintext: tmdbKey, label: "tmdb" });
|
||||
next.metadata.tmdbApiKeySecretRef = newTmdbRef;
|
||||
const ref = await ctx.secrets.create({ purpose: sl.purpose, plaintext: sl.plaintext, label: sl.label });
|
||||
created.push(ref);
|
||||
sl.set(next, ref);
|
||||
}
|
||||
}
|
||||
const keys = (Object.keys(patch) as SettingsKey[]).filter((k) => patch[k] !== undefined);
|
||||
@@ -60,17 +67,56 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (newTmdbRef) await ctx.secrets.remove(newTmdbRef).catch(() => {});
|
||||
for (const ref of created) await ctx.secrets.remove(ref).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (dropTmdbRef) await ctx.secrets.remove(dropTmdbRef).catch(() => {});
|
||||
for (const ref of dropped) await ctx.secrets.remove(ref).catch(() => {});
|
||||
if (isCached(ctx.settings)) ctx.settings.invalidate();
|
||||
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
|
||||
// 审计里不留明文密钥
|
||||
const logged: Record<string, unknown> = { ...patch };
|
||||
if (patch.metadata && "tmdbApiKey" in patch.metadata)
|
||||
logged.metadata = { ...patch.metadata, tmdbApiKey: tmdbKey === null ? "(cleared)" : "(set)" };
|
||||
logged.metadata = { ...patch.metadata, tmdbApiKey: patch.metadata.tmdbApiKey === null ? "(cleared)" : "(set)" };
|
||||
const ws = patch.agent?.webSearch;
|
||||
if (ws && "apiKey" in ws)
|
||||
logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: ws.apiKey === null ? "(cleared)" : "(set)" } };
|
||||
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
interface SecretSlot {
|
||||
plaintext: string | null;
|
||||
purpose: SecretPurpose;
|
||||
label: string;
|
||||
get(s: GlobalSettings): string | null;
|
||||
set(s: GlobalSettings, ref: string | null): void;
|
||||
}
|
||||
|
||||
/** PATCH 里出现的只写密钥字段(undefined = 不改;null = 清除;字符串 = 设置) */
|
||||
function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
|
||||
const out: SecretSlot[] = [];
|
||||
const tmdb = patch.metadata?.tmdbApiKey;
|
||||
if (tmdb !== undefined)
|
||||
out.push({
|
||||
plaintext: tmdb,
|
||||
purpose: "metadata_api_key",
|
||||
label: "tmdb",
|
||||
get: (s) => s.metadata.tmdbApiKeySecretRef,
|
||||
set: (s, ref) => {
|
||||
s.metadata.tmdbApiKeySecretRef = ref;
|
||||
},
|
||||
});
|
||||
const ws = patch.agent?.webSearch?.apiKey;
|
||||
if (ws !== undefined)
|
||||
out.push({
|
||||
plaintext: ws,
|
||||
purpose: "other",
|
||||
label: "websearch",
|
||||
get: (s) => s.agent.webSearch?.apiKeySecretRef ?? null,
|
||||
set: (s, ref) => {
|
||||
s.agent.webSearch.apiKeySecretRef = ref;
|
||||
},
|
||||
});
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user