Skip to content

Commit 73cb20c

Browse files
committed
refactor(core): 优化安全中心响应数据解析及数据结构支持
- 重构security模块,新增parseSecurityBody函数用于解析双层DataV2信封格式 - 支持legacy平面信封和裸数据直接返回,统一错误抛出处理 - securityGet调用parseSecurityBody解析响应体,保证兼容性和错误提示准确 - securityOverview中检测卡片支持REST接口snake_case及console-gateway camelCase字段 - SCAN_CARDS数据结构更新,支持多个字段键以适配不同来源 - SecurityToggle新增count字段,支持展示对应资产数量 - 完善安全响应体测试用例,涵盖多种信封格式及异常场景 - 优化展示安全检测开关状态时,增加对应数量显示信息 - 移除不再需要的SecurityEnvelope接口定义,精简代码逻辑
1 parent 2d89b3f commit 73cb20c

6 files changed

Lines changed: 234 additions & 53 deletions

File tree

packages/commands/src/commands/security/overview.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ export default defineCommand({
6868
}
6969

7070
const data = await securityGet<SecurityOverview>(ctx.client, endpoint);
71-
7271
if (!data) {
7372
if (format === "json") emitResult({}, format);
7473
else emitBare("Overview unavailable.");
@@ -80,11 +79,14 @@ export default defineCommand({
8079
return;
8180
}
8281

83-
// Banner totals are client-side sums across the detection cards.
84-
const cards = SCAN_CARDS.map(([key, label]) => ({
85-
label,
86-
stat: data[key] as SecurityScanStat | null | undefined,
87-
}));
82+
// Banner totals are client-side sums across the detection cards. Each card
83+
// accepts the snake_case (REST) or camelCase (console-gateway) field.
84+
const cards = SCAN_CARDS.map(({ label, keys }) => {
85+
const stat = keys
86+
.map((key) => data[key] as SecurityScanStat | null | undefined)
87+
.find((value) => value !== undefined);
88+
return { label, stat: stat ?? null };
89+
});
8890
const sum = (pick: (stat: SecurityScanStat) => number | null): number =>
8991
cards.reduce((total, card) => total + (card.stat ? (pick(card.stat) ?? 0) : 0), 0);
9092

packages/commands/src/commands/security/shared.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ export const PROTECTION_LABELS: Record<string, string> = {
5656
external_agent: "BYOA 托管",
5757
};
5858

59-
/** Detection cards summed into the overview banner (headline + total). */
60-
export const SCAN_CARDS: Array<[keyof SecurityOverview, string]> = [
61-
["content_safety", "内容安全"],
62-
["file_scan", "文件扫描"],
63-
["skill_scan", "技能扫描"],
59+
/** Detection cards summed into the overview banner: label + snake_case (REST) / camelCase (gateway) keys. */
60+
export const SCAN_CARDS: Array<{ label: string; keys: Array<keyof SecurityOverview> }> = [
61+
{ label: "内容安全", keys: ["content_safety", "contentSafety"] },
62+
{ label: "文件扫描", keys: ["file_scan", "fileScan"] },
63+
{ label: "技能扫描", keys: ["skill_scan", "skillScan"] },
6464
];
6565

6666
/** Append a query param only when present; arrays append each item (repeatable). */
@@ -89,7 +89,8 @@ export function renderToggles(
8989
return;
9090
}
9191
for (const toggle of toggles) {
92-
emitBare(` ${toggle.enabled ? "on " : "off"} ${labels[toggle.key] ?? toggle.key}`);
92+
const count = typeof toggle.count === "number" ? ` (${toggle.count})` : "";
93+
emitBare(` ${toggle.enabled ? "on " : "off"} ${labels[toggle.key] ?? toggle.key}${count}`);
9394
}
9495
}
9596

packages/core/src/client/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export {
6464
} from "./headers.ts";
6565
export type { HttpDeps, RequestOpts } from "./http.ts";
6666
export { request, requestJson } from "./http.ts";
67-
export { securityGet, isDashScopeGateway, type SecurityEnvelope } from "./security.ts";
67+
export { securityGet, parseSecurityBody, isDashScopeGateway } from "./security.ts";
6868
export { createInstrumentedFetch, type FetchImplementation } from "./instrumented-fetch.ts";
6969
export {
7070
Client,

packages/core/src/client/security.ts

Lines changed: 110 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,6 @@ import { BailianError } from "../errors/base.ts";
33
import { ExitCode } from "../errors/codes.ts";
44
import type { Client } from "./client.ts";
55

6-
/**
7-
* Agent Security Center (AgentStudio) response envelope.
8-
*
9-
* Unlike DashScope's `{ code, message }` shape — which `requestJson` already
10-
* understands — AgentStudio reports failures as `{ success: false, errorCode,
11-
* errorMsg }` over HTTP 200, so it needs its own unwrap layer. Missing data is
12-
* not an error by contract: `success: true` with a null `data` is valid and
13-
* surfaces as `null` here rather than throwing.
14-
*/
15-
export interface SecurityEnvelope<T> {
16-
success: boolean;
17-
data: T | null;
18-
errorCode?: string;
19-
errorMsg?: string;
20-
}
21-
226
/** Documented AgentStudio error codes → actionable hint. */
237
const SECURITY_ERROR_HINTS: Record<string, string> = {
248
"12000090": "STS credentials unavailable — retry in a moment",
@@ -41,40 +25,126 @@ export function isDashScopeGateway(origin: string): boolean {
4125
return DASHSCOPE_GATEWAY_ORIGINS.has(origin.replace(/\/+$/, ""));
4226
}
4327

28+
function asRecord(value: unknown): Record<string, unknown> | undefined {
29+
return value && typeof value === "object" && !Array.isArray(value)
30+
? (value as Record<string, unknown>)
31+
: undefined;
32+
}
33+
34+
/** First non-empty string among the candidates, else undefined (treats "" as absent). */
35+
function firstNonEmptyString(...candidates: unknown[]): string | undefined {
36+
for (const candidate of candidates) {
37+
if (typeof candidate === "string" && candidate.length > 0) return candidate;
38+
}
39+
return undefined;
40+
}
41+
42+
function throwSecurityFailure(
43+
errorCode: string | undefined,
44+
errorMsg: string | undefined,
45+
rawResponse: string,
46+
): never {
47+
const code = errorCode ?? "unknown";
48+
// When neither a code nor a message is present the envelope shape is
49+
// unrecognised (e.g. a backend contract change) — surface a body snippet so
50+
// the failure is diagnosable instead of an opaque "unknown - no message".
51+
const bodySnippet =
52+
errorCode === undefined && errorMsg === undefined
53+
? `\nUnexpected response body (truncated): ${rawResponse.slice(0, 800)}`
54+
: "";
55+
throw new BailianError(
56+
`Security API failed: ${code} - ${errorMsg ?? "no message"}${bodySnippet}`,
57+
// 12000092 (no permission to create the service-linked role) is an auth
58+
// problem the caller can act on; everything else is a generic failure.
59+
code === "12000092" ? ExitCode.AUTH : ExitCode.GENERAL,
60+
SECURITY_ERROR_HINTS[code],
61+
{ rawResponse: rawResponse.slice(0, 500) },
62+
);
63+
}
64+
4465
/**
45-
* GET a Security Center endpoint and unwrap its envelope.
66+
* Parse an Agent Security Center response body and return the business payload.
67+
*
68+
* The backend serves these through the Zelda "DataV2" double-envelope — the same
69+
* shape the console gateway returns (see console/models.ts unwrapResponse):
4670
*
47-
* `url` is an absolute per-workspace AgentStudio URL (see `securityOverviewEndpoint`
48-
* / `securityAgentLogsEndpoint`); the Client detects the absolute form, uses it
49-
* verbatim, and injects the Bearer token — so the model-domain `base_url` never
50-
* leaks into these calls. Genuine HTTP errors (non-2xx) surface through the
51-
* Client transport; only HTTP 200 envelopes reach the `success` check below.
52-
* Returns null when the server reports success with no data.
71+
* { code, successResponse, requestId,
72+
* data: { success, errorCode, errorMsg,
73+
* DataV2: { ret: ["SUCCESS::…"],
74+
* data: { success, failed, data: <payload> } } } }
75+
*
76+
* The payload lives at `data.DataV2.data.data`. The legacy flat envelope
77+
* `{ success, data, errorCode, errorMsg }` is still accepted so any endpoint
78+
* that has not migrated keeps working. A failed or unrecognized shape throws
79+
* with the raw body attached (surfaced by --output json) for diagnosis.
5380
*/
54-
export async function securityGet<T>(client: Client, url: string): Promise<T | null> {
55-
const response = await client.request({ path: url, method: "GET" });
56-
57-
let body: SecurityEnvelope<T>;
81+
export function parseSecurityBody<T>(raw: string, contentType?: string | null): T | null {
82+
let body: unknown;
5883
try {
59-
body = (await response.json()) as SecurityEnvelope<T>;
84+
body = JSON.parse(raw);
6085
} catch {
61-
const contentType = response.headers.get("content-type") || "unknown type";
6286
throw new BailianError(
63-
`Security API returned non-JSON response (${contentType}).`,
87+
`Security API returned non-JSON response (${contentType ?? "unknown type"}).`,
6488
ExitCode.GENERAL,
89+
undefined,
90+
{ rawResponse: raw.slice(0, 500) },
6591
);
6692
}
6793

68-
if (!body.success) {
69-
const code = body.errorCode ?? "unknown";
70-
throw new BailianError(
71-
`Security API failed: ${code} - ${body.errorMsg ?? "no message"}`,
72-
// 12000092 (no permission to create the service-linked role) is an auth
73-
// problem the caller can act on; everything else is a generic failure.
74-
code === "12000092" ? ExitCode.AUTH : ExitCode.GENERAL,
75-
SECURITY_ERROR_HINTS[code],
76-
);
94+
const root = asRecord(body);
95+
const data = root ? asRecord(root.data) : undefined;
96+
const dataV2 = data ? asRecord(data.DataV2) : undefined;
97+
98+
// Zelda / DataV2 double-envelope (current backend contract).
99+
if (dataV2) {
100+
const inner = asRecord(dataV2.data);
101+
const ret = Array.isArray(dataV2.ret) ? dataV2.ret.map((entry) => String(entry)) : [];
102+
const retOk = ret.length === 0 || ret.some((line) => line.startsWith("SUCCESS"));
103+
const errorCode = firstNonEmptyString(data?.errorCode, root?.errorCode);
104+
const errorMsg =
105+
firstNonEmptyString(data?.errorMsg, root?.errorMsg) ?? (retOk ? undefined : ret.join("; "));
106+
const failed =
107+
root?.successResponse === false ||
108+
data?.success === false ||
109+
inner?.success === false ||
110+
inner?.failed === true ||
111+
!retOk ||
112+
errorCode !== undefined;
113+
if (failed) throwSecurityFailure(errorCode, errorMsg, raw);
114+
return ((inner ? inner.data : undefined) ?? null) as T | null;
115+
}
116+
117+
// Legacy flat envelope: { success, data, errorCode, errorMsg }.
118+
if (root && "success" in root) {
119+
if (!root.success) {
120+
throwSecurityFailure(
121+
firstNonEmptyString(root.errorCode),
122+
firstNonEmptyString(root.errorMsg),
123+
raw,
124+
);
125+
}
126+
return (root.data ?? null) as T | null;
77127
}
78128

79-
return body.data ?? null;
129+
// Bare payload: the REST endpoint currently returns the business object
130+
// directly, with no envelope. Treat the root object as the payload.
131+
if (root) return root as T;
132+
133+
// Not an object at all — surface the raw body so the contract change is visible.
134+
throwSecurityFailure(undefined, undefined, raw);
135+
}
136+
137+
/**
138+
* GET a Security Center endpoint and unwrap its envelope.
139+
*
140+
* `url` is an absolute AgentStudio URL (see securityOverviewEndpoint /
141+
* securityAgentLogsEndpoint, or a --base-url override); the Client uses it
142+
* verbatim and injects the Bearer token. Genuine HTTP errors (non-2xx) surface
143+
* through the Client transport; only HTTP 200 bodies reach parseSecurityBody.
144+
* Returns null when the server reports success with no payload.
145+
*/
146+
export async function securityGet<T>(client: Client, url: string): Promise<T | null> {
147+
const response = await client.request({ path: url, method: "GET" });
148+
const raw = await response.text();
149+
return parseSecurityBody<T>(raw, response.headers.get("content-type"));
80150
}

packages/core/src/types/security.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
export interface SecurityToggle {
77
key: string;
88
enabled: boolean;
9+
/** Protection entries carry an asset count; absent for some (e.g. external_agent). */
10+
count?: number | null;
911
}
1012

1113
/** Detection card: `hit` is the headline number, `scanned` the total. */
@@ -18,9 +20,15 @@ export interface SecurityScanStat {
1820
export interface SecurityOverview {
1921
capabilities?: SecurityToggle[] | null;
2022
protection?: SecurityToggle[] | null;
23+
// Detection cards: the REST endpoint returns snake_case, the console-gateway
24+
// (DataV2) shape returns camelCase. Accept both; the renderer picks whichever
25+
// is present.
2126
content_safety?: SecurityScanStat | null;
2227
file_scan?: SecurityScanStat | null;
2328
skill_scan?: SecurityScanStat | null;
29+
contentSafety?: SecurityScanStat | null;
30+
fileScan?: SecurityScanStat | null;
31+
skillScan?: SecurityScanStat | null;
2432
}
2533

2634
export type SecurityRiskLevel = "high" | "medium" | "low";
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { expect, test } from "vite-plus/test";
2+
import { parseSecurityBody } from "../src/client/security.ts";
3+
import { BailianError } from "../src/errors/base.ts";
4+
import { ExitCode } from "../src/errors/codes.ts";
5+
6+
// The backend serves Agent Security Center through the Zelda "DataV2" double
7+
// envelope; the business payload lives at data.DataV2.data.data and the
8+
// detection cards are camelCase. parseSecurityBody must unwrap it, still accept
9+
// the legacy flat envelope, and turn failures into BailianError with the right
10+
// exit code.
11+
12+
function dataV2Envelope(payload: unknown): string {
13+
return JSON.stringify({
14+
code: "200",
15+
successResponse: true,
16+
requestId: "req-1",
17+
data: {
18+
success: true,
19+
errorCode: "",
20+
errorMsg: "",
21+
DataV2: {
22+
ret: ["SUCCESS::接口调用成功"],
23+
data: { success: true, failed: false, data: payload },
24+
},
25+
},
26+
});
27+
}
28+
29+
function catchError(run: () => unknown): BailianError {
30+
try {
31+
run();
32+
} catch (error) {
33+
return error as BailianError;
34+
}
35+
throw new Error("expected parseSecurityBody to throw");
36+
}
37+
38+
test("unwraps the DataV2 double-envelope to the camelCase payload", () => {
39+
const payload = {
40+
contentSafety: { hit: 4, scanned: 253055 },
41+
fileScan: { hit: 52, scanned: 579 },
42+
skillScan: { hit: 2, scanned: 37 },
43+
capabilities: [{ key: "agent_identity", enabled: true }],
44+
protection: [
45+
{ key: "flow_agent", enabled: true, count: 1656 },
46+
{ key: "external_agent", enabled: false },
47+
],
48+
};
49+
expect(parseSecurityBody<typeof payload>(dataV2Envelope(payload))).toEqual(payload);
50+
});
51+
52+
test("returns a bare payload (no envelope) as the REST endpoint sends", () => {
53+
const bare = JSON.stringify({
54+
capabilities: [{ key: "agent_identity", enabled: true, count: null }],
55+
protection: [{ key: "flow_agent", enabled: true, count: 1656 }],
56+
content_safety: { hit: 4, scanned: 253561 },
57+
file_scan: { hit: 52, scanned: 579 },
58+
skill_scan: { hit: 2, scanned: 37 },
59+
});
60+
const result = parseSecurityBody<Record<string, unknown>>(bare);
61+
expect(result?.content_safety).toEqual({ hit: 4, scanned: 253561 });
62+
expect(result?.protection).toEqual([{ key: "flow_agent", enabled: true, count: 1656 }]);
63+
});
64+
65+
test("still accepts the legacy flat envelope", () => {
66+
expect(parseSecurityBody<{ a: number }>('{"success":true,"data":{"a":1}}')).toEqual({ a: 1 });
67+
});
68+
69+
test("success with a null payload returns null, not an error", () => {
70+
expect(parseSecurityBody(dataV2Envelope(null))).toBeNull();
71+
});
72+
73+
test("maps legacy 12000092 to the AUTH exit code", () => {
74+
const error = catchError(() =>
75+
parseSecurityBody('{"success":false,"errorCode":"12000092","errorMsg":"no permission"}'),
76+
);
77+
expect(error.exitCode).toBe(ExitCode.AUTH);
78+
expect(error.message).toContain("12000092");
79+
});
80+
81+
test("surfaces a DataV2 failure errorCode as a GENERAL error", () => {
82+
const body = JSON.stringify({
83+
code: "500",
84+
successResponse: false,
85+
data: {
86+
success: false,
87+
errorCode: "12000093",
88+
errorMsg: "service down",
89+
DataV2: { ret: ["FAIL::boom"], data: { success: false, failed: true } },
90+
},
91+
});
92+
const error = catchError(() => parseSecurityBody(body));
93+
expect(error.exitCode).toBe(ExitCode.GENERAL);
94+
expect(error.message).toContain("12000093");
95+
});
96+
97+
test("rejects a non-JSON body with the content type", () => {
98+
const error = catchError(() => parseSecurityBody("<html>gateway</html>", "text/html"));
99+
expect(error.message).toContain("non-JSON");
100+
});

0 commit comments

Comments
 (0)