fix(security): W4-E1 approvals admin-session only, shared redact table, TMDB key redaction, prod mock gate
- POST /approvals and PATCH /approvals/{id} require a console admin session
(EndpointDef auth:"session"); API keys incl. admin scope get 403, so an
organize.execute key can no longer approve its own plan (S1)
- tests for single-use approval checks: expired, revision/payload hash
mismatch, consumed, revoked, pending/rejected, plan mismatch; re-approve
after plan change succeeds once and cannot be replayed (S4)
- one redact key table in @umt/contracts shared by server logger and agent
redactForModel: rsskey, torrent_pass, authkey, downhash, c_secure_pass,
passkey variants, x-api-key, cookie headers, nested objects/arrays (S2)
- TMDB network errors: message and cause redacted via redactUrl (S3)
- web Agent ?mock=1 only in DEV builds or when pi.provider is fake (S7)
- EndpointDef auth annotations for /auth/*, asserted against
PUBLIC_AUTH_PATHS (S10); doc drift fixes (S11)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
79cdd26457
commit
f48e1aac96
25 files changed
+825
-235
No files matched your search
@@ -1,16 +1,12 @@
|
|||||||
|
import { isSensitiveKey, REDACTED, redactString } from "@umt/contracts";
|
||||||
import pino, { type Logger } from "pino";
|
import pino, { type Logger } from "pino";
|
||||||
|
|
||||||
export const REDACTED = "[REDACTED]";
|
// 键表与 URL query / 请求头 / Bearer 规则与 Agent 的 redactForModel 共用一份(@umt/contracts redact.ts):
|
||||||
|
// 键名规范化后命中 passkey / rsskey / torrent_pass / authkey / downhash / c_secure_pass / cookie / x-api-key / *token …
|
||||||
|
// 即整体替换(数值型如 token 计数除外);其余字符串按 redactString 处理。
|
||||||
|
export { REDACTED, redactString };
|
||||||
|
|
||||||
// 字段名包含这些词即视为敏感(数值型如 token 计数除外)
|
const sensitive = (k: string, v: unknown) => isSensitiveKey(k) && typeof v !== "number" && v != null;
|
||||||
const SENSITIVE_KEY = /passkey|token|cookie|authorization|password|secret|api[-_]?key/i;
|
|
||||||
// URL query 中的敏感参数
|
|
||||||
const SENSITIVE_QUERY = /([?&](?:passkey|token|access_token|api_key|apikey|auth|sign)=)[^&#\s"'<>]*/gi;
|
|
||||||
|
|
||||||
/** 脱敏字符串中的 URL query 参数 */
|
|
||||||
export function redactString(s: string): string {
|
|
||||||
return s.replace(SENSITIVE_QUERY, `$1${REDACTED}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 递归脱敏:敏感字段整体替换,其余字符串做 URL query 脱敏 */
|
/** 递归脱敏:敏感字段整体替换,其余字符串做 URL query 脱敏 */
|
||||||
export function redact(value: unknown, depth = 0): unknown {
|
export function redact(value: unknown, depth = 0): unknown {
|
||||||
@@ -21,7 +17,7 @@ export function redact(value: unknown, depth = 0): unknown {
|
|||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(value)) {
|
for (const [k, v] of Object.entries(value)) {
|
||||||
if (SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null) out[k] = REDACTED;
|
if (sensitive(k, v)) out[k] = REDACTED;
|
||||||
else out[k] = redact(v, depth + 1);
|
else out[k] = redact(v, depth + 1);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -37,7 +33,7 @@ function redactError(err: Error, depth: number): Record<string, unknown> {
|
|||||||
const out: Record<string, unknown> = { type: err.name, message: redactString(err.message) };
|
const out: Record<string, unknown> = { type: err.name, message: redactString(err.message) };
|
||||||
if (err.stack) out.stack = redactString(err.stack);
|
if (err.stack) out.stack = redactString(err.stack);
|
||||||
for (const [k, v] of Object.entries(err)) {
|
for (const [k, v] of Object.entries(err)) {
|
||||||
out[k] = SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null ? REDACTED : redact(v, depth + 1);
|
out[k] = sensitive(k, v) ? REDACTED : redact(v, depth + 1);
|
||||||
}
|
}
|
||||||
if (err.cause !== undefined) out.cause = redact(err.cause, depth + 1);
|
if (err.cause !== undefined) out.cause = redact(err.cause, depth + 1);
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createRoute, type RouteConfig } from "@hono/zod-openapi";
|
|||||||
import { type Actor, type ApiScope, apiResponse, type EndpointDef } from "@umt/contracts";
|
import { type Actor, type ApiScope, apiResponse, type EndpointDef } from "@umt/contracts";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts";
|
||||||
import type { ModuleApp } from "../types.ts";
|
import type { ModuleApp } from "../types.ts";
|
||||||
|
|
||||||
// 本模块路由封装:吃 contracts 的 EndpointDef(Api.xxx),createRoute 进 OpenAPI + requireScope + 入参校验,
|
// 本模块路由封装:吃 contracts 的 EndpointDef(Api.xxx),createRoute 进 OpenAPI + requireScope + 入参校验,
|
||||||
@@ -44,7 +44,11 @@ export function route<D extends EndpointDef>(
|
|||||||
path: def.path,
|
path: def.path,
|
||||||
summary: def.summary,
|
summary: def.summary,
|
||||||
tags: [opts.tag ?? "agent"],
|
tags: [opts.tag ?? "agent"],
|
||||||
middleware: [requireScope(def.scope as ApiScope)] as never,
|
// auth: "session" 的端点(审批申请/决定)在 scope 之外再要求控制台管理员会话,API Key 一律 403
|
||||||
|
middleware: [
|
||||||
|
requireScope(def.scope as ApiScope),
|
||||||
|
...(def.auth === "session" ? [requireAdminSession()] : []),
|
||||||
|
] as never,
|
||||||
request,
|
request,
|
||||||
responses: {
|
responses: {
|
||||||
[status]: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
|
[status]: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export function registerAgentRoutes(
|
|||||||
},
|
},
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
);
|
);
|
||||||
route(app, Api.approvals.update!, async ({ params, body, actorId }) => {
|
route(app, Api.approvals.update, async ({ params, body, actorId }) => {
|
||||||
const r = await db()
|
const r = await db()
|
||||||
.transaction()
|
.transaction()
|
||||||
.execute(async (trx) => {
|
.execute(async (trx) => {
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import {
|
import { Api, CSRF_COOKIE, SESSION_COOKIE } from "@umt/contracts";
|
||||||
AdminSetupRequest,
|
|
||||||
CSRF_COOKIE,
|
|
||||||
LoginRequest,
|
|
||||||
LoginResult,
|
|
||||||
PasswordChangeRequest,
|
|
||||||
PasswordChangeResult,
|
|
||||||
SESSION_COOKIE,
|
|
||||||
SessionInfo,
|
|
||||||
TotpAction,
|
|
||||||
TotpActionResult,
|
|
||||||
TotpStatus,
|
|
||||||
} from "@umt/contracts";
|
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { deleteCookie, setCookie } from "hono/cookie";
|
import { deleteCookie, setCookie } from "hono/cookie";
|
||||||
import { z } from "zod";
|
import { type AuthEnv, deriveCsrfToken } from "../../http/auth.ts";
|
||||||
import { type AuthEnv, deriveCsrfToken, requireAdminSession } from "../../http/auth.ts";
|
|
||||||
import { ApiException } from "../../http/errors.ts";
|
import { ApiException } from "../../http/errors.ts";
|
||||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||||
import {
|
import {
|
||||||
@@ -38,56 +25,18 @@ import { audit, type Def, endpoint } from "./endpoint.ts";
|
|||||||
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
|
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
|
||||||
|
|
||||||
const tags = ["auth"];
|
const tags = ["auth"];
|
||||||
const OkResult = z.object({ ok: z.literal(true) });
|
|
||||||
|
|
||||||
|
// 端点定义直接取 contracts 注册表(scope 与 auth 标注只维护一份):setup/login/session 为 auth:"public",
|
||||||
|
// password/totp 为 auth:"session"(endpoint() 据此跳过 requireScope 或追加 requireAdminSession)。
|
||||||
const D = {
|
const D = {
|
||||||
setup: {
|
setup: { ...Api.auth.setup, tags },
|
||||||
method: "post",
|
login: { ...Api.auth.login, tags },
|
||||||
path: "/auth/setup",
|
logout: { ...Api.auth.logout, tags },
|
||||||
scope: "read",
|
session: { ...Api.auth.session, tags },
|
||||||
summary: "首次初始化管理员",
|
password: { ...Api.auth.password, tags },
|
||||||
body: AdminSetupRequest,
|
totpGet: { ...Api.auth.totpStatus, tags },
|
||||||
response: SessionInfo,
|
totpPost: { ...Api.auth.totp, tags },
|
||||||
tags,
|
} satisfies Record<string, Def>;
|
||||||
},
|
|
||||||
login: {
|
|
||||||
method: "post",
|
|
||||||
path: "/auth/login",
|
|
||||||
scope: "read",
|
|
||||||
summary: "管理员登录",
|
|
||||||
body: LoginRequest,
|
|
||||||
response: LoginResult,
|
|
||||||
tags,
|
|
||||||
},
|
|
||||||
logout: { method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult, tags },
|
|
||||||
session: {
|
|
||||||
method: "get",
|
|
||||||
path: "/auth/session",
|
|
||||||
scope: "read",
|
|
||||||
summary: "当前登录态(免鉴权)",
|
|
||||||
response: SessionInfo,
|
|
||||||
tags,
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
method: "post",
|
|
||||||
path: "/auth/password",
|
|
||||||
scope: "admin",
|
|
||||||
summary: "修改密码(吊销其它会话)",
|
|
||||||
body: PasswordChangeRequest,
|
|
||||||
response: PasswordChangeResult,
|
|
||||||
tags,
|
|
||||||
},
|
|
||||||
totpGet: { method: "get", path: "/auth/totp", scope: "admin", summary: "TOTP 状态", response: TotpStatus, tags },
|
|
||||||
totpPost: {
|
|
||||||
method: "post",
|
|
||||||
path: "/auth/totp",
|
|
||||||
scope: "admin",
|
|
||||||
summary: "TOTP 绑定/启用/停用",
|
|
||||||
body: TotpAction,
|
|
||||||
response: TotpActionResult,
|
|
||||||
tags,
|
|
||||||
},
|
|
||||||
} as const satisfies Record<string, Def>;
|
|
||||||
|
|
||||||
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
|
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
|
||||||
|
|
||||||
@@ -142,53 +91,38 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
|||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
endpoint(
|
endpoint(app, D.setup, async ({ body, c }) => {
|
||||||
app,
|
const admin = await setupAdmin(deps(), body, clientIp(c));
|
||||||
D.setup,
|
const s = await startSession(c, admin.id);
|
||||||
async ({ body, c }) => {
|
const actor = adminActor(admin);
|
||||||
const admin = await setupAdmin(deps(), body, clientIp(c));
|
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
|
||||||
const s = await startSession(c, admin.id);
|
ctx.logger.info({ username: admin.username }, "管理员已初始化");
|
||||||
const actor = adminActor(admin);
|
return await sessionInfo(ctx.db, actor, s.expiresAt);
|
||||||
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
|
});
|
||||||
ctx.logger.info({ username: admin.username }, "管理员已初始化");
|
|
||||||
return await sessionInfo(ctx.db, actor, s.expiresAt);
|
|
||||||
},
|
|
||||||
{ public: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
endpoint(
|
endpoint(app, D.login, async ({ body, c }) => {
|
||||||
app,
|
const r = await verifyLogin(deps(), body, clientIp(c));
|
||||||
D.login,
|
if (r.status === "totp_required") return { status: "totp_required" as const };
|
||||||
async ({ body, c }) => {
|
const s = await startSession(c, r.admin.id);
|
||||||
const r = await verifyLogin(deps(), body, clientIp(c));
|
const actor = adminActor(r.admin);
|
||||||
if (r.status === "totp_required") return { status: "totp_required" as const };
|
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
||||||
const s = await startSession(c, r.admin.id);
|
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||||
const actor = adminActor(r.admin);
|
});
|
||||||
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
|
||||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
|
||||||
},
|
|
||||||
{ public: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
endpoint(
|
endpoint(app, D.session, async ({ c }) => {
|
||||||
app,
|
const session = c.get("session");
|
||||||
D.session,
|
// CSRF cookie 丢了(被清)时顺手补发
|
||||||
async ({ c }) => {
|
if (session) {
|
||||||
const session = c.get("session");
|
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
|
||||||
// CSRF cookie 丢了(被清)时顺手补发
|
httpOnly: false,
|
||||||
if (session) {
|
sameSite: "Lax",
|
||||||
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
|
path: "/",
|
||||||
httpOnly: false,
|
secure: isHttps(c),
|
||||||
sameSite: "Lax",
|
expires: session.expiresAt,
|
||||||
path: "/",
|
});
|
||||||
secure: isHttps(c),
|
}
|
||||||
expires: session.expiresAt,
|
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
|
|
||||||
},
|
|
||||||
{ public: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
endpoint(app, D.logout, async ({ c, actor }) => {
|
endpoint(app, D.logout, async ({ c, actor }) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
@@ -200,44 +134,34 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
});
|
});
|
||||||
|
|
||||||
endpoint(
|
endpoint(app, D.password, async ({ body, c, actor }) => {
|
||||||
app,
|
const session = c.get("session");
|
||||||
D.password,
|
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
|
||||||
async ({ body, c, actor }) => {
|
const revoked = await changePassword(deps(), body, {
|
||||||
const session = c.get("session");
|
adminUserId: session.adminUserId,
|
||||||
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
|
sessionId: session.id,
|
||||||
const revoked = await changePassword(deps(), body, {
|
ip: clientIp(c),
|
||||||
adminUserId: session.adminUserId,
|
});
|
||||||
sessionId: session.id,
|
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
|
||||||
ip: clientIp(c),
|
return { revokedSessions: revoked };
|
||||||
});
|
});
|
||||||
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
|
|
||||||
return { revokedSessions: revoked };
|
|
||||||
},
|
|
||||||
{ middleware: [requireAdminSession()] },
|
|
||||||
);
|
|
||||||
|
|
||||||
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)), { middleware: [requireAdminSession()] });
|
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)));
|
||||||
|
|
||||||
endpoint(
|
endpoint(app, D.totpPost, async ({ body, actor }) => {
|
||||||
app,
|
const admin = await loadAdmin(ctx.db);
|
||||||
D.totpPost,
|
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
||||||
async ({ body, actor }) => {
|
if (body.action === "begin") {
|
||||||
const admin = await loadAdmin(ctx.db);
|
const r = await totpBegin(deps(), admin);
|
||||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
|
||||||
if (body.action === "begin") {
|
}
|
||||||
const r = await totpBegin(deps(), admin);
|
if (body.action === "enable") {
|
||||||
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
|
await totpEnable(deps(), admin, body.code);
|
||||||
}
|
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
||||||
if (body.action === "enable") {
|
} else {
|
||||||
await totpEnable(deps(), admin, body.code);
|
await totpDisable(deps(), admin, body);
|
||||||
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
||||||
} else {
|
}
|
||||||
await totpDisable(deps(), admin, body);
|
return { status: totpStatus(await loadAdmin(ctx.db)) };
|
||||||
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
});
|
||||||
}
|
|
||||||
return { status: totpStatus(await loadAdmin(ctx.db)) };
|
|
||||||
},
|
|
||||||
{ middleware: [requireAdminSession()] },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ import { type DB, type Kysely, sql } from "@umt/db";
|
|||||||
import type { Context, MiddlewareHandler } from "hono";
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts";
|
||||||
import { ApiException } from "../../http/errors.ts";
|
import { ApiException } from "../../http/errors.ts";
|
||||||
import type { ModuleApp } from "../types.ts";
|
import type { ModuleApp } from "../types.ts";
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export function endpoint<D extends Def>(
|
|||||||
extra: {
|
extra: {
|
||||||
middleware?: MiddlewareHandler<AuthEnv>[];
|
middleware?: MiddlewareHandler<AuthEnv>[];
|
||||||
status?: 200 | 201;
|
status?: 200 | 201;
|
||||||
/** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope */
|
/** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope;等价于 def.auth = "public" */
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -53,7 +53,11 @@ export function endpoint<D extends Def>(
|
|||||||
path: def.path,
|
path: def.path,
|
||||||
summary: def.summary,
|
summary: def.summary,
|
||||||
tags: def.tags ?? ["auth-settings"],
|
tags: def.tags ?? ["auth-settings"],
|
||||||
middleware: [...(extra.public ? [] : [requireScope(def.scope)]), ...(extra.middleware ?? [])] as never,
|
middleware: [
|
||||||
|
...(extra.public || def.auth === "public" ? [] : [requireScope(def.scope)]),
|
||||||
|
...(def.auth === "session" ? [requireAdminSession()] : []),
|
||||||
|
...(extra.middleware ?? []),
|
||||||
|
] as never,
|
||||||
request,
|
request,
|
||||||
responses: {
|
responses: {
|
||||||
[status]: {
|
[status]: {
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import type { CapabilitySpec } from "@umt/contracts";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createAuthorizer } from "../../src/modules/agent/authorize.ts";
|
||||||
|
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||||
|
import { createAgentModule } from "../../src/modules/agent/index.ts";
|
||||||
|
import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts";
|
||||||
|
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||||
|
import { DEV_KEY, json } from "../helpers.ts";
|
||||||
|
|
||||||
|
// 验收 S1 / S4:单次审批的申请与决定只接受控制台管理员会话;服务端授权器的审批分支
|
||||||
|
// (过期、revision / payload hash 变化、已使用、已撤销、计划不符)全部拒绝,通过一次后不可重放。
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
const PASSWORD = "correct horse battery";
|
||||||
|
|
||||||
|
// organize.execute 的最小规格:只关心授权与账本,处理器计数
|
||||||
|
const ORGANIZE_SPEC = {
|
||||||
|
id: "organize.execute",
|
||||||
|
version: 1,
|
||||||
|
title: "执行整理",
|
||||||
|
description: "测试用",
|
||||||
|
kind: "execute",
|
||||||
|
scope: "organize.execute",
|
||||||
|
riskLevel: "high",
|
||||||
|
idempotent: true,
|
||||||
|
supportsPreview: false,
|
||||||
|
replay: "never",
|
||||||
|
inputSchema: z.object({ planId: z.string(), approvalId: z.string().optional() }),
|
||||||
|
outputSchema: z.object({ ok: z.literal(true) }),
|
||||||
|
} as unknown as CapabilitySpec;
|
||||||
|
|
||||||
|
describe.skipIf(!hasPg)("单次审批(真实 PG)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let admin: Browser;
|
||||||
|
let orgKey: string;
|
||||||
|
let adminKey: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createHarness({ extraModules: [createAgentModule()] });
|
||||||
|
const token = await ensureSetupToken(h.ctx);
|
||||||
|
admin = new Browser(h.app);
|
||||||
|
const r = await admin.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: PASSWORD });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const mk = async (name: string, scopes: string[]) =>
|
||||||
|
(await json(await admin.req("POST", "/api-keys", { name, scopes }))).data.plaintextKey as string;
|
||||||
|
orgKey = await mk("organizer", ["organize.execute"]);
|
||||||
|
adminKey = await mk("ops", ["admin"]);
|
||||||
|
}, 120_000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 整理计划(artifact kind=plan):payload hash 即 content_hash,revision 固定 1 */
|
||||||
|
async function seedPlan(hash = "hash-v1") {
|
||||||
|
const r = await h.db
|
||||||
|
.insertInto("artifact")
|
||||||
|
.values({ kind: "plan", title: "organize plan", content: JSON.stringify({}), content_hash: hash })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return r.id;
|
||||||
|
}
|
||||||
|
async function seedApproval(
|
||||||
|
planId: string,
|
||||||
|
over: Partial<{ status: string; payload_hash: string; expected_revision: number; expires_at: Date | null }> = {},
|
||||||
|
) {
|
||||||
|
const r = await h.db
|
||||||
|
.insertInto("approval")
|
||||||
|
.values({
|
||||||
|
plan_type: "organize_plan",
|
||||||
|
plan_id: planId,
|
||||||
|
payload_hash: over.payload_hash ?? "hash-v1",
|
||||||
|
expected_revision: over.expected_revision ?? 1,
|
||||||
|
status: (over.status ?? "approved") as never,
|
||||||
|
requested_by: "user:admin",
|
||||||
|
expires_at: over.expires_at === undefined ? new Date(Date.now() + 3600_000) : over.expires_at,
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return r.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("审批端点只接受管理员会话(S1)", () => {
|
||||||
|
it("持 organize.execute 的 API Key 建审批 403;自批 403;admin scope 的 Key 同样 403", async () => {
|
||||||
|
const planId = await seedPlan();
|
||||||
|
const org = bearer(h.app, orgKey);
|
||||||
|
const ops = bearer(h.app, adminKey);
|
||||||
|
expect((await org("POST", "/approvals", { planType: "organize_plan", planId })).status).toBe(403);
|
||||||
|
expect((await ops("POST", "/approvals", { planType: "organize_plan", planId })).status).toBe(403);
|
||||||
|
expect((await bearer(h.app, DEV_KEY)("POST", "/approvals", { planType: "organize_plan", planId })).status).toBe(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 管理员会话申请一条 pending 审批;Key 想替自己的计划批准 → 403,状态不变
|
||||||
|
const created = await admin.req("POST", "/approvals", { planType: "organize_plan", planId });
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
const ap = (await json(created)).data;
|
||||||
|
expect(ap).toMatchObject({ status: "pending", payloadHash: "hash-v1", expectedRevision: 1 });
|
||||||
|
for (const k of [org, ops]) {
|
||||||
|
const r = await k("PATCH", `/approvals/${ap.id}`, { status: "approved", payloadHash: ap.payloadHash });
|
||||||
|
expect(r.status).toBe(403);
|
||||||
|
}
|
||||||
|
const still = await h.db.selectFrom("approval").select("status").where("id", "=", ap.id).executeTakeFirst();
|
||||||
|
expect(still?.status).toBe("pending");
|
||||||
|
// 查看仍按 read scope 开放给 Key
|
||||||
|
expect((await ops("GET", `/approvals/${ap.id}`)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("管理员会话可批准;缺 CSRF 403;payloadHash 不一致 409;重复决定 409", async () => {
|
||||||
|
const planId = await seedPlan();
|
||||||
|
const ap = (await json(await admin.req("POST", "/approvals", { planType: "organize_plan", planId }))).data;
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await admin.req(
|
||||||
|
"PATCH",
|
||||||
|
`/approvals/${ap.id}`,
|
||||||
|
{ status: "approved", payloadHash: ap.payloadHash },
|
||||||
|
{ csrf: false },
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await admin.req("PATCH", `/approvals/${ap.id}`, { status: "approved", payloadHash: "other" })).status,
|
||||||
|
).toBe(409);
|
||||||
|
const ok = await admin.req("PATCH", `/approvals/${ap.id}`, { status: "approved", payloadHash: ap.payloadHash });
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect((await json(ok)).data).toMatchObject({ status: "approved", decidedBy: expect.stringMatching(/^user:/) });
|
||||||
|
expect(
|
||||||
|
(await admin.req("PATCH", `/approvals/${ap.id}`, { status: "rejected", payloadHash: ap.payloadHash })).status,
|
||||||
|
).toBe(409);
|
||||||
|
const audits = await sql<{ n: number }>`
|
||||||
|
select count(*)::int as n from audit_event where action = 'approval.approved' and target_id = ${ap.id}`.execute(
|
||||||
|
h.db,
|
||||||
|
);
|
||||||
|
expect(audits.rows[0]!.n).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("授权器的单次审批分支(S4)", () => {
|
||||||
|
const check = (input: Record<string, unknown>) =>
|
||||||
|
createAuthorizer({ db: h.db, settings: h.ctx.settings })({
|
||||||
|
descriptor: ORGANIZE_SPEC,
|
||||||
|
input,
|
||||||
|
ctx: { actorId: "api_key:k1", taskId: "t", scopeIds: [], signal: AbortSignal.timeout(10_000), source: "web" },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有效审批放行并带回 approvalId", async () => {
|
||||||
|
const planId = await seedPlan();
|
||||||
|
const approvalId = await seedApproval(planId);
|
||||||
|
expect(await check({ planId, approvalId })).toEqual({ allowed: true, approvalId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["过期", { expires_at: new Date(Date.now() - 1000) }, /已过期/],
|
||||||
|
["revision 不匹配", { expected_revision: 2 }, /revision 已变化/],
|
||||||
|
["payload hash 不匹配", { payload_hash: "hash-v0" }, /内容已变化/],
|
||||||
|
["已使用", { status: "consumed" }, /consumed/],
|
||||||
|
["已撤销", { status: "revoked" }, /revoked/],
|
||||||
|
["未批准", { status: "pending" }, /pending/],
|
||||||
|
["已拒绝", { status: "rejected" }, /rejected/],
|
||||||
|
] as const)("%s → 拒绝", async (_name, over, reason) => {
|
||||||
|
const planId = await seedPlan();
|
||||||
|
const approvalId = await seedApproval(planId, over);
|
||||||
|
const d = await check({ planId, approvalId });
|
||||||
|
expect(d.allowed).toBe(false);
|
||||||
|
expect((d as { reason: string }).reason).toMatch(reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("审批绑定的计划与本次调用不符 → 拒绝;审批不存在 → 拒绝", async () => {
|
||||||
|
const planA = await seedPlan();
|
||||||
|
const planB = await seedPlan();
|
||||||
|
const approvalId = await seedApproval(planA);
|
||||||
|
expect(await check({ planId: planB, approvalId })).toMatchObject({ allowed: false, reason: /不一致/ });
|
||||||
|
expect(await check({ planId: planA, approvalId: "00000000-0000-4000-8000-000000000000" })).toMatchObject({
|
||||||
|
allowed: false,
|
||||||
|
reason: /不存在/,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("审批后改计划 → 被拒 → 重新审批 → 成功且只消费一次,之后不可重放", async () => {
|
||||||
|
const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
|
||||||
|
let calls = 0;
|
||||||
|
registry.register(ORGANIZE_SPEC, async () => {
|
||||||
|
calls++;
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
|
const invoke = (input: Record<string, unknown>, taskId: string) =>
|
||||||
|
registry.invoke("organize.execute", input, {
|
||||||
|
actorId: "api_key:k1",
|
||||||
|
taskId,
|
||||||
|
scopeIds: [],
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
source: "web",
|
||||||
|
});
|
||||||
|
|
||||||
|
const planId = await seedPlan("hash-a");
|
||||||
|
const ap1 = (await json(await admin.req("POST", "/approvals", { planType: "organize_plan", planId }))).data;
|
||||||
|
await admin.req("PATCH", `/approvals/${ap1.id}`, { status: "approved", payloadHash: ap1.payloadHash });
|
||||||
|
// 计划内容变了:旧审批失效
|
||||||
|
await h.db.updateTable("artifact").set({ content_hash: "hash-b" }).where("id", "=", planId).execute();
|
||||||
|
await expect(invoke({ planId, approvalId: ap1.id }, "http:r1")).rejects.toThrow(/内容已变化/);
|
||||||
|
expect(calls).toBe(0);
|
||||||
|
|
||||||
|
const ap2 = (await json(await admin.req("POST", "/approvals", { planType: "organize_plan", planId }))).data;
|
||||||
|
expect(ap2.payloadHash).toBe("hash-b");
|
||||||
|
await admin.req("PATCH", `/approvals/${ap2.id}`, { status: "approved", payloadHash: ap2.payloadHash });
|
||||||
|
await expect(invoke({ planId, approvalId: ap2.id }, "http:r2")).resolves.toEqual({ ok: true });
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
const row = await h.db.selectFrom("approval").select("status").where("id", "=", ap2.id).executeTakeFirst();
|
||||||
|
expect(row?.status).toBe("consumed");
|
||||||
|
|
||||||
|
// 换一个请求重放同一审批 → 拒绝,处理器不再执行
|
||||||
|
await expect(invoke({ planId, approvalId: ap2.id }, "http:r3")).rejects.toThrow(/consumed/);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
const ledger = await h.db
|
||||||
|
.selectFrom("capability_invocation")
|
||||||
|
.select(["approval_id", "status"])
|
||||||
|
.where("approval_id", "=", ap2.id)
|
||||||
|
.execute();
|
||||||
|
expect(ledger).toEqual([{ approval_id: ap2.id, status: "succeeded" }]);
|
||||||
|
const denied = await sql<{ n: number }>`
|
||||||
|
select count(*)::int as n from audit_event where action = 'capability.denied' and target_id = 'organize.execute'`.execute(
|
||||||
|
h.db,
|
||||||
|
);
|
||||||
|
expect(denied.rows[0]!.n).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { API_PREFIX, listEndpoints } from "@umt/contracts";
|
import { API_PREFIX, listEndpoints } from "@umt/contracts";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createApp } from "../src/http/app.ts";
|
import { createApp } from "../src/http/app.ts";
|
||||||
|
import { PUBLIC_AUTH_PATHS } from "../src/http/auth.ts";
|
||||||
import { EventBus } from "../src/http/sse.ts";
|
import { EventBus } from "../src/http/sse.ts";
|
||||||
import { SERVER_MODULES } from "../src/modules/index.ts";
|
import { SERVER_MODULES } from "../src/modules/index.ts";
|
||||||
import type { ServerContext } from "../src/modules/types.ts";
|
import type { ServerContext } from "../src/modules/types.ts";
|
||||||
@@ -41,3 +42,50 @@ describe("Api 注册表覆盖", () => {
|
|||||||
expect(unimplemented, `以下端点登记在 api.ts 但未实现:\n${unimplemented.join("\n")}`).toEqual([]);
|
expect(unimplemented, `以下端点登记在 api.ts 但未实现:\n${unimplemented.join("\n")}`).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 验收 S10:注册表的鉴权标注与服务端实际行为一致(生成 scope 文档不误导集成方)
|
||||||
|
describe("Api 注册表鉴权标注", () => {
|
||||||
|
it("auth:'public' 的端点恰好是 PUBLIC_AUTH_PATHS", () => {
|
||||||
|
const pub = listEndpoints()
|
||||||
|
.filter((e) => e.auth === "public")
|
||||||
|
.map((e) => e.path)
|
||||||
|
.sort();
|
||||||
|
expect(pub).toEqual([...PUBLIC_AUTH_PATHS].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只接受控制台会话的端点都标 auth:'session' 且 scope 为 admin", () => {
|
||||||
|
const session = listEndpoints()
|
||||||
|
.filter((e) => e.auth === "session")
|
||||||
|
.map((e) => `${e.method.toUpperCase()} ${e.path}`)
|
||||||
|
.sort();
|
||||||
|
expect(session).toEqual([
|
||||||
|
"GET /auth/totp",
|
||||||
|
"PATCH /approvals/{id}",
|
||||||
|
"POST /approvals",
|
||||||
|
"POST /auth/password",
|
||||||
|
"POST /auth/totp",
|
||||||
|
]);
|
||||||
|
for (const e of listEndpoints().filter((x) => x.auth === "session")) expect(e.scope).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auth:'session' 的端点拒绝 Bearer(DEV_ADMIN_KEY 也不行)", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||||
|
logger: memoryLogger().logger,
|
||||||
|
bus: new EventBus(5),
|
||||||
|
role: "all",
|
||||||
|
version: "0.0.0-test",
|
||||||
|
modules: SERVER_MODULES,
|
||||||
|
ctx: { role: "all" } as unknown as ServerContext,
|
||||||
|
});
|
||||||
|
for (const e of listEndpoints().filter((x) => x.auth === "session")) {
|
||||||
|
const path = e.path.replace("{id}", "00000000-0000-4000-8000-000000000000");
|
||||||
|
const res = await app.request(`${API_PREFIX}${path}`, {
|
||||||
|
method: e.method.toUpperCase(),
|
||||||
|
headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" },
|
||||||
|
...(e.method === "get" ? {} : { body: "{}" }),
|
||||||
|
});
|
||||||
|
expect(res.status, `${e.method} ${e.path}`).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ import { EventBus } from "../../src/http/sse.ts";
|
|||||||
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||||
import { createAuthSettingsModule } from "../../src/modules/auth-settings/index.ts";
|
import { createAuthSettingsModule } from "../../src/modules/auth-settings/index.ts";
|
||||||
import { createSecretStore, createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
|
import { createSecretStore, createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
|
||||||
import type { ServerContext } from "../../src/modules/types.ts";
|
import type { ServerContext, ServerModule } from "../../src/modules/types.ts";
|
||||||
import { DEV_KEY, memoryLogger } from "../helpers.ts";
|
import { DEV_KEY, memoryLogger } from "../helpers.ts";
|
||||||
|
|
||||||
// 真实 PG 的 W2-A 测试夹具:独立业务 schema + 独立引擎 schema;任务引擎用假对象(只测 cancel 透传)。
|
// 真实 PG 的 W2-A 测试夹具:独立业务 schema + 独立引擎 schema;任务引擎用假对象(只测 cancel 透传)。
|
||||||
@@ -64,7 +64,7 @@ async function adminQuery(q: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createHarness(opts: { engine?: boolean } = {}) {
|
export async function createHarness(opts: { engine?: boolean; extraModules?: ServerModule[] } = {}) {
|
||||||
const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
const schema = `w2a_test_${uniq}`;
|
const schema = `w2a_test_${uniq}`;
|
||||||
const engineSchema = `${schema}_ow`;
|
const engineSchema = `${schema}_ow`;
|
||||||
@@ -100,7 +100,16 @@ export async function createHarness(opts: { engine?: boolean } = {}) {
|
|||||||
capabilities: createCapabilityRegistry({ logger }),
|
capabilities: createCapabilityRegistry({ logger }),
|
||||||
} as ServerContext;
|
} as ServerContext;
|
||||||
const mod = createAuthSettingsModule({ setupTokenOnStart: false, engineSchema });
|
const mod = createAuthSettingsModule({ setupTokenOnStart: false, engineSchema });
|
||||||
const app = createApp({ config, logger, bus, db, role: "all", version: "0.0.0-test", modules: [mod], ctx });
|
const app = createApp({
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
bus,
|
||||||
|
db,
|
||||||
|
role: "all",
|
||||||
|
version: "0.0.0-test",
|
||||||
|
modules: [mod, ...(opts.extraModules ?? [])],
|
||||||
|
ctx,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -60,3 +60,96 @@ describe("日志脱敏:child 绑定与 Error 序列化", () => {
|
|||||||
expect(redact(new Error("x?token=abc"))).toMatchObject({ type: "Error", message: "x?token=[REDACTED]" });
|
expect(redact(new Error("x?token=abc"))).toMatchObject({ type: "Error", message: "x?token=[REDACTED]" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 验收 S2:站点常见凭证参数/键名全部覆盖,键表与 Agent redactForModel 同源(@umt/contracts)
|
||||||
|
describe("日志脱敏:站点凭证键全集", () => {
|
||||||
|
const PARAMS = [
|
||||||
|
"passkey",
|
||||||
|
"PassKey",
|
||||||
|
"pass_key",
|
||||||
|
"pass-key",
|
||||||
|
"rsskey",
|
||||||
|
"torrent_pass",
|
||||||
|
"authkey",
|
||||||
|
"downhash",
|
||||||
|
"c_secure_pass",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"token",
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(PARAMS)("URL query 参数 %s 被遮住", (name) => {
|
||||||
|
const out = redactString(`GET https://pt.example/rss.php?id=7&${name}=SECRETVALUE9&x=1`);
|
||||||
|
expect(out).not.toContain("SECRETVALUE9");
|
||||||
|
expect(out).toContain(`${name}=[REDACTED]`);
|
||||||
|
expect(out).toContain("id=7");
|
||||||
|
expect(out).toContain("x=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("验收报告里的实测串一次性全部遮住", () => {
|
||||||
|
const out = redactString("/rss?rsskey=abc1&torrent_pass=dead2&authkey=zz3&downhash=q14&c_secure_pass=cc5");
|
||||||
|
for (const s of ["abc1", "dead2", "zz3", "q14", "cc5"]) expect(out).not.toContain(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
const KEYS = [
|
||||||
|
"passkey",
|
||||||
|
"PassKey",
|
||||||
|
"pass-key",
|
||||||
|
"pass_key",
|
||||||
|
"PASSKEY",
|
||||||
|
"rsskey",
|
||||||
|
"rssKey",
|
||||||
|
"torrent_pass",
|
||||||
|
"torrentPass",
|
||||||
|
"authkey",
|
||||||
|
"authKey",
|
||||||
|
"downhash",
|
||||||
|
"c_secure_pass",
|
||||||
|
"x-api-key",
|
||||||
|
"X-Api-Key",
|
||||||
|
"apiKey",
|
||||||
|
"cookie",
|
||||||
|
"Cookie",
|
||||||
|
"set-cookie",
|
||||||
|
"authorization",
|
||||||
|
"accessToken",
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(KEYS)("对象键 %s 在嵌套对象与数组里都整体替换", (key) => {
|
||||||
|
const input = { top: { [key]: "SECRETV" }, list: [{ deep: { [key]: "SECRETW" } }], [key]: ["SECRETX"] };
|
||||||
|
const out = redact(input) as Record<string, unknown>;
|
||||||
|
expect(JSON.stringify(out)).not.toMatch(/SECRET[VWX]/);
|
||||||
|
expect((out.top as Record<string, unknown>)[key]).toBe("[REDACTED]");
|
||||||
|
expect(out[key]).toBe("[REDACTED]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cookie / x-api-key 请求头行(字符串里)被遮住", () => {
|
||||||
|
const out = redactString("request headers:\ncookie: c_secure_pass=abc; uid=1\nx-api-key: KKKKKKKK\nAccept: */*");
|
||||||
|
expect(out).not.toMatch(/abc|KKKKKKKK/);
|
||||||
|
expect(out).toContain("Accept: */*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("日志输出里 headers / 数组中的凭证不出现", () => {
|
||||||
|
const { logger, lines } = memoryLogger();
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
req: { headers: { Cookie: "SECRET_A", "X-Api-Key": "SECRET_B", accept: "json" } },
|
||||||
|
urls: ["https://x/rss?rsskey=SECRET_C", "https://x/dl?torrent_pass=SECRET_D&downhash=SECRET_E"],
|
||||||
|
site: { authKey: "SECRET_F", c_secure_pass: "SECRET_G" },
|
||||||
|
usage: { inputTokens: 42 },
|
||||||
|
},
|
||||||
|
"fetch https://x/?authkey=SECRET_H&c_secure_pass=SECRET_I",
|
||||||
|
);
|
||||||
|
const out = lines.join("");
|
||||||
|
expect(out).not.toMatch(/SECRET_[A-I]/);
|
||||||
|
const line = JSON.parse(lines.at(-1)!);
|
||||||
|
expect(line.req.headers.accept).toBe("json");
|
||||||
|
expect(line.usage.inputTokens).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("与 Agent 的 redactForModel 同源:同一输入得到同样的遮挡结果", async () => {
|
||||||
|
const { redactString: agentRedact } = await import("@umt/agent");
|
||||||
|
const s = "https://x/rss?rsskey=a1&torrent_pass=b2&authkey=c3&downhash=d4&c_secure_pass=e5&passkey=f6";
|
||||||
|
expect(redactString(s)).toBe(agentRedact(s));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,20 @@
|
|||||||
# @umt/web
|
# @umt/web
|
||||||
|
|
||||||
React Router 7 SPA 控制台(`ssr:false`),沿用 uhd_web 设计系统(token 见 `app/app.css`,规范见 `docs/design/`)。只通过 `/api/v1` 与 server 通信,请求/响应类型一律来自 `@umt/contracts`,不手写重复 DTO。开发端口 5180,`/api` 代理到 http://localhost:4380。
|
React Router 7 SPA 控制台(`ssr:false`),沿用 uhd_web 设计系统(token 见 `app/app.css`,规范见 `docs/design/`)。只通过 `/api/v1` 与 server 通信,请求/响应类型一律来自 `@umt/contracts`,不手写重复 DTO。开发端口 5180,`/api` 代理到 http://localhost:4380。
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
- `deno task dev:web`:开发服务器(5180)。
|
||||||
|
- `cd apps/web && deno task build`:生产构建到 `build/client`,由 server 的 `WEB_ROOT` 同端口托管。
|
||||||
|
- `cd apps/web && deno task test`:单测(`app/**/*.test.ts`)。
|
||||||
|
- `deno task test:web:smoke`:Playwright 全页面冒烟,前提与环境变量见仓库 README。
|
||||||
|
- `deno task check:tokens`:设计 token 守卫。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `app/routes/`:页面(`routes.ts` 登记全部路由);`app/routes/console/` 为控制台。
|
||||||
|
- `app/api/`:按资源分的 react-query hook,路径与 schema 取 `@umt/contracts` 的 `Api` 注册表。
|
||||||
|
- `app/components/`:业务组件与 `ui/` 基础组件。
|
||||||
|
- Agent 页的 `?mock=1` 演示模式只在开发构建或后端 `pi.provider = "fake"` 时生效,生产构建连真实模型时忽略。
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { agentMockAllowed, resolveAgentMock } from "./agent.ts";
|
||||||
|
|
||||||
|
class MemStore {
|
||||||
|
m = new Map<string, string>();
|
||||||
|
getItem(k: string) {
|
||||||
|
return this.m.get(k) ?? null;
|
||||||
|
}
|
||||||
|
setItem(k: string, v: string) {
|
||||||
|
this.m.set(k, v);
|
||||||
|
}
|
||||||
|
removeItem(k: string) {
|
||||||
|
this.m.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验收 S7:生产构建里 ?mock=1 不得把演示数据当真实运行展示
|
||||||
|
describe("Agent 演示开关", () => {
|
||||||
|
it("只在开发构建或后端 provider=fake 时允许", () => {
|
||||||
|
expect(agentMockAllowed({ dev: true })).toBe(true);
|
||||||
|
expect(agentMockAllowed({ dev: false, provider: "fake" })).toBe(true);
|
||||||
|
expect(agentMockAllowed({ dev: false, provider: "anthropic" })).toBe(false);
|
||||||
|
expect(agentMockAllowed({ dev: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("生产 + 真实模型:?mock=1 与 VITE_AGENT_MOCK 都被忽略,并清掉记住的开关", () => {
|
||||||
|
const storage = new MemStore();
|
||||||
|
storage.setItem("umt-agent-mock", "1");
|
||||||
|
expect(resolveAgentMock({ allowed: false, envFlag: "1", search: "?mock=1", storage })).toBe(false);
|
||||||
|
expect(storage.getItem("umt-agent-mock")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("允许时:?mock=1 记住,?mock=0 退出,构建期开关直接打开", () => {
|
||||||
|
const storage = new MemStore();
|
||||||
|
expect(resolveAgentMock({ allowed: true, search: "?mock=1", storage })).toBe(true);
|
||||||
|
expect(resolveAgentMock({ allowed: true, search: "", storage })).toBe(true);
|
||||||
|
expect(resolveAgentMock({ allowed: true, search: "?mock=0", storage })).toBe(false);
|
||||||
|
expect(resolveAgentMock({ allowed: true, envFlag: "1", search: "", storage })).toBe(true);
|
||||||
|
expect(resolveAgentMock({ allowed: true, search: "?mock=1", storage: null })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+69
-10
@@ -13,10 +13,12 @@ import {
|
|||||||
import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts";
|
import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts";
|
||||||
import { TTL_SECOND } from "~/lib/api.ts";
|
import { TTL_SECOND } from "~/lib/api.ts";
|
||||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||||
|
import { useSettings } from "./settings.ts";
|
||||||
|
|
||||||
// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort),
|
// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort),
|
||||||
// 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。
|
// 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。
|
||||||
// ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互。
|
// ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互;
|
||||||
|
// 仅开发构建或后端 pi.provider = "fake" 时生效,生产构建连真实模型时忽略(见 agentMockAllowed)。
|
||||||
|
|
||||||
export const AGENT_BASE = "/api/v1/agent/runs";
|
export const AGENT_BASE = "/api/v1/agent/runs";
|
||||||
|
|
||||||
@@ -34,19 +36,76 @@ export type RunSnapshot = Omit<RunDetail, "events" | "nextAfterSeq">;
|
|||||||
|
|
||||||
const RunPage = cursorPage(AgentRun);
|
const RunPage = cursorPage(AgentRun);
|
||||||
|
|
||||||
export function agentMockEnabled(): boolean {
|
/**
|
||||||
if (import.meta.env.VITE_AGENT_MOCK === "1") return true;
|
* 演示开关是否可用:只在开发构建(import.meta.env.DEV)或后端 pi.provider = "fake"(脚本化假模型)时生效。
|
||||||
if (typeof window === "undefined") return false;
|
* 生产构建连着真实模型时一律忽略 ?mock=1 / VITE_AGENT_MOCK,并清掉 sessionStorage 里记住的开关,
|
||||||
const p = new URLSearchParams(window.location.search);
|
* 避免把演示数据当成真实运行展示(验收 S7 / AT78)。
|
||||||
|
*/
|
||||||
|
export function agentMockAllowed(o: { dev: boolean; provider?: string | null | undefined }): boolean {
|
||||||
|
return o.dev || o.provider === "fake";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockFlagStore {
|
||||||
|
getItem(k: string): string | null;
|
||||||
|
setItem(k: string, v: string): void;
|
||||||
|
removeItem(k: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_KEY = "umt-agent-mock";
|
||||||
|
|
||||||
|
/** 纯函数:按是否允许、构建期开关、URL 参数与会话存储决定是否进演示模式(单测覆盖) */
|
||||||
|
export function resolveAgentMock(o: {
|
||||||
|
allowed: boolean;
|
||||||
|
envFlag?: string | undefined;
|
||||||
|
search: string;
|
||||||
|
storage?: MockFlagStore | null;
|
||||||
|
}): boolean {
|
||||||
|
if (!o.allowed) {
|
||||||
|
try {
|
||||||
|
o.storage?.removeItem(MOCK_KEY);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时无事可清
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (o.envFlag === "1") return true;
|
||||||
|
const p = new URLSearchParams(o.search);
|
||||||
try {
|
try {
|
||||||
if (p.get("mock") === "1") sessionStorage.setItem("umt-agent-mock", "1");
|
if (!o.storage) return p.get("mock") === "1";
|
||||||
if (p.get("mock") === "0") sessionStorage.removeItem("umt-agent-mock");
|
if (p.get("mock") === "1") o.storage.setItem(MOCK_KEY, "1");
|
||||||
return sessionStorage.getItem("umt-agent-mock") === "1";
|
if (p.get("mock") === "0") o.storage.removeItem(MOCK_KEY);
|
||||||
|
return o.storage.getItem(MOCK_KEY) === "1";
|
||||||
} catch {
|
} catch {
|
||||||
return p.get("mock") === "1";
|
return p.get("mock") === "1";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 最近一次读到的后端 pi.provider 是否为 fake(由 useAgentMockGate 从 /settings 缓存同步) */
|
||||||
|
let fakeProvider = false;
|
||||||
|
|
||||||
|
export function agentMockEnabled(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
let storage: MockFlagStore | null = null;
|
||||||
|
try {
|
||||||
|
storage = window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
storage = null;
|
||||||
|
}
|
||||||
|
return resolveAgentMock({
|
||||||
|
allowed: agentMockAllowed({ dev: import.meta.env.DEV, provider: fakeProvider ? "fake" : null }),
|
||||||
|
envFlag: import.meta.env.VITE_AGENT_MOCK,
|
||||||
|
search: window.location.search,
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent 页的列表/详情 hook 里调用:同步后端 provider,再判断演示开关 */
|
||||||
|
function useAgentMockGate(): boolean {
|
||||||
|
const settings = useSettings();
|
||||||
|
fakeProvider = settings.data?.pi?.provider === "fake";
|
||||||
|
return agentMockEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
export const agentKeys = {
|
export const agentKeys = {
|
||||||
all: ["agent"] as const,
|
all: ["agent"] as const,
|
||||||
runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const,
|
runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const,
|
||||||
@@ -57,7 +116,7 @@ const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"];
|
|||||||
export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s);
|
export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s);
|
||||||
|
|
||||||
export function useAgentRuns(status?: AgentRunStatus) {
|
export function useAgentRuns(status?: AgentRunStatus) {
|
||||||
const mock = agentMockEnabled();
|
const mock = useAgentMockGate();
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: [...agentKeys.runs(status), mock],
|
queryKey: [...agentKeys.runs(status), mock],
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
@@ -75,7 +134,7 @@ export function useAgentRuns(status?: AgentRunStatus) {
|
|||||||
const NO_EVENTS = 2 ** 31 - 1;
|
const NO_EVENTS = 2 ** 31 - 1;
|
||||||
|
|
||||||
export function useAgentRun(id: string | undefined) {
|
export function useAgentRun(id: string | undefined) {
|
||||||
const mock = agentMockEnabled();
|
const mock = useAgentMockGate();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...agentKeys.run(id ?? ""), mock],
|
queryKey: [...agentKeys.run(id ?? ""), mock],
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
| 数据库 | PostgreSQL 16;Kysely + pg;SQL 迁移文件 + Kysely Migrator;`kysely-codegen` 或手写 DB 类型 | 开发用 docker-compose 起 `postgres:16-alpine`,端口 55433。 |
|
| 数据库 | PostgreSQL 16;Kysely + pg;SQL 迁移文件 + Kysely Migrator;`kysely-codegen` 或手写 DB 类型 | 开发用 docker-compose 起 `postgres:16-alpine`,端口 55433。 |
|
||||||
| 持久任务引擎 | 优先 `[email protected]`(peer: postgres.js);**须先通过 spike 验证**(重启恢复/等待信号/取消/重试)。未通过则用 `packages/tasks` 内的 PG 租约式任务表(lease + attempts + dead-letter)。 | 结论写 ADR-002。 |
|
| 持久任务引擎 | 优先 `[email protected]`(peer: postgres.js);**须先通过 spike 验证**(重启恢复/等待信号/取消/重试)。未通过则用 `packages/tasks` 内的 PG 租约式任务表(lease + attempts + dead-letter)。 | 结论写 ADR-002。 |
|
||||||
| Agent 内核 | `@earendil-works/[email protected]` + `@earendil-works/[email protected]`,锁版本;通过自有 `AgentRuntimePort` 封装,显式工具白名单,不开放 bash/文件系统/任意网络 | 测试用脚本化 fake 模型 provider;真实上游由设置页配置。 |
|
| Agent 内核 | `@earendil-works/[email protected]` + `@earendil-works/[email protected]`,锁版本;通过自有 `AgentRuntimePort` 封装,显式工具白名单,不开放 bash/文件系统/任意网络 | 测试用脚本化 fake 模型 provider;真实上游由设置页配置。 |
|
||||||
| 校验/测试 | vitest 5(`deno run -A npm:vitest run`,`deno task test` 逐成员执行);类型门禁两道:tsc 7.0.2(`npm:[email protected]`,Go 原生实现,入口 `tsc`)+ `deno check`(Deno 内置 TS 6.0.3);lint/format 用 biome 2.5.14(开启 `useImportExtensions`,相对 import 必须带扩展名);web 沿用 token 守卫脚本 | `deno task ci` = typecheck(tsc 7.0.2 + deno check)+ lint + test + check:tokens + build(web 构建 + `deno compile` 单文件 server)。W1.5 实测(2026-09-27)连跑 3 次全绿,约 75 秒/次;测试数 server 24、adapters 214(+2 skip)、agent 27、contracts 361、db 11、domain 230、tasks 15、testkit 1、web 2 |
|
| 校验/测试 | vitest 5(`deno run -A npm:vitest run`,`deno task test` 逐成员执行);类型门禁两道:tsc 7.0.2(`npm:[email protected]`,Go 原生实现,入口 `tsc`)+ `deno check`(Deno 内置 TS 6.0.3);lint/format 用 biome 2.5.14(开启 `useImportExtensions`,相对 import 必须带扩展名);web 沿用 token 守卫脚本 | `deno task ci` = typecheck(tsc 7.0.2 + deno check)+ lint + test + check:tokens + build(web 构建 + `deno compile` 单文件 server)。W1.5 实测(2026-09-27)连跑 3 次全绿,约 75 秒/次(v0.1 验收时已涨到约 135 秒/次,server 与 tasks 测试是主要耗时,见 04);测试数 server 24、adapters 214(+2 skip)、agent 27、contracts 361、db 11、domain 230、tasks 15、testkit 1、web 2 |
|
||||||
| 端口 | web dev 5180,API 4380,PG 55433 | 本机 3000/5175/5176/55432 已被占用,不要用 |
|
| 端口 | web dev 5180,API 4380,PG 55433 | 本机 3000/5175/5176/55432 已被占用,不要用 |
|
||||||
| 参考源码 | MoviePilot v3: `/opt/moviepilot-plus/upstream/MoviePilot`(提交 e6820a8,GPLv3,只做行为对照与 TS 重写,不复制文件)。**数据来源适配(站点 spider、馒头、RSS、TMDB、qB)照抄 MP v3 实现迁移**(用户 2026-09-27 明确):`app/modules/indexer/spider/__init__.py`、`spider/mtorrent.py`、`app/application/rss.py`、`app/modules/themoviedb/tmdbapi.py`、`app/modules/qbittorrent/qbittorrent.py`;站点定义按 `app/application/site/sites.pyi` 的 schema 手写(MP 的定义文件 `user.sites.v3.bin` 是加密的,无法取用)。**用户 2026-09-27 追加:MP 开源的站点框架全部移植**——所有专用 spider(haidan/hddolby/rousi/sunnypt/tnode/torrentleech/yema/mtorrent)与所有站点类型用户信息解析器(nexus_php/nexus_audiences/nexus_hhanclub/nexus_project/nexus_rabbit/gazelle/unit3d/discuz/ipt/file_list/small_horse/zhixing/bitpt/…),文件与函数一一对应,并维护 `docs/porting/mp-sync-ledger.md` + `scripts/mp-sync` 差异脚本,以后跟随 MP 提交增量移植;MovieClaw 若本机不存在则按 REQ 附录链接对照 | 迁移对照记录写 `docs/porting/` |
|
| 参考源码 | MoviePilot v3: `/opt/moviepilot-plus/upstream/MoviePilot`(提交 e6820a8,GPLv3,只做行为对照与 TS 重写,不复制文件)。**数据来源适配(站点 spider、馒头、RSS、TMDB、qB)照抄 MP v3 实现迁移**(用户 2026-09-27 明确):`app/modules/indexer/spider/__init__.py`、`spider/mtorrent.py`、`app/application/rss.py`、`app/modules/themoviedb/tmdbapi.py`、`app/modules/qbittorrent/qbittorrent.py`;站点定义按 `app/application/site/sites.pyi` 的 schema 手写(MP 的定义文件 `user.sites.v3.bin` 是加密的,无法取用)。**用户 2026-09-27 追加:MP 开源的站点框架全部移植**——所有专用 spider(haidan/hddolby/rousi/sunnypt/tnode/torrentleech/yema/mtorrent)与所有站点类型用户信息解析器(nexus_php/nexus_audiences/nexus_hhanclub/nexus_project/nexus_rabbit/gazelle/unit3d/discuz/ipt/file_list/small_horse/zhixing/bitpt/…),文件与函数一一对应,并维护 `docs/porting/mp-sync-ledger.md` + `scripts/mp-sync` 差异脚本,以后跟随 MP 提交增量移植;MovieClaw 若本机不存在则按 REQ 附录链接对照 | 迁移对照记录写 `docs/porting/` |
|
||||||
|
|
||||||
## 2. 仓库结构(pnpm monorepo)
|
## 2. 仓库结构(Deno workspace)
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/web React Router 7 SPA 控制台(复用 uhd_web 设计系统)
|
apps/web React Router 7 SPA 控制台(复用 uhd_web 设计系统)
|
||||||
@@ -41,7 +41,7 @@ scripts dev/ci 辅助脚本
|
|||||||
- `packages/domain` 不 import 任何 IO 库,100% 可单元测试。
|
- `packages/domain` 不 import 任何 IO 库,100% 可单元测试。
|
||||||
- 每个包有 `src/index.ts` 作为唯一公开入口;跨包只通过入口引用。
|
- 每个包有 `src/index.ts` 作为唯一公开入口;跨包只通过入口引用。
|
||||||
- 命名:表名 snake_case 与 REQ §12 实体名一致;TS 类型 PascalCase;能力 ID 用 `域.动作`(如 `download.submit`)。
|
- 命名:表名 snake_case 与 REQ §12 实体名一致;TS 类型 PascalCase;能力 ID 用 `域.动作`(如 `download.submit`)。
|
||||||
- 提交:conventional commits(`feat(server): ...`),每个子任务结束前 `pnpm ci` 必须绿再提交。
|
- 提交:conventional commits(`feat(server): ...`),每个子任务结束前 `deno task ci` 必须绿再提交。
|
||||||
- 凭证一律 secret reference(`secret_ref` 列 + 加密存储),日志/错误/模型上下文脱敏;含 passkey 的 URL 不得进入日志。
|
- 凭证一律 secret reference(`secret_ref` 列 + 加密存储),日志/错误/模型上下文脱敏;含 passkey 的 URL 不得进入日志。
|
||||||
|
|
||||||
## 3. v0.1 范围(= REQ M0 + M1 纵切,可在无真实 PT 账号下用 mock 全链路验证)
|
## 3. v0.1 范围(= REQ M0 + M1 纵切,可在无真实 PT 账号下用 mock 全链路验证)
|
||||||
@@ -73,7 +73,7 @@ scripts dev/ci 辅助脚本
|
|||||||
- **W3 控制台**(按页面分组并行)+ e2e。
|
- **W3 控制台**(按页面分组并行)+ e2e。
|
||||||
- **W4 集成验收与修复**:跑 §3.1 第 13 条全部;补 runbook;打 tag v0.1.0。
|
- **W4 集成验收与修复**:跑 §3.1 第 13 条全部;补 runbook;打 tag v0.1.0。
|
||||||
|
|
||||||
每波结束的验收由独立审查子代理执行:`pnpm ci` 绿 + 对照本文件逐条勾选 + 抽查 REQ §2.1 五原则未被破坏。
|
每波结束的验收由独立审查子代理执行:`deno task ci` 绿 + 对照本文件逐条勾选 + 抽查 REQ §2.1 五原则未被破坏。
|
||||||
|
|
||||||
## 5. W2 服务编排拆分(2026-09-27 定稿)
|
## 5. W2 服务编排拆分(2026-09-27 定稿)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| `POST /agent/runs/{id}/messages {text}` | 追加用户消息。运行中时,当前会话在本轮结束后消费;已结束或等待中时,会唤醒一次新的 runAgent 续跑 |
|
| `POST /agent/runs/{id}/messages {text}` | 追加用户消息。运行中时,当前会话在本轮结束后消费;已结束或等待中时,会唤醒一次新的 runAgent 续跑 |
|
||||||
| `POST /agent/runs/{id}/abort {reason?}` | 写取消标志,会话监视器会中止模型并收尾为 cancelled。只停止后续步骤,不撤销已经完成的外部动作 |
|
| `POST /agent/runs/{id}/abort {reason?}` | 写取消标志,会话监视器会中止模型并收尾为 cancelled。只停止后续步骤,不撤销已经完成的外部动作 |
|
||||||
| `GET/POST /automation-grants`、`PATCH /automation-grants/{id}` | 持续授权。PATCH 只能撤销或缩短有效期,扩大范围要新建一条 |
|
| `GET/POST /automation-grants`、`PATCH /automation-grants/{id}` | 持续授权。PATCH 只能撤销或缩短有效期,扩大范围要新建一条 |
|
||||||
| `POST /approvals {planType, planId}`、`PATCH /approvals/{id} {status, payloadHash}` | 单次审批。批准时要回传 payloadHash,计划变化后旧审批失效 |
|
| `POST /approvals {planType, planId}`、`PATCH /approvals/{id} {status, payloadHash}` | 单次审批。**只接受控制台管理员会话**(API Key 一律 403,持 `organize.execute` 的 Key 不能给自己的计划开审批再自批)。批准时要回传 payloadHash;计划 payload hash / revision 变化、过期、撤销后旧审批失效,执行成功一次即 consumed,不可重放(`test/agent/approvals.test.ts`) |
|
||||||
| `GET /capabilities`、`POST /capabilities/{id}/invoke` | 能力目录与调用,HTTP scope 按能力描述符再校验一次 |
|
| `GET /capabilities`、`POST /capabilities/{id}/invoke` | 能力目录与调用,HTTP scope 按能力描述符再校验一次 |
|
||||||
| `GET /tasks[/{id}]`、`POST /tasks/{id}/cancel`、`POST /tasks/{id}/retry` | 业务任务;`?subjectId=` 按业务对象查(submitDownload 的 subject 是计划,verifyAndImport 的是下载任务) |
|
| `GET /tasks[/{id}]`、`POST /tasks/{id}/cancel`、`POST /tasks/{id}/retry` | 业务任务;`?subjectId=` 按业务对象查(submitDownload 的 subject 是计划,verifyAndImport 的是下载任务) |
|
||||||
| `POST /acquisition-plans {sourceReleaseId, slotId? \| entityId?, note?}` | 手动提交下载(工作台下载入口):依次调 `download.plan` → `download.submit`,和 Agent 同一条能力路径(授权 / 审计 / 幂等键 / 账本)。没给 slotId 时按作品的 Slot 创建顺序逐个尝试,第一个生成计划的胜出;都没生成时 `submitted=false` 并逐个 Slot 给出原因。控制台管理员算人工授权;API Key 需要持续授权或单次审批 |
|
| `POST /acquisition-plans {sourceReleaseId, slotId? \| entityId?, note?}` | 手动提交下载(工作台下载入口):依次调 `download.plan` → `download.submit`,和 Agent 同一条能力路径(授权 / 审计 / 幂等键 / 账本)。没给 slotId 时按作品的 Slot 创建顺序逐个尝试,第一个生成计划的胜出;都没生成时 `submitted=false` 并逐个 Slot 给出原因。控制台管理员算人工授权;API Key 需要持续授权或单次审批 |
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
**增量推送**:事件先写 `agent_run_event`(run_id + seq 唯一),再经 `ctx.events` 推到全局 SSE(`GET /events`)。推送的类型有 `agent.run_status`、`agent.text_delta`、`agent.tool_call`;未授权导致的工具失败,`phase` 为 `denied`。断线后,先用全局 `?after=<seq>` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。SSE 只给控制台(cookie 会话);API Key 调用者用 `GET /events?after=<seq>&limit=<n>` 取 JSON 页轮询(见 auth.md「SSE 多进程总线」)。
|
**增量推送**:事件先写 `agent_run_event`(run_id + seq 唯一),再经 `ctx.events` 推到全局 SSE(`GET /events`)。推送的类型有 `agent.run_status`、`agent.text_delta`、`agent.tool_call`;未授权导致的工具失败,`phase` 为 `denied`。断线后,先用全局 `?after=<seq>` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。SSE 只给控制台(cookie 会话);API Key 调用者用 `GET /events?after=<seq>&limit=<n>` 取 JSON 页轮询(见 auth.md「SSE 多进程总线」)。
|
||||||
|
|
||||||
控制台 Agent 页直接用这些端点(合同取 `@umt/contracts` 的 `AgentRun` / `AgentRunDetail` / `AgentGoalReport`),`?mock=1` 只是演示开关(本地假数据,不连后端,`?mock=0` 退出)。页面收到 `agent.*` 事件后按 `afterSeq` 增量补读,`agent.run_status` 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。
|
控制台 Agent 页直接用这些端点(合同取 `@umt/contracts` 的 `AgentRun` / `AgentRunDetail` / `AgentGoalReport`),`?mock=1` 只是演示开关(本地假数据,不连后端,`?mock=0` 退出),只在开发构建或后端 `pi.provider = "fake"` 时生效;生产构建连真实模型时忽略并清掉记住的开关。页面收到 `agent.*` 事件后按 `afterSeq` 增量补读,`agent.run_status` 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。
|
||||||
|
|
||||||
## runAgent 做什么
|
## runAgent 做什么
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ update admin_session set revoked_at = now(), revoke_reason = 'reset' where revok
|
|||||||
- `Authorization: Bearer <key>` → API Key(开发模式另认 `DEV_ADMIN_KEY`,生产忽略);
|
- `Authorization: Bearer <key>` → API Key(开发模式另认 `DEV_ADMIN_KEY`,生产忽略);
|
||||||
- 否则 `umt_session` cookie → 管理员会话(scopes 视为 `["admin"]`)。
|
- 否则 `umt_session` cookie → 管理员会话(scopes 视为 `["admin"]`)。
|
||||||
|
|
||||||
处理器里:`c.get("actor")` → `{ type: "admin" | "api_key", id, name, scopes }`(contracts `Actor`);`c.get("session")` 仅 cookie 会话有值。路由照旧挂 `requireScope("read" | "organize.read" | "organize.execute" | "admin")`,两种调用者一视同仁;只允许控制台会话的操作挂 `requireAdminSession()`。旧的 `c.get("principal")` 仍可用(`kind` 多了 `admin_session`)。写审计时 actor_type:admin → `user`,API Key → `api_key`。
|
处理器里:`c.get("actor")` → `{ type: "admin" | "api_key", id, name, scopes }`(contracts `Actor`);`c.get("session")` 仅 cookie 会话有值。路由照旧挂 `requireScope("read" | "organize.read" | "organize.execute" | "admin")`,两种调用者一视同仁;只允许控制台会话的操作在 contracts 注册表里标 `auth: "session"`(路由封装据此自动挂 `requireAdminSession()`,API Key 包括 admin scope 与 `DEV_ADMIN_KEY` 一律 403),免鉴权端点标 `auth: "public"` 且必须与 `PUBLIC_AUTH_PATHS` 一致(`api-registry.test` 断言)。目前标 session 的有:`POST /auth/password`、`GET/POST /auth/totp`、`POST /approvals`、`PATCH /approvals/{id}`。旧的 `c.get("principal")` 仍可用(`kind` 多了 `admin_session`)。写审计时 actor_type:admin → `user`,API Key → `api_key`。
|
||||||
|
|
||||||
## 密钥服务 `ctx.secrets`
|
## 密钥服务 `ctx.secrets`
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ docker compose ps && docker compose logs --tail=50 app
|
|||||||
| 步骤 | 结果 |
|
| 步骤 | 结果 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功(缓存命中 2 分 24 秒);镜像 489 MB(压缩后 107 MB):二进制 213 MB、gosu+postgresql-client 79 MB、web 2 MB |
|
| `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功(缓存命中 2 分 24 秒);镜像 489 MB(压缩后 107 MB):二进制 213 MB、gosu+postgresql-client 79 MB、web 2 MB |
|
||||||
| `compose up -d` | postgres healthy 后 app 启动,约 6 秒 healthy;21 个迁移 + 引擎迁移,`启动自检通过 dataDir=/data sessionDir=/data/agent-sessions` |
|
| `compose up -d` | postgres healthy 后 app 启动,约 6 秒 healthy;22 个迁移(编号 0017 空缺)+ 引擎迁移,`启动自检通过 dataDir=/data sessionDir=/data/agent-sessions` |
|
||||||
| 进程 | 两个 `umt-server`(api、worker)uid/gid 均为 10001;`/data/*` 属主 10001;`/app/umt-server` 为 `r-xr-xr-x root`,运行用户写入 → Permission denied |
|
| 进程 | 两个 `umt-server`(api、worker)uid/gid 均为 10001;`/data/*` 属主 10001;`/app/umt-server` 为 `r-xr-xr-x root`,运行用户写入 → Permission denied |
|
||||||
| `GET /` 与 `/console/settings` | 200,SPA 由同端口托管 |
|
| `GET /` 与 `/console/settings` | 200,SPA 由同端口托管 |
|
||||||
| setup token(日志)→ `POST /auth/setup` → `POST /auth/login` | 成功,拿到会话与 csrf cookie |
|
| setup token(日志)→ `POST /auth/setup` → `POST /auth/login` | 成功,拿到会话与 csrf cookie |
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
# @umt/adapters
|
# @umt/adapters
|
||||||
|
|
||||||
外部系统适配层:qBittorrent、RSS、站点(观众 NexusPHP / 馒头 API)、TMDB、Emby 兼容服务器与文件系统。对上只暴露端口接口,凭证经 secret reference 注入,日志一律脱敏。
|
外部系统适配层:qBittorrent、RSS、站点(观众 NexusPHP / 馒头 API)、TMDB、Emby 兼容服务器、浏览器服务与文件系统。对上只暴露端口接口,凭证经 secret reference 注入,日志一律脱敏(键表与规则取 `@umt/contracts` 的 `redact.ts`)。
|
||||||
|
|
||||||
|
包入口 `src/index.ts` 逐个 `export *` 下列子模块:
|
||||||
|
|
||||||
|
| 子模块 | 主要入口 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`qbittorrent`](src/qbittorrent/README.md) | `createQbClient` / `QbClient`、`DownloaderRegistry`、`assertSafeToDeleteData`、`mapQbState` | qB WebUI v2:提交、标签接管、暂停/恢复、带安全校验的删数据 |
|
||||||
|
| [`rss`](src/rss/README.md) | `createRssFetcher`、`applyFeedItems`、`redactUrl`、`buildRssLinkRequest` | RSS 2.0 / RDF / Atom 抓取解析、条件请求、退避熔断、去重 |
|
||||||
|
| [`sites`](src/sites/README.md) | `createSiteAdapter` / `getSiteAdapter`、`listSiteDefinitions`、`siteFetch` | 观众(NexusPHP 配置驱动 spider)与馒头 M-Team API |
|
||||||
|
| [`tmdb`](src/tmdb/README.md) | `createTmdbClient` / `TmdbClient`、`matchTmdb`、`matchTmdbMulti`、`TmdbError` | 多语言搜索、详情、季集;缓存限流;错误信息不带 api_key |
|
||||||
|
| [`emby`](src/emby/README.md) | `createEmbyClient` / `EmbyClient`、`MediaServerRegistry` | Emby / Jellyfin 库存查询,失败返回 `unknown` 而不是"不存在" |
|
||||||
|
| [`fs`](src/fs/README.md) | `copyWithVerify`、`moveSafe`、`crossDeviceMove`、`checkRoot`、`scanIncremental`、`diskUsage`、`ffprobe` | 校验复制、跨卷移动、库根哨兵、增量扫描 |
|
||||||
|
| [`browser`](src/browser/README.md) | `BrowserProvider` 合同、`createNoneBrowserProvider`、camofox 适配骨架、`redactBrowserUrl` | 浏览器服务接口(M1 只有接口与骨架) |
|
||||||
|
|
||||||
|
测试:`deno task test`(vitest,外部服务全部用 `@umt/testkit` 的 mock)。
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
* match/match_multi 的匹配链见 match.ts。
|
* match/match_multi 的匹配链见 match.ts。
|
||||||
* 调整:网络/鉴权/限流错误以 TmdbError 抛出而非返回 None,调用方不得把"查询失败"当成"没有结果"。
|
* 调整:网络/鉴权/限流错误以 TmdbError 抛出而非返回 None,调用方不得把"查询失败"当成"没有结果"。
|
||||||
*/
|
*/
|
||||||
|
import { redactUrl } from "@umt/contracts";
|
||||||
import { RequestLimiter, TtlLruCache } from "./cache.ts";
|
import { RequestLimiter, TtlLruCache } from "./cache.ts";
|
||||||
|
|
||||||
export type TmdbErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol";
|
export type TmdbErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol";
|
||||||
@@ -122,6 +123,11 @@ export class TmdbClient {
|
|||||||
this.fetchImpl = cfg.fetch ?? fetch;
|
this.fetchImpl = cfg.fetch ?? fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 错误信息里去掉 api_key / access token(query 参数与明文两种形态都遮) */
|
||||||
|
private redact(text: string): string {
|
||||||
|
return redactUrl(text, [this.cfg.apiKey, this.cfg.accessToken]);
|
||||||
|
}
|
||||||
|
|
||||||
cacheStats() {
|
cacheStats() {
|
||||||
return { size: this.cache.size, hits: this.cache.hits, misses: this.cache.misses, coalesced: this.cache.coalesced };
|
return { size: this.cache.size, hits: this.cache.hits, misses: this.cache.misses, coalesced: this.cache.coalesced };
|
||||||
}
|
}
|
||||||
@@ -173,7 +179,12 @@ export class TmdbClient {
|
|||||||
const name = (e as { name?: string })?.name;
|
const name = (e as { name?: string })?.name;
|
||||||
if (name === "TimeoutError" || name === "AbortError")
|
if (name === "TimeoutError" || name === "AbortError")
|
||||||
throw new TmdbError("timeout", "TMDB request timed out", { path });
|
throw new TmdbError("timeout", "TMDB request timed out", { path });
|
||||||
throw new TmdbError("network", `TMDB request failed: ${(e as Error)?.message}`, { path, cause: e });
|
// Deno fetch 的错误信息带完整 URL(含 api_key):message 先脱敏,cause 只留脱敏后的名字与信息,不透传原对象
|
||||||
|
const msg = this.redact(String((e as Error)?.message ?? e));
|
||||||
|
throw new TmdbError("network", `TMDB request failed: ${msg}`, {
|
||||||
|
path,
|
||||||
|
cause: { name: (e as Error)?.name ?? "Error", message: msg },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
await res.body?.cancel();
|
await res.body?.cancel();
|
||||||
@@ -199,7 +210,10 @@ export class TmdbClient {
|
|||||||
try {
|
try {
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new TmdbError("protocol", "TMDB returned invalid JSON", { path, cause: e });
|
throw new TmdbError("protocol", "TMDB returned invalid JSON", {
|
||||||
|
path,
|
||||||
|
cause: { name: (e as Error)?.name ?? "Error", message: this.redact(String((e as Error)?.message ?? e)) },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,37 @@ describe("TmdbClient 缓存/限流/错误分类", () => {
|
|||||||
expect((await dead.details("movie", 1).catch((e) => e)).kind).toBe("network");
|
expect((await dead.details("movie", 1).catch((e) => e)).kind).toBe("network");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("网络/HTTP 错误的 message 与 cause 不含 api_key(验收 S3)", async () => {
|
||||||
|
const KEY = "tmdbSecretKey0123456789abcdef";
|
||||||
|
// 真实连接失败:Deno fetch 的错误信息带完整 URL
|
||||||
|
const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: KEY, retries: 0 });
|
||||||
|
const e1 = await dead.details("movie", 1).catch((x) => x);
|
||||||
|
expect(e1.kind).toBe("network");
|
||||||
|
// 模拟把 URL(含 api_key 参数与编码后的明文)拼进信息的 fetch 实现
|
||||||
|
const leaky: typeof fetch = async (input) => {
|
||||||
|
throw new TypeError(`error sending request for url (${String(input)}): key ${encodeURIComponent(KEY)}`);
|
||||||
|
};
|
||||||
|
const e2 = await createTmdbClient({ baseUrl: "http://tmdb.local/3", apiKey: KEY, retries: 0, fetch: leaky })
|
||||||
|
.details("movie", 1)
|
||||||
|
.catch((x) => x);
|
||||||
|
expect(e2.kind).toBe("network");
|
||||||
|
expect(e2.message).toContain("api_key=[REDACTED]");
|
||||||
|
// HTTP 错误
|
||||||
|
const { mock } = await setup();
|
||||||
|
mock.fault("/3/movie/872585", { status: 503 });
|
||||||
|
const e3 = await createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: KEY, retries: 0 })
|
||||||
|
.details("movie", 872585)
|
||||||
|
.catch((x) => x);
|
||||||
|
expect(e3.kind).toBe("protocol");
|
||||||
|
for (const e of [e1, e2, e3]) {
|
||||||
|
expect(e).toBeInstanceOf(TmdbError);
|
||||||
|
expect(e.message).not.toContain(KEY);
|
||||||
|
expect(e.stack ?? "").not.toContain(KEY);
|
||||||
|
expect(JSON.stringify(e.detail)).not.toContain(KEY);
|
||||||
|
expect(e.detail.cause instanceof Error).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("Bearer token 鉴权可用", async () => {
|
it("Bearer token 鉴权可用", async () => {
|
||||||
mock = await createMockTmdb({ accessToken: "v4-token" });
|
mock = await createMockTmdb({ accessToken: "v4-token" });
|
||||||
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, accessToken: "v4-token" });
|
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, accessToken: "v4-token" });
|
||||||
|
|||||||
@@ -1,50 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 敏感信息守卫:工具输入输出进入模型上下文、事件流、审计前统一经过这里。
|
* 敏感信息守卫:工具输入输出进入模型上下文、事件流、审计前统一经过这里。
|
||||||
* 覆盖 cookie / passkey / token / apiKey / authorization / 含 passkey 等私密参数的 URL / URL 内嵌账号密码。
|
* 键表见 @umt/contracts 的 redact.ts(cookie / passkey / rsskey / torrent_pass / authkey / downhash / c_secure_pass / token / apiKey …)。
|
||||||
* 宁可多遮不可漏遮:误伤只影响可读性,漏遮会把凭证送给外部模型。
|
* 宁可多遮不可漏遮:误伤只影响可读性,漏遮会把凭证送给外部模型。
|
||||||
*/
|
*/
|
||||||
export const REDACTED = "[REDACTED]";
|
import { isSensitiveKey, REDACTED, redactString } from "@umt/contracts";
|
||||||
|
|
||||||
const SENSITIVE_KEY_PARTS = [
|
// 键表与字符串规则只保留一份,在 @umt/contracts(redact.ts);服务端 logger 与这里共用。
|
||||||
"cookie",
|
export { isSensitiveKey, REDACTED, redactString };
|
||||||
"passkey",
|
|
||||||
"authkey",
|
|
||||||
"torrentpass",
|
|
||||||
"rsskey",
|
|
||||||
"apikey",
|
|
||||||
"authorization",
|
|
||||||
"secret",
|
|
||||||
"password",
|
|
||||||
"passwd",
|
|
||||||
"credential",
|
|
||||||
"privatekey",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 规范化键名(去掉 - _ 并小写)后判断。以 token 结尾算敏感,tokens(用量统计)不算。 */
|
|
||||||
export function isSensitiveKey(key: string): boolean {
|
|
||||||
const k = key.toLowerCase().replace(/[-_\s]/g, "");
|
|
||||||
if (k.endsWith("token")) return true;
|
|
||||||
if (k === "sign" || k === "signature") return true;
|
|
||||||
return SENSITIVE_KEY_PARTS.some((part) => k.includes(part));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** URL 查询参数/片段中视为凭证的参数名 */
|
|
||||||
const SENSITIVE_PARAM =
|
|
||||||
"passkey|authkey|torrent_pass|rsskey|api[_-]?key|access[_-]?token|refresh[_-]?token|token|auth|sign|signature|credential|secret|password|key|uid_key|downhash";
|
|
||||||
const PARAM_RE = new RegExp(`([?&;#]|\\b)(${SENSITIVE_PARAM})=([^&;#\\s"'<>]+)`, "gi");
|
|
||||||
const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi;
|
|
||||||
const BEARER_RE = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{6,}/g;
|
|
||||||
const HEADER_RE = /\b(cookie|set-cookie|authorization|x-api-key)\s*:\s*[^\r\n]+/gi;
|
|
||||||
const KNOWN_KEY_RE = /\b(sk-[A-Za-z0-9_-]{8,}|sk-ant-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{16,})\b/g;
|
|
||||||
|
|
||||||
export function redactString(input: string): string {
|
|
||||||
return input
|
|
||||||
.replace(HEADER_RE, (_m, name: string) => `${name}: ${REDACTED}`)
|
|
||||||
.replace(URL_USERINFO_RE, (_m, scheme: string) => `${scheme}${REDACTED}@`)
|
|
||||||
.replace(PARAM_RE, (_m, lead: string, name: string) => `${lead}${name}=${REDACTED}`)
|
|
||||||
.replace(BEARER_RE, (_m, scheme: string) => `${scheme} ${REDACTED}`)
|
|
||||||
.replace(KNOWN_KEY_RE, REDACTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 深度脱敏,返回新对象;不修改入参。循环引用替换为 "[Circular]"。 */
|
/** 深度脱敏,返回新对象;不修改入参。循环引用替换为 "[Circular]"。 */
|
||||||
export function redactForModel<T>(value: T): T {
|
export function redactForModel<T>(value: T): T {
|
||||||
|
|||||||
@@ -74,3 +74,25 @@ describe("buildSystemPrompt", () => {
|
|||||||
expect(p).toContain("- 只给方案,不执行");
|
expect(p).toContain("- 只给方案,不执行");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("redactForModel:站点凭证键全集(与服务端 logger 同一键表)", () => {
|
||||||
|
it("rsskey / torrent_pass / authkey / downhash / c_secure_pass / passkey 变体 / x-api-key / cookie", () => {
|
||||||
|
const input = {
|
||||||
|
rsskey: "S1",
|
||||||
|
torrent_pass: "S2",
|
||||||
|
authKey: "S3",
|
||||||
|
downhash: "S4",
|
||||||
|
c_secure_pass: "S5",
|
||||||
|
"Pass-Key": "S6",
|
||||||
|
"x-api-key": "S7",
|
||||||
|
Cookie: "S8",
|
||||||
|
nested: [{ pass_key: "S9" }],
|
||||||
|
url: "https://x/rss?rsskey=S10&torrent_pass=S11&authkey=S12&downhash=S13&c_secure_pass=S14&PassKey=S15",
|
||||||
|
usage: { totalTokens: 10 },
|
||||||
|
};
|
||||||
|
const out = JSON.stringify(redactForModel(input));
|
||||||
|
expect(out).not.toMatch(/S\d+"/);
|
||||||
|
expect(out).not.toMatch(/=S\d+/);
|
||||||
|
expect(out).toContain('"totalTokens":10');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,10 +23,18 @@ export const API_PREFIX = "/api/v1";
|
|||||||
|
|
||||||
export type HttpMethod = "get" | "post" | "patch" | "put" | "delete";
|
export type HttpMethod = "get" | "post" | "patch" | "put" | "delete";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 鉴权方式(不写即按 scope 校验,cookie 会话与 API Key 同样适用):
|
||||||
|
* - public:免鉴权(必须与服务端 http/auth.ts 的 PUBLIC_AUTH_PATHS 一致,测试断言);
|
||||||
|
* - session:只接受控制台管理员 cookie 会话,API Key(含 admin scope)一律 403。
|
||||||
|
*/
|
||||||
|
export type EndpointAuth = "public" | "session";
|
||||||
|
|
||||||
export interface EndpointDef {
|
export interface EndpointDef {
|
||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string;
|
path: string;
|
||||||
scope: ApiScope;
|
scope: ApiScope;
|
||||||
|
auth?: EndpointAuth;
|
||||||
summary: string;
|
summary: string;
|
||||||
params?: z.ZodObject;
|
params?: z.ZodObject;
|
||||||
query?: z.ZodType;
|
query?: z.ZodType;
|
||||||
@@ -793,16 +801,27 @@ export const Api = {
|
|||||||
"审批",
|
"审批",
|
||||||
{
|
{
|
||||||
row: agent.Approval,
|
row: agent.Approval,
|
||||||
update: agent.ApprovalUpdate,
|
|
||||||
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
|
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
|
||||||
},
|
},
|
||||||
{ read: "read", write: "organize.execute" },
|
R,
|
||||||
),
|
),
|
||||||
|
// 审批的申请与决定只在控制台管理员会话里做:API Key(哪怕持 organize.execute)不能给自己的计划开审批(验收 S1)
|
||||||
|
update: ep({
|
||||||
|
method: "patch",
|
||||||
|
path: "/approvals/{id}",
|
||||||
|
scope: "admin",
|
||||||
|
auth: "session",
|
||||||
|
summary: "批准/拒绝/撤销单次审批(只接受控制台管理员会话)",
|
||||||
|
params: IdParam,
|
||||||
|
body: agent.ApprovalUpdate,
|
||||||
|
response: agent.Approval,
|
||||||
|
}),
|
||||||
create: ep({
|
create: ep({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/approvals",
|
path: "/approvals",
|
||||||
scope: "organize.execute",
|
scope: "admin",
|
||||||
summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期)",
|
auth: "session",
|
||||||
|
summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期;只接受控制台管理员会话)",
|
||||||
body: agent.ApprovalRequest,
|
body: agent.ApprovalRequest,
|
||||||
response: agent.Approval,
|
response: agent.Approval,
|
||||||
}),
|
}),
|
||||||
@@ -1040,6 +1059,7 @@ export const Api = {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/auth/setup",
|
path: "/auth/setup",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
|
auth: "public",
|
||||||
summary: "首次初始化管理员",
|
summary: "首次初始化管理员",
|
||||||
body: auth.AdminSetupRequest,
|
body: auth.AdminSetupRequest,
|
||||||
response: auth.SessionInfo,
|
response: auth.SessionInfo,
|
||||||
@@ -1048,6 +1068,7 @@ export const Api = {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/auth/login",
|
path: "/auth/login",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
|
auth: "public",
|
||||||
summary: "管理员登录",
|
summary: "管理员登录",
|
||||||
body: auth.LoginRequest,
|
body: auth.LoginRequest,
|
||||||
response: auth.LoginResult,
|
response: auth.LoginResult,
|
||||||
@@ -1057,6 +1078,7 @@ export const Api = {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/auth/session",
|
path: "/auth/session",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
|
auth: "public",
|
||||||
summary: "当前登录态(免鉴权)",
|
summary: "当前登录态(免鉴权)",
|
||||||
response: auth.SessionInfo,
|
response: auth.SessionInfo,
|
||||||
}),
|
}),
|
||||||
@@ -1064,6 +1086,7 @@ export const Api = {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/auth/password",
|
path: "/auth/password",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
|
auth: "session",
|
||||||
summary: "修改密码(吊销其它会话)",
|
summary: "修改密码(吊销其它会话)",
|
||||||
body: auth.PasswordChangeRequest,
|
body: auth.PasswordChangeRequest,
|
||||||
response: auth.PasswordChangeResult,
|
response: auth.PasswordChangeResult,
|
||||||
@@ -1072,6 +1095,7 @@ export const Api = {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/auth/totp",
|
path: "/auth/totp",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
|
auth: "session",
|
||||||
summary: "TOTP 状态",
|
summary: "TOTP 状态",
|
||||||
response: auth.TotpStatus,
|
response: auth.TotpStatus,
|
||||||
}),
|
}),
|
||||||
@@ -1079,6 +1103,7 @@ export const Api = {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/auth/totp",
|
path: "/auth/totp",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
|
auth: "session",
|
||||||
summary: "TOTP 绑定/启用/停用",
|
summary: "TOTP 绑定/启用/停用",
|
||||||
body: auth.TotpAction,
|
body: auth.TotpAction,
|
||||||
response: auth.TotpActionResult,
|
response: auth.TotpActionResult,
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ export * from "./instances.ts";
|
|||||||
export * from "./ops.ts";
|
export * from "./ops.ts";
|
||||||
export * from "./organization.ts";
|
export * from "./organization.ts";
|
||||||
export * from "./policy.ts";
|
export * from "./policy.ts";
|
||||||
|
export * from "./redact.ts";
|
||||||
export * from "./settings.ts";
|
export * from "./settings.ts";
|
||||||
export * from "./sources.ts";
|
export * from "./sources.ts";
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 凭证脱敏的唯一键表与字符串规则(服务端 logger、Agent 模型上下文、适配器错误信息共用)。
|
||||||
|
* 覆盖 cookie / passkey / rsskey / torrent_pass / authkey / downhash / c_secure_pass / token / apiKey /
|
||||||
|
* authorization / x-api-key 头 / URL 内嵌账号密码。键名先规范化(小写、去掉 - _ 空白)再判断,
|
||||||
|
* 所以 PassKey、pass-key、pass_key、X-Api-Key 都能命中。
|
||||||
|
* 宁可多遮不可漏遮:误伤只影响可读性,漏遮会把凭证送进日志或外部模型。
|
||||||
|
*/
|
||||||
|
export const REDACTED = "[REDACTED]";
|
||||||
|
|
||||||
|
/** 规范化后的键名只要包含其中一项即视为敏感 */
|
||||||
|
export const SENSITIVE_KEY_PARTS: readonly string[] = [
|
||||||
|
"cookie",
|
||||||
|
"passkey",
|
||||||
|
"authkey",
|
||||||
|
"torrentpass",
|
||||||
|
"securepass",
|
||||||
|
"rsskey",
|
||||||
|
"downhash",
|
||||||
|
"apikey",
|
||||||
|
"authorization",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"credential",
|
||||||
|
"privatekey",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 规范化键名(去掉 - _ 空白并小写)后判断。以 token 结尾算敏感,tokens(用量统计)不算。 */
|
||||||
|
export function isSensitiveKey(key: string): boolean {
|
||||||
|
const k = key.toLowerCase().replace(/[-_\s]/g, "");
|
||||||
|
if (k.endsWith("token")) return true;
|
||||||
|
if (k === "sign" || k === "signature") return true;
|
||||||
|
return SENSITIVE_KEY_PARTS.some((part) => k.includes(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 查询参数/片段中视为凭证的参数名(正则片段,大小写不敏感;- _ 可选的写成 [_-]?) */
|
||||||
|
export const SENSITIVE_QUERY_PARAMS: readonly string[] = [
|
||||||
|
"pass[_-]?key",
|
||||||
|
"auth[_-]?key",
|
||||||
|
"torrent[_-]?pass",
|
||||||
|
"rss[_-]?key",
|
||||||
|
"c[_-]?secure[_-]?pass",
|
||||||
|
"down[_-]?hash",
|
||||||
|
"api[_-]?key",
|
||||||
|
"access[_-]?token",
|
||||||
|
"refresh[_-]?token",
|
||||||
|
"token",
|
||||||
|
"auth",
|
||||||
|
"sign",
|
||||||
|
"signature",
|
||||||
|
"credential",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"uid[_-]?key",
|
||||||
|
"key",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PARAM_RE = new RegExp(`([?&;#]|\\b)(${SENSITIVE_QUERY_PARAMS.join("|")})=([^&;#\\s"'<>]+)`, "gi");
|
||||||
|
const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi;
|
||||||
|
const BEARER_RE = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{6,}/g;
|
||||||
|
const HEADER_RE = /\b(cookie|set-cookie|authorization|x-api-key)\s*:\s*[^\r\n]+/gi;
|
||||||
|
const KNOWN_KEY_RE = /\b(sk-[A-Za-z0-9_-]{8,}|sk-ant-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{16,})\b/g;
|
||||||
|
|
||||||
|
/** 文本脱敏:请求头行、URL userinfo、query 凭证参数、Bearer/Basic、已知格式的密钥 */
|
||||||
|
export function redactString(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(HEADER_RE, (_m, name: string) => `${name}: ${REDACTED}`)
|
||||||
|
.replace(URL_USERINFO_RE, (_m, scheme: string) => `${scheme}${REDACTED}@`)
|
||||||
|
.replace(PARAM_RE, (_m, lead: string, name: string) => `${lead}${name}=${REDACTED}`)
|
||||||
|
.replace(BEARER_RE, (_m, scheme: string) => `${scheme} ${REDACTED}`)
|
||||||
|
.replace(KNOWN_KEY_RE, REDACTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL(或含 URL 的错误信息)脱敏。`secrets` 为调用方已知的明文凭证,出现在任何位置都会被替换
|
||||||
|
* (兜底 fetch 实现把 URL 编码后再拼进错误信息之类的情况)。
|
||||||
|
*/
|
||||||
|
export function redactUrl(input: string, secrets: readonly (string | undefined | null)[] = []): string {
|
||||||
|
let out = redactString(input);
|
||||||
|
for (const s of secrets) {
|
||||||
|
if (!s || s.length < 4) continue;
|
||||||
|
out = out.split(s).join(REDACTED);
|
||||||
|
const enc = encodeURIComponent(s);
|
||||||
|
if (enc !== s) out = out.split(enc).join(REDACTED);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
# @umt/testkit
|
# @umt/testkit
|
||||||
|
|
||||||
测试夹具与 mock 服务:mock qB WebUI、mock NexusPHP 站点、mock M-Team API、mock RSS、mock TMDB 与临时目录夹具。只供测试依赖,不进入生产构建。
|
测试夹具与 mock 服务,只供测试依赖,不进入生产构建:
|
||||||
|
|
||||||
|
| 入口 | 模拟对象 |
|
||||||
|
|---|---|
|
||||||
|
| `createMockQb` | qBittorrent WebUI v2(见下文"落盘") |
|
||||||
|
| `createMockNexusPhpSite`、`createMockMteamApi`、`createMockFetch` | 观众(NexusPHP)站点页面、馒头 M-Team API(见 `src/sites/README.md`) |
|
||||||
|
| `createMockRssServer` | RSS / Atom Feed(见 `src/rss/README.md`) |
|
||||||
|
| `createMockTmdb` | TMDB v3(api_key 与 Bearer 两种鉴权,`fault()` 注入 429/5xx/延迟) |
|
||||||
|
| `createMockEmby` | Emby / Jellyfin(`flavor`),`/Users`、`/Items`、`/Shows/{id}/Seasons|Episodes`,夹具 `EMBY_FIXTURE_ITEMS` |
|
||||||
|
| `createMockCamofox` | camofox-browser(tabs / navigate / snapshot / screenshot,可选 accessKey;快照里故意带密码、Cookie、passkey 供脱敏测试) |
|
||||||
|
| `createTempTree` | 临时目录树(可写库根哨兵) |
|
||||||
|
|
||||||
## mock qB 落盘
|
## mock qB 落盘
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user