Skip to content

Commit 9fc6434

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/iteration1-w1-foundation
2 parents 43abf0a + 2dffee5 commit 9fc6434

24 files changed

Lines changed: 1088 additions & 89 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Skill / 命令手册随 `skills/bailian-*/` 经 `npx skills add modelstudioai/cl
6666
| Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) |
6767
| 错误文案变更 |`BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
6868
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
69+
| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-change.md) |
6970
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
7071
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
7172
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |

docs/agents/telemetry-change.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# 埋点变更
2+
3+
## 触发条件
4+
5+
- 调整 AEM 命令事件、事件字段或参数 allowlist
6+
- 调整 `User-Agent``x-dashscope-source-config` 或其他后端渠道标识
7+
- 新增鉴权域、请求网关或绕开统一 Client 的网络出口
8+
- 排查命令量、成功率、版本、鉴权域或后端渠道数据不一致
9+
10+
## 当前数据流
11+
12+
三套鉴权对应三套请求域,但不代表三套网关使用相同的后端埋点。命令侧另有一套覆盖所有实际执行命令的 AEM 客户端事件,两者必须分开理解。
13+
14+
```text
15+
命令进入 run
16+
├─ telemetryStage
17+
│ ├─ ~/.bailian/telemetry.jsonl
18+
│ └─ AEM(pid=bailian-cli-node, event name=命令路径)
19+
20+
└─ authStage
21+
├─ apiKey → DashScope / 模型域
22+
├─ console → Bailian Console Gateway
23+
├─ openapi → 阿里云 OpenAPI
24+
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
25+
```
26+
27+
### 1. 三套鉴权与埋点标识
28+
29+
| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识(AEM) |
30+
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
31+
| `auth: "apiKey"` | API Key;DashScope / OpenAI-compatible 模型域 | `Client.request/requestJson``McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent``x-dashscope-source-config` | 有:`pid=bailian-cli-node``authMethod=apiKey` |
32+
| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()``/cli/api.json` || 有:`pid=bailian-cli-node``authMethod=console` |
33+
| `auth: "openapi"` | AccessKey ID/Secret,可选 STS token;阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config` | 有:`pid=bailian-cli-node``authMethod=openapi` |
34+
| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 || 有:`pid=bailian-cli-node``authMethod=none` |
35+
36+
`authMethod` 记录的是命令声明的鉴权域,不是凭证来源。它不会区分 API Key 来自 flag、env 还是 config。
37+
鉴权域是命令的准入门槛和主请求域,不保证命令内部只有一种网络出口;例如部分 `apiKey` 命令也可能读取匿名 Console 公共目录,Managed Agent 还可能访问其他 provider。
38+
39+
表中的后端埋点按该鉴权域的主要业务请求填写:
40+
41+
- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 仅对阿里云 host 注入
42+
- DashScope 上传策略 `getPolicy` 只有 `x-dashscope-source-config`,没有显式 CLI `User-Agent`
43+
- OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product``action``api` 是鉴权或路由字段,不计为埋点标识
44+
45+
### 2. 后端渠道参数
46+
47+
当前 `x-dashscope-source-config` 结构为:
48+
49+
```json
50+
{
51+
"channel": "bailian-cli",
52+
"tags": {
53+
"t1": "public",
54+
"t2": "bl 或 kscli",
55+
"t3": "实际 CLI 版本"
56+
}
57+
}
58+
```
59+
60+
- `t2` 取产品 `identity.binName`:完整 CLI 为 `bl`,Knowledge Studio CLI 为 `kscli`
61+
- `t3` 取产品 `identity.version`,由产品入口的 `package.json` 注入
62+
- `channel``t1` 是当前固定口径
63+
- `User-Agent` 是独立标识:`bl``bailian-cli/<version>``kscli``knowledge-studio-cli/<version>`
64+
65+
source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网络传输:
66+
67+
| 请求 | source-config |
68+
| ------------------------------------ | ------------- |
69+
| 模型 API、任务提交与轮询 ||
70+
| Bailian MCP / OpenAPI ||
71+
| DashScope 上传策略 `getPolicy` ||
72+
| OSS 文件上传 ||
73+
| 图片、视频、音频、转录结果下载 ||
74+
| npm / 二进制更新检查、Skill registry ||
75+
76+
当前已知例外:Pipeline runtime 自建的 `Identity.version``0.0.0-dev`,因此 Pipeline 内部模型请求的 `t3` 不代表产品包版本;现阶段不纳入本轮收敛。
77+
78+
### 3. 全命令 AEM 客户端埋点
79+
80+
`packages/runtime/src/middleware.ts``telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`
81+
82+
以下情况不会形成命令事件,因为没有进入 middleware 的 `run`
83+
84+
- 根帮助、子命令 `--help``--version`
85+
- 未识别命令、参数解析失败、缺少必填参数
86+
- `defineCommand.validate` 在 dispatch 阶段拒绝的请求
87+
88+
遥测默认开启;`DO_NOT_TRACK=1` 一票否决,配置文件 `telemetry: false` 也可关闭。关闭后本地和远端均不记录。
89+
90+
单条 `TrackingEvent` 当前包含:
91+
92+
- `command``timestamp``durationMs``success`
93+
- `cliVersion``nodeVersion``os`
94+
- `authMethod`
95+
- 失败时的 `errorMessage``httpStatus``requestId`
96+
- 安全 allowlist 过滤后的 `params`
97+
98+
参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts``PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。
99+
100+
事件同时写入两处:
101+
102+
1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建
103+
2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod`
104+
105+
底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及由本机网络标识计算的 MD5 `device_id`
106+
107+
当前 AEM 事件没有 `binName``clientName` 产品维度,并且 `bl``kscli` 共用 `pid=bailian-cli-node`。两边相同路径的 `config show``config set``update` 无法仅凭当前事件稳定区分产品;Knowledge 命令虽然因路径映射不同而表现为 `knowledge chat``chat`,也不应把命令路径当作长期产品标识。后端 source-config 的 `t2` 已能区分 `bl/kscli`,但这个维度尚未进入 AEM 客户端事件。
108+
109+
AEM 映射:
110+
111+
| AEM 字段 | 内容 |
112+
| ---------- | ----------------------------------------- |
113+
| event name | 命令路径 |
114+
| `et` | `EXP` |
115+
| `ext` |`command``params` 外的结构化事件字段 |
116+
| `c1` | allowlist 参数 |
117+
| `c2` | `success` / `failure` |
118+
| `c3` | HTTP status |
119+
| `c4` | 错误文案,最多 500 字符 |
120+
| `c5` | request ID |
121+
122+
远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。
123+
124+
## 必查清单
125+
126+
### A. 新增或调整命令
127+
128+
- [ ] `defineCommand({ auth })` 必须声明真实请求域;AEM 的 `authMethod` 直接读取该值
129+
- [ ] 新命令进入 `run` 后自动有基础事件,不得在命令内重复发送同名事件
130+
- [ ] 需要按产品分析 AEM 数据时,必须显式设计产品字段;不得从命令路径推断 `bl/kscli`
131+
- [ ] 只有可枚举、数值或布尔等低风险字段才可加入 `PARAM_ALLOWLIST`
132+
- [ ] 新增 console raw API flag 时只允许记录公开 API 名,不得记录请求 `data`
133+
134+
### B. 调整后端渠道参数
135+
136+
- [ ] 同时核对 `packages/core/src/client/http.ts``mcp.ts``instrumented-fetch.ts``client.ts``files/upload.ts`
137+
- [ ] 产品身份必须来自 `Identity`;不得从命令路径、环境变量或 `process.argv` 猜测
138+
- [ ] `bl``kscli` 必须分别验证 `binName``clientName``version`
139+
- [ ] OSS、结果文件、npm、二进制和 Skill 下载不得为了业务渠道统计新增 source-config
140+
- [ ] 改 URL / host 范围时同时执行 [URL / 渠道变更](url-change.md) 清单
141+
142+
### C. 调整 AEM 事件
143+
144+
- [ ] 更新 `TrackingEvent``createTrackingEvent()``buildRemoteAemOptions()` 的字段映射
145+
- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径
146+
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
147+
- [ ] 检查 `DO_NOT_TRACK=1``telemetry: false` 两个关闭入口
148+
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
149+
150+
## 完成后自查
151+
152+
```sh
153+
rg -n "trackingHeaders|x-dashscope-source-config|User-Agent" packages --glob '*.ts'
154+
rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions" packages/core packages/runtime --glob '*.ts'
155+
vp check
156+
vp test packages/core/tests packages/commands/tests/e2e/auth.e2e.test.ts
157+
```
158+
159+
## 常见漏点
160+
161+
- ✗ 只看 AEM 命令事件,误以为它能替代网关侧请求渠道统计
162+
- ✗ 把 `authMethod` 当成实际凭证来源;它只是命令声明的鉴权域
163+
- ✗ 新增 bypass `fetch` 后漏掉应由网关消费的 source-config,或把它发给 OSS / npm / 第三方下载地址
164+
- ✗ 只改 `bl` 入口,导致 `kscli` 的产品名或版本标签错误
165+
- ✗ 把帮助、版本或参数校验失败算进“全部命令”;这些路径当前没有进入 telemetry middleware

packages/cli/postinstall.js

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
1010
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
1111
* legacy fallback to skill.tar.br when the entry has no valid object field
12-
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
12+
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir,
13+
* then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer)
1314
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
1415
* 5. Write ~/.bailian/wiki-sync-state.json
1516
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
@@ -20,10 +21,12 @@
2021
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
2122
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
2223
*/
24+
import { createHash } from "node:crypto";
2325
import {
2426
createWriteStream,
2527
existsSync,
2628
mkdirSync,
29+
readdirSync,
2730
readFileSync,
2831
renameSync,
2932
rmSync,
@@ -106,6 +109,9 @@ async function downloadBuffer(url) {
106109

107110
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
108111
function isSafeEntryName(name) {
112+
// Symmetric with core skills/extract.ts: backslashes can escape the extraction
113+
// dir on Windows (path.join expands "\.." segments, leading "\" hits drive root)
114+
if (name.includes("\\") || name.includes("\0")) return false;
109115
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
110116
return !name.split("/").includes("..");
111117
}
@@ -140,6 +146,30 @@ async function extractTarBr(tarBrBuffer, destDir) {
140146
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
141147
}
142148

149+
/**
150+
* Recompute the publisher's deterministic content hash over an extracted directory
151+
* (same accumulation as core skills/extract.ts computeDirContentHash): regular files
152+
* sorted by "/"-separated relative path, sha256 over relPath + bytes.
153+
*/
154+
function computeDirContentHash(dir) {
155+
const relPaths = [];
156+
const walk = (sub) => {
157+
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
158+
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
159+
if (dirent.isDirectory()) walk(rel);
160+
else if (dirent.isFile()) relPaths.push(rel);
161+
}
162+
};
163+
walk("");
164+
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
165+
const hash = createHash("sha256");
166+
for (const rel of relPaths) {
167+
hash.update(rel);
168+
hash.update(readFileSync(join(dir, rel)));
169+
}
170+
return `sha256:${hash.digest("hex")}`;
171+
}
172+
143173
/** Atomic swap: tmpDir (same volume) → catalogDir. */
144174
function atomicSwap(tmpDir, catalogDir) {
145175
mkdirSync(dirname(catalogDir), { recursive: true });
@@ -166,12 +196,22 @@ async function main() {
166196
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
167197
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
168198

169-
// 3. Extract to same-volume temp dir + atomic swap
199+
// 3. Extract to same-volume temp dir + integrity check + atomic swap
170200
const catalogDir = getCatalogDir();
171201
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
172202
try {
173203
mkdirSync(tmpDir, { recursive: true });
174204
await extractTarBr(tarBuf, tmpDir);
205+
// Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
206+
// before touching the canonical dir
207+
if (entry.contentHash.startsWith("sha256:")) {
208+
const actualContentHash = computeDirContentHash(tmpDir);
209+
if (actualContentHash !== entry.contentHash) {
210+
throw new Error(
211+
`content hash mismatch: index says ${entry.contentHash}, archive is ${actualContentHash}`,
212+
);
213+
}
214+
}
175215
atomicSwap(tmpDir, catalogDir);
176216
} catch (err) {
177217
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });

packages/commands/src/commands/skill/add.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ export default defineCommand({
5656
return { name, status: "failed", reason: "skill not found in registry" };
5757
}
5858
try {
59-
const record = await installSkillWithFanout(name, entry, agents);
59+
const record = await installSkillWithFanout(
60+
name,
61+
entry,
62+
agents,
63+
lock.skills[name]?.links ?? [],
64+
);
6065
lock.skills[name] = record.lockEntry;
6166
return {
6267
name,

packages/commands/src/commands/skill/update.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
defineCommand,
55
detectOutputFormat,
66
detectInstalledAgents,
7+
fanOutSkillToAgents,
78
fetchSkillsIndex,
89
getSkillRegistryBaseUrl,
910
installSkillWithFanout,
@@ -45,6 +46,7 @@ export default defineCommand({
4546
const lock = readSkillLock();
4647
const disk = new Set(listSkillDirsOnDisk());
4748

49+
const agents = detectInstalledAgents();
4850
const results: UpdateOutcome[] = [];
4951
const targets: string[] = [];
5052
if (requested === "all") {
@@ -60,6 +62,11 @@ export default defineCommand({
6062
continue;
6163
}
6264
if (entry.contentHash === locked.contentHash && disk.has(name)) {
65+
// Self-healing: content unchanged, but still fill fan-out links for agents
66+
// detected since the last install (and refresh recorded copies); the merged
67+
// ledger keeps paths of unvisited agents reclaimable by bl skill remove
68+
const fanout = fanOutSkillToAgents(name, agents, locked.links ?? []);
69+
lock.skills[name] = { ...locked, links: fanout.links };
6370
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
6471
continue;
6572
}
@@ -80,14 +87,18 @@ export default defineCommand({
8087
}
8188
}
8289

83-
const agents = detectInstalledAgents();
8490
const tasks = targets.map((name) => async (): Promise<UpdateOutcome> => {
8591
const entry = index.skills[name];
8692
if (!entry) {
8793
return { name, status: "failed", reason: "skill not found in registry" };
8894
}
8995
try {
90-
const record = await installSkillWithFanout(name, entry, agents);
96+
const record = await installSkillWithFanout(
97+
name,
98+
entry,
99+
agents,
100+
lock.skills[name]?.links ?? [],
101+
);
91102
lock.skills[name] = record.lockEntry;
92103
return { name, status: "updated", publishedAt: entry.publishedAt };
93104
} catch (err) {

packages/commands/src/commands/speech/recognize.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
type DashScopeASRRequest,
1010
type DashScopeASRTaskResult,
1111
type DashScopeAsyncResponse,
12-
trackingHeaders,
1312
stripUndefined,
1413
taskPath,
1514
speechRecognizePath,
@@ -201,9 +200,7 @@ async function handleAsyncMode(
201200
}
202201

203202
// Fetch transcription JSON
204-
const transRes = await fetch(subResult.transcription_url, {
205-
headers: trackingHeaders(),
206-
});
203+
const transRes = await fetch(subResult.transcription_url);
207204
if (!transRes.ok) {
208205
throw new BailianError(
209206
`Failed to download transcription: HTTP ${transRes.status}`,

0 commit comments

Comments
 (0)