feat(agent): 非bailian provider也走鉴权逻辑

This commit is contained in:
chenanran555
2026-07-27 21:33:36 +08:00
parent a03ee0c72c
commit 9819eb6ddc
22 changed files with 91 additions and 228 deletions
+5 -5
View File
@@ -55,18 +55,18 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入
`bl managed-agent *` 按调用链分两层,不再全命令硬门禁:
`bl managed-agent *` 按调用链分两层:
- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言(`plan --no-refresh` 与 `plan --dry-run` 同样传 `"none"` 并强制 `refresh: false`:不联网、不回写 state)
- **provider-aware 命令** — `plan`(默认)、`apply`、`destroy`、`state import`、`skill-list`、全部 `session *`:仍声明 `auth: "apiKey"` 但加 `authOptional: true` —— authStage 照常经 `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config)并注入 `ctx.client`,但缺失不在 authStage 抛;真正的门禁在引擎层 `assertProviderCredentials`,只校验本次运行涉及的 provider(`CredentialScope`:`--provider` / state 地址里的 provider / 配置默认 provider 链)。配了四个 provider 只跑 claude 时,缺 bailian key 不阻塞。
- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言
- **联网命令** — `plan`、`apply`、`destroy`、`state import`、`skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁 —— 无论目标 provider 是谁,authStage 都经 `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层 `assertProviderCredentials` 再对 agents.yaml 里**全部已声明 provider** 的空 key 拦截并给 provider 专属 hint。例外:`plan --no-refresh` / `plan --dry-run` 传 `credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state,不查 provider key),其中 `--dry-run` 连登录也不要求(authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。
凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步:
1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错
2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免范围外 provider 在 SDK zod 层报 "received null")
2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免离线命令下空 key 在 SDK zod 层报 "received null")
3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:有凭证时 `api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀,无凭证时用 client 默认域名补齐以满足 schema)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充
4. `scrubCredentialEnv()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承)
5. `assertProviderCredentials(providers, required)` — 按 `CredentialScope` 算出的 `required` 范围校验:范围内 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);范围外 provider 允许空 key
5. `assertProviderCredentials(providers)` — 任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);离线命令传 `credentials: "none"` 整体跳过
`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
-27
View File
@@ -1,27 +0,0 @@
version: "1"
providers:
bailian:
# bl auth login --api-key <key> sets DASHSCOPE_API_KEY; --agentstudio-base-url <url> sets BAILIAN_BASE_URL
api_key: ${DASHSCOPE_API_KEY}
base_url: ${BAILIAN_BASE_URL}
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]
@@ -11,7 +11,6 @@ import {
injectProviderCredentials,
normalizeInterpolatedProviderBlocks,
prepareProviderEnv,
resolveTargetProviderNames,
scrubCredentialEnv,
} from "./credentials.ts";
import { loadFileState } from "./file-state-manager.ts";
@@ -20,15 +19,12 @@ import { type HostContext, installSdkTransport } from "./transport.ts";
export { CREDENTIALS_NOTE, OFFLINE_NOTE } from "./credentials.ts";
/**
* Which providers this run requires a non-empty key for:
* - "targets" (default) — the run's target providers per the config's
* default provider chain (mirrors the SDK's plan/apply targeting)
* Whether this run requires provider keys:
* - "all" (default) — online command: every provider declared in agents.yaml
* must have a non-empty key after injection
* - "none" — offline command (local config/state only), skip the check
* - "all" — every configured provider (`--provider all`)
* - any other name — the run was narrowed to that provider
* (`--provider <name>` / a provider-qualified state address)
*/
export type CredentialScope = "targets" | "none" | "all" | (string & {});
export type CredentialScope = "all" | "none";
interface AgentConfigOptions {
resolveEnv?: boolean;
@@ -46,8 +42,8 @@ interface AgentConfigOptions {
* 3. override the bailian block with the CLI auth chain's credential (in-memory)
* 4. scrub all credential vars from process.env (real values now live only in
* the config object → provider adapters, never the environment)
* 5. fail with a CLI-authoritative AUTH error if a provider within this run's
* {@link CredentialScope} has an empty key (offline commands pass "none")
* 5. fail with a CLI-authoritative AUTH error if any provider's key is empty
* (offline commands pass `credentials: "none"` to skip the check)
*/
export async function resolveAgentProjectConfig(
host: CredentialHost,
@@ -59,16 +55,8 @@ export async function resolveAgentProjectConfig(
normalizeInterpolatedProviderBlocks(resolved.config.providers);
injectProviderCredentials(resolved.config.providers, host);
scrubCredentialEnv();
const scope = options.credentials ?? "targets";
if (scope !== "none") {
assertProviderCredentials(
resolved.config.providers,
scope === "targets"
? resolveTargetProviderNames(resolved.config)
: scope === "all"
? Object.keys(resolved.config.providers)
: [scope],
);
if ((options.credentials ?? "all") !== "none") {
assertProviderCredentials(resolved.config.providers);
}
return resolved;
}
@@ -51,7 +51,6 @@ export interface CredentialHost {
export const CREDENTIALS_NOTE = [
"Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).",
"Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.",
"Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.",
"Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.",
];
@@ -89,10 +88,10 @@ export function prepareProviderEnv(): void {
* `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource
* paths onto it verbatim; a value already ending in the suffix is left as-is.
* It is filled even without a credential — `client.baseUrl` is readable
* credential-less (defaults to the CLI's model-domain base URL) — so offline /
* out-of-scope runs still satisfy the SDK's "workspace_id or base_url" schema.
* With no credential the `api_key` is left untouched: an in-scope empty key is
* rejected by {@link assertProviderCredentials}, out-of-scope ones may stay empty.
* credential-less (defaults to the CLI's model-domain base URL) — so offline
* commands (which skip the credential assert) still satisfy the SDK's
* "workspace_id or base_url" schema. With no credential the `api_key` is left
* untouched: online commands reject it via {@link assertProviderCredentials}.
*/
export function injectProviderCredentials(
providers: Record<string, unknown>,
@@ -133,10 +132,11 @@ export function scrubCredentialEnv(): void {
/**
* The SDK interpolates `${VAR}` into the raw YAML text, so an empty env var
* leaves `api_key:` with nothing after it — YAML parses that as null. Normalize
* every null provider field back to "" so the pipeline stays uniform: an empty
* api_key is caught by {@link assertProviderCredentials} when the provider is
* in scope, and out-of-scope blocks still satisfy the SDK's string schemas
* instead of failing zod with "received null" before the run even starts.
* every null provider field back to "" so the pipeline stays uniform: for
* online commands an empty api_key is caught by {@link
* assertProviderCredentials}; for offline commands (which skip the assert) the
* blocks still satisfy the SDK's string schemas instead of failing zod with
* "received null" before the run even starts.
*/
export function normalizeInterpolatedProviderBlocks(providers: Record<string, unknown>): void {
for (const raw of Object.values(providers)) {
@@ -149,37 +149,15 @@ export function normalizeInterpolatedProviderBlocks(providers: Record<string, un
}
/**
* The providers a run targets when no explicit `--provider` narrows it: the
* config's default provider, or every configured provider when the default is
* absent or "all". Mirrors the SDK's config-based `resolveTargetProviders`
* (not exported from the SDK's public surface).
*/
export function resolveTargetProviderNames(config: {
providers: Record<string, unknown>;
defaults?: { provider?: string };
}): string[] {
const defaultProvider = config.defaults?.provider;
if (!defaultProvider || defaultProvider === "all") return Object.keys(config.providers);
return [defaultProvider];
}
/**
* After injection, fail with a CLI-authoritative AUTH error if a required
* After injection, fail with a CLI-authoritative AUTH error if any configured
* provider's `api_key` resolved empty (missing env var, or no bl login for
* bailian). Replaces the SDK's raw `Environment variable '...' is not set` /
* zod config error with a clean message plus a provider-specific hint.
* `required` limits the check to the providers this run actually involves
* (← --provider / state address / config default chain); providers outside
* that scope may keep empty keys — a project stays runnable per provider.
* Names without a matching config block are skipped: "provider not
* configured" is the engine's error to raise, not a credential problem.
* zod config error with a clean message plus a provider-specific hint. Validates
* every declared provider, so a project is only runnable once all its providers'
* keys are available; offline commands skip the check entirely.
*/
export function assertProviderCredentials(
providers: Record<string, unknown>,
required?: readonly string[],
): void {
for (const name of required ?? Object.keys(providers)) {
const raw = providers[name];
export function assertProviderCredentials(providers: Record<string, unknown>): void {
for (const [name, raw] of Object.entries(providers)) {
if (!raw || typeof raw !== "object") continue;
const block = raw as Record<string, unknown>;
if (!("api_key" in block)) continue;
@@ -46,8 +46,6 @@ const APPLY_FLAGS = {
export default defineCommand({
description: "Apply planned changes to create/update/delete agent resources",
auth: "apiKey",
// Provider-aware gate: only the providers this apply targets need credentials.
authOptional: true,
usageArgs: "[--file <path>] [--provider <name>] [--yes] [--concurrency <n>]",
flags: APPLY_FLAGS,
exampleArgs: ["--yes", "--provider bailian --yes"],
@@ -75,9 +73,7 @@ export default defineCommand({
const planned = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
assertProviderConfigured(runtime, flags.provider);
return planProjectContext(runtime, {
provider: flags.provider,
@@ -31,8 +31,6 @@ const DESTROY_FLAGS = {
export default defineCommand({
description: "Destroy all managed agent resources tracked in state",
auth: "apiKey",
// Provider-aware gate: only the run's target providers need credentials.
authOptional: true,
usageArgs: "[--file <path>] [--yes] [--cascade]",
flags: DESTROY_FLAGS,
exampleArgs: ["--yes", "--yes --cascade"],
@@ -56,9 +54,7 @@ export default defineCommand({
const planned = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
return planDestroyProjectContext(runtime);
}),
);
@@ -41,16 +41,12 @@ const PLAN_FLAGS = {
export default defineCommand({
description: "Show what changes would be applied to agent infrastructure",
auth: "apiKey",
// Provider-aware gate: --no-refresh / --dry-run plan fully offline; a
// refreshing run only needs credentials for the providers it targets
// (see CredentialScope).
authOptional: true,
usageArgs: "[--file <path>] [--provider <name>] [--no-refresh] [--refresh-only]",
flags: PLAN_FLAGS,
exampleArgs: ["", "--provider bailian", "--no-refresh"],
notes: [
...CREDENTIALS_NOTE,
"--no-refresh and --dry-run plan offline from local config and state: no credentials, no remote requests, no state writes.",
"--no-refresh and --dry-run plan offline from local config and state: no remote requests, no state writes, provider keys are not checked.",
],
async run(ctx) {
const { settings, flags } = ctx;
@@ -58,12 +54,14 @@ export default defineCommand({
const file = flags.file ?? "agents.yaml";
// Offline mode never talks to a provider and never saves refreshed state:
// --no-refresh by explicit request, --dry-run by contract (read-only run).
// Provider keys are skipped then; the bl login gate (auth: "apiKey") still
// applies except under --dry-run (authStage's dry-run exemption).
const offline = Boolean(flags.noRefresh) || settings.dryRun;
const planned = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: offline ? "none" : (flags.provider ?? "targets"),
credentials: offline ? "none" : "all",
});
assertProviderConfigured(runtime, flags.provider);
return planProjectContext(runtime, {
@@ -43,8 +43,6 @@ const SESSION_CREATE_FLAGS = {
export default defineCommand({
description: "Create a new session for an agent",
auth: "apiKey",
// Provider-aware gate: only the session's provider needs credentials.
authOptional: true,
usageArgs: "[--agent <name>] [--environment <name>] [--title <title>] [--file <path>]",
flags: SESSION_CREATE_FLAGS,
exampleArgs: ["", "--agent assistant", "--agent assistant --title 'debug run'"],
@@ -74,9 +72,7 @@ export default defineCommand({
const run = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
return createSessionForAgent(runtime, {
agent: flags.agent,
provider: flags.provider,
@@ -27,8 +27,6 @@ const SESSION_DELETE_FLAGS = {
export default defineCommand({
description: "Delete a session",
auth: "apiKey",
// Provider-aware gate: only the session's provider needs credentials.
authOptional: true,
usageArgs: "--session-id <id> [--provider <name>] [--file <path>]",
flags: SESSION_DELETE_FLAGS,
exampleArgs: ["--session-id sess_abc123"],
@@ -52,9 +50,7 @@ export default defineCommand({
await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
await deleteSession(runtime, flags.sessionId, flags.provider);
}),
);
@@ -38,8 +38,6 @@ const SESSION_EVENTS_FLAGS = {
export default defineCommand({
description: "List event history for a session",
auth: "apiKey",
// Provider-aware gate: only the session's provider needs credentials.
authOptional: true,
usageArgs: "--session-id <id> [--limit <n>] [--all] [--file <path>]",
flags: SESSION_EVENTS_FLAGS,
exampleArgs: ["--session-id sess_abc123", "--session-id sess_abc123 --all"],
@@ -51,9 +49,7 @@ export default defineCommand({
const { items: events, hasMore } = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
return fetchAllPages(async (page) => {
const result = await listSessionEvents(runtime, flags.sessionId, {
provider: flags.provider,
@@ -27,8 +27,6 @@ const SESSION_GET_FLAGS = {
export default defineCommand({
description: "Get details of a session",
auth: "apiKey",
// Provider-aware gate: only the session's provider needs credentials.
authOptional: true,
usageArgs: "--session-id <id> [--provider <name>] [--file <path>]",
flags: SESSION_GET_FLAGS,
exampleArgs: ["--session-id sess_abc123"],
@@ -40,9 +38,7 @@ export default defineCommand({
const session = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
return getSession(runtime, flags.sessionId, flags.provider);
}),
);
@@ -31,8 +31,6 @@ const SESSION_LIST_FLAGS = {
export default defineCommand({
description: "List sessions from the provider",
auth: "apiKey",
// Provider-aware gate: only the session's provider needs credentials.
authOptional: true,
usageArgs: "[--agent <name>] [--all] [--provider <name>] [--file <path>]",
flags: SESSION_LIST_FLAGS,
exampleArgs: ["", "--agent assistant", "--all"],
@@ -44,9 +42,7 @@ export default defineCommand({
const { items: summaries, hasMore } = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
return fetchAllPages(async (page) => {
const result = await listSessionSummaries(runtime, {
agent: flags.agent,
@@ -57,8 +57,6 @@ const SESSION_RUN_FLAGS = {
export default defineCommand({
description: "Create a session, send a message, and stream the response",
auth: "apiKey",
// Provider-aware gate: only the session's provider needs credentials.
authOptional: true,
usageArgs: "--prompt <text> [--agent <name>] [--no-stream] [--file <path>]",
flags: SESSION_RUN_FLAGS,
exampleArgs: ['--prompt "hello"', '--agent assistant --prompt "summarize this repo"'],
@@ -103,9 +101,7 @@ export default defineCommand({
await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
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`);
@@ -38,8 +38,6 @@ const SESSION_SEND_FLAGS = {
export default defineCommand({
description: "Send a message to an existing session and stream the response",
auth: "apiKey",
// Provider-aware gate: only the session's provider needs credentials.
authOptional: true,
usageArgs: "--session-id <id> --message <text> [--no-stream] [--file <path>]",
flags: SESSION_SEND_FLAGS,
exampleArgs: ['--session-id sess_abc123 --message "continue"'],
@@ -68,9 +66,7 @@ export default defineCommand({
await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
if (flags.noStream) {
const result = await sendSessionMessagePolling(runtime, flags.sessionId, flags.message, {
provider: flags.provider,
@@ -30,8 +30,6 @@ const SKILL_LIST_FLAGS = {
export default defineCommand({
description: "List skills from the provider's skill catalog",
auth: "apiKey",
// Provider-aware gate: only the resolved catalog provider needs credentials.
authOptional: true,
usageArgs: "[--source custom|official|all] [--provider <name>] [--file <path>]",
flags: SKILL_LIST_FLAGS,
exampleArgs: [
@@ -58,9 +56,7 @@ export default defineCommand({
const skills = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file, {
credentials: flags.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
if (source !== "all") {
return listSkills(runtime, { provider: flags.provider, source });
}
@@ -33,8 +33,6 @@ const STATE_IMPORT_FLAGS = {
export default defineCommand({
description: "Import an existing remote resource into agents state",
auth: "apiKey",
// Provider-aware gate: only the address's provider needs credentials.
authOptional: true,
usageArgs:
"--address <provider.type.name> --remote-id <id> [--resource-version <n>] [--file <path>]",
flags: STATE_IMPORT_FLAGS,
@@ -64,13 +62,11 @@ export default defineCommand({
await withAgentErrors(() =>
withStdoutProtected(async () => {
// Parse first: the address names the one provider this import touches.
// Parse first so a malformed address fails fast, before any config I/O.
const parsed = parseStateAddress(flags.address, {
requireProvider: true,
});
const runtime = await buildAgentRuntime(ctx, file, {
credentials: parsed.provider ?? "targets",
});
const runtime = await buildAgentRuntime(ctx, file);
await importResource(runtime, parsed, flags.remoteId, {
resourceVersion: flags.resourceVersion,
});
@@ -13,15 +13,14 @@ import {
type CredentialHost,
injectProviderCredentials,
prepareProviderEnv,
resolveTargetProviderNames,
scrubCredentialEnv,
} from "../src/commands/managed-agent/_engine/credentials.ts";
/**
* 凭证内存注入管道:injectProviderCredentials 把 authStage 解析进 Client 的凭证
* 权威覆写 bailian 配置块(不落 env),scrubCredentialEnv 清空所有凭证 env,
* assertProviderCredentials 按本次运行涉及的 provider 范围对空 key 给 CLI 权威
* AUTH 错误。用快照隔离凭证 env。
* assertProviderCredentials 对任一已声明 provider 的空 key 给 CLI 权威 AUTH
* 错误(离线命令跳过断言)。用快照隔离凭证 env。
*/
const TRACKED_ENV = [
"DASHSCOPE_API_KEY",
@@ -192,33 +191,6 @@ test("assert:bailian key 为空(dry-run/未登录)抛 AUTH 且 hint 指向 bl au
expect(err.hint).toContain("bl auth login");
});
test("assert:required 限定范围后,范围外 provider 的空 key 不拦截", () => {
const providers = {
bailian: { api_key: "" },
claude: { api_key: "sk-ant" },
};
// 本次只涉及 claude(如 --provider claude):bailian 未登录不应阻塞
expect(() => assertProviderCredentials(providers, ["claude"])).not.toThrow();
// 反向:范围内的空 key 仍拦截
expect(() => assertProviderCredentials(providers, ["bailian"])).toThrow();
});
test("assert:required 里未配置的 provider 名被跳过(由引擎报未配置错误)", () => {
expect(() => assertProviderCredentials({ bailian: { api_key: "" } }, ["qoder"])).not.toThrow();
});
test("targets:默认 provider 链镜像 SDK —— default 为单个时只涉及它,缺失/all 时为全部", () => {
const providers = { bailian: {}, claude: {} };
expect(resolveTargetProviderNames({ providers, defaults: { provider: "claude" } })).toEqual([
"claude",
]);
expect(resolveTargetProviderNames({ providers })).toEqual(["bailian", "claude"]);
expect(resolveTargetProviderNames({ providers, defaults: { provider: "all" } })).toEqual([
"bailian",
"claude",
]);
});
test("scrub:所有凭证 env 变量被删除", () => {
process.env.DASHSCOPE_API_KEY = "x";
process.env.ANTHROPIC_API_KEY = "y";
@@ -9,8 +9,8 @@ import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
/**
* managed-agent 凭证链 e2e:验证 bl 自有配置体系(config 写入 / 命名 Profile /
* logout)与错误映射如何流入 SDK 引擎。全部离线:凭证门禁用 `managed-agent plan`
* 验证(provider-aware:空 state 不发网络请求,但仍按目标 provider 校验凭证);
* `validate` / `state list` / `plan --no-refresh` 属离线命令,无凭证也必须可用。
* 验证(空 state 不发网络请求,但 auth: "apiKey" 硬门禁 + 引擎全量 provider key
* 断言照常生效);`validate` / `state list` 属离线命令,无凭证也必须可用。
* 配置一律通过 BAILIAN_CONFIG_DIR 指向临时目录,绝不触碰真实用户配置。
*/
@@ -47,7 +47,7 @@ function validateArgs(file: string): string[] {
return ["managed-agent", "validate", "--file", file, "--quiet"];
}
/** plan 是凭证门禁命令:空 state 下不发网络,但仍按目标 provider 校验凭证。 */
/** plan 是凭证门禁命令:空 state 下不发网络,但 authStage + 引擎断言照常生效。 */
function planArgs(file: string): string[] {
return ["managed-agent", "plan", "--file", file, "--quiet"];
}
@@ -184,7 +184,7 @@ describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错
});
});
describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-aware 按需校验)", () => {
describe("e2e: managed-agent 鉴权分层(离线命令免登录 / 联网命令统一 apiKey 门禁)", () => {
test("validate 无任何凭证也离线通过 (0)", async () => {
const env = makeConfigEnv({});
const { stderr, exitCode } = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env);
@@ -203,17 +203,33 @@ describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-awa
expect(Array.isArray(data.resources)).toBe(true);
});
test("plan --no-refresh 无任何凭证也离线通过 (0)", async () => {
test("plan --no-refresh 无登录时仍被 apiKey 硬门禁拦住 (3)", async () => {
const env = makeConfigEnv({});
const { stderr, exitCode } = await runCommandE2e(
ROUTES,
[...planArgs(AGENTS_YAML), "--no-refresh"],
env,
);
expect(exitCode).toBe(3);
expect(stderr).toMatch(/auth login|API key/i);
});
test("已登录 bailian 时,多 provider 配置下 plan --no-refresh 离线通过,不查其他 provider key (0)", async () => {
const env = {
...makeConfigEnv({ api_key: "sk-e2e-no-refresh" }),
...isolatedAgentsConfigEnv(),
ANTHROPIC_API_KEY: "",
CLAUDE_API_KEY: "",
};
const { stderr, exitCode } = await runCommandE2e(
ROUTES,
[...planArgs(AGENTS_YAML_MULTI), "--no-refresh"],
env,
);
expect(exitCode, stderr).toBe(0);
});
test("多 provider 下 plan --provider claude 只需 claude 凭证,bailian 未登录不阻塞 (0)", async () => {
test("统一登录门禁:只配 claude key 未登录 bailian 时,plan --provider claude 仍报 AUTH (3)", async () => {
const env = {
...makeConfigEnv({}),
...isolatedAgentsConfigEnv(),
@@ -225,10 +241,11 @@ describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-awa
[...planArgs(AGENTS_YAML_MULTI), "--provider", "claude"],
env,
);
expect(exitCode, stderr).toBe(0);
expect(exitCode).toBe(3);
expect(stderr).toMatch(/auth login|API key/i);
});
test("plan --provider claude 缺 claude key 时报 AUTH (3),hint 指向 ANTHROPIC_API_KEY", async () => {
test("已登录但缺 claude key 时,全量断言拦住并给 ANTHROPIC_API_KEY hint (3)", async () => {
const env = {
...makeConfigEnv({ api_key: "sk-e2e-bailian-present" }),
...isolatedAgentsConfigEnv(),
@@ -6,7 +6,7 @@ import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
* managed-agent:help / 缺参不依赖密钥;所有 mutation 命令的 --dry-run
* 必须在构建 SDK runtime(凭证注入 / 联网 / 写盘)之前短路,因此同样不需要密钥。
* 鉴权分层:离线命令(init/validate/state list|show|rm)auth: "none";联网命令
* provider-aware,只校验本次涉及的 provider(见 managed-agent-auth-chain e2e)。
* 统一 auth: "apiKey" 硬门禁(见 managed-agent-auth-chain e2e)。
* 真实集成(apply/destroy/session 流程)依赖工作区内的 agents.yaml 与远端资源,
* 属批量场景,暂仅覆盖 dry-run 契约。
*/
@@ -69,17 +69,21 @@ describe("e2e: managed-agent", () => {
});
test("managed-agent skill-list --source all 通过参数校验(缺配置文件时才失败)", async () => {
// provider-aware 鉴权不再前置硬门禁:无需注入假 key,命令在配置加载阶段
// 因文件缺失短路,不产生任何网络请求。
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"skill-list",
"--source",
"all",
"--file",
"agents.e2e-missing.yaml",
"--quiet",
]);
// auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证,
// 命令仍会在配置加载阶段因文件缺失短路,不产生任何网络请求。
const { stderr, exitCode } = await runCommandE2e(
MANAGED_AGENT_ROUTES,
[
"managed-agent",
"skill-list",
"--source",
"all",
"--file",
"agents.e2e-missing.yaml",
"--quiet",
],
{ DASHSCOPE_API_KEY: "sk-e2e-skill-list" },
);
// all 是合法值:不应报 --source 用法错误,而是走到配置加载后因文件缺失退出
expect(exitCode).toBe(2);
expect(stderr).not.toMatch(/--source must be one of/i);
-8
View File
@@ -207,14 +207,6 @@ export interface Command<F extends FlagsDef = FlagsDef> {
description: string;
/** Credential this command requires. See {@link AuthRequirement}. */
auth: AuthRequirement;
/**
* Soften the auth gate: authStage still resolves the `auth` domain's
* credential into `ctx.client` when available, but a missing credential no
* longer fails before `run`. For commands that enforce their own scoped
* credential requirements (e.g. managed-agent commands, where a run may only
* involve third-party providers and must not be blocked on a Bailian key).
*/
authOptional?: boolean;
/** Usage line arg portion, e.g. "--prompt <text> [flags]". Manually written. */
usageArgs?: string;
/** Example arg strings (without the `<bin> <path>` prefix). */
+3 -6
View File
@@ -75,8 +75,6 @@ export function compose(stack: Middleware[]): (ctx: RunContext) => Promise<void>
* Bake the credential for the command's declared `auth` into `ctx.client`, and
* gate: no credential → throw before the command runs. dry-run 例外:凭证解析失败
* 不抛(dry-run 只打印请求,无需凭证;console 的 dry-run 展示读 settings.console*)。
* `authOptional` 例外:凭证可用则注入,缺失不在此处抛 —— 命令自行按实际涉及范围
* 校验(如 managed-agent 只校验本次运行涉及的 provider)。
* `auth: "none"` commands keep a credential-less client.
*/
export const authStage: Middleware = async (ctx, next) => {
@@ -86,13 +84,12 @@ export const authStage: Middleware = async (ctx, next) => {
settings,
baseUrl: resolveModelBaseUrl(sources),
};
const tolerateMissing = settings.dryRun || command.authOptional === true;
if (command.auth === "apiKey") {
let cred: ApiKeyCredential | undefined;
try {
cred = resolveApiKey(sources);
} catch (err) {
if (!tolerateMissing) throw err;
if (!settings.dryRun) throw err;
}
ctx.client = new Client({ ...base, apiCred: cred });
if (cred) maybeShowStatusBar(settings, cred.token, cred);
@@ -101,7 +98,7 @@ export const authStage: Middleware = async (ctx, next) => {
try {
cred = resolveConsole(sources);
} catch (err) {
if (!tolerateMissing) throw err;
if (!settings.dryRun) throw err;
}
if (cred) ctx.client = new Client({ ...base, consoleCred: cred });
} else if (command.auth === "openapi") {
@@ -109,7 +106,7 @@ export const authStage: Middleware = async (ctx, next) => {
try {
cred = resolveOpenApi(sources);
} catch (err) {
if (!tolerateMissing) throw err;
if (!settings.dryRun) throw err;
}
ctx.client = new Client({ ...base, openApiCred: cred });
}
+1 -13
View File
@@ -53,7 +53,6 @@ Index: [index.md](index.md)
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
@@ -88,7 +87,6 @@ bl managed-agent apply --provider bailian --yes
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
@@ -155,9 +153,8 @@ bl managed-agent init --provider all
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
- --no-refresh and --dry-run plan offline from local config and state: no credentials, no remote requests, no state writes.
- --no-refresh and --dry-run plan offline from local config and state: no remote requests, no state writes, provider keys are not checked.
#### Examples
@@ -199,7 +196,6 @@ bl managed-agent plan --no-refresh
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
@@ -238,7 +234,6 @@ bl managed-agent session create --agent assistant --title 'debug run'
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
@@ -271,7 +266,6 @@ bl managed-agent session delete --session-id sess_abc123
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
@@ -306,7 +300,6 @@ bl managed-agent session events --session-id sess_abc123 --all
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
@@ -338,7 +331,6 @@ bl managed-agent session get --session-id sess_abc123
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
@@ -383,7 +375,6 @@ bl managed-agent session list --all
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
- --output json emits one envelope: { session_id, provider, agent, events } — read session_id to chain `session send/get/events/delete`.
@@ -421,7 +412,6 @@ bl managed-agent session run --agent assistant --prompt "summarize this repo"
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
@@ -452,7 +442,6 @@ bl managed-agent session send --session-id sess_abc123 --message "continue"
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
- Providers without a skill listing API (e.g. ark) return an empty list.
- For agent-driven skill selection, use `--source all --output json`: one call returns both catalogs with per-skill `source` and `description` fields to pick from.
@@ -499,7 +488,6 @@ bl managed-agent skill-list --source custom --provider bailian
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples