From d6bd38a46a685dd185d76326d34dbab99699b1eb Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Wed, 22 Jul 2026 13:40:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20agent=E7=9B=B8=E5=85=B3cli?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=9A=84client=E5=B1=82=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90cli=20client=E7=9A=84=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/auth-change.md | 4 + packages/cli/.gitignore | 5 +- packages/cli/agents.yaml | 26 +++++ .../commands/agent/_engine/config-loader.ts | 13 ++- .../src/commands/agent/_engine/credentials.ts | 47 ++++++++- .../src/commands/agent/_engine/errors.ts | 44 ++++++++- .../src/commands/agent/_engine/transport.ts | 29 ++++++ packages/commands/src/commands/agent/apply.ts | 9 +- .../commands/src/commands/agent/destroy.ts | 5 +- packages/commands/src/commands/agent/init.ts | 7 +- packages/commands/src/commands/agent/plan.ts | 9 +- .../src/commands/agent/session-create.ts | 11 ++- .../src/commands/agent/session-delete.ts | 11 ++- .../src/commands/agent/session-events.ts | 28 ++++-- .../src/commands/agent/session-get.ts | 11 ++- .../src/commands/agent/session-list.ts | 28 ++++-- .../src/commands/agent/session-run.ts | 22 ++++- .../src/commands/agent/session-send.ts | 16 +++- .../src/commands/agent/state-import.ts | 9 +- .../commands/src/commands/agent/state-list.ts | 5 +- .../commands/src/commands/agent/state-rm.ts | 9 +- .../commands/src/commands/agent/state-show.ts | 9 +- .../commands/src/commands/agent/validate.ts | 3 +- packages/commands/tests/agent-errors.test.ts | 88 +++++++++++++++++ .../commands/tests/credentials-bridge.test.ts | 95 +++++++++++++++++++ packages/core/src/client/index.ts | 3 +- .../core/src/client/instrumented-fetch.ts | 76 +++++++++++++++ .../core/tests/instrumented-fetch.test.ts | 84 ++++++++++++++++ skills/bailian-cli/reference/agent.md | 75 +++++++++++++++ 29 files changed, 723 insertions(+), 58 deletions(-) create mode 100644 packages/cli/agents.yaml create mode 100644 packages/commands/src/commands/agent/_engine/transport.ts create mode 100644 packages/commands/tests/agent-errors.test.ts create mode 100644 packages/commands/tests/credentials-bridge.test.ts create mode 100644 packages/core/src/client/instrumented-fetch.ts create mode 100644 packages/core/tests/instrumented-fetch.test.ts diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index fde6101..ccc1aa8 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -50,6 +50,10 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx 命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。 +### 例外:agent 命令的 SDK 凭证桥接 + +`bl agent *` 命令声明 `auth: "none"`,凭证由 `@openagentpack/sdk` 自主从 env 解析(agents.yaml 的 `${DASHSCOPE_API_KEY}` / `${BAILIAN_WORKSPACE_ID}` 插值)。为让 bl 登录态复用,`packages/commands/src/commands/agent/_engine/credentials.ts` 的 `bridgeBailianCredentials()` 会**直接 `readConfigFile()`**,把 config 的 `api_key` / `workspace_id` 作为最低优先级兜底填入对应 env,仅填空值,不覆盖已有。这是唯一允许命令层直接读 config 的场景(SDK 只认 env,不走 `ctx.client`);优先级链:`~/.agents/config.json` > shell env > `.env` > `~/.bailian/config.json`。 + ## 必查清单 ### A. core 层(类型 + 解析) diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 3e9713e..fccd164 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -2,4 +2,7 @@ node_modules dist *.log .DS_Store -outputs/ \ No newline at end of file +outputs/ +# agents +agents.state.json +.env diff --git a/packages/cli/agents.yaml b/packages/cli/agents.yaml new file mode 100644 index 0000000..e86f410 --- /dev/null +++ b/packages/cli/agents.yaml @@ -0,0 +1,26 @@ +version: "1" + +providers: + bailian: + api_key: ${DASHSCOPE_API_KEY} + workspace_id: ${BAILIAN_WORKSPACE_ID} + +defaults: + provider: bailian + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + +agents: + assistant: + description: "General-purpose assistant" + model: qwen3.7-max + instructions: | + You are a helpful assistant. + environment: dev + tools: + builtin: [bash, read, glob, grep] diff --git a/packages/commands/src/commands/agent/_engine/config-loader.ts b/packages/commands/src/commands/agent/_engine/config-loader.ts index 0e75494..433549d 100644 --- a/packages/commands/src/commands/agent/_engine/config-loader.ts +++ b/packages/commands/src/commands/agent/_engine/config-loader.ts @@ -6,16 +6,27 @@ import { } from "@openagentpack/sdk"; import { ensureCredentials } from "./credentials.ts"; import { loadFileState } from "./file-state-manager.ts"; +import { type HostContext, installSdkTransport } from "./transport.ts"; + +export { CREDENTIALS_NOTE } from "./credentials.ts"; /** * Build a full ProjectRuntimeContext from a config file path — the standard * entry point for agent commands that need the SDK engine. Mirrors OpenAgentPack * CLI's buildCliRuntime: resolve config → load local state → assemble runtime. + * Takes the host context first so every SDK-engine command wires the + * instrumented transport (UA / tracking headers / verbose) by construction. */ export async function buildAgentRuntime( + host: HostContext, filePath: string, - options: { resolveEnv?: boolean; projectName?: string; statePath?: string } = {}, + options: { + resolveEnv?: boolean; + projectName?: string; + statePath?: string; + } = {}, ): Promise { + installSdkTransport(host); ensureCredentials(); const { config, configPath, projectName } = await resolveProjectConfig(filePath, options); const state = await loadFileState(configPath, options.statePath, projectName); diff --git a/packages/commands/src/commands/agent/_engine/credentials.ts b/packages/commands/src/commands/agent/_engine/credentials.ts index 875d433..1c1cc63 100644 --- a/packages/commands/src/commands/agent/_engine/credentials.ts +++ b/packages/commands/src/commands/agent/_engine/credentials.ts @@ -1,18 +1,59 @@ import { bootstrapRuntimeCredentialsSync } from "@openagentpack/sdk"; +import { readConfigFile } from "bailian-cli-core"; let bootstrapped = false; +/** + * Shared `--help` note documenting where agent commands get provider + * credentials. Mirrors the credential-source hint bl's native commands surface + * (knowledge / usage / token-plan), adapted for the SDK's env-based resolution + * and the {@link bridgeBailianCredentials} fallback. Attach to every command + * that loads agents.yaml. `bl` prefix is safe: agent commands ship on `bl` only. + */ +export const CREDENTIALS_NOTE = [ + "Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}).", + "For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id `.", +]; + +/** + * Bridge bl's own login state into the env vars the OpenAgentPack SDK reads for + * the bailian provider. bl persists `api_key` / `workspace_id` in + * `~/.bailian/config.json` (via `bl auth login` / `bl config set`); mirror them + * onto `DASHSCOPE_API_KEY` / `BAILIAN_WORKSPACE_ID` so users don't have to + * re-declare the same credentials for `bl agent *`. + * + * Lowest priority: only fills a var that is still unset, so anything already in + * the environment (shell export, `.env`, or `~/.agents/config.json` — which the + * SDK bootstrap has already applied) wins. Missing values are left alone; the + * SDK surfaces its own error when interpolation can't resolve, and non-bailian + * providers (claude/qoder/ark) don't need DashScope credentials at all. + */ +export function bridgeBailianCredentials(): void { + const file = readConfigFile(); + if (!process.env.DASHSCOPE_API_KEY?.trim() && file.api_key) { + process.env.DASHSCOPE_API_KEY = file.api_key; + } + if (!process.env.BAILIAN_WORKSPACE_ID?.trim() && file.workspace_id) { + process.env.BAILIAN_WORKSPACE_ID = file.workspace_id; + } +} + /** * Lazily load `.env` and `~/.agents/config.json` into `process.env` so the * OpenAgentPack SDK can resolve provider credentials (e.g. DASHSCOPE_API_KEY, - * BAILIAN_WORKSPACE_ID). Safe to call repeatedly — only the first call does I/O. + * BAILIAN_WORKSPACE_ID), then bridge bl's own config as a fallback. Safe to call + * repeatedly — only the first call does I/O. + * + * Effective precedence for bailian provider fields: + * ~/.agents/config.json > shell env > .env > ~/.bailian/config.json * * NOTE: agent commands declare `auth: "none"` and let the SDK own credential - * resolution. This is the documented tradeoff of the wholesale SDK integration; - * bl's own `--api-key` / `bl auth login` flow is bridged separately as a follow-up. + * resolution. The bl-config bridge is a best-effort fallback; it never overrides + * a value the SDK bootstrap already resolved. */ export function ensureCredentials(): void { if (bootstrapped) return; bootstrapped = true; bootstrapRuntimeCredentialsSync(); + bridgeBailianCredentials(); } diff --git a/packages/commands/src/commands/agent/_engine/errors.ts b/packages/commands/src/commands/agent/_engine/errors.ts index 18a40c8..b02d54e 100644 --- a/packages/commands/src/commands/agent/_engine/errors.ts +++ b/packages/commands/src/commands/agent/_engine/errors.ts @@ -1,11 +1,46 @@ import { UserError } from "@openagentpack/sdk"; -import { BailianError, ExitCode } from "bailian-cli-core"; +import { type ApiErrorBody, BailianError, ExitCode, mapApiError } from "bailian-cli-core"; + +/** + * Structural shape of the SDK's `ApiError` (thrown by provider clients on HTTP + * 4xx/5xx). Matched on fields instead of `instanceof` because the installed SDK + * version does not export the class yet, and structural matching keeps this + * check stable across SDK versions either way. + */ +interface SdkApiErrorLike extends Error { + statusCode: number; + responseBody: string; +} + +function isSdkApiError(error: Error): error is SdkApiErrorLike { + const candidate = error as Partial; + return typeof candidate.statusCode === "number" && typeof candidate.responseBody === "string"; +} + +/** + * The SDK embeds the raw response body in its error message; recover the + * structured fields (message / code / request_id) when the body is JSON so + * `mapApiError` surfaces a clean server message plus api metadata. Non-JSON + * bodies pass through verbatim as the message. + */ +function parseSdkResponseBody(raw: string): ApiErrorBody { + try { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as ApiErrorBody; + } catch { + /* non-JSON body */ + } + return { message: raw.trim() || undefined }; +} /** * Run an SDK-backed operation, translating SDK error types into BailianError so * bl's error handler produces the right exit code and hint formatting. - * SDK `UserError` → USAGE/GENERAL; any other Error → GENERAL (message passed - * through, per bl's "don't translate server errors" boundary). + * SDK `UserError` → USAGE; SDK `ApiError` (server HTTP error) → GENERAL via + * `mapApiError` (server message passed through verbatim, with + * httpStatus/apiCode/requestId metadata for --output json); any other Error → + * GENERAL (message passed through, per bl's "don't translate server errors" + * boundary). */ export async function withAgentErrors(fn: () => Promise): Promise { try { @@ -13,6 +48,9 @@ export async function withAgentErrors(fn: () => Promise): Promise { } catch (error) { if (error instanceof BailianError) throw error; if (error instanceof UserError) throw new BailianError(error.message, ExitCode.USAGE); + if (error instanceof Error && isSdkApiError(error)) { + throw mapApiError(error.statusCode, parseSdkResponseBody(error.responseBody)); + } if (error instanceof Error) throw new BailianError(error.message, ExitCode.GENERAL); throw error; } diff --git a/packages/commands/src/commands/agent/_engine/transport.ts b/packages/commands/src/commands/agent/_engine/transport.ts new file mode 100644 index 0000000..1d99e5c --- /dev/null +++ b/packages/commands/src/commands/agent/_engine/transport.ts @@ -0,0 +1,29 @@ +import * as sdk from "@openagentpack/sdk"; +import { + createInstrumentedFetch, + type FetchImplementation, + type Identity, + type Settings, +} from "bailian-cli-core"; + +/** The slice of CommandContext the transport wrapper needs (UA identity + verbose). */ +export interface HostContext { + identity: Identity; + settings: Settings; +} + +let installed = false; + +/** + * Route the SDK's provider-client requests through the CLI's instrumented + * fetch (UA, host-gated tracking headers, --verbose logging). Feature-detected: + * `setDefaultFetch` landed after @openagentpack/sdk 0.1.0 — on older versions + * this is a silent no-op and the SDK keeps using the global fetch as before. + */ +export function installSdkTransport(host: HostContext): void { + if (installed) return; + const setDefaultFetch = (sdk as Record).setDefaultFetch; + if (typeof setDefaultFetch !== "function") return; + (setDefaultFetch as (fetchImpl: FetchImplementation) => void)(createInstrumentedFetch(host)); + installed = true; +} diff --git a/packages/commands/src/commands/agent/apply.ts b/packages/commands/src/commands/agent/apply.ts index 809c9f5..cd66f1a 100644 --- a/packages/commands/src/commands/agent/apply.ts +++ b/packages/commands/src/commands/agent/apply.ts @@ -8,7 +8,11 @@ import { import { emitBare, emitResult } from "bailian-cli-runtime"; import { executePlannedProject, planProjectContext } from "@openagentpack/sdk"; import { formatResourceLabel } from "./_engine/address-utils.ts"; -import { assertProviderConfigured, buildAgentRuntime } from "./_engine/config-loader.ts"; +import { + assertProviderConfigured, + buildAgentRuntime, + CREDENTIALS_NOTE, +} from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; import { renderAgentFeedback } from "./_engine/feedback.ts"; @@ -45,6 +49,7 @@ export default defineCommand({ usageArgs: "[--file ] [--provider ] [--yes] [--concurrency ]", flags: APPLY_FLAGS, exampleArgs: ["--yes", "--provider bailian --yes"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -52,7 +57,7 @@ export default defineCommand({ const planned = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); assertProviderConfigured(runtime, flags.provider); return planProjectContext(runtime, { provider: flags.provider, diff --git a/packages/commands/src/commands/agent/destroy.ts b/packages/commands/src/commands/agent/destroy.ts index 52fd395..6b5e6fa 100644 --- a/packages/commands/src/commands/agent/destroy.ts +++ b/packages/commands/src/commands/agent/destroy.ts @@ -8,7 +8,7 @@ import { import { emitBare, emitResult } from "bailian-cli-runtime"; import { destroyPlannedProjectResources, planDestroyProjectContext } from "@openagentpack/sdk"; import { formatResourceLabel } from "./_engine/address-utils.ts"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -34,6 +34,7 @@ export default defineCommand({ usageArgs: "[--file ] [--yes] [--cascade]", flags: DESTROY_FLAGS, exampleArgs: ["--yes", "--yes --cascade"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -41,7 +42,7 @@ export default defineCommand({ const planned = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); return planDestroyProjectContext(runtime); }), ); diff --git a/packages/commands/src/commands/agent/init.ts b/packages/commands/src/commands/agent/init.ts index dc16f65..467caee 100644 --- a/packages/commands/src/commands/agent/init.ts +++ b/packages/commands/src/commands/agent/init.ts @@ -18,7 +18,7 @@ agents.state.json const PROVIDERS = ["bailian", "claude", "qoder", "ark", "all"] as const; const PROVIDER_BLOCKS: Record = { - bailian: ` bailian:\n api_key: \${DASHSCOPE_API_KEY}\n workspace_id: \${BAILIAN_WORKSPACE_ID}`, + bailian: ` bailian:\n # bl auth login sets DASHSCOPE_API_KEY; bl config set workspace_id sets BAILIAN_WORKSPACE_ID\n api_key: \${DASHSCOPE_API_KEY}\n workspace_id: \${BAILIAN_WORKSPACE_ID}`, claude: ` claude:\n api_key: \${ANTHROPIC_API_KEY}`, qoder: ` qoder:\n api_key: \${QODER_PAT}\n gateway: "https://api.qoder.com/api/v1/cloud"`, ark: ` ark:\n api_key: \${ARK_API_KEY}`, @@ -135,6 +135,11 @@ export default defineCommand({ emitResult({ created: file, provider, agent: agentName }, format); } else { emitBare(`Created ${file}`); + if (provider === "bailian" || provider === "all") { + emitBare( + "Credentials: run `bl auth login` and `bl config set workspace_id `, or set DASHSCOPE_API_KEY / BAILIAN_WORKSPACE_ID.", + ); + } emitBare("Next: edit agents.yaml, then run `bl agent plan`."); } }, diff --git a/packages/commands/src/commands/agent/plan.ts b/packages/commands/src/commands/agent/plan.ts index 4ded10e..ceec110 100644 --- a/packages/commands/src/commands/agent/plan.ts +++ b/packages/commands/src/commands/agent/plan.ts @@ -8,7 +8,11 @@ import { import { emitBare, emitResult } from "bailian-cli-runtime"; import { planProjectContext } from "@openagentpack/sdk"; import { formatResourceLabel } from "./_engine/address-utils.ts"; -import { assertProviderConfigured, buildAgentRuntime } from "./_engine/config-loader.ts"; +import { + assertProviderConfigured, + buildAgentRuntime, + CREDENTIALS_NOTE, +} from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; import { renderAgentFeedback } from "./_engine/feedback.ts"; @@ -40,6 +44,7 @@ export default defineCommand({ usageArgs: "[--file ] [--provider ] [--no-refresh] [--refresh-only]", flags: PLAN_FLAGS, exampleArgs: ["", "--provider bailian", "--no-refresh"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -47,7 +52,7 @@ export default defineCommand({ const planned = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); assertProviderConfigured(runtime, flags.provider); return planProjectContext(runtime, { provider: flags.provider, diff --git a/packages/commands/src/commands/agent/session-create.ts b/packages/commands/src/commands/agent/session-create.ts index faf082e..af6a92d 100644 --- a/packages/commands/src/commands/agent/session-create.ts +++ b/packages/commands/src/commands/agent/session-create.ts @@ -1,7 +1,7 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { createSessionForAgent } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; import { parseMemoryStores } from "./_engine/session-render.ts"; @@ -22,7 +22,11 @@ const SESSION_CREATE_FLAGS = { valueHint: "", description: "Override agent's declared environment", }, - vault: { type: "string", valueHint: "", description: "Override agent's declared vault" }, + vault: { + type: "string", + valueHint: "", + description: "Override agent's declared vault", + }, memoryStores: { type: "string", valueHint: "", @@ -42,6 +46,7 @@ export default defineCommand({ usageArgs: "[--agent ] [--environment ] [--title ] [--file <path>]", flags: SESSION_CREATE_FLAGS, exampleArgs: ["", "--agent assistant", "--agent assistant --title 'debug run'"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -49,7 +54,7 @@ export default defineCommand({ const run = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); return createSessionForAgent(runtime, { agent: flags.agent, provider: flags.provider, diff --git a/packages/commands/src/commands/agent/session-delete.ts b/packages/commands/src/commands/agent/session-delete.ts index e85e754..e12f53d 100644 --- a/packages/commands/src/commands/agent/session-delete.ts +++ b/packages/commands/src/commands/agent/session-delete.ts @@ -1,7 +1,7 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { deleteSession } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -17,7 +17,11 @@ const SESSION_DELETE_FLAGS = { valueHint: "<path>", description: "Config file path (default: agents.yaml)", }, - provider: { type: "string", valueHint: "<name>", description: "Target provider" }, + provider: { + type: "string", + valueHint: "<name>", + description: "Target provider", + }, } satisfies FlagsDef; export default defineCommand({ @@ -26,6 +30,7 @@ export default defineCommand({ usageArgs: "--session-id <id> [--provider <name>] [--file <path>]", flags: SESSION_DELETE_FLAGS, exampleArgs: ["--session-id sess_abc123"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -33,7 +38,7 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); await deleteSession(runtime, flags.sessionId, flags.provider); }), ); diff --git a/packages/commands/src/commands/agent/session-events.ts b/packages/commands/src/commands/agent/session-events.ts index 8887a35..7a6cac7 100644 --- a/packages/commands/src/commands/agent/session-events.ts +++ b/packages/commands/src/commands/agent/session-events.ts @@ -2,7 +2,7 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-co import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; import { listSessionEvents } from "@openagentpack/sdk"; import { sanitizeSessionEvents } from "@openagentpack/sdk/session-events"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; import { fetchAllPages } from "./_engine/pagination.ts"; @@ -19,9 +19,20 @@ const SESSION_EVENTS_FLAGS = { valueHint: "<path>", description: "Config file path (default: agents.yaml)", }, - provider: { type: "string", valueHint: "<name>", description: "Target provider" }, - limit: { type: "number", valueHint: "<n>", description: "Maximum number of events to fetch" }, - all: { type: "switch", description: "Fetch all pages by following the cursor" }, + provider: { + type: "string", + valueHint: "<name>", + description: "Target provider", + }, + limit: { + type: "number", + valueHint: "<n>", + description: "Maximum number of events to fetch", + }, + all: { + type: "switch", + description: "Fetch all pages by following the cursor", + }, } satisfies FlagsDef; export default defineCommand({ @@ -30,6 +41,7 @@ export default defineCommand({ usageArgs: "--session-id <id> [--limit <n>] [--all] [--file <path>]", flags: SESSION_EVENTS_FLAGS, exampleArgs: ["--session-id sess_abc123", "--session-id sess_abc123 --all"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -37,14 +49,18 @@ export default defineCommand({ const { items: events, hasMore } = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); return fetchAllPages(async (page) => { const result = await listSessionEvents(runtime, flags.sessionId, { provider: flags.provider, limit: flags.limit, page_token: page, }); - return { items: result.events, hasMore: result.has_more, nextPage: result.next_page }; + return { + items: result.events, + hasMore: result.has_more, + nextPage: result.next_page, + }; }, flags.all); }), ); diff --git a/packages/commands/src/commands/agent/session-get.ts b/packages/commands/src/commands/agent/session-get.ts index 203facb..6da3e67 100644 --- a/packages/commands/src/commands/agent/session-get.ts +++ b/packages/commands/src/commands/agent/session-get.ts @@ -1,7 +1,7 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { getSession } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -17,7 +17,11 @@ const SESSION_GET_FLAGS = { valueHint: "<path>", description: "Config file path (default: agents.yaml)", }, - provider: { type: "string", valueHint: "<name>", description: "Target provider" }, + provider: { + type: "string", + valueHint: "<name>", + description: "Target provider", + }, } satisfies FlagsDef; export default defineCommand({ @@ -26,6 +30,7 @@ export default defineCommand({ usageArgs: "--session-id <id> [--provider <name>] [--file <path>]", flags: SESSION_GET_FLAGS, exampleArgs: ["--session-id sess_abc123"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -33,7 +38,7 @@ export default defineCommand({ const session = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); return getSession(runtime, flags.sessionId, flags.provider); }), ); diff --git a/packages/commands/src/commands/agent/session-list.ts b/packages/commands/src/commands/agent/session-list.ts index 0686c6b..375feeb 100644 --- a/packages/commands/src/commands/agent/session-list.ts +++ b/packages/commands/src/commands/agent/session-list.ts @@ -1,7 +1,7 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; import { listSessionSummaries } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; import { fetchAllPages } from "./_engine/pagination.ts"; @@ -12,9 +12,20 @@ const SESSION_LIST_FLAGS = { valueHint: "<path>", description: "Config file path (default: agents.yaml)", }, - agent: { type: "string", valueHint: "<name>", description: "Filter by agent name" }, - all: { type: "switch", description: "Fetch all pages by following the cursor" }, - provider: { type: "string", valueHint: "<name>", description: "Target provider" }, + agent: { + type: "string", + valueHint: "<name>", + description: "Filter by agent name", + }, + all: { + type: "switch", + description: "Fetch all pages by following the cursor", + }, + provider: { + type: "string", + valueHint: "<name>", + description: "Target provider", + }, } satisfies FlagsDef; export default defineCommand({ @@ -23,6 +34,7 @@ export default defineCommand({ usageArgs: "[--agent <name>] [--all] [--provider <name>] [--file <path>]", flags: SESSION_LIST_FLAGS, exampleArgs: ["", "--agent assistant", "--all"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -30,14 +42,18 @@ export default defineCommand({ const { items: summaries, hasMore } = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); return fetchAllPages(async (page) => { const result = await listSessionSummaries(runtime, { agent: flags.agent, provider: flags.provider, filter: page ? { page } : undefined, }); - return { items: result.summaries, hasMore: result.hasMore, nextPage: result.nextPage }; + return { + items: result.summaries, + hasMore: result.hasMore, + nextPage: result.nextPage, + }; }, flags.all); }), ); diff --git a/packages/commands/src/commands/agent/session-run.ts b/packages/commands/src/commands/agent/session-run.ts index 5f75e59..34824a3 100644 --- a/packages/commands/src/commands/agent/session-run.ts +++ b/packages/commands/src/commands/agent/session-run.ts @@ -1,6 +1,6 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { startSessionRun, startSessionRunPolling } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; import { @@ -31,15 +31,26 @@ const SESSION_RUN_FLAGS = { valueHint: "<name>", description: "Override agent's declared environment", }, - vault: { type: "string", valueHint: "<name>", description: "Override agent's declared vault" }, + vault: { + type: "string", + valueHint: "<name>", + description: "Override agent's declared vault", + }, memoryStores: { type: "string", valueHint: "<names>", description: "Override agent's memory stores (comma-separated)", }, title: { type: "string", valueHint: "<title>", description: "Session title" }, - provider: { type: "string", valueHint: "<name>", description: "Target provider" }, - noStream: { type: "switch", description: "Use polling instead of SSE streaming" }, + provider: { + type: "string", + valueHint: "<name>", + description: "Target provider", + }, + noStream: { + type: "switch", + description: "Use polling instead of SSE streaming", + }, } satisfies FlagsDef; export default defineCommand({ @@ -48,6 +59,7 @@ export default defineCommand({ usageArgs: "--prompt <text> [--agent <name>] [--no-stream] [--file <path>]", flags: SESSION_RUN_FLAGS, exampleArgs: ['--prompt "hello"', '--agent assistant --prompt "summarize this repo"'], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -65,7 +77,7 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); if (flags.noStream) { const run = await startSessionRunPolling(runtime, flags.prompt, runOptions); if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`); diff --git a/packages/commands/src/commands/agent/session-send.ts b/packages/commands/src/commands/agent/session-send.ts index a0b2611..643f9fe 100644 --- a/packages/commands/src/commands/agent/session-send.ts +++ b/packages/commands/src/commands/agent/session-send.ts @@ -1,6 +1,6 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { sendSessionMessagePolling, sendSessionMessageStreaming } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; import { renderCollectedEvents, streamAndRenderEvents } from "./_engine/session-render.ts"; @@ -23,8 +23,15 @@ const SESSION_SEND_FLAGS = { valueHint: "<path>", description: "Config file path (default: agents.yaml)", }, - provider: { type: "string", valueHint: "<name>", description: "Target provider" }, - noStream: { type: "switch", description: "Use polling instead of SSE streaming" }, + provider: { + type: "string", + valueHint: "<name>", + description: "Target provider", + }, + noStream: { + type: "switch", + description: "Use polling instead of SSE streaming", + }, } satisfies FlagsDef; export default defineCommand({ @@ -33,6 +40,7 @@ export default defineCommand({ usageArgs: "--session-id <id> --message <text> [--no-stream] [--file <path>]", flags: SESSION_SEND_FLAGS, exampleArgs: ['--session-id sess_abc123 --message "continue"'], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -41,7 +49,7 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); if (flags.noStream) { const result = await sendSessionMessagePolling(runtime, flags.sessionId, flags.message, { provider: flags.provider, diff --git a/packages/commands/src/commands/agent/state-import.ts b/packages/commands/src/commands/agent/state-import.ts index 8d4faba..69c3329 100644 --- a/packages/commands/src/commands/agent/state-import.ts +++ b/packages/commands/src/commands/agent/state-import.ts @@ -1,7 +1,7 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { importResource, parseStateAddress } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -37,6 +37,7 @@ export default defineCommand({ "--address <provider.type.name> --remote-id <id> [--resource-version <n>] [--file <path>]", flags: STATE_IMPORT_FLAGS, exampleArgs: ["--address bailian.agent.assistant --remote-id agent-abc123"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -44,8 +45,10 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); - const parsed = parseStateAddress(flags.address, { requireProvider: true }); + const runtime = await buildAgentRuntime(ctx, file); + const parsed = parseStateAddress(flags.address, { + requireProvider: true, + }); await importResource(runtime, parsed, flags.remoteId, { resourceVersion: flags.resourceVersion, }); diff --git a/packages/commands/src/commands/agent/state-list.ts b/packages/commands/src/commands/agent/state-list.ts index 68d26ed..49ff8a8 100644 --- a/packages/commands/src/commands/agent/state-list.ts +++ b/packages/commands/src/commands/agent/state-list.ts @@ -1,6 +1,6 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -18,6 +18,7 @@ export default defineCommand({ usageArgs: "[--file <path>]", flags: STATE_LIST_FLAGS, exampleArgs: ["", "--file agents.yaml"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -25,7 +26,7 @@ export default defineCommand({ const resources = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); + const runtime = await buildAgentRuntime(ctx, file); return runtime.state.listResources(); }), ); diff --git a/packages/commands/src/commands/agent/state-rm.ts b/packages/commands/src/commands/agent/state-rm.ts index 44022d1..633d5db 100644 --- a/packages/commands/src/commands/agent/state-rm.ts +++ b/packages/commands/src/commands/agent/state-rm.ts @@ -7,7 +7,7 @@ import { } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { parseStateAddress } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -31,6 +31,7 @@ export default defineCommand({ usageArgs: "--address <provider.type.name> [--file <path>]", flags: STATE_RM_FLAGS, exampleArgs: ["--address bailian.agent.assistant"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -38,8 +39,10 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); - const parsed = parseStateAddress(flags.address, { requireProvider: false }); + const runtime = await buildAgentRuntime(ctx, file); + const parsed = parseStateAddress(flags.address, { + requireProvider: false, + }); const found = runtime.state.findResource(parsed); if (!found) { throw new BailianError(`Resource not found: ${flags.address}`, ExitCode.GENERAL); diff --git a/packages/commands/src/commands/agent/state-show.ts b/packages/commands/src/commands/agent/state-show.ts index 5a5f2dd..316a1a6 100644 --- a/packages/commands/src/commands/agent/state-show.ts +++ b/packages/commands/src/commands/agent/state-show.ts @@ -7,7 +7,7 @@ import { } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { parseStateAddress } from "@openagentpack/sdk"; -import { buildAgentRuntime } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -31,6 +31,7 @@ export default defineCommand({ usageArgs: "--address <provider.type.name> [--file <path>]", flags: STATE_SHOW_FLAGS, exampleArgs: ["--address bailian.agent.assistant"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -38,8 +39,10 @@ export default defineCommand({ const found = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(file); - const parsed = parseStateAddress(flags.address, { requireProvider: false }); + const runtime = await buildAgentRuntime(ctx, file); + const parsed = parseStateAddress(flags.address, { + requireProvider: false, + }); return runtime.state.findResource(parsed); }), ); diff --git a/packages/commands/src/commands/agent/validate.ts b/packages/commands/src/commands/agent/validate.ts index 195c02b..7a74681 100644 --- a/packages/commands/src/commands/agent/validate.ts +++ b/packages/commands/src/commands/agent/validate.ts @@ -7,7 +7,7 @@ import { } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { resolveProjectConfig, validateProjectConfig } from "@openagentpack/sdk"; -import { ensureCredentials } from "./_engine/credentials.ts"; +import { CREDENTIALS_NOTE, ensureCredentials } from "./_engine/credentials.ts"; import { withAgentErrors } from "./_engine/errors.ts"; const VALIDATE_FLAGS = { @@ -24,6 +24,7 @@ export default defineCommand({ usageArgs: "[--file <path>]", flags: VALIDATE_FLAGS, exampleArgs: ["", "--file agents.yaml"], + notes: CREDENTIALS_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); diff --git a/packages/commands/tests/agent-errors.test.ts b/packages/commands/tests/agent-errors.test.ts new file mode 100644 index 0000000..c93e3b9 --- /dev/null +++ b/packages/commands/tests/agent-errors.test.ts @@ -0,0 +1,88 @@ +import { UserError } from "@openagentpack/sdk"; +import { BailianError, ExitCode } from "bailian-cli-core"; +import { expect, test } from "vite-plus/test"; +import { withAgentErrors } from "../src/commands/agent/_engine/errors.ts"; + +/** + * Structural stand-in for the SDK's internal `ApiError` (not exported by the + * installed SDK version): withAgentErrors matches on statusCode/responseBody + * fields, so any Error carrying them must map through mapApiError. + */ +class FakeSdkApiError extends Error { + constructor( + readonly statusCode: number, + readonly responseBody: string, + ) { + super(`Bailian API ${statusCode}: ${responseBody}`); + } +} + +async function catchMapped(error: unknown): Promise<BailianError> { + try { + await withAgentErrors(() => Promise.reject(error)); + } catch (mapped) { + expect(mapped).toBeInstanceOf(BailianError); + return mapped as BailianError; + } + throw new Error("expected withAgentErrors to throw"); +} + +test("BailianError passes through untouched", async () => { + const original = new BailianError("already mapped", ExitCode.AUTH); + const mapped = await catchMapped(original); + expect(mapped).toBe(original); +}); + +test("SDK UserError maps to USAGE", async () => { + const mapped = await catchMapped(new UserError("bad agents.yaml")); + expect(mapped.exitCode).toBe(ExitCode.USAGE); + expect(mapped.message).toBe("bad agents.yaml"); +}); + +test("SDK ApiError with DashScope-style JSON body surfaces clean message and api metadata", async () => { + const body = JSON.stringify({ + code: "InvalidParameter", + message: "agent name already exists", + request_id: "req-123", + }); + const mapped = await catchMapped(new FakeSdkApiError(400, body)); + expect(mapped.exitCode).toBe(ExitCode.GENERAL); + expect(mapped.message).toBe("agent name already exists"); + expect(mapped.api).toEqual({ + httpStatus: 400, + apiCode: "InvalidParameter", + requestId: "req-123", + }); +}); + +test("SDK ApiError with OpenAI-style error envelope extracts message and type", async () => { + const body = JSON.stringify({ + error: { message: "model does not exist", type: "invalid_request_error" }, + request_id: "req-456", + }); + const mapped = await catchMapped(new FakeSdkApiError(404, body)); + expect(mapped.exitCode).toBe(ExitCode.GENERAL); + expect(mapped.message).toBe("model does not exist"); + expect(mapped.api?.apiCode).toBe("invalid_request_error"); + expect(mapped.api?.requestId).toBe("req-456"); +}); + +test("SDK ApiError with non-JSON body passes raw text through as message", async () => { + const mapped = await catchMapped(new FakeSdkApiError(502, "Bad Gateway")); + expect(mapped.exitCode).toBe(ExitCode.GENERAL); + expect(mapped.message).toBe("Bad Gateway"); + expect(mapped.api?.httpStatus).toBe(502); +}); + +test("SDK ApiError with empty body falls back to HTTP status message", async () => { + const mapped = await catchMapped(new FakeSdkApiError(503, "")); + expect(mapped.message).toBe("HTTP 503"); + expect(mapped.api?.httpStatus).toBe(503); +}); + +test("plain Error maps to GENERAL with message passed through", async () => { + const mapped = await catchMapped(new Error("boom")); + expect(mapped.exitCode).toBe(ExitCode.GENERAL); + expect(mapped.message).toBe("boom"); + expect(mapped.api).toBeUndefined(); +}); diff --git a/packages/commands/tests/credentials-bridge.test.ts b/packages/commands/tests/credentials-bridge.test.ts new file mode 100644 index 0000000..9e18f14 --- /dev/null +++ b/packages/commands/tests/credentials-bridge.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { expect, test } from "vite-plus/test"; +import { bridgeBailianCredentials } from "../src/commands/agent/_engine/credentials.ts"; + +/** + * bridgeBailianCredentials 把 ~/.bailian/config.json 的 api_key / workspace_id + * 作为最低优先级兜底填入 DASHSCOPE_API_KEY / BAILIAN_WORKSPACE_ID。 + * 用临时 config dir + env 保存恢复隔离,验证优先级与不抛错语义。 + */ +async function inScenario( + scenario: { + config?: { api_key?: string; workspace_id?: string }; + env?: { DASHSCOPE_API_KEY?: string; BAILIAN_WORKSPACE_ID?: string }; + }, + assert: () => void, +): Promise<void> { + const savedConfigDir = process.env.BAILIAN_CONFIG_DIR; + const savedApiKey = process.env.DASHSCOPE_API_KEY; + const savedWorkspace = process.env.BAILIAN_WORKSPACE_ID; + + const dir = mkdtempSync(join(tmpdir(), "bl-cred-bridge-")); + process.env.BAILIAN_CONFIG_DIR = dir; + if (scenario.config) { + writeFileSync(join(dir, "config.json"), JSON.stringify(scenario.config), "utf-8"); + } + + // 显式设置/清除 env,避免继承宿主环境干扰断言 + if (scenario.env?.DASHSCOPE_API_KEY === undefined) delete process.env.DASHSCOPE_API_KEY; + else process.env.DASHSCOPE_API_KEY = scenario.env.DASHSCOPE_API_KEY; + if (scenario.env?.BAILIAN_WORKSPACE_ID === undefined) delete process.env.BAILIAN_WORKSPACE_ID; + else process.env.BAILIAN_WORKSPACE_ID = scenario.env.BAILIAN_WORKSPACE_ID; + + try { + assert(); + } finally { + restore("BAILIAN_CONFIG_DIR", savedConfigDir); + restore("DASHSCOPE_API_KEY", savedApiKey); + restore("BAILIAN_WORKSPACE_ID", savedWorkspace); + rmSync(dir, { recursive: true, force: true }); + } +} + +function restore(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +test("bridge:env 已有值时不被 bl config 覆盖(最低优先级)", async () => { + await inScenario( + { + config: { api_key: "sk-from-config", workspace_id: "ws-from-config" }, + env: { DASHSCOPE_API_KEY: "sk-from-env", BAILIAN_WORKSPACE_ID: "ws-from-env" }, + }, + () => { + bridgeBailianCredentials(); + expect(process.env.DASHSCOPE_API_KEY).toBe("sk-from-env"); + expect(process.env.BAILIAN_WORKSPACE_ID).toBe("ws-from-env"); + }, + ); +}); + +test("bridge:env 缺失且 bl config 有值时填充", async () => { + await inScenario( + { config: { api_key: "sk-from-config", workspace_id: "ws-from-config" }, env: {} }, + () => { + bridgeBailianCredentials(); + expect(process.env.DASHSCOPE_API_KEY).toBe("sk-from-config"); + expect(process.env.BAILIAN_WORKSPACE_ID).toBe("ws-from-config"); + }, + ); +}); + +test("bridge:仅缺失项被填,已有项保留(逐字段独立)", async () => { + await inScenario( + { + config: { api_key: "sk-from-config", workspace_id: "ws-from-config" }, + env: { DASHSCOPE_API_KEY: "sk-from-env" }, + }, + () => { + bridgeBailianCredentials(); + expect(process.env.DASHSCOPE_API_KEY).toBe("sk-from-env"); + expect(process.env.BAILIAN_WORKSPACE_ID).toBe("ws-from-config"); + }, + ); +}); + +test("bridge:env 与 bl config 皆缺失时不抛错且不写入", async () => { + await inScenario({ env: {} }, () => { + expect(() => bridgeBailianCredentials()).not.toThrow(); + expect(process.env.DASHSCOPE_API_KEY).toBeUndefined(); + expect(process.env.BAILIAN_WORKSPACE_ID).toBeUndefined(); + }); +}); diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index b4308e3..03bfc83 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -19,8 +19,9 @@ export { videoGeneratePath, } from "./endpoints.ts"; export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts"; -export type { RequestOpts } from "./http.ts"; +export type { HttpDeps, RequestOpts } from "./http.ts"; export { request, requestJson } from "./http.ts"; +export { createInstrumentedFetch, type FetchImplementation } from "./instrumented-fetch.ts"; export { Client, type ClientRequestOpts, type ClientOpenApiQueryOpts } from "./client.ts"; export { buildAcsCanonicalQuery, diff --git a/packages/core/src/client/instrumented-fetch.ts b/packages/core/src/client/instrumented-fetch.ts new file mode 100644 index 0000000..da27495 --- /dev/null +++ b/packages/core/src/client/instrumented-fetch.ts @@ -0,0 +1,76 @@ +import { maskToken } from "../utils/token.ts"; +import type { HttpDeps } from "./http.ts"; +import { trackingHeaders } from "./headers.ts"; + +/** + * fetch-compatible signature, structurally identical to the SDK-side `FetchLike` + * seam. Declared locally so core stays free of SDK imports. + */ +export type FetchImplementation = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise<Response>; + +/** + * Tracking headers are DashScope-specific: only attach them to Alibaba Cloud + * hosts, never to third-party providers (Anthropic / Ark / Qoder) that an + * embedded SDK may also call through this fetch. + */ +function isAlibabaCloudHost(url: string): boolean { + try { + const { hostname } = new URL(url); + return hostname === "aliyuncs.com" || hostname.endsWith(".aliyuncs.com"); + } catch { + return false; + } +} + +function requestUrl(input: string | URL | Request): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +/** + * A transparent fetch wrapper carrying the client-layer cross-cutting request + * concerns (UA, tracking headers, `--verbose` logging) for network stacks that + * bypass {@link request} — e.g. an embedded SDK's provider clients. Deliberately + * transport-only: no auth injection, no baseUrl handling, no timeout, and no + * error mapping, so the caller's response semantics (status handling, SSE, + * conflict detection) stay intact. + */ +export function createInstrumentedFetch(deps: HttpDeps): FetchImplementation { + return async (input, init = {}) => { + const url = requestUrl(input); + const headers = new Headers( + init.headers ?? (input instanceof Request ? input.headers : undefined), + ); + + if (!headers.has("user-agent")) { + headers.set("User-Agent", `${deps.identity.clientName}/${deps.identity.version}`); + } + if (isAlibabaCloudHost(url)) { + for (const [name, value] of Object.entries(trackingHeaders())) { + headers.set(name, value); + } + } + + if (deps.settings.verbose) { + console.error(`> ${init.method ?? "GET"} ${url}`); + const auth = headers.get("authorization"); + if (auth) console.error(`> Auth: ${maskToken(auth.replace(/^Bearer /, ""))}`); + } + + const res = await fetch(input, { ...init, headers }); + + if (deps.settings.verbose) { + console.error(`< ${res.status} ${res.statusText}`); + const reqId = res.headers.get("x-request-id"); + if (reqId) { + console.error(`request_id: ${reqId}`); + } + } + + return res; + }; +} diff --git a/packages/core/tests/instrumented-fetch.test.ts b/packages/core/tests/instrumented-fetch.test.ts new file mode 100644 index 0000000..501c262 --- /dev/null +++ b/packages/core/tests/instrumented-fetch.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "vite-plus/test"; +import type { Identity, Settings } from "../src/index.ts"; +import { createInstrumentedFetch, SOURCE_CONFIG } from "../src/index.ts"; + +const identity: Identity = { + binName: "bl", + clientName: "bailian-cli", + version: "1.2.3", + npmPackage: "bailian-cli", +}; + +const settings: Settings = { timeout: 60, verbose: false } as Settings; + +interface CapturedRequest { + url: string; + headers: Headers; +} + +/** Run the wrapper against a stubbed globalThis.fetch and capture what reaches it. */ +async function capture( + input: string | URL | Request, + init?: RequestInit, +): Promise<CapturedRequest> { + const originalFetch = globalThis.fetch; + let captured: CapturedRequest | undefined; + globalThis.fetch = (async (fetchInput: string | URL | Request, fetchInit?: RequestInit) => { + captured = { + url: + typeof fetchInput === "string" + ? fetchInput + : fetchInput instanceof URL + ? fetchInput.href + : fetchInput.url, + headers: new Headers(fetchInit?.headers), + }; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + try { + await createInstrumentedFetch({ identity, settings })(input, init); + } finally { + globalThis.fetch = originalFetch; + } + if (!captured) throw new Error("stubbed fetch was not called"); + return captured; +} + +test("adds UA and tracking header on Alibaba Cloud hosts", async () => { + const { headers } = await capture( + "https://ws-1.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/agents", + { method: "POST", headers: { Authorization: "Bearer k" } }, + ); + expect(headers.get("user-agent")).toBe("bailian-cli/1.2.3"); + expect(headers.get("x-dashscope-source-config")).toBe(SOURCE_CONFIG); + expect(headers.get("authorization")).toBe("Bearer k"); +}); + +test("adds UA but no tracking header on third-party hosts", async () => { + const { headers } = await capture("https://api.anthropic.com/v1/messages", { + method: "POST", + }); + expect(headers.get("user-agent")).toBe("bailian-cli/1.2.3"); + expect(headers.get("x-dashscope-source-config")).toBeNull(); +}); + +test("does not override a caller-provided User-Agent", async () => { + const { headers } = await capture("https://dashscope.aliyuncs.com/api/v1/tasks/t1", { + headers: { "User-Agent": "custom/9.9" }, + }); + expect(headers.get("user-agent")).toBe("custom/9.9"); +}); + +test("does not invent a Content-Type (FormData boundary safety)", async () => { + const { headers } = await capture("https://dashscope.aliyuncs.com/api/v1/files", { + method: "POST", + }); + expect(headers.get("content-type")).toBeNull(); +}); + +test("passes non-URL-parseable inputs through without tracking headers", async () => { + const { url, headers } = await capture("/relative/path"); + expect(url).toBe("/relative/path"); + expect(headers.get("x-dashscope-source-config")).toBeNull(); +}); diff --git a/skills/bailian-cli/reference/agent.md b/skills/bailian-cli/reference/agent.md index c7abb32..01df0f7 100644 --- a/skills/bailian-cli/reference/agent.md +++ b/skills/bailian-cli/reference/agent.md @@ -46,6 +46,11 @@ Index: [index.md](index.md) | `--no-refresh` | switch | no | Skip refreshing state from remote before planning | | `--concurrency <n>` | number | no | Max independent resources to apply in parallel (default 6, max 10) | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -72,6 +77,11 @@ bl agent apply --provider bailian --yes | `--yes` | switch | no | Confirm and destroy without an interactive prompt (required) | | `--cascade` | switch | no | Auto-delete dependent resources (e.g. sessions referencing an environment) | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -130,6 +140,11 @@ bl agent init --provider all | `--no-refresh` | switch | no | Skip refreshing state from remote before planning | | `--refresh-only` | switch | no | Refresh state and show drift without planning remote mutations | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -164,6 +179,11 @@ bl agent plan --no-refresh | `--title <title>` | string | no | Session title | | `--provider <name>` | string | no | Target provider (multi-provider agents) | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -194,6 +214,11 @@ bl agent session create --agent assistant --title 'debug run' | `--file <path>` | string | no | Config file path (default: agents.yaml) | | `--provider <name>` | string | no | Target provider | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -218,6 +243,11 @@ bl agent session delete --session-id sess_abc123 | `--limit <n>` | number | no | Maximum number of events to fetch | | `--all` | switch | no | Fetch all pages by following the cursor | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -244,6 +274,11 @@ bl agent session events --session-id sess_abc123 --all | `--file <path>` | string | no | Config file path (default: agents.yaml) | | `--provider <name>` | string | no | Target provider | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -267,6 +302,11 @@ bl agent session get --session-id sess_abc123 | `--all` | switch | no | Fetch all pages by following the cursor | | `--provider <name>` | string | no | Target provider | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -303,6 +343,11 @@ bl agent session list --all | `--provider <name>` | string | no | Target provider | | `--no-stream` | switch | no | Use polling instead of SSE streaming | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -331,6 +376,11 @@ bl agent session run --agent assistant --prompt "summarize this repo" | `--provider <name>` | string | no | Target provider | | `--no-stream` | switch | no | Use polling instead of SSE streaming | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -354,6 +404,11 @@ bl agent session send --session-id sess_abc123 --message "continue" | `--resource-version <n>` | number | no | Resource version (for versioned resources like agents) | | `--file <path>` | string | no | Config file path (default: agents.yaml) | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -374,6 +429,11 @@ bl agent state import --address bailian.agent.assistant --remote-id agent-abc123 | --------------- | ------ | -------- | --------------------------------------- | | `--file <path>` | string | no | Config file path (default: agents.yaml) | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -399,6 +459,11 @@ bl agent state list --file agents.yaml | `--address <provider.type.name>` | string | yes | Resource state address (required) | | `--file <path>` | string | no | Config file path (default: agents.yaml) | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -420,6 +485,11 @@ bl agent state rm --address bailian.agent.assistant | `--address <provider.type.name>` | string | yes | Resource state address (required) | | `--file <path>` | string | no | Config file path (default: agents.yaml) | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash @@ -440,6 +510,11 @@ bl agent state show --address bailian.agent.assistant | --------------- | ------ | -------- | --------------------------------------- | | `--file <path>` | string | no | Config file path (default: agents.yaml) | +#### Notes + +- Credentials come from the env vars referenced in agents.yaml (e.g. ${DASHSCOPE_API_KEY}, ${BAILIAN_WORKSPACE_ID}). +- For the bailian provider, bl fills these from your login as a fallback: `bl auth login` (API key) and `bl config set workspace_id <id>`. + #### Examples ```bash