@@ -3,22 +3,6 @@ import { BailianError } from "../errors/base.ts";
33import { ExitCode } from "../errors/codes.ts" ;
44import 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. */
237const 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}
0 commit comments