@mindscraft/branch-video-agent-cli 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -65
- package/dist/commands/workbench.js +15 -3
- package/dist/lib/http.d.ts +7 -0
- package/dist/lib/http.js +2 -0
- package/dist/lib/uploadEncryption.d.ts +10 -0
- package/dist/lib/uploadEncryption.js +40 -0
- package/dist/lib/uploadTemp.d.ts +1 -0
- package/dist/lib/uploadTemp.js +29 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -252,61 +252,65 @@ AI Gateway Seed Audio 音频生成并等待示例:
|
|
|
252
252
|
'@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- ai-gateway seed-audio-create -- --wait --raw
|
|
253
253
|
```
|
|
254
254
|
|
|
255
|
-
`tts-create` / `tts-get` 是 Seed Audio 的历史兼容别名;新脚本优先使用 `seed-audio-create` / `seed-audio-get`。
|
|
256
|
-
|
|
257
|
-
## RPG2 生产工作台
|
|
258
|
-
|
|
259
|
-
工作台 CLI 只提交调用方显式登记的项目、产物和归档清单;它不会扫描 RPG 阶段目录,也不会根据文件名推断版本关系。认证仍使用 `AIHUB_AGENT_TOKEN` 与 `BRANCH_VIDEO_AGENT_BASE_URL`,不要在 JSON、命令行或日志中保存 token。
|
|
260
|
-
|
|
261
|
-
项目、生产记录和外部 CDN 产物可使用 JSON 文件提交:
|
|
262
|
-
|
|
263
|
-
```powershell
|
|
264
|
-
branch-video-agent workbench project create --file .\project.json --raw
|
|
265
|
-
branch-video-agent workbench record put --project wb_project_id --file .\record.json --raw
|
|
266
|
-
branch-video-agent workbench submit --file .\artifact-external.json --raw
|
|
267
|
-
branch-video-agent workbench annotation put --project wb_project_id --file .\annotation.json --raw
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
同一产物用稳定的 `artifactKey` 和由流程侧持久维护的递增 `sourceRevision`。`idempotencyKey` 由流程侧保存并在重试时复用。已有持久媒体使用 `content.type: "external"`;本地文档、脱敏 session 或其他需要长期保存的文件使用 `content.type: "local"` 与 `sourcePath`。CLI
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
"
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
"
|
|
282
|
-
"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
"
|
|
289
|
-
"
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
branch-video-agent workbench
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
255
|
+
`tts-create` / `tts-get` 是 Seed Audio 的历史兼容别名;新脚本优先使用 `seed-audio-create` / `seed-audio-get`。
|
|
256
|
+
|
|
257
|
+
## RPG2 生产工作台
|
|
258
|
+
|
|
259
|
+
工作台 CLI 只提交调用方显式登记的项目、产物和归档清单;它不会扫描 RPG 阶段目录,也不会根据文件名推断版本关系。认证仍使用 `AIHUB_AGENT_TOKEN` 与 `BRANCH_VIDEO_AGENT_BASE_URL`,不要在 JSON、命令行或日志中保存 token。
|
|
260
|
+
|
|
261
|
+
项目、生产记录和外部 CDN 产物可使用 JSON 文件提交:
|
|
262
|
+
|
|
263
|
+
```powershell
|
|
264
|
+
branch-video-agent workbench project create --file .\project.json --raw
|
|
265
|
+
branch-video-agent workbench record put --project wb_project_id --file .\record.json --raw
|
|
266
|
+
branch-video-agent workbench submit --file .\artifact-external.json --raw
|
|
267
|
+
branch-video-agent workbench annotation put --project wb_project_id --file .\annotation.json --raw
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
同一产物用稳定的 `artifactKey` 和由流程侧持久维护的递增 `sourceRevision`。`idempotencyKey` 由流程侧保存并在重试时复用。已有持久媒体使用 `content.type: "external"`;本地文档、脱敏 session 或其他需要长期保存的文件使用 `content.type: "local"` 与 `sourcePath`。CLI 先计算原文件 SHA-256、声明 blob。0.5.2 对尚未 ready 的 blob 要求服务端返回 `uploadTransport` 公钥配置;缺少配置时失败,不自动回退。文件先 gzip,再使用每次随机 AES-256-GCM 密钥加密,密钥以服务端 RSA3072 OAEP-SHA256 公钥封装。传输使用 `application/octet-stream`、`x-workbench-upload-format: gzip-aes256gcm-v1`,以及受限的 key-id/wrapped-key/nonce/auth-tag headers,不使用 Content-Encoding。服务端完整认证密文后才解压,校验原始大小和 SHA-256;下载返回原字节,版本和幂等键不变。相同字节复用首次声明的 MIME,CLI 使用该权威 MIME 构造 AAD 和提交描述;不会更改 blob 的 purpose 或脱敏证明。
|
|
271
|
+
|
|
272
|
+
原文件上限 64 MiB,密文/压缩流上限 65 MiB。两端使用私有临时文件,成功、失败和取消时清理;下次上传只清理确定已退出 PID 的历史目录,保留活跃、无权探测及 PID 复用目录,因此不是总磁盘硬上限。TMPDIR 不得跨 PID namespace 共享(服务端 pod 私有 /tmp;CLI 宿主同 namespace)。异常强杀后恢复清理依赖下一次上传;仍需现有磁盘容量监控。
|
|
273
|
+
|
|
274
|
+
公钥附加在已授权 blob 声明响应;私钥仅存服务端独立 Mongo 集合,所有 pod 读取同一权威记录。私钥不进入 Blob DTO、CLI、日志或镜像。此机制保护上传传输,不保护数据库泄露或能替换公钥的可信代理;网关不能检查加密后的文件正文,服务端仍执行鉴权、大小及完整性验证。部署先服务端后 CLI。服务端保留旧 raw/gzip 兼容;ready blob 直接复用,无需公钥或再传输。CLI 不发送本地文件路径。
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"schemaVersion": "workbench.artifact/1",
|
|
279
|
+
"projectId": "wb_project_id",
|
|
280
|
+
"artifactKey": "document:director-program",
|
|
281
|
+
"sourceRevision": 2,
|
|
282
|
+
"idempotencyKey": "persisted-event-uuid",
|
|
283
|
+
"kind": "markdown",
|
|
284
|
+
"stageKey": "04",
|
|
285
|
+
"title": "导演稿",
|
|
286
|
+
"content": {
|
|
287
|
+
"type": "local",
|
|
288
|
+
"sourcePath": "C:/production/canonical/director.md",
|
|
289
|
+
"mime": "text/markdown"
|
|
290
|
+
},
|
|
291
|
+
"source": {
|
|
292
|
+
"productionRecordId": "record_id",
|
|
293
|
+
"archiveGeneration": 0,
|
|
294
|
+
"sourceSchema": "director-program/4"
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
上传响应在网络中丢失时,CLI 会先查询同一个已声明 blob;ready 状态不会重传,也不会重新登记业务版本。失败交给流程侧持久待传队列用相同事件重试。
|
|
300
|
+
|
|
301
|
+
`sync` 只读取 `--manifest` 中显式的 `items`。每个 item 为 `type: "artifact"`(默认)或 `type: "annotation"`,可直接携带提交字段或放进 `submission`;不会递归任何工作目录。
|
|
302
|
+
|
|
303
|
+
```powershell
|
|
304
|
+
branch-video-agent workbench sync --manifest .\.workbench\pending-items.json --raw
|
|
305
|
+
branch-video-agent workbench archive prepare --manifest .\.workbench\archive-wrapper.json --raw
|
|
306
|
+
branch-video-agent workbench archive finalize --project wb_project_id --record record_id --revision 1 --manifest-hash <sha256> --raw
|
|
307
|
+
branch-video-agent workbench archive status --project wb_project_id --record record_id --raw
|
|
308
|
+
branch-video-agent workbench archive download --project wb_project_id --record record_id --revision 1 --file .\entry.json --output .\session.jsonl.zst --raw
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`archive prepare` 的文件是调用方维护的 wrapper:`{ manifest, manifestHash, expectedPreviousRevision }`;CLI 原样提交规范化清单和 hash。`archive finalize` 只校验已经提交的清单,不补扫文件。下载的 `entry.json` 至少包含 `{ "entryId": "..." }`;受权限保护的字节写入 `--output`,stdout 仍只输出 JSON 回执。
|
|
312
|
+
|
|
313
|
+
`record put` 的路径参数是稳定的流程 `recordKey`,返回对象的 `id` 才是后续 `end-intent`、`reopen` 和所有 `archive` 命令使用的 `productionRecordId`(也可传给 `--record`)。不要把 `recordKey` 当成归档路由 ID。
|
|
310
314
|
|
|
311
315
|
## 在仓库内构建发布包
|
|
312
316
|
|
|
@@ -314,16 +318,16 @@ branch-video-agent workbench archive download --project wb_project_id --record r
|
|
|
314
318
|
|
|
315
319
|
```powershell
|
|
316
320
|
npm run agent:cli:package:build
|
|
317
|
-
npm run agent:cli:package:smoke
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
需要运行会启动 standalone Mongo、真实 CLI HTTP 流、Python adapter 或 Chromium 的 Production Workbench 集成验收时,使用专用命令;常规 `npm test` 会排除这些外部运行时依赖。adapter E2E 需要相邻的 rpg2 adapter checkout,或显式设置 `RPG2_WORKBENCH_ADAPTER_ROOT`;缺失 checkout、`zstd` 或 Chromium 时命令应失败。
|
|
321
|
-
|
|
322
|
-
```powershell
|
|
323
|
-
npm run test:workbench-cli-integration
|
|
324
|
-
npm run test:workbench-adapter-e2e
|
|
325
|
-
npm run test:workbench-integration
|
|
326
|
-
```
|
|
321
|
+
npm run agent:cli:package:smoke
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
需要运行会启动 standalone Mongo、真实 CLI HTTP 流、Python adapter 或 Chromium 的 Production Workbench 集成验收时,使用专用命令;常规 `npm test` 会排除这些外部运行时依赖。adapter E2E 需要相邻的 rpg2 adapter checkout,或显式设置 `RPG2_WORKBENCH_ADAPTER_ROOT`;缺失 checkout、`zstd` 或 Chromium 时命令应失败。
|
|
325
|
+
|
|
326
|
+
```powershell
|
|
327
|
+
npm run test:workbench-cli-integration
|
|
328
|
+
npm run test:workbench-adapter-e2e
|
|
329
|
+
npm run test:workbench-integration
|
|
330
|
+
```
|
|
327
331
|
|
|
328
332
|
产物输出到:
|
|
329
333
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encryptUpload } from '../lib/uploadEncryption.js';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import { createReadStream } from 'node:fs';
|
|
3
4
|
import { stat, writeFile } from 'node:fs/promises';
|
|
@@ -68,19 +69,30 @@ async function uploadLocalContent(context, sourcePath, content) {
|
|
|
68
69
|
timeoutMs: context.flags.timeoutMs,
|
|
69
70
|
});
|
|
70
71
|
const blobId = getRequiredString(declared.data, 'id');
|
|
72
|
+
const declaredMime = declared.data.mime;
|
|
73
|
+
if (declared.data.sha256 !== sha256 || declared.data.size !== fileStat.size || typeof declaredMime !== 'string' || !/^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/.test(declaredMime) || declaredMime.length >= 200)
|
|
74
|
+
throw new CliCommandError('Blob declaration does not match the local file', 'BLOB_METADATA_MISMATCH');
|
|
71
75
|
if (declared.data.state !== 'ready') {
|
|
72
76
|
let uploadError;
|
|
77
|
+
const encrypted = await encryptUpload(sourcePath, projectId(context.payload, context.flags.project), declared.data);
|
|
78
|
+
const stream = createReadStream(encrypted.path);
|
|
73
79
|
try {
|
|
74
80
|
await context.client.requestStream(`${workbenchPath}/projects/${encodeURIComponent(projectId(context.payload, context.flags.project))}/blobs/${encodeURIComponent(blobId)}/content`, {
|
|
75
81
|
method: 'PUT',
|
|
76
|
-
body: Readable.toWeb(
|
|
77
|
-
contentType:
|
|
82
|
+
body: Readable.toWeb(stream),
|
|
83
|
+
contentType: 'application/octet-stream',
|
|
84
|
+
uploadFormat: 'gzip-aes256gcm-v1',
|
|
85
|
+
encryption: encrypted.headers,
|
|
78
86
|
timeoutMs: context.flags.timeoutMs,
|
|
79
87
|
});
|
|
80
88
|
}
|
|
81
89
|
catch (error) {
|
|
82
90
|
uploadError = error;
|
|
83
91
|
}
|
|
92
|
+
finally {
|
|
93
|
+
stream.destroy();
|
|
94
|
+
await encrypted.cleanup();
|
|
95
|
+
}
|
|
84
96
|
if (uploadError) {
|
|
85
97
|
const status = await context.client.request(`${workbenchPath}/projects/${encodeURIComponent(projectId(context.payload, context.flags.project))}/blobs/${encodeURIComponent(blobId)}`, {
|
|
86
98
|
method: 'GET',
|
|
@@ -90,7 +102,7 @@ async function uploadLocalContent(context, sourcePath, content) {
|
|
|
90
102
|
throw uploadError;
|
|
91
103
|
}
|
|
92
104
|
}
|
|
93
|
-
return { type: 'blob', blobId, sha256, size: fileStat.size, mime };
|
|
105
|
+
return { type: 'blob', blobId, sha256, size: fileStat.size, mime: declaredMime };
|
|
94
106
|
}
|
|
95
107
|
function validateSubmission(payload) {
|
|
96
108
|
if (payload.schemaVersion !== 'workbench.artifact/1') {
|
package/dist/lib/http.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ type StreamRequestOptions = {
|
|
|
13
13
|
method: string;
|
|
14
14
|
body: ReadableStream<Uint8Array>;
|
|
15
15
|
contentType: string;
|
|
16
|
+
uploadFormat?: 'gzip' | 'gzip-aes256gcm-v1';
|
|
17
|
+
encryption?: {
|
|
18
|
+
kid: string;
|
|
19
|
+
wrappedKey: string;
|
|
20
|
+
nonce: string;
|
|
21
|
+
tag: string;
|
|
22
|
+
};
|
|
16
23
|
timeoutMs?: number;
|
|
17
24
|
};
|
|
18
25
|
type BinaryRequestResult = {
|
package/dist/lib/http.js
CHANGED
|
@@ -93,6 +93,8 @@ export class CliHttpClient {
|
|
|
93
93
|
headers: {
|
|
94
94
|
Authorization: `Bearer ${this.token}`,
|
|
95
95
|
'content-type': options.contentType,
|
|
96
|
+
...(options.encryption ? { 'x-workbench-key-id': options.encryption.kid, 'x-workbench-wrapped-key': options.encryption.wrappedKey, 'x-workbench-nonce': options.encryption.nonce, 'x-workbench-auth-tag': options.encryption.tag } : {}),
|
|
97
|
+
...(options.uploadFormat ? { 'x-workbench-upload-format': options.uploadFormat } : {}),
|
|
96
98
|
},
|
|
97
99
|
body: options.body,
|
|
98
100
|
duplex: 'half',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { uploadTempDirectory } from './uploadTemp.js';
|
|
2
|
+
import { constants, createCipheriv, createHash, createPublicKey, publicEncrypt, randomBytes } from 'node:crypto';
|
|
3
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
|
+
import { rm } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { Transform } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { createGzip } from 'node:zlib';
|
|
9
|
+
import { CliCommandError } from './errors.js';
|
|
10
|
+
export async function encryptUpload(sourcePath, projectId, blob) {
|
|
11
|
+
const config = blob.uploadTransport;
|
|
12
|
+
if (!config || config.format !== 'gzip-aes256gcm-v1' || config.keyAlgorithm !== 'RSA-OAEP-256' || config.contentAlgorithm !== 'A256GCM' || typeof config.kid !== 'string' || !/^[a-f0-9]{64}$/.test(config.kid) || typeof config.publicKeySpki !== 'string' || config.publicKeySpki.length > 1024)
|
|
13
|
+
throw new CliCommandError('Server does not offer the required encrypted upload protocol', 'UPLOAD_ENCRYPTION_REQUIRED');
|
|
14
|
+
let publicKey;
|
|
15
|
+
try {
|
|
16
|
+
const der = Buffer.from(config.publicKeySpki, 'base64');
|
|
17
|
+
publicKey = createPublicKey({ key: der, type: 'spki', format: 'der' });
|
|
18
|
+
if (publicKey.asymmetricKeyType !== 'rsa' || publicKey.asymmetricKeyDetails?.modulusLength !== 3072 || der.toString('base64') !== config.publicKeySpki || createHash('sha256').update(der).digest('hex') !== config.kid)
|
|
19
|
+
throw new Error();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new CliCommandError('Invalid upload public key', 'UPLOAD_ENCRYPTION_REQUIRED');
|
|
23
|
+
}
|
|
24
|
+
const key = randomBytes(32), nonce = randomBytes(12);
|
|
25
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 });
|
|
26
|
+
cipher.setAAD(Buffer.from(JSON.stringify([config.format, config.kid, projectId, blob.id, blob.sha256, blob.size, blob.mime])));
|
|
27
|
+
const wrappedKey = publicEncrypt({ key: publicKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, key);
|
|
28
|
+
key.fill(0);
|
|
29
|
+
const directory = await uploadTempDirectory();
|
|
30
|
+
const path = join(directory, 'payload.enc');
|
|
31
|
+
const limit = (max) => { let size = 0; return new Transform({ transform(chunk, _encoding, done) { size += chunk.length; done(size > max ? new CliCommandError('Upload exceeds file size limit', 'FILE_TOO_LARGE') : null, chunk); } }); };
|
|
32
|
+
try {
|
|
33
|
+
await pipeline(createReadStream(sourcePath), limit(64 * 1024 * 1024), createGzip(), cipher, limit(65 * 1024 * 1024), createWriteStream(path, { flags: 'wx', mode: 0o600 }));
|
|
34
|
+
return { path, headers: { kid: config.kid, wrappedKey: wrappedKey.toString('base64url'), nonce: nonce.toString('base64url'), tag: cipher.getAuthTag().toString('base64url') }, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
await rm(directory, { recursive: true, force: true });
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function uploadTempDirectory(): Promise<string>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
// This directory must belong to one host/container PID namespace. Never
|
|
5
|
+
// share TMPDIR across PID namespaces. Cleanup runs only on the next upload;
|
|
6
|
+
// it is not a hard total-disk cap, and reused PIDs are preserved.
|
|
7
|
+
export async function uploadTempDirectory() {
|
|
8
|
+
const base = join(tmpdir(), 'workbench-upload-client');
|
|
9
|
+
await mkdir(base, { recursive: true, mode: 0o700 });
|
|
10
|
+
const info = await lstat(base);
|
|
11
|
+
if (!info.isDirectory() || info.isSymbolicLink() || (process.platform !== 'win32' && ((info.mode & 0o077) !== 0 || info.uid !== process.getuid?.())))
|
|
12
|
+
throw new Error('Unsafe upload temporary directory');
|
|
13
|
+
for (const entry of await readdir(base, { withFileTypes: true })) {
|
|
14
|
+
const match = /^([1-9][0-9]*)-[A-Za-z0-9]+$/.exec(entry.name);
|
|
15
|
+
if (!match || !entry.isDirectory() || entry.isSymbolicLink())
|
|
16
|
+
continue;
|
|
17
|
+
let dead = false;
|
|
18
|
+
try {
|
|
19
|
+
process.kill(Number(match[1]), 0);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
dead = error.code === 'ESRCH';
|
|
23
|
+
}
|
|
24
|
+
// EPERM and reused/live PIDs are conservatively preserved. No age heuristic.
|
|
25
|
+
if (dead)
|
|
26
|
+
await rm(join(base, entry.name), { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
return mkdtemp(join(base, `${process.pid}-`));
|
|
29
|
+
}
|