智能体原先只能从 site.list 推断下载器,会把没看到汇报成下载器不在系统内。 新增只读能力 downloader.list(下载器与媒体服务器、连通状态、管理的任务数); 原因码表补能力错误、下载盘判定、站点登录与待处理类型,带分类前缀的码按后半段查; 汇报格式加「数量以工具返回为准」。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
166 lines
7.4 KiB
TypeScript
166 lines
7.4 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { CapabilityRegistry, InvokeContext } from "@umt/agent";
|
|
import { CAPABILITY_IDS, capabilitySpec } from "@umt/contracts";
|
|
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
|
import { registerSourcesCapabilities } from "../../src/modules/sources-identity/capabilities.ts";
|
|
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
|
|
|
|
// W10-pre2 站点能力:site.list / site.health_check / site.refresh_login / site.user_data。
|
|
// 规格(类别 / 风险 / 重放)、execute 类要持续授权、输出不含凭证与 Cookie。
|
|
|
|
const up = await pgAvailable();
|
|
const TiB = 1024 ** 4;
|
|
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
|
|
|
|
describe("站点能力规格", () => {
|
|
it("进入首批能力目录(17 + 4 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 40);类别 / 风险 / 重放按约定", () => {
|
|
expect(CAPABILITY_IDS).toHaveLength(40);
|
|
for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id);
|
|
expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
|
expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
|
expect(capabilitySpec("site.health_check")).toMatchObject({
|
|
kind: "execute",
|
|
riskLevel: "low",
|
|
replay: "safe",
|
|
idempotent: true,
|
|
});
|
|
expect(capabilitySpec("site.refresh_login")).toMatchObject({
|
|
kind: "execute",
|
|
riskLevel: "medium",
|
|
replay: "never",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!up)("站点能力(mock NexusPHP)", () => {
|
|
let h: Harness;
|
|
let mock: MockNexusPhpSite;
|
|
let registry: CapabilityRegistry;
|
|
let siteId: string;
|
|
const PASSWORD = "cap-s3cret-pw";
|
|
|
|
const agentCtx = (): InvokeContext => ({
|
|
actorId: "agent:site-test",
|
|
taskId: randomUUID(),
|
|
scopeIds: [],
|
|
signal: new AbortController().signal,
|
|
source: "agent",
|
|
});
|
|
/** 输出里不许出现的东西:密码、会话 Cookie、secret 引用 */
|
|
const assertRedacted = async (out: unknown) => {
|
|
const text = JSON.stringify(out);
|
|
expect(text).not.toContain(PASSWORD);
|
|
expect(text).not.toMatch(/secretRef|secret_ref|cookie|loginName|userAgent/i);
|
|
for (const v of h.secrets.plain.values()) expect(text).not.toContain(v);
|
|
for (const ref of h.secrets.plain.keys()) expect(text).not.toContain(ref);
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
mock = await createMockNexusPhpSite({ username: "alice", password: PASSWORD });
|
|
h = await createHarness("sitecaps");
|
|
registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
|
|
registerSourcesCapabilities(registry, () => h.rt);
|
|
const r = await h.req("/sites", {
|
|
body: {
|
|
key: "audiences",
|
|
name: "观众",
|
|
adapter: "nexusphp",
|
|
baseUrl: `${mock.baseUrl}/`,
|
|
rateLimit: { minIntervalMs: 0, concurrency: 10 },
|
|
},
|
|
});
|
|
siteId = ((await r.json()) as { data: { id: string } }).data.id;
|
|
const c = await h.req(`/sites/${siteId}/credentials`, {
|
|
method: "PUT",
|
|
body: { kind: "password", username: "alice", password: PASSWORD },
|
|
});
|
|
expect(c.status).toBe(200);
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await mock?.close();
|
|
});
|
|
|
|
it("四个站点能力都挂上处理器", () => {
|
|
const ids = registry.list().map((c) => c.descriptor.id);
|
|
for (const id of SITE_IDS) expect(ids).toContain(id);
|
|
});
|
|
|
|
it("site.refresh_login:没有持续授权 → 拒绝,账号不动", async () => {
|
|
await expect(registry.invoke("site.refresh_login", { siteId }, agentCtx())).rejects.toThrow();
|
|
const acc = await h.db.selectFrom("site_account").select(["status"]).where("site_id", "=", siteId).execute();
|
|
expect(acc.map((a) => a.status)).toEqual(["unknown"]);
|
|
});
|
|
|
|
it("site.refresh_login:有持续授权 → 登录成功并拉用户数据;输出脱敏", async () => {
|
|
await h.db.insertInto("automation_grant").values({ action: "site.refresh_login", granted_by: "test" }).execute();
|
|
const out = (await registry.invoke("site.refresh_login", { siteId }, agentCtx())) as {
|
|
ok: boolean;
|
|
status: string;
|
|
userData: { upload: number } | null;
|
|
};
|
|
expect(out).toMatchObject({ ok: true, status: "active", reason: null });
|
|
expect(out.userData?.upload).toBe(12.5 * TiB);
|
|
await assertRedacted(out);
|
|
const audits = await h.db
|
|
.selectFrom("audit_event")
|
|
.select(["action"])
|
|
.where("target_id", "=", "site.refresh_login")
|
|
.execute();
|
|
expect(audits.map((a) => a.action)).toEqual(expect.arrayContaining(["capability.denied", "capability.invoke"]));
|
|
});
|
|
|
|
it("site.list:健康、账号状态与凭证有无;不含凭证、Cookie、secret 引用", async () => {
|
|
const out = (await registry.invoke("site.list", {}, agentCtx())) as {
|
|
items: { id: string; accountStatus: string; accounts: { hasSession: boolean; status: string }[] }[];
|
|
};
|
|
const item = out.items.find((s) => s.id === siteId);
|
|
expect(item).toMatchObject({ key: "audiences", enabled: true, accountStatus: "active" });
|
|
expect(item?.accounts[0]).toMatchObject({ status: "active", hasSession: true, authKind: "password" });
|
|
await assertRedacted(out);
|
|
// 停用站点缺省不列
|
|
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", siteId).execute();
|
|
const hidden = (await registry.invoke("site.list", {}, agentCtx())) as { items: unknown[] };
|
|
expect(hidden.items).toHaveLength(0);
|
|
const all = (await registry.invoke("site.list", { includeDisabled: true }, agentCtx())) as { items: unknown[] };
|
|
expect(all.items).toHaveLength(1);
|
|
await h.db.updateTable("site").set({ enabled: true }).where("id", "=", siteId).execute();
|
|
});
|
|
|
|
it("site.user_data:最新用户数据 + 按天历史", async () => {
|
|
const out = (await registry.invoke("site.user_data", { siteId }, agentCtx())) as {
|
|
accounts: { userData: { seeding: number } | null }[];
|
|
history: { upload: number }[];
|
|
};
|
|
expect(out.accounts).toHaveLength(1);
|
|
expect(out.accounts[0]?.userData?.seeding).toBe(31);
|
|
expect(out.history).toHaveLength(1);
|
|
expect(out.history[0]?.upload).toBe(12.5 * TiB);
|
|
await assertRedacted(out);
|
|
});
|
|
|
|
it("site.health_check:execute 同样要授权;授权后写回健康列", async () => {
|
|
await expect(registry.invoke("site.health_check", { siteId }, agentCtx())).rejects.toThrow();
|
|
await h.db.insertInto("automation_grant").values({ action: "site.health_check", granted_by: "test" }).execute();
|
|
const out = (await registry.invoke("site.health_check", { siteId }, agentCtx())) as {
|
|
ok: boolean;
|
|
healthStatus: string;
|
|
};
|
|
expect(out).toMatchObject({ siteId, ok: true, loggedIn: true, healthStatus: "healthy" });
|
|
await assertRedacted(out);
|
|
const s = await h.db
|
|
.selectFrom("site")
|
|
.select(["health_status", "last_health_at"])
|
|
.where("id", "=", siteId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(s.health_status).toBe("healthy");
|
|
expect(s.last_health_at).toBeTruthy();
|
|
});
|
|
|
|
it("不存在的站点:明确报错", async () => {
|
|
await expect(registry.invoke("site.user_data", { siteId: randomUUID() }, agentCtx())).rejects.toThrow(/站点不存在/);
|
|
});
|
|
});
|