Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/agents/auth-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,19 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
`bl managed-agent *` 按调用链分两层:

- **离线命令** — `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` 仍需登录。
- **联网命令** — `plan`、`apply`、`destroy`、`state import`、`skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁,authStage 经 `resolveApiKey(sources)` 解析 Bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层再断言 Bailian key 非空。例外:`plan --no-refresh` / `plan --dry-run` 传 `credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state,不查 provider key),其中 `--dry-run` 连登录也不要求(authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。

`bl managed-agent` 是 Bailian-only 产品入口:命令不暴露 `--provider`,`init` 只生成 `providers.bailian`,所有远端调用固定传 `provider: "bailian"`。`resolveAgentProjectConfig` 在创建 SDK runtime 前通过 `assertBailianOnlyProviders` 拒绝包含非 Bailian Provider 的手写配置;共享 `@openagentpack/sdk` 仍可保留多 Provider 能力。

凭证不以真实值写入 `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` 归一为 `""`(避免离线命令下空 key 在 SDK zod 层报 "received null")
1. `prepareProviderEnv()` — 调用 SDK 的凭证 bootstrap,再把凭证类 env(`CREDENTIAL_ENV_KEYS`,含兼容别名)中仍为 undefined 的项占位为 `""`,使 agents.yaml 插值阶段能够完成并由 CLI 输出明确的 Bailian-only 配置错误
2. `resolveProjectConfig` — 完成插值;随后 `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)` — 任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);离线命令传 `credentials: "none"` 整体跳过
5. `assertBailianOnlyProviders(providers)` — 拒绝非 Bailian Provider;随后 `assertProviderCredentials(providers)` 在 Bailian `api_key` 为空时给出 CLI 权威 `AUTH` 错误和登录 hint离线命令传 `credentials: "none"` 跳过 key 断言,但仍执行 Bailian-only 配置校验

`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
禁止命令层直接 `readConfigFile` 裸读凭证;Bailian 字段以 CLI 鉴权链为唯一信源。SDK bootstrap 期间读取到的兼容凭证变量也会在配置解析后统一清扫

## 必查清单

Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,48 @@ import {
managedAgentSessionSend,
managedAgentSessionEvents,
managedAgentSkillList,
managedAgentAgentCreate,
managedAgentEnvironmentCreate,
managedAgentSkillCreate,
managedAgentVaultCreate,
managedAgentVaultCredentialCreate,
managedAgentDeploymentCreate,
managedAgentAgentList,
managedAgentAgentGet,
managedAgentAgentSearch,
managedAgentAgentVersions,
managedAgentEnvironmentList,
managedAgentEnvironmentGet,
managedAgentEnvironmentSearch,
managedAgentSkillGet,
managedAgentSkillSearch,
managedAgentSkillVersions,
managedAgentSkillDownload,
managedAgentVaultList,
managedAgentVaultGet,
managedAgentVaultSearch,
managedAgentDeploymentList,
managedAgentDeploymentGet,
managedAgentDeploymentSearch,
managedAgentDeploymentRunsList,
managedAgentDeploymentRunsGet,
managedAgentDeploymentRun,
managedAgentDeploymentPause,
managedAgentDeploymentUnpause,
managedAgentSessionSearch,
managedAgentSessionUpdate,
managedAgentSessionArchive,
managedAgentSessionEventList,
managedAgentSessionEventSend,
managedAgentSessionEventStream,
managedAgentSessionDebug,
managedAgentSessionExport,
managedAgentFileUpload,
managedAgentFileList,
managedAgentFileGet,
managedAgentFileSearch,
managedAgentFileDownload,
managedAgentFileDelete,
} from "bailian-cli-commands";

// Full bailian-cli product: every command, exposed under the `bl` binary.
Expand Down Expand Up @@ -314,6 +356,49 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent session send": managedAgentSessionSend,
"managed-agent session events": managedAgentSessionEvents,
"managed-agent skill-list": managedAgentSkillList,
"managed-agent agent create": managedAgentAgentCreate,
"managed-agent agent list": managedAgentAgentList,
"managed-agent agent get": managedAgentAgentGet,
"managed-agent agent search": managedAgentAgentSearch,
"managed-agent agent versions": managedAgentAgentVersions,
"managed-agent environment create": managedAgentEnvironmentCreate,
"managed-agent environment list": managedAgentEnvironmentList,
"managed-agent environment get": managedAgentEnvironmentGet,
"managed-agent environment search": managedAgentEnvironmentSearch,
"managed-agent skill create": managedAgentSkillCreate,
"managed-agent skill list": managedAgentSkillList,
"managed-agent skill get": managedAgentSkillGet,
"managed-agent skill search": managedAgentSkillSearch,
"managed-agent skill versions": managedAgentSkillVersions,
"managed-agent skill download": managedAgentSkillDownload,
"managed-agent vault create": managedAgentVaultCreate,
"managed-agent vault credential create": managedAgentVaultCredentialCreate,
"managed-agent vault list": managedAgentVaultList,
"managed-agent vault get": managedAgentVaultGet,
"managed-agent vault search": managedAgentVaultSearch,
"managed-agent deployment create": managedAgentDeploymentCreate,
"managed-agent deployment list": managedAgentDeploymentList,
"managed-agent deployment get": managedAgentDeploymentGet,
"managed-agent deployment search": managedAgentDeploymentSearch,
"managed-agent deployment runs list": managedAgentDeploymentRunsList,
"managed-agent deployment runs get": managedAgentDeploymentRunsGet,
"managed-agent deployment run": managedAgentDeploymentRun,
"managed-agent deployment pause": managedAgentDeploymentPause,
"managed-agent deployment unpause": managedAgentDeploymentUnpause,
"managed-agent session search": managedAgentSessionSearch,
"managed-agent session update": managedAgentSessionUpdate,
"managed-agent session archive": managedAgentSessionArchive,
"managed-agent session event list": managedAgentSessionEventList,
"managed-agent session event send": managedAgentSessionEventSend,
"managed-agent session event stream": managedAgentSessionEventStream,
"managed-agent session debug": managedAgentSessionDebug,
"managed-agent session export": managedAgentSessionExport,
"managed-agent file upload": managedAgentFileUpload,
"managed-agent file list": managedAgentFileList,
"managed-agent file get": managedAgentFileGet,
"managed-agent file search": managedAgentFileSearch,
"managed-agent file download": managedAgentFileDownload,
"managed-agent file delete": managedAgentFileDelete,
};

/**
Expand Down
28 changes: 25 additions & 3 deletions packages/cli/tests/skill-risk-confirmation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@ import { expect, test } from "vite-plus/test";

const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "../../..");
const skillsRoot = join(repositoryRoot, "skills");
const scopedCreateCommands = new Set([
"bl managed-agent agent create",
"bl managed-agent deployment create",
"bl managed-agent environment create",
"bl managed-agent skill create",
"bl managed-agent vault create",
"bl managed-agent vault credential create",
]);

test("every generated high-risk command reference requires user confirmation before --yes", () => {
test("generated references distinguish runtime high-risk confirmation from scoped create execution", () => {
let highRiskCommandCount = 0;
const seenScopedCreateCommands = new Set<string>();

for (const skillDirectory of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!skillDirectory.isDirectory()) continue;
Expand All @@ -27,9 +36,21 @@ test("every generated high-risk command reference requires user confirmation bef
const commandSections = markdown.split(/(?=^### `bl )/m).slice(1);

for (const commandSection of commandSections) {
if (!commandSection.includes("`--yes`")) continue;
const commandName = commandSection.match(/^### `([^`]+)`/m)?.[1];
const hasConfirmationFlag = commandSection.includes("`--yes`");
const hasHighRiskMetadata = /\|\s+\*\*Risk\*\*\s+\|\s+`high`\s+\|/.test(commandSection);

if (!hasHighRiskMetadata) {
if (!hasConfirmationFlag) continue;
expect(commandName).toBeDefined();
expect(scopedCreateCommands.has(commandName ?? "")).toBe(true);
expect(commandSection).toMatch(/Without --yes, .*preview/i);
seenScopedCreateCommands.add(commandName ?? "");
continue;
}

highRiskCommandCount += 1;
expect(commandSection).toMatch(/\|\s+\*\*Risk\*\*\s+\|\s+`high`\s+\|/);
expect(hasConfirmationFlag).toBe(true);
expect(commandSection).toMatch(/\|\s+\*\*Risk message\*\*\s+\|\s+.+\|/);
expect(commandSection).toMatch(/type=.*requires_confirmation/);
const agentSafetyLine = commandSection
Expand All @@ -44,4 +65,5 @@ test("every generated high-risk command reference requires user confirmation bef
}

expect(highRiskCommandCount).toBeGreaterThan(0);
expect([...seenScopedCreateCommands].sort()).toEqual([...scopedCreateCommands].sort());
});
2 changes: 1 addition & 1 deletion packages/commands/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"check": "vp check"
},
"dependencies": {
"@openagentpack/sdk": "0.3.2",
"@openagentpack/sdk": "0.5.0",
"bailian-cli-core": "workspace:*",
"bailian-cli-runtime": "workspace:*",
"boxen": "catalog:",
Expand Down
196 changes: 196 additions & 0 deletions packages/commands/src/commands/managed-agent/_engine/api-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import type { FlagsDef } from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
import type { PagedResult } from "./pagination.ts";

export const API_TARGET_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: {
"en-US": "Config file path (default: agents.yaml)",
"zh-CN": "配置文件路径(默认:agents.yaml)",
},
},
} satisfies FlagsDef;

export const CURSOR_FLAGS = {
limit: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Page size (1-100)",
"zh-CN": "单页数量(1-100)",
},
},
page: {
type: "string",
valueHint: "<cursor>",
description: {
"en-US": "Opaque page cursor returned by a previous request",
"zh-CN": "上一次请求返回的不透明分页 Cursor",
},
},
all: {
type: "switch",
description: {
"en-US": "Fetch all pages by following opaque cursors",
"zh-CN": "跟随不透明 Cursor 获取全部分页",
},
},
} satisfies FlagsDef;

export const SEARCH_FLAGS = {
query: {
type: "string",
valueHint: "<text>",
required: true,
description: {
"en-US": "Case-insensitive text to find in IDs, names, and descriptions",
"zh-CN": "在 ID、名称和描述中进行不区分大小写的文本搜索",
},
},
pageLimit: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Maximum pages to scan for client-side search (default: 10)",
"zh-CN": "客户端搜索最多扫描的页数(默认:10)",
},
},
} satisfies FlagsDef;

export const INCLUDE_ARCHIVED_FLAG = {
includeArchived: {
type: "switch",
description: {
"en-US": "Include archived resources",
"zh-CN": "包含已归档资源",
},
},
} satisfies FlagsDef;

export function validateLimitAndPageLimit(flags: {
limit?: number;
pageLimit?: number;
}): string | undefined {
if (
flags.limit !== undefined &&
(!Number.isInteger(flags.limit) || flags.limit < 1 || flags.limit > 100)
) {
return "--limit must be an integer between 1 and 100.";
}
if (
flags.pageLimit !== undefined &&
(!Number.isInteger(flags.pageLimit) || flags.pageLimit < 1 || flags.pageLimit > 100)
) {
return "--page-limit must be an integer between 1 and 100.";
}
return undefined;
}

export function splitCommaSeparated(value?: string): string[] | undefined {
if (!value) return undefined;
const values = value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
return values.length > 0 ? values : undefined;
}

export function matchesQuery(query: string, ...values: unknown[]): boolean {
const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return true;
return values.some((value) => {
if (value === undefined || value === null) return false;
const text = typeof value === "string" ? value : JSON.stringify(value);
return text.toLocaleLowerCase().includes(normalized);
});
}

export interface SearchResult<T> extends PagedResult<T> {
scannedPages: number;
truncated: boolean;
}

export async function searchCursorPages<T>(
fetchPage: (page?: string) => Promise<PagedResult<T>>,
matches: (item: T) => boolean,
pageLimit = 10,
): Promise<SearchResult<T>> {
const items: T[] = [];
let page: string | undefined;
let hasMore = false;
let nextPage: string | undefined;
let scannedPages = 0;

do {
const result = await fetchPage(page);
scannedPages += 1;
items.push(...result.items.filter(matches));
hasMore = result.hasMore;
nextPage = result.nextPage;
page = result.nextPage;
} while (hasMore && page && scannedPages < pageLimit);

return {
items,
hasMore,
nextPage,
scannedPages,
truncated: Boolean(hasMore && nextPage),
};
}

export function emitCollection<T>(options: {
format: "json" | "text";
key: string;
items: T[];
headers: string[];
rows: string[][];
hasMore?: boolean;
nextPage?: string;
truncated?: boolean;
scannedPages?: number;
emptyMessage?: string;
}): void {
const {
format,
key,
items,
headers,
rows,
hasMore = false,
nextPage,
truncated,
scannedPages,
emptyMessage = "No resources found.",
} = options;
if (format === "json") {
emitResult(
{
[key]: items,
has_more: hasMore,
next_page: nextPage,
...(truncated === undefined ? {} : { truncated }),
...(scannedPages === undefined ? {} : { scanned_pages: scannedPages }),
},
format,
);
return;
}
if (items.length === 0) {
emitBare(emptyMessage);
return;
}
for (const line of formatTable(headers, rows)) emitBare(line);
emitBare(`\nTotal: ${items.length}`);
if (truncated) emitBare("Search stopped at --page-limit; more pages remain.");
else if (hasMore)
emitBare(`More results are available.${nextPage ? ` Next page: ${nextPage}` : ""}`);
}

export function displayValue(value: unknown, maxLength = 40): string {
if (value === undefined || value === null || value === "") return "-";
const text = typeof value === "string" ? value : JSON.stringify(value);
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
}
Loading