diff --git a/docs/ack-feishu-bug-intake.md b/docs/ack-feishu-bug-intake.md new file mode 100644 index 0000000..f31cd2c --- /dev/null +++ b/docs/ack-feishu-bug-intake.md @@ -0,0 +1,89 @@ +# ACK 飞书多维表格 Bug 收件箱 + +## 目标 + +让用户在飞书多维表格中跨设备记录文字和截图,随后由 ACK Coordinator 通过官方 +`lark-cli` 读取项目配置的 `ACK Ready` 视图,整理为独立、可验收、可幂等追踪的 ACK +任务。不同项目通过不同 CLI profile 访问各自飞书租户。 + +## 非目标 + +- 不用 Skill 承担人工记录入口。 +- 不抓取公开网页或依赖浏览器登录态。 +- 不把 App Secret、access token 或飞书用户凭据写入项目。 +- 第一版不反向更新飞书记录,不把飞书状态与 ACK 状态做双向同步。 +- 不把多条互不相关的 Bug 合成一次 Developer 派发。 + +## 项目配置契约 + +可选配置位于 `docs/ack/tasks.yaml` 的 `project.bugIntake`。未配置时 ACK 保持现有行为。 +配置存在时必须包含: + +| 字段 | 约束 | +|------|------| +| `provider` | 固定为 `feishu-base` | +| `profile` | `lark-cli` profile 名称;每次命令显式传入 | +| `baseToken` | 飞书 Base token | +| `tableId` | 数据表 ID | +| `viewId` | 只包含可导入记录的 `ACK Ready` 视图 ID | +| `fields` | 逻辑字段到飞书字段 ID/名称的映射 | + +`fields` 必须映射 `title`、`actual`、`expected`、`stepsToReproduce`、 +`acceptance`、`priority`、`attachments`、`updatedAt`。字段值只作为单个 argv 传给 +`lark-cli`,不经过 shell。 + +## CLI 契约 + +- 可执行文件固定为可信路径中的 `lark-cli`。 +- 允许官方 npm 包生成且 package/bin 映射校验通过的 `run.js` wrapper,并解析到包内 + native binary 执行;拒绝缺少 native binary 或其它同名软链接目标。 +- profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。 +- 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。 +- 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。 +- 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。 +- 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。 +- 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与 + 元数据一致。 +- 标准输出必须是单一 JSON;CLI stderr 只作为错误摘要,不把可能的凭据写入结果。 + +## 标准化结果 + +读取器输出批次元数据和 `records`。每条记录至少包含: + +- `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256 + 后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本; +- `recordId` 与 `updatedAt`; +- title、actual、expected、steps、acceptance、priority; +- 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用; +- 原始字段中无法映射但不影响导入的警告。 + +输出不得包含 App ID、App Secret、Base token、附件 file token、tenant/user access +token 或 CLI 配置文件内容。 + +## Coordinator 整理规则 + +1. 先运行读取器 `check`,确认 `lark-cli`、项目 profile 和所需只读能力可用。 +2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift + 整理动作;需要看截图时使用临时下载目录。 +3. 将记录分类为 Bug、已有功能、接受的改进、样式偏好或超范围;只导入确认接受的项。 +4. 每条导入任务保存 `source.kind=feishu-base`、`source.ref`、`source.recordId`、 + `source.updatedAt`,并把截图观察转成文字证据。 +5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。 +6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。 +7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。 + +## 可观测验收信号 + +1. 两个假租户 profile 同时存在时,项目指定 `tenant-b`,所有记录和附件命令都显式包含 `--profile tenant-b`,不会读取 active 的 `tenant-a`。 +2. 只读取配置的 `ACK Ready` view ID,并投影配置字段;不查询整张表或其它视图。 +3. 一条含截图附件的假记录被标准化为稳定 `sourceRef`、完整文字字段和本地附件路径,输出中不存在任何 secret/token 凭据。 +4. CLI 缺失、profile 缺失、畸形 JSON、错位矩阵、分页越界、路径穿越或附件下载失败均返回非零退出码且不输出伪成功结果。 +5. ACK 文档明确要求按 `source.ref` 幂等整理;同一读取结果重复提交不会生成第二个来源任务。 +6. 现有无 `bugIntake` 的 ACK 项目仍能通过任务板校验并按原流程工作。 + +## 最脆弱假设与降级 + +本设计假设官方 `lark-cli` 的 Base JSON 输出保持 `fields`、`record_id_list` 与行矩阵 +对应关系。读取器必须校验三者长度和字段映射;若上游输出契约变化,立即失败并提示 +升级适配器,不能错列生成 Bug。飞书或 CLI 不可用时,只停止新的同步,已经进入 +`tasks.yaml` 的任务继续按 ACK 闭环执行。 diff --git a/docs/ack/knowledge.yaml b/docs/ack/knowledge.yaml new file mode 100644 index 0000000..4856612 --- /dev/null +++ b/docs/ack/knowledge.yaml @@ -0,0 +1,9 @@ +# 复制为 docs/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。 +# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。 +version: 1 +updatedAt: "2026-08-01T12:43:06+08:00" +project: + name: "skills" +# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。 +verificationRegistry: {} +entries: [] diff --git a/docs/ack/project.md b/docs/ack/project.md new file mode 100644 index 0000000..3a3f26e --- /dev/null +++ b/docs/ack/project.md @@ -0,0 +1,71 @@ +# Agent Skills 仓库 ACK 协作协议(项目覆盖层) + +> 本项目基于 ACK Skill v0.12.0。通用规范由 `/ack` 从 Skill 自身的 +> `references/` 读取;本文件只保存当前仓库差异。 + +## 项目概览 + +- 项目:`skills` +- 技术栈:Python 3、Markdown、JSON Schema +- 运行命令:`python3 -m unittest discover -s tests -p 'test_*.py'` +- Base URL:`n/a` +- 任务板:`docs/ack/tasks.yaml` +- 项目知识:`docs/ack/knowledge.yaml` +- 覆盖层文件:`docs/ack/project.md` + +## 通用规范(由 ACK Skill 按需读取) + +- 开始需求:`references/kickoff.md` +- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md` +- 模型档位与升级规则:`references/model-routing.md` +- 闭环流程与 worktree 对齐:`references/closed-loop.md` +- 验收信号与三轮策略:`references/optimization-method.md` +- 派发 prompt 模板:`references/prompt-templates.md` +- Orca 编排命令:`references/orca-adapter.md` + +## Worker 路由 + +机器可校验的 worker profile、允许 worktree 和 receipt 只以 +`docs/ack/tasks.yaml` 为准。本次 Developer 使用当前 `record-bug` worktree;Test +必须使用 fresh worker,并在独立临时项目、独立假 CLI 配置目录中完成黑盒演练。 + +## 路径权限 + +| 路径 | Coordinator | Test | Developer | 说明 | +|------|:-----------:|:----:|:---------:|------| +| `docs/ack-feishu-bug-intake.md` | R/W | Read-only | Read-only | 本需求规格与验收契约 | +| `docs/ack/` | R/W | Read-only | Read-only | ACK 项目状态,只有 Coordinator 写 | +| `skills/ack/` | Read-only | Read-only | R/W | ACK Skill 实现、模板与通用规范 | +| `tests/test_ack_feishu_intake_unit.py` | Read-only | Read-only | R/W | Developer 白盒测试 | +| `tests/test_ack_feishu_intake_e2e.py` | Read-only | R/W | Read-only | Test 独立黑盒演练与回归测试 | +| 其它 `tests/test_ack*.py` | Read-only | Read-only | Read-only | 既有 ACK 回归测试 | +| 临时测试目录 | Read-only | R/W | R/W | 必须由 `tempfile`/`mktemp -d` 创建,不保存凭据 | +| `~/.lark-cli/` | Read-only | Read-only | Read-only | 不读取、不修改真实飞书 profile;测试只能使用假 CLI | + +## 命令 + +Developer 白盒验证: + +```bash +python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py' +python3 -m unittest tests.test_ack_skill tests.test_ack_tasks_validation +python3 -m compileall -q skills/ack/scripts +``` + +Test 黑盒复测: + +```bash +python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py' +python3 -m unittest discover -s tests -p 'test_ack*.py' +python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml +python3 skills/ack/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml +``` + +## 本次任务硬规则 + +- 只支持官方 `lark-cli`,所有读取命令必须显式携带项目配置的 `--profile`,不能依赖当前 active profile。 +- 项目只保存 profile 名称、Base/table/view 标识和字段映射;App Secret、token 与用户凭据不得进入仓库、任务板或日志。 +- 飞书接入第一版只读,不更新或删除表格记录,不切换 CLI active profile。 +- 外部 CLI 缺失、profile 不存在、返回结构异常、附件下载失败时 fail closed,不回退到网页抓取。 +- 只有 `ACK Ready` 视图中的记录可进入整理;每条记录保留稳定 `record_id` 来源引用,重复读取不得制造重复 ACK 来源。 +- 不修改 `AGENTS.md`、`CLAUDE.md`,不提交或推送。 diff --git a/docs/ack/tasks.yaml b/docs/ack/tasks.yaml new file mode 100644 index 0000000..4501ab8 --- /dev/null +++ b/docs/ack/tasks.yaml @@ -0,0 +1,533 @@ +version: 1 +updatedAt: "2026-08-01T13:34:00+08:00" +source: "Coordinator (PM) Agent" +ackVersion: "0.12.0" +project: + name: "skills" + repoPath: "/home/ace/orca/workspaces/.skills/record-bug" + baseUrl: "n/a" + devWorktree: "/home/ace/orca/workspaces/.skills/record-bug" + overlayFile: "docs/ack/project.md" + knowledgeFile: "docs/ack/knowledge.yaml" + orchestration: + profileVersion: 1 + mode: "orca" + allowedWorktrees: + - "/home/ace/orca/workspaces/.skills/record-bug" + modelAllowlist: + codex: + developer: + standard: ["gpt-5.6-terra"] + strong: ["gpt-5.6-sol"] + test: + standard: ["gpt-5.6-luna"] + profiles: + codex-dev-standard: + role: "developer" + cli: "codex" + tier: "standard" + model: "gpt-5.6-terra" + reasoningEffort: "medium" + permissionMode: "workspace-write" + codex-test-standard: + role: "test" + cli: "codex" + tier: "standard" + model: "gpt-5.6-luna" + reasoningEffort: "low" + permissionMode: "workspace-write" + codex-dev-strong: + role: "developer" + cli: "codex" + tier: "strong" + model: "gpt-5.6-sol" + reasoningEffort: "high" + permissionMode: "workspace-write" + defaults: + developer: "codex-dev-standard" + test: "codex-test-standard" + developerUpgraded: "codex-dev-strong" + +workerReceipts: + - receiptVersion: 1 + id: "WR-1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d" + launchId: "1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d" + profileId: "codex-dev-standard" + profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b" + launchFingerprint: "sha256:7de2f9ebf3065e7c044150fa87aed39582a8018b63a0dabac22194bf52ad52b7" + slot: 1 + createdFor: + taskId: "FEISHU-001" + attemptId: "FEISHU-001-A1" + role: "developer" + worktree: + path: "/home/ace/orca/workspaces/.skills/record-bug" + device: 2049 + inode: 1705498 + gitCommonDir: "/home/ace/.skills/.git" + gitCommonDevice: 2049 + gitCommonInode: 558841 + requested: + cli: "codex" + tier: "standard" + model: "gpt-5.6-terra" + reasoningEffort: "medium" + permissionMode: "workspace-write" + executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + executableDevice: 2049 + executableInode: 1453403 + cliVersion: "codex-cli 0.146.0" + argv: + - "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + - "--strict-config" + - "--model" + - "gpt-5.6-terra" + - "--config" + - "model_reasoning_effort=medium" + - "--sandbox" + - "workspace-write" + - "--ask-for-approval" + - "never" + - "--cd" + - "/home/ace/orca/workspaces/.skills/record-bug" + argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee" + environmentPolicy: "per-cli-allowlist-v1" + binding: + orchestrator: "orca" + runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a" + handle: "term_4d6278c9-83ac-4876-a29d-022adc346e6c" + incarnationId: "6daffb4c-e705-47f3-b7c2-d1ee1fb16c56" + observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug" + connected: true + writable: true + boundAt: "2026-08-01T04:46:51.629061Z" + createdAt: "2026-08-01T04:46:42.869861Z" + receiptHash: "sha256:93e7e4454438a4d1c9a64eb7429563bb4b71348715cc4cb3e42c96cb9ca75fa4" + - receiptVersion: 1 + id: "WR-035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3" + launchId: "035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3" + profileId: "codex-test-standard" + profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078" + launchFingerprint: "sha256:981fe3c23594573f564de9ab321e724324962958ed689ad3c0c98d7d165c1aa9" + slot: 2 + createdFor: + taskId: "FEISHU-001" + attemptId: "FEISHU-001-A1" + role: "test" + worktree: + path: "/home/ace/orca/workspaces/.skills/record-bug" + device: 2049 + inode: 1705498 + gitCommonDir: "/home/ace/.skills/.git" + gitCommonDevice: 2049 + gitCommonInode: 558841 + requested: + cli: "codex" + tier: "standard" + model: "gpt-5.6-luna" + reasoningEffort: "low" + permissionMode: "workspace-write" + executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + executableDevice: 2049 + executableInode: 1453403 + cliVersion: "codex-cli 0.146.0" + argv: + - "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + - "--strict-config" + - "--model" + - "gpt-5.6-luna" + - "--config" + - "model_reasoning_effort=low" + - "--sandbox" + - "workspace-write" + - "--ask-for-approval" + - "never" + - "--cd" + - "/home/ace/orca/workspaces/.skills/record-bug" + argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369" + environmentPolicy: "per-cli-allowlist-v1" + binding: + orchestrator: "orca" + runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a" + handle: "term_ab858ba7-b972-45fb-bf50-61e96ced2830" + incarnationId: "9e20c158-7ac3-4a18-8dca-f9fec50126d6" + observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug" + connected: true + writable: true + boundAt: "2026-08-01T04:57:33.909375Z" + createdAt: "2026-08-01T04:57:25.086609Z" + receiptHash: "sha256:1245532d66814f5d3b2a6a3b22293ead597f04390e628333569a7ba8763d0a84" + - receiptVersion: 1 + id: "WR-3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1" + launchId: "3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1" + profileId: "codex-dev-standard" + profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b" + launchFingerprint: "sha256:8264af6531e9828c0f994845b3a24931e22abea72d8a1d54a7c764577e544873" + slot: 3 + createdFor: + taskId: "FEISHU-001" + attemptId: "FEISHU-001-A2" + role: "developer" + worktree: + path: "/home/ace/orca/workspaces/.skills/record-bug" + device: 2049 + inode: 1705498 + gitCommonDir: "/home/ace/.skills/.git" + gitCommonDevice: 2049 + gitCommonInode: 558841 + requested: + cli: "codex" + tier: "standard" + model: "gpt-5.6-terra" + reasoningEffort: "medium" + permissionMode: "workspace-write" + executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + executableDevice: 2049 + executableInode: 1453403 + cliVersion: "codex-cli 0.146.0" + argv: + - "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + - "--strict-config" + - "--model" + - "gpt-5.6-terra" + - "--config" + - "model_reasoning_effort=medium" + - "--sandbox" + - "workspace-write" + - "--ask-for-approval" + - "never" + - "--cd" + - "/home/ace/orca/workspaces/.skills/record-bug" + argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee" + environmentPolicy: "per-cli-allowlist-v1" + binding: + orchestrator: "orca" + runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a" + handle: "term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c" + incarnationId: "f77f05ff-fb2b-4099-ab03-070f61671673" + observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug" + connected: true + writable: true + boundAt: "2026-08-01T05:01:26.824435Z" + createdAt: "2026-08-01T05:01:17.965038Z" + receiptHash: "sha256:c20c47c7491b5b399d2c29fce67a7035b432969bda0ccb74948eb03c3d085380" + - receiptVersion: 1 + id: "WR-12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd" + launchId: "12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd" + profileId: "codex-test-standard" + profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078" + launchFingerprint: "sha256:621c7d5d6b7182d9a1ebf211fa11a80d7b100a3b682f8800f61031a651f5bf2a" + slot: 4 + createdFor: + taskId: "FEISHU-001" + attemptId: "FEISHU-001-A2" + role: "test" + worktree: + path: "/home/ace/orca/workspaces/.skills/record-bug" + device: 2049 + inode: 1705498 + gitCommonDir: "/home/ace/.skills/.git" + gitCommonDevice: 2049 + gitCommonInode: 558841 + requested: + cli: "codex" + tier: "standard" + model: "gpt-5.6-luna" + reasoningEffort: "low" + permissionMode: "workspace-write" + executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + executableDevice: 2049 + executableInode: 1453403 + cliVersion: "codex-cli 0.146.0" + argv: + - "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + - "--strict-config" + - "--model" + - "gpt-5.6-luna" + - "--config" + - "model_reasoning_effort=low" + - "--sandbox" + - "workspace-write" + - "--ask-for-approval" + - "never" + - "--cd" + - "/home/ace/orca/workspaces/.skills/record-bug" + argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369" + environmentPolicy: "per-cli-allowlist-v1" + binding: + orchestrator: "orca" + runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a" + handle: "term_1ec9d2e1-a1b6-45db-8477-097e39a93ee4" + incarnationId: "a6f79ac3-9cc6-40a1-a38a-e7b7c2a49b02" + observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug" + connected: true + writable: true + boundAt: "2026-08-01T05:05:36.189001Z" + createdAt: "2026-08-01T05:05:27.500696Z" + receiptHash: "sha256:824227bda0e78558807e10da9d89ec32ee4d7f37d782b866bca4629c5ac9e8fb" + - receiptVersion: 1 + id: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21" + launchId: "90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21" + profileId: "codex-dev-standard" + profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b" + launchFingerprint: "sha256:97ef498caf4a5f0d48fef22773c942a5065dfcf5cecefa22a6ad21df8d04bcd1" + slot: 5 + createdFor: + taskId: "FEISHU-001" + attemptId: "FEISHU-001-A3" + role: "developer" + worktree: + path: "/home/ace/orca/workspaces/.skills/record-bug" + device: 2049 + inode: 1705498 + gitCommonDir: "/home/ace/.skills/.git" + gitCommonDevice: 2049 + gitCommonInode: 558841 + requested: + cli: "codex" + tier: "standard" + model: "gpt-5.6-terra" + reasoningEffort: "medium" + permissionMode: "workspace-write" + executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + executableDevice: 2049 + executableInode: 1453403 + cliVersion: "codex-cli 0.146.0" + argv: + - "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + - "--strict-config" + - "--model" + - "gpt-5.6-terra" + - "--config" + - "model_reasoning_effort=medium" + - "--sandbox" + - "workspace-write" + - "--ask-for-approval" + - "never" + - "--cd" + - "/home/ace/orca/workspaces/.skills/record-bug" + argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee" + environmentPolicy: "per-cli-allowlist-v1" + binding: + orchestrator: "orca" + runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a" + handle: "term_88079d90-29a3-48ac-9b97-bec011abc249" + incarnationId: "5d4f785f-e0b5-4ab3-9de3-72ebc2da3b06" + observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug" + connected: true + writable: true + boundAt: "2026-08-01T05:16:40.565911Z" + createdAt: "2026-08-01T05:16:31.788668Z" + receiptHash: "sha256:c62417b2ee8e9815228a18b2ee1be84b2844dada50eb9703a061e08e51e57eb7" + - receiptVersion: 1 + id: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7" + launchId: "8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7" + profileId: "codex-test-standard" + profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078" + launchFingerprint: "sha256:6c7a45f7dd119bb6ef3bfaed7961c30f257285d940f248e0b16678dcad1bd19d" + slot: 6 + createdFor: + taskId: "FEISHU-001" + attemptId: "FEISHU-001-A3" + role: "test" + worktree: + path: "/home/ace/orca/workspaces/.skills/record-bug" + device: 2049 + inode: 1705498 + gitCommonDir: "/home/ace/.skills/.git" + gitCommonDevice: 2049 + gitCommonInode: 558841 + requested: + cli: "codex" + tier: "standard" + model: "gpt-5.6-luna" + reasoningEffort: "low" + permissionMode: "workspace-write" + executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + executableDevice: 2049 + executableInode: 1453403 + cliVersion: "codex-cli 0.146.0" + argv: + - "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex" + - "--strict-config" + - "--model" + - "gpt-5.6-luna" + - "--config" + - "model_reasoning_effort=low" + - "--sandbox" + - "workspace-write" + - "--ask-for-approval" + - "never" + - "--cd" + - "/home/ace/orca/workspaces/.skills/record-bug" + argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369" + environmentPolicy: "per-cli-allowlist-v1" + binding: + orchestrator: "orca" + runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a" + handle: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b" + incarnationId: "6e37ac75-5725-410c-87af-63f8bb42a911" + observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug" + connected: true + writable: true + boundAt: "2026-08-01T05:25:08.564556Z" + createdAt: "2026-08-01T05:24:59.720042Z" + receiptHash: "sha256:dcbed5b2a3fa99312deba61951253992741eca885068e8f649613bd97eb3f59f" + +summary: + verified: ["FEISHU-001"] + open: [] + failedRetest: [] + leftovers: [] + +tasks: + - id: "FEISHU-001" + type: "feature" + title: "ACK 从飞书多维表格按项目 profile 读取并整理 Bug" + priority: "P1" + status: "verified" + assignee: "test" + component: "skills/ack" + specRefs: + - "docs/ack-feishu-bug-intake.md" + testRefs: + - "tests/test_ack_feishu_intake_unit.py" + - "tests/test_ack_feishu_intake_e2e.py" + knowledgeRefs: [] + knowledgeApplied: [] + knowledgeCandidates: [] + knowledgeChecks: [] + + description: > + 扩展完整 ACK Skill,使项目可以在 tasks.yaml 中声明飞书多维表格 Bug 收件箱和 + lark-cli profile。ACK 通过可信、无 shell 的读取器读取 ACK Ready 视图、按需 + 下载截图并输出可幂等整理的标准化记录;不同项目不会串用飞书租户。 + + stepsToReproduce: + - "创建两个假 lark-cli profile:tenant-a 为 active,项目配置 tenant-b" + - "在 tenant-b 的 ACK Ready 视图返回一条带截图附件的 Bug 记录" + - "运行 ACK 飞书读取器 check/fetch,并重复提交同一标准化结果" + + expected: > + 读取器每次显式使用 tenant-b,仅读取配置 view,正确标准化文字与截图附件, + 对所有错误 fail closed;ACK 按稳定 source.ref 整理,重复来源不产生新任务, + 未配置飞书的旧项目保持兼容。 + + actual: > + 当前 ACK 没有飞书 Bug 数据源、CLI profile 配置、附件读取或来源幂等规则。 + + evidence: + browser: "飞书多维表格记录可包含文字字段和截图附件;本次使用假 CLI 离线复现" + api: "官方 lark-cli base +record-list 与 +record-download-attachment" + logs: "本机当前没有 lark-cli,验证必须通过隔离假 CLI 完成" + + verification: + commands: + - "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'" + - "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'" + - "python3 -m unittest discover -s tests -p 'test_ack*.py'" + - "python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml" + browser: + page: "n/a" + checks: + - "假 CLI 记录证明所有数据命令显式使用 tenant-b profile" + - "标准化输出包含唯一 sourceRef 与本地截图路径" + - "重复来源整理结果只对应一个 ACK 任务" + + dispatch: + developer: + profileId: "codex-dev-standard" + receiptId: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21" + attemptId: "FEISHU-001-A3" + taskId: "task_9bc1dba1e21a" + dispatchId: "ctx_0791582a526f" + test: + profileId: "codex-test-standard" + receiptId: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7" + attemptId: "FEISHU-001-A3" + taskId: "task_4f70d2496d13" + dispatchId: "ctx_66ab3460dda2" + rounds: + - round: 1 + attemptId: "FEISHU-001-A1" + result: failed + evidence: > + Isolated fake tenant and screenshot intake passed profile isolation, + download containment, fail-closed behavior, organization rules and + legacy validation, but normalized output leaked baseToken inside + sourceRef and exposed the attachment file token. + - round: 2 + attemptId: "FEISHU-001-A2" + result: failed + evidence: > + Strengthened isolated E2E passed after the secret-output fix, but + Coordinator final gate compared the adapter with official lark-cli + source: profile list returns a raw JSON array and has no --format + flag, while the adapter requires an object and sends --format. + Executable lookup also trusts arbitrary PATH instead of the fixed + trusted directories required by the product spec; template source + reference still documents the obsolete raw form. + - round: 3 + attemptId: "FEISHU-001-A3" + result: passed + evidence: > + Fresh Test ran a file-backed fake Feishu submission containing all + Bug text fields and a screenshot through the production check/fetch + entry points. Six focused E2E tests and five unit tests passed. The + run verified the official raw profile array and data.data matrix, + explicit tenant-b selection, trusted executable resolution, + screenshot containment, stable opaque sourceRef deduplication and + fail-closed handling for malformed, ambiguous, non-progressing and + over-limit responses. Validators, skiff check, compileall and diff + checks also passed. + + resolution: + fixedBy: "term_88079d90-29a3-48ac-9b97-bec011abc249" + verifiedBy: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b" + verifiedAt: "2026-08-01T13:31:40+08:00" + leftoverReason: null + evidence: + developer: > + FEISHU-001-A1 implemented ACK 0.12.0 Feishu Base intake. Focused unit + tests, ACK contract tests, compileall, task validation and diff check + passed. The fresh worker could not deliver worker_done through its + workspace-write sandbox because Orca relay/FUSE was unavailable; + Coordinator preserved the terminal evidence in Orca task + task_ed87b3386a80 and did not treat it as final verification. + testRound1: > + Fresh Test term_ab858ba7-b972-45fb-bf50-61e96ced2830 created the + isolated E2E fixture and found secret-safe output failing while all + other targeted signals passed. Evidence is retained in Orca task + task_251a9b5265cd; Test relay was unavailable for the same sandbox + reason, so this remains a failed retest rather than verification. + developerRound2: > + Fresh Developer term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c changed + sourceRef to a domain-separated SHA-256 digest and kept file tokens + internal to download calls. Focused unit, ACK contract, read-only E2E, + compileall and diff checks passed; independent round-2 Test is still + required. + coordinatorGateRound2: > + Targeted Test evidence was green, but final source-contract review + against official larksuite/cli cmd/profile/list.go rejected the round: + actual profile list output is a JSON array, the command exposes no + format flag, and the current reader resolves lark-cli through caller + PATH rather than fixed trusted directories. + developerRound3: > + Fresh Developer term_88079d90-29a3-48ac-9b97-bec011abc249 added the + trusted resolver, official raw profile-list and data.data parsing, + strict envelopes, sanitized execution PATH, revised unit coverage and + complete setup/source examples. Five focused unit tests, 34 ACK + contracts (one skipped), compileall, task validation and diff check + passed; final independent Test remains required. + testRound3: > + Fresh Test term_44ab2675-445a-4f51-a0f5-83960bc1ca3b passed six + isolated E2E cases and all five Feishu unit tests. It exercised the + production entry points with a real fake-CLI subprocess and a separate + submitted-bug JSON file, downloaded a screenshot into a temporary + directory, repeated the fetch to prove a stable opaque sourceRef, and + simulated ACK source.ref organization without duplicates. The four + failures in the wider legacy suite reproduce unchanged on base commit + f08edb6 and are classified as existing environment/layout failures. + Orca worker_done transport was unavailable inside the worker sandbox + because FUSE was absent; the full fresh-terminal evidence is retained + in Orca task task_4f70d2496d13. diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index d934f2b..15917ff 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -96,14 +96,18 @@ description: >- docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root ` 校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目 缺少可选交付配置而宣称失败。 -5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且 +5. 若存在 `project.bugIntake`,运行 + `python3 /scripts/feishu_bug_intake.py check docs/ack/tasks.yaml`。 + 它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见 + `references/feishu-bug-intake.md`。 +6. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且 `stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。 -6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、 +7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、 允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt 必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId` 必须同时为空或同时填写。 缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。 -7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。 +8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。 ## 工作 @@ -121,6 +125,11 @@ description: >- worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active` 知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文; 不全量注入知识库。 + 配置了 `project.bugIntake` 时,先按 `references/feishu-bug-intake.md` 运行 check, + 再运行 plan 获取标准化记录及 `create` / `refresh` / `unchanged` / `drift` 整理动作。 + 按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述; + `dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和 + `leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。 4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验, 然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的 步骤放入同一份计划。确认前不派发实现,也不执行交付。 diff --git a/skills/ack/VERSION b/skills/ack/VERSION index d9df1bb..ac454c6 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.11.0 +0.12.0 diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index ea05105..cf0c228 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -1,6 +1,6 @@ # notes-web Agent 协作协议(示例,项目覆盖层) -> 本项目基于 ack v0.11.0。 +> 本项目基于 ack v0.12.0。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index 0458311..6f08615 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -3,7 +3,7 @@ version: 1 updatedAt: "2026-07-06T09:40:00+08:00" source: "Coordinator (PM) Agent" -ackVersion: "0.11.0" +ackVersion: "0.12.0" project: name: "notes-web" repoPath: "/home/dev/notes-web" @@ -104,6 +104,12 @@ tasks: evidence: "服务实例、worktree 与 commit 9f8e7d6 一致" checkedBy: "test-worker-1" checkedAt: "2026-07-06T09:36:00+08:00" + # 飞书导入时使用不透明 digest;不要在 source.ref 中放 profile、Base、table 或 record ID。 + source: + kind: "feishu-base" + ref: "feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + recordId: "recExample" + updatedAt: "2026-07-06T09:00:00Z" description: > 用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。 stepsToReproduce: diff --git a/skills/ack/references/feishu-bug-intake.md b/skills/ack/references/feishu-bug-intake.md new file mode 100644 index 0000000..94a6ce1 --- /dev/null +++ b/skills/ack/references/feishu-bug-intake.md @@ -0,0 +1,76 @@ +# 飞书 Base Bug 收件箱 + +这是可选的只读接入。项目在 `docs/ack/tasks.yaml` 的 `project.bugIntake` 声明 +`provider: feishu-base`、显式 `profile`、`baseToken`、`tableId`、ACK Ready 的 `viewId` +和八个字段映射;不要保存 App Secret、access token 或任何 profile 凭据。 + +## 一次性安装与 profile 设置 + +在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo +shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm +包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin +映射,然后直接执行该官方包下载的 native binary;这样不依赖 nvm PATH,也不会让 Node +运行时变量进入凭据边界。缺少 native binary 或同名仿冒 wrapper 都会被拒绝: + +```bash +npm install --global --prefix "$HOME/.local" @larksuite/cli@latest +``` + +固定 `--prefix "$HOME/.local"` 是接入契约的一部分,确保 npm wrapper 落在读取器会检查的 +账号级可信目录;不要依赖 nvm 或其它由当前 shell 动态注入的 PATH 位置。 + +随后在受控终端中用 stdin 提供 App Secret,选择 Feishu brand,避免 secret 进入 shell +history、进程参数或项目文件: + +```bash +printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \ + --name project-feishu --app-id "$FEISHU_APP_ID" \ + --app-secret-stdin --brand feishu +``` + +随后在 project 配置里只填写 `profile: project-feishu`。不要执行 `profile use`,也不要 +依赖 active profile;每次读取和附件下载都由 adapter 显式传 `--profile project-feishu`。 +为读取记录和下载附件,profile 必须具有 `base:record:read` 和 +`docs:document.media:download`。请在飞书开发者后台的应用权限中为该 app 授予这两个 +scope;不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的 +stored user token。 + +## 使用 + +```bash +python3 /scripts/feishu_bug_intake.py check docs/ack/tasks.yaml +tmpdir=$(mktemp -d) +python3 /scripts/feishu_bug_intake.py fetch docs/ack/tasks.yaml \ + --output-dir "$tmpdir" +python3 /scripts/feishu_bug_intake.py plan docs/ack/tasks.yaml \ + --output-dir "$tmpdir" +``` + +`fetch` 只调用官方 `base +record-list`(限定配置的 view 和字段)和按配置附件字段的 +`base +record-download-attachment`。它输出单一 JSON,验证行矩阵、分页和下载路径;CLI、 +profile、JSON、分页、附件或路径任一异常都会失败且不输出伪成功结果。 + +`plan` 在同一批标准化记录上读取现有 `tasks`,只输出整理计划而不修改文件:新来源为 +`create`,同来源且现有任务为 `open`、来源时间有变化时为 `refresh`,未变化为 +`unchanged`,其它 ACK 状态发生来源变化时为 `drift`。任务板或读取结果出现重复 +`sourceRef` 会直接失败。 + +子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的 +`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入,避免环境凭据或运行时 +注入绕过项目 profile。每条记录最多 10 个附件、单批最多 100 个,单个附件最多 +20 MiB、合计最多 200 MiB,整批附件下载最多 5 分钟,并校验声明大小与落盘大小; +请始终使用新的临时目录作为 `--output-dir`。 + +`profile list` 只作存在性检查,不会输出 profile 内容。官方 record-list JSON 使用 +`data.fields`、`data.record_id_list` 与同长度的 `data.data` 行矩阵;`ok: true` 或 +`code: 0` 是唯一可接受的成功 envelope。 + +## Coordinator 整理 + +读取结果的每条 `sourceRef` 是稳定且不透明的键,例如 +`feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef`。 +导入时写入 task 的 +`source.kind: feishu-base`、`source.ref`、`source.recordId` 与 `source.updatedAt`;先按 +`source.ref` 查重。相同来源只更新 `open` 任务;`dispatched`、`fixed_by_dev`、 +`retesting`、`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移, +由用户决定是否新建任务;飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。 diff --git a/skills/ack/scripts/feishu_bug_intake.py b/skills/ack/scripts/feishu_bug_intake.py new file mode 100644 index 0000000..8d5fc20 --- /dev/null +++ b/skills/ack/scripts/feishu_bug_intake.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +"""Read an ACK-ready Feishu Base view through the official lark-cli. + +This is deliberately a small, non-mutating adapter. It never reads the +active profile and emits one JSON document only on success. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import pwd +import re +import resource +import signal +import stat +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader + +REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt") +MAX_PAGES = 100 +MAX_RECORDS = 10_000 +PAGE_SIZE = 100 +MAX_CLI_STDOUT = 1024 * 1024 +MAX_CLI_STDERR = 64 * 1024 +MAX_ATTACHMENTS_PER_RECORD = 10 +MAX_TOTAL_ATTACHMENTS = 100 +MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 +MAX_TOTAL_ATTACHMENT_BYTES = 200 * 1024 * 1024 +MAX_ATTACHMENT_BATCH_SECONDS = 300 +SAFE_VALUE = re.compile(r"^[^\s\x00-\x1f]{1,256}$") +PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") +SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$") + + +class IntakeError(Exception): + pass + + +def account_home() -> Path: + """Return the actual account home, never a caller-controlled HOME value.""" + try: + home = Path(pwd.getpwuid(os.getuid()).pw_dir).resolve(strict=True) + except (KeyError, OSError) as exc: + raise IntakeError("cannot resolve current account home") from exc + if not home.is_dir(): + raise IntakeError("current account home is not a directory") + return home + + +def trusted_lark_cli_dirs() -> list[Path]: + """Fixed account and system locations; intentionally never consult PATH.""" + home = account_home() + candidates = ( + home / ".local" / "bin", + home / ".local" / "share" / "mise" / "shims", + home / ".cargo" / "bin", + Path("/home/linuxbrew/.linuxbrew/bin"), + Path("/usr/local/go/bin"), + Path("/usr/local/bin"), + Path("/usr/bin"), + Path("/bin"), + ) + result: list[Path] = [] + for candidate in candidates: + try: + resolved = candidate.resolve(strict=True) + except OSError: + continue + if resolved.is_dir() and resolved not in result: + result.append(resolved) + return result + + +def resolve_lark_cli() -> Path: + """Resolve a safe lark-cli from the fixed trusted locations only.""" + for directory in trusted_lark_cli_dirs(): + candidate = directory / "lark-cli" + try: + candidate_metadata = os.lstat(candidate) + resolved = candidate.resolve(strict=True) + metadata = resolved.stat() + except OSError: + continue + if not (stat.S_ISREG(candidate_metadata.st_mode) or stat.S_ISLNK(candidate_metadata.st_mode)): + continue + if candidate_metadata.st_uid not in {0, os.getuid()}: + continue + if not stat.S_ISREG(metadata.st_mode) or not os.access(resolved, os.X_OK): + continue + if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022: + continue + if resolved.name == "lark-cli": + return resolved + if not stat.S_ISLNK(candidate_metadata.st_mode): + continue + official_binary = official_npm_binary(resolved) + if official_binary is not None: + return official_binary + raise IntakeError("lark-cli is not installed in a trusted account or system directory") + + +def official_npm_binary(path: Path) -> Path | None: + """Resolve a validated npm wrapper to its downloaded native CLI binary.""" + if path.name != "run.js" or path.parent.name != "scripts": + return None + manifest = path.parent.parent / "package.json" + try: + metadata = manifest.stat() + if not stat.S_ISREG(metadata.st_mode): + return None + if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022: + return None + package = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + if not ( + isinstance(package, dict) + and package.get("name") == "@larksuite/cli" + and isinstance(package.get("bin"), dict) + and package["bin"].get("lark-cli") == "scripts/run.js" + ): + return None + native = path.parent.parent / "bin" / "lark-cli" + try: + native_lstat = os.lstat(native) + resolved = native.resolve(strict=True) + metadata = resolved.stat() + except OSError: + return None + if not stat.S_ISREG(native_lstat.st_mode) or resolved.name != "lark-cli": + return None + if not stat.S_ISREG(metadata.st_mode) or not os.access(resolved, os.X_OK): + return None + if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022: + return None + return resolved + + +def cli_environment() -> dict[str, str]: + """Build a minimal environment so env credentials cannot override `--profile`.""" + environment = { + "HOME": str(account_home()), + "PATH": os.pathsep.join(str(path) for path in trusted_lark_cli_dirs()), + } + for name in ("LANG", "LC_ALL", "LC_CTYPE"): + value = os.environ.get(name) + if value and "\x00" not in value and len(value) <= 256: + environment[name] = value + return environment + + +def load_board(path: Path) -> dict[str, Any]: + try: + text = path.read_text(encoding="utf-8") + if path.suffix.lower() == ".json": + value = load_json_unique(text) + else: + try: + import yaml # type: ignore + value = yaml.load(text, Loader=make_unique_pyyaml_loader(yaml)) + except ImportError: + value = load_yaml_subset(text) + except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc: + raise IntakeError(f"cannot read task board: {exc}") from exc + except Exception as exc: # PyYAML errors are intentionally not exposed verbatim. + raise IntakeError("cannot parse task board") from exc + if not isinstance(value, dict): + raise IntakeError("task board must be an object") + return value + + +def config_from_board(board: dict[str, Any]) -> dict[str, Any]: + project = board.get("project") + if not isinstance(project, dict) or "bugIntake" not in project: + raise IntakeError("project.bugIntake is not configured") + config = project["bugIntake"] + if not isinstance(config, dict): + raise IntakeError("project.bugIntake must be an object") + allowed = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"} + unknown = sorted(set(config) - allowed) + if unknown: + raise IntakeError("bugIntake has unknown fields") + if config.get("provider") != "feishu-base": + raise IntakeError("bugIntake.provider must be feishu-base") + profile = config.get("profile") + if not isinstance(profile, str) or not PROFILE.fullmatch(profile): + raise IntakeError("bugIntake.profile is invalid") + for key in ("baseToken", "tableId", "viewId"): + value = config.get(key) + if not isinstance(value, str) or not SAFE_VALUE.fullmatch(value): + raise IntakeError(f"bugIntake.{key} is invalid") + fields = config.get("fields") + if not isinstance(fields, dict) or set(fields) != set(REQUIRED_FIELDS): + raise IntakeError("bugIntake.fields must map exactly the required logical fields") + if any(not isinstance(value, str) or not SAFE_VALUE.fullmatch(value) for value in fields.values()): + raise IntakeError("bugIntake.fields values are invalid") + if len(set(fields.values())) != len(fields): + raise IntakeError("bugIntake.fields values must be unique") + return config + + +def limit_child_file_size(limit: int) -> None: + """Bound regular-file writes by the CLI and any child spawned by its wrapper.""" + _, hard = resource.getrlimit(resource.RLIMIT_FSIZE) + bounded = limit if hard == resource.RLIM_INFINITY else min(limit, hard) + resource.setrlimit(resource.RLIMIT_FSIZE, (bounded, bounded)) + + +def run_cli( + args: list[str], *, allow_profile_list: bool = False, max_file_bytes: int = 0, + timeout: float = 60, cwd: Path | None = None, +) -> Any: + """Run the official CLI and accept only explicit successful JSON shapes.""" + executable = resolve_lark_cli() + file_limit = max(MAX_CLI_STDOUT, MAX_CLI_STDERR, max_file_bytes) + with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file: + try: + process = subprocess.Popen( + [str(executable), *args], + shell=False, + stdin=subprocess.DEVNULL, + stdout=stdout_file, + stderr=stderr_file, + env=cli_environment(), + cwd=cwd, + start_new_session=True, + preexec_fn=lambda: limit_child_file_size(file_limit), + ) + try: + returncode = process.wait(timeout=timeout) + except subprocess.TimeoutExpired as exc: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + raise IntakeError("lark-cli failed to execute") from exc + except (OSError, subprocess.SubprocessError) as exc: + raise IntakeError("lark-cli failed to execute") from exc + stdout_size = os.fstat(stdout_file.fileno()).st_size + stderr_size = os.fstat(stderr_file.fileno()).st_size + if stdout_size > MAX_CLI_STDOUT or stderr_size > MAX_CLI_STDERR: + raise IntakeError("lark-cli output exceeded the safety limit") + if returncode: + raise IntakeError("lark-cli command failed") + stdout_file.seek(0) + try: + stdout = stdout_file.read(MAX_CLI_STDOUT + 1).decode("utf-8") + except UnicodeDecodeError as exc: + raise IntakeError("lark-cli returned malformed JSON") from exc + try: + value = json.loads( + stdout, + parse_constant=lambda value: (_ for _ in ()).throw( + ValueError(f"non-finite JSON constant: {value}") + ), + ) + except (json.JSONDecodeError, ValueError) as exc: + raise IntakeError("lark-cli returned malformed JSON") from exc + if isinstance(value, list): + if allow_profile_list: + return value + raise IntakeError("lark-cli returned an unexpected JSON array") + if not isinstance(value, dict): + raise IntakeError("lark-cli returned an invalid JSON response") + if "ok" in value and value["ok"] is not True: + raise IntakeError("lark-cli returned an error response") + if "code" in value and (not isinstance(value["code"], int) or isinstance(value["code"], bool) or value["code"] != 0): + raise IntakeError("lark-cli returned an error response") + if "ok" not in value and "code" not in value: + raise IntakeError("lark-cli returned an ambiguous JSON response") + return value + + +def profile_check(config: dict[str, Any]) -> None: + # `profile list` is the official non-mutating profile inspection command. + value = run_cli(["profile", "list"], allow_profile_list=True) + if isinstance(value, list): + profiles = value + else: + # Compatibility wrapper: only a successful envelope with a direct list + # is accepted. Do not loosen this into arbitrary nested objects. + profiles = value.get("data") + if not isinstance(profiles, list): + raise IntakeError("profile check returned an invalid response") + matching_profile: dict[str, Any] | None = None + for item in profiles: + # Match the official profile-list item shape. `user` and + # `tokenStatus` are optional and deliberately never propagated. + if ( + not isinstance(item, dict) + or not isinstance(item.get("name"), str) + or not PROFILE.fullmatch(item["name"]) + or not isinstance(item.get("appId"), str) + or not isinstance(item.get("brand"), str) + or not isinstance(item.get("active"), bool) + ): + raise IntakeError("profile check returned an invalid profile entry") + if item["name"] == config["profile"]: + matching_profile = item + if matching_profile is None: + raise IntakeError("configured lark-cli profile does not exist") + if matching_profile["brand"] != "feishu": + raise IntakeError("configured lark-cli profile must use the feishu brand") + + +def text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return " ".join(value.split()) + if isinstance(value, float) and not math.isfinite(value): + raise IntakeError("text field contains a non-finite number") + if isinstance(value, (int, float, bool)): + return str(value) + if isinstance(value, list): + return "\n".join(part for part in (text(item) for item in value) if part) + if isinstance(value, dict): + for key in ("text", "name", "value"): + if key in value: + return text(value[key]) + raise IntakeError("text field contains an unsupported object") + raise IntakeError("text field contains an unsupported value") + + +def attachment_items(value: Any) -> list[tuple[dict[str, Any], str]]: + if value in (None, ""): + return [] + if not isinstance(value, list): + raise IntakeError("attachments cell must be a list") + if len(value) > MAX_ATTACHMENTS_PER_RECORD: + raise IntakeError("record exceeded the attachment count limit") + attachments: list[tuple[dict[str, Any], str]] = [] + for item in value: + if not isinstance(item, dict): + raise IntakeError("attachment metadata must be an object") + token = item.get("file_token", item.get("token")) + if not isinstance(token, str) or not SAFE_VALUE.fullmatch(token): + raise IntakeError("attachment token is invalid") + metadata = {"name": text(item.get("name")), "type": text(item.get("type", item.get("mime_type"))), "size": item.get("size")} + if ( + not isinstance(metadata["size"], int) + or isinstance(metadata["size"], bool) + or metadata["size"] < 0 + or metadata["size"] > MAX_ATTACHMENT_BYTES + ): + raise IntakeError("attachment size is invalid") + attachments.append((metadata, token)) + return attachments + + +def matrix_from_response(response: dict[str, Any], field_ids: list[str]) -> tuple[list[str], list[list[Any]]]: + data = response.get("data", response) + if not isinstance(data, dict): + raise IntakeError("record list data is invalid") + fields = data.get("fields") + ids = data.get("record_id_list", data.get("recordIds")) + rows = data.get("data", data.get("records", data.get("items", data.get("rows")))) + if not isinstance(fields, list) or not all(isinstance(x, str) for x in fields): + raise IntakeError("record list fields are invalid") + if fields != field_ids: + raise IntakeError("record list fields do not match configured projection") + if not isinstance(ids, list) or not all(isinstance(x, str) and RECORD_ID.fullmatch(x) for x in ids): + raise IntakeError("record list record_id_list is invalid") + if not isinstance(rows, list) or len(rows) != len(ids) or any(not isinstance(row, list) or len(row) != len(fields) for row in rows): + raise IntakeError("record list matrix does not match fields and record_id_list") + return ids, rows + + +def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]: + field_ids = [config["fields"][logical] for logical in REQUIRED_FIELDS] + all_rows: list[tuple[str, list[Any]]] = [] + offset = 0 + for _ in range(MAX_PAGES): + args = ["base", "+record-list", "--profile", config["profile"], "--base-token", config["baseToken"], "--table-id", config["tableId"], "--view-id", config["viewId"], "--format", "json", "--offset", str(offset), "--limit", str(PAGE_SIZE)] + for field_id in field_ids: + args.extend(["--field-id", field_id]) + response = run_cli(args) + ids, rows = matrix_from_response(response, field_ids) + if len(ids) > PAGE_SIZE: + raise IntakeError("record list exceeded requested page size") + all_rows.extend(zip(ids, rows)) + if len(all_rows) > MAX_RECORDS: + raise IntakeError("record list exceeded record limit") + data = response.get("data", response) + has_more = data.get("has_more", data.get("hasMore", False)) + if not isinstance(has_more, bool): + raise IntakeError("record list pagination marker is invalid") + if not has_more: + return all_rows + if not ids: + raise IntakeError("record list pagination made no progress") + offset += len(ids) + raise IntakeError("record list exceeded page limit") + + +def download( + config: dict[str, Any], record_id: str, file_token: str, + output_dir: Path, expected_size: int, timeout: float, +) -> str: + try: + output_dir.mkdir(parents=True, exist_ok=False) + except OSError as exc: + raise IntakeError("attachment output directory is unsafe") from exc + if not output_dir.is_dir() or output_dir.is_symlink(): + raise IntakeError("attachment output directory is unsafe") + run_cli( + ["base", "+record-download-attachment", "--profile", config["profile"], "--base-token", config["baseToken"], "--table-id", config["tableId"], "--record-id", record_id, "--file-token", file_token, "--output", output_dir.name], + max_file_bytes=expected_size, + timeout=timeout, + cwd=output_dir.parent, + ) + try: + created = list(output_dir.iterdir()) + except OSError as exc: + raise IntakeError("attachment download output is unreadable") from exc + if len(created) != 1 or not created[0].is_file() or created[0].is_symlink(): + raise IntakeError("attachment download did not produce one safe file") + root = output_dir.resolve() + resolved = created[0].resolve() + if root not in resolved.parents: + raise IntakeError("attachment download escaped output directory") + if resolved.stat().st_size != expected_size: + raise IntakeError("attachment download size did not match metadata") + return str(resolved) + + +def source_ref(config: dict[str, Any], record_id: str) -> str: + """Return a stable opaque identity without serializing configured identifiers.""" + identity = "\x1f".join(("ack-feishu-base-source-ref-v1", config["profile"], config["baseToken"], config["tableId"], record_id)) + return f"feishu-base:sha256:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}" + + +def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]: + profile_check(config) + prepared: list[tuple[dict[str, Any], list[tuple[dict[str, Any], str]]]] = [] + total_attachments = 0 + total_attachment_bytes = 0 + for record_id, row in fetch_pages(config): + cells = dict(zip(REQUIRED_FIELDS, row)) + attachment_data = attachment_items(cells["attachments"]) + total_attachments += len(attachment_data) + total_attachment_bytes += sum(metadata["size"] for metadata, _ in attachment_data) + if total_attachments > MAX_TOTAL_ATTACHMENTS: + raise IntakeError("batch exceeded the attachment count limit") + if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES: + raise IntakeError("batch exceeded the attachment byte limit") + record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []} + for field in ("title", "actual", "expected", "steps", "acceptance", "priority", "updatedAt"): + if not record[field]: + raise IntakeError(f"record {field} must not be empty") + prepared.append((record, attachment_data)) + download_root: Path | None = None + if output_dir is not None: + try: + output_dir.mkdir(parents=True, exist_ok=True) + if not output_dir.is_dir() or output_dir.is_symlink(): + raise OSError("unsafe output directory") + download_root = output_dir.resolve(strict=True) + except OSError as exc: + raise IntakeError("attachment output directory is unsafe") from exc + records = [] + attachment_deadline = time.monotonic() + MAX_ATTACHMENT_BATCH_SECONDS + for record, attachment_data in prepared: + if download_root is not None: + for index, (attachment, file_token) in enumerate(attachment_data, start=1): + remaining = attachment_deadline - time.monotonic() + if remaining <= 0: + raise IntakeError("attachment batch exceeded the time limit") + attachment_dir = download_root / record["recordId"] / f"attachment-{index:02d}" + attachment["localPath"] = download( + config, record["recordId"], file_token, attachment_dir, + attachment["size"], min(60, remaining), + ) + records.append(record) + return {"provider": "feishu-base", "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records} + + +def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, str]]: + """Plan idempotent Coordinator actions without mutating the task board.""" + tasks = board.get("tasks") + if not isinstance(tasks, list): + raise IntakeError("task board tasks must be a list") + existing: dict[str, dict[str, Any]] = {} + for task in tasks: + if not isinstance(task, dict): + continue + source = task.get("source") + if not isinstance(source, dict) or source.get("kind") != "feishu-base": + continue + ref = source.get("ref") + task_id = task.get("id") + status = task.get("status") + updated_at = source.get("updatedAt") + if ( + not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None + or not isinstance(task_id, str) or not task_id + or not isinstance(status, str) or not status + or not isinstance(updated_at, str) or not updated_at + ): + raise IntakeError("existing Feishu task source is invalid") + if ref in existing: + raise IntakeError("task board contains duplicate Feishu source references") + existing[ref] = task + + actions: list[dict[str, str]] = [] + seen_records: set[str] = set() + for record in records: + ref = record.get("sourceRef") + record_id = record.get("recordId") + updated_at = record.get("updatedAt") + if ( + not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None + or not isinstance(record_id, str) or RECORD_ID.fullmatch(record_id) is None + or not isinstance(updated_at, str) or not updated_at + ): + raise IntakeError("normalized Feishu record identity is invalid") + if ref in seen_records: + raise IntakeError("fetched records contain a duplicate source reference") + seen_records.add(ref) + task = existing.get(ref) + if task is None: + actions.append({"sourceRef": ref, "recordId": record_id, "action": "create"}) + continue + source = task["source"] + if source["updatedAt"] == updated_at: + action = "unchanged" + elif task["status"] == "open": + action = "refresh" + else: + action = "drift" + actions.append({ + "sourceRef": ref, + "recordId": record_id, + "taskId": task["id"], + "status": task["status"], + "action": action, + }) + return actions + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Read a configured Feishu Base bug intake") + sub = parser.add_subparsers(dest="command", required=True) + for name in ("check", "fetch", "plan"): + command = sub.add_parser(name) + command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board") + if name in {"fetch", "plan"}: + command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments") + args = parser.parse_args(argv) + try: + board = load_board(args.tasks) + config = config_from_board(board) + if args.command == "check": + profile_check(config) + output = {"provider": "feishu-base", "profile": config["profile"], "ok": True} + elif args.command == "fetch": + output = fetch(config, args.output_dir) + else: + output = fetch(config, args.output_dir) + output["actions"] = plan_actions(board, output["records"]) + except IntakeError as exc: + sys.stderr.write(f"Feishu bug intake failed: {exc}\n") + return 1 + except (OSError, subprocess.SubprocessError): + sys.stderr.write("Feishu bug intake failed: local I/O failed\n") + return 1 + print(json.dumps(output, ensure_ascii=False, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ack/scripts/validate_tasks.py b/skills/ack/scripts/validate_tasks.py index bef7659..5e6bc1c 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -113,6 +113,15 @@ DELIVERY_STATUSES = { } DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"} DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"} +FEISHU_REQUIRED_FIELDS = { + "title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", + "attachments", "updatedAt", +} +FEISHU_CONFIG_FIELDS = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"} +FEISHU_SOURCE_FIELDS = {"kind", "ref", "recordId", "updatedAt"} +FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$") +FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") DISPATCH_FIELDS = { "taskId", "dispatchId", @@ -654,6 +663,28 @@ def validate_builtin(data: dict) -> list[str]: {"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"}, "project", ) + if "bugIntake" in project: + intake = project["bugIntake"] + if not isinstance(intake, dict): + errors.append("project.bugIntake 必须是对象") + else: + reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors) + if intake.get("provider") != "feishu-base": + errors.append("project.bugIntake.provider 必须是 feishu-base") + profile = intake.get("profile") + if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None: + errors.append("project.bugIntake.profile 非法") + for key in ("baseToken", "tableId", "viewId"): + value = intake.get(key) + if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value): + errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串") + fields = intake.get("fields") + if not isinstance(fields, dict) or set(fields) != FEISHU_REQUIRED_FIELDS: + errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段") + elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()): + errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串") + elif len(set(fields.values())) != len(fields): + errors.append("project.bugIntake.fields 字段值不能重复") if ( "knowledgeFile" in project and project.get("knowledgeFile") != "docs/ack/knowledge.yaml" @@ -710,6 +741,7 @@ def validate_builtin(data: dict) -> list[str]: return errors seen_ids: set[str] = set() + seen_source_refs: set[str] = set() for i, task in enumerate(tasks): where = f"tasks[{i}]" if not isinstance(task, dict): @@ -756,6 +788,25 @@ def validate_builtin(data: dict) -> list[str]: ) validate_object_fields(task, {"evidence", "verification"}, where) + if "source" in task: + source = task["source"] + # `source` was historically an open extension point. Preserve + # non-Feishu strings/objects and tighten only the namespaced shape. + if isinstance(source, dict) and source.get("kind") == "feishu-base": + reject_unknown_fields(source, FEISHU_SOURCE_FIELDS, f"{where}.source", errors) + ref = source.get("ref") + if not isinstance(ref, str) or FEISHU_SOURCE_REF_RE.fullmatch(ref) is None: + errors.append(f"{where}.source.ref: 必须是不透明 feishu-base SHA-256 引用") + else: + if ref in seen_source_refs: + errors.append(f"{where}.source.ref: 来源引用重复") + seen_source_refs.add(ref) + record_id = source.get("recordId") + if not isinstance(record_id, str) or FEISHU_RECORD_ID_RE.fullmatch(record_id) is None: + errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID") + if not _nonempty_string(source.get("updatedAt")): + errors.append(f"{where}.source.updatedAt: 必须是非空字符串") + validate_knowledge_fields(task, where, status, errors) if "dispatch" not in task: diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 28554b3..3c5a225 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -42,6 +42,7 @@ "const": "docs/ack/delivery.yaml", "description": "可选项目交付契约的唯一权威路径" }, + "bugIntake": { "$ref": "#/definitions/feishuBugIntake" }, "orchestration": { "$ref": "#/definitions/orchestration" } @@ -164,6 +165,50 @@ "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "feishuBugIntake": { + "type": "object", + "required": ["provider", "profile", "baseToken", "tableId", "viewId", "fields"], + "additionalProperties": false, + "properties": { + "provider": { "const": "feishu-base" }, + "profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, + "baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "viewId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "fields": { + "type": "object", + "required": ["title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt"], + "additionalProperties": false, + "properties": { + "title": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "expected": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "stepsToReproduce": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "acceptance": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } + } + } + } + }, + "feishuTaskSource": { + "type": "object", + "required": ["kind", "ref", "recordId", "updatedAt"], + "additionalProperties": false, + "properties": { + "kind": { "const": "feishu-base" }, + "ref": { + "type": "string", + "pattern": "^feishu-base:sha256:[0-9a-f]{64}$" + }, + "recordId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$" + }, + "updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" } + } + }, "profileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$" @@ -940,6 +985,16 @@ "type": "array", "items": { "$ref": "#/definitions/knowledgeCheck" } }, + "source": { + "if": { + "type": "object", + "required": ["kind"], + "properties": { "kind": { "const": "feishu-base" } } + }, + "then": { + "$ref": "#/definitions/feishuTaskSource" + } + }, "description": { "type": "string" }, "stepsToReproduce": { "type": "array", "items": { "type": "string" } }, "expected": { "type": "string" }, diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index 371486b..dfb097d 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -11,6 +11,22 @@ project: overlayFile: "docs/ack/project.md" knowledgeFile: "docs/ack/knowledge.yaml" deliveryFile: "docs/ack/delivery.yaml" + # 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。 + # bugIntake: + # provider: "feishu-base" + # profile: "project-feishu" + # baseToken: "" + # tableId: "" + # viewId: "" + # fields: + # title: "" + # actual: "" + # expected: "" + # stepsToReproduce: "" + # acceptance: "" + # priority: "" + # attachments: "" + # updatedAt: "" orchestration: profileVersion: 1 mode: "orca" @@ -95,6 +111,13 @@ tasks: knowledgeCandidates: [] knowledgeChecks: [] + # 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。 + # source: + # kind: "feishu-base" + # ref: "feishu-base:sha256:<64-lowercase-hex>" + # recordId: "" + # updatedAt: "" + description: > diff --git a/tests/test_ack_feishu_intake_e2e.py b/tests/test_ack_feishu_intake_e2e.py new file mode 100644 index 0000000..67cc008 --- /dev/null +++ b/tests/test_ack_feishu_intake_e2e.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +import contextlib +import io +from unittest import mock +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +READER = ROOT / "skills/ack/scripts/feishu_bug_intake.py" +sys.path.insert(0, str(READER.parent)) +import feishu_bug_intake # noqa: E402 + + +class FeishuIntakeBlackBox(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory(prefix="feishu-e2e-") + self.d = Path(self.tmp.name) + self.bin = self.d / "bin" + self.bin.mkdir() + self.fixture = self.d / "submitted-bug.json" + self.fixture.write_text(json.dumps({ + "recordId": "recBug1", + "title": "Crash on save", "actual": "button crashes", + "expected": "save succeeds", "steps": ["open app", "click Save"], + "acceptance": "regression covered", "priority": "P1", + "attachments": [{"file_token": "fileTok", "name": "screen.png", "type": "image/png", "size": 4}], + "updatedAt": "2026-08-01T12:00:00Z", + }), encoding="utf-8") + self.mode_file = self.d / "mode.txt" + self.mode_file.write_text("", encoding="utf-8") + self.log = self.d / "argv.jsonl" + self.fake = self.bin / "lark-cli" + self.fake.write_text( + "#!" + sys.executable + "\n" + "import json, os, pathlib, sys\n" + f"fixture=json.loads(pathlib.Path({str(self.fixture)!r}).read_text())\n" + f"mode=pathlib.Path({str(self.mode_file)!r}).read_text().strip()\n" + f"log=pathlib.Path({str(self.log)!r}); a=sys.argv[1:]\n" + "with log.open('a') as f: f.write(json.dumps(a)+'\\n')\n" + "p=a[a.index('--profile')+1] if '--profile' in a else None\n" + "if a[:2]==['profile','list']:\n" + " if '--format' in a: raise SystemExit(8)\n" + " print(json.dumps([{'name':'tenant-a','appId':'cli_a','brand':'feishu','active':True},{'name':'tenant-b','appId':'cli_b','brand':'feishu','active':False}])); raise SystemExit\n" + "if p != 'tenant-b': print('wrong tenant',file=sys.stderr); raise SystemExit(9)\n" + "if mode=='malformed': print('{bad'); raise SystemExit\n" + "if a[:2]==['base','+record-list']:\n" + " if mode=='okfalse': print(json.dumps({'ok':False})); raise SystemExit\n" + " if mode=='code': print(json.dumps({'code':7})); raise SystemExit\n" + " if mode=='ambiguous': print(json.dumps({'data':{}})); raise SystemExit\n" + " if mode=='noprog': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':[],'data':[],'has_more':True}})); raise SystemExit\n" + " if mode=='max': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['x']*8],'has_more':True}})); raise SystemExit\n" + " if mode=='matrix': print(json.dumps({'code':0,'data':{'fields':['fTitle'],'record_id_list':['recBug1'],'records':[]}})); raise SystemExit\n" + " if mode=='cell': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[[{'unexpected':'value'},'actual','expected','steps','accept','P1',[],f['updatedAt']]]}})); raise SystemExit\n" + " if mode=='empty': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['title',None,'expected','steps','accept','P1',[],f['updatedAt']]]}})); raise SystemExit\n" + " f=fixture; print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':[f['recordId']],'data':[[f['title'],f['actual'],f['expected'],f['steps'],f['acceptance'],f['priority'],f['attachments'],f['updatedAt']]]}})); raise SystemExit\n" + "if a[:2]==['base','+record-download-attachment']:\n" + " out=pathlib.Path(a[a.index('--output')+1]); out.mkdir(parents=True,exist_ok=True)\n" + " if mode=='escape': (out/'escape').symlink_to('/tmp'); raise SystemExit\n" + " (out/'screen.png').write_bytes(b'fake'); print(json.dumps({'ok':True})); raise SystemExit\n" + "raise SystemExit(2)\n", encoding="utf-8") + self.fake.chmod(0o755) + self.env = {**os.environ, "PATH": f"{self.bin}{os.pathsep}{os.environ.get('PATH','')}"} + self.env["FAKE_FIXTURE"] = str(self.fixture) + self.board = self.d / "tasks.yaml" + self.board.write_text("""version: 1 +project: + name: isolated-fake + bugIntake: + provider: feishu-base + profile: tenant-b + baseToken: base-secret + tableId: tbl-bugs + viewId: view-ready + fields: + title: fTitle + actual: fActual + expected: fExpected + stepsToReproduce: fSteps + acceptance: fAcceptance + priority: fPriority + attachments: fAttachments + updatedAt: fUpdated +tasks: [] +""", encoding="utf-8") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def invoke(self, command: str, *, mode: str | None = None, output: Path | None = None, resolver=None): + env = dict(self.env) + self.mode_file.write_text(mode or "", encoding="utf-8") + args = [command, str(self.board)] + if output: + args += ["--output-dir", str(output)] + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=self.fake) if resolver is None else mock.patch.object(feishu_bug_intake, "resolve_lark_cli", side_effect=resolver), \ + mock.patch.dict(os.environ, env, clear=True), \ + contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + code = feishu_bug_intake.main(args) + except SystemExit as exc: + code = int(exc.code or 0) + return subprocess.CompletedProcess(args, code, stdout.getvalue(), stderr.getvalue()) + + def test_isolated_tenant_check_fetch_and_triage_contract(self) -> None: + self.assertEqual(self.invoke("check").returncode, 0) + downloads = self.d / "downloads" + result = self.invoke("fetch", output=downloads) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + record = payload["records"][0] + self.assertEqual(record["recordId"], "recBug1") + self.assertEqual(record["priority"], "P1") + self.assertEqual(record["updatedAt"], "2026-08-01T12:00:00Z") + self.assertIn("Crash on save", record["title"]) + self.assertTrue(Path(record["attachments"][0]["localPath"]).is_relative_to(downloads)) + self.assertRegex(record["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$") + for secret in ("tenant-b", "base-secret", "tbl-bugs", "recBug1", "fileTok"): + self.assertNotIn(secret, record["sourceRef"]) + for secret in ("base-secret", "fileTok", "app-secret-sentinel"): + self.assertNotIn(secret, result.stdout) + second = self.invoke("fetch", output=self.d / "repeat") + self.assertEqual(second.returncode, 0, second.stderr) + second_record = json.loads(second.stdout)["records"][0] + self.assertRegex(second_record["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$") + self.assertEqual(record["sourceRef"], second_record["sourceRef"]) + for secret in ("tenant-b", "base-secret", "tbl-bugs", "recBug1", "fileTok"): + self.assertNotIn(secret, second_record["sourceRef"]) + for secret in ("base-secret", "fileTok", "app-secret-sentinel"): + self.assertNotIn(secret, second.stdout) + calls = [json.loads(x) for x in self.log.read_text().splitlines()] + for call in calls: + if call[:1] == ["base"]: + self.assertEqual(call[call.index("--profile") + 1], "tenant-b") + self.assertNotIn("tenant-a", call) + if call[:2] == ["base", "+record-download-attachment"]: + self.assertFalse(Path(call[call.index("--output") + 1]).is_absolute()) + self.assertEqual(sum(1 for c in calls if c[:2] == ["base", "+record-list"]), 2) + list_call = next(c for c in reversed(calls) if c[:2] == ["base", "+record-list"]) + self.assertEqual(list_call[list_call.index("--view-id") + 1], "view-ready") + self.assertEqual(list_call[:2], ["base", "+record-list"]) + self.assertEqual(list_call.count("--field-id"), 8) + + def test_fail_closed_wrong_profile_and_unsafe_response(self) -> None: + bad = self.board.read_text().replace("profile: tenant-b", "profile: tenant-a") + self.board.write_text(bad) + result = self.invoke("fetch") + self.assertNotEqual(result.returncode, 0) + self.board.write_text(bad.replace("profile: tenant-a", "profile: tenant-b")) + result = self.invoke("fetch", mode="malformed") + self.assertNotEqual(result.returncode, 0) + result = self.invoke("fetch", mode="matrix", output=self.d / "matrix-output") + self.assertNotEqual(result.returncode, 0) + result = self.invoke("fetch", mode="cell", output=self.d / "cell-output") + self.assertNotEqual(result.returncode, 0) + result = self.invoke("fetch", mode="empty", output=self.d / "empty-output") + self.assertNotEqual(result.returncode, 0) + result = self.invoke("fetch", mode="escape", output=self.d / "downloads") + self.assertNotEqual(result.returncode, 0) + + def test_resolver_trust_and_no_executable_override_surface(self) -> None: + trusted, hostile = self.d / "trusted", self.d / "hostile" + trusted.mkdir(); hostile.mkdir() + safe = trusted / "lark-cli" + safe.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8"); safe.chmod(0o755) + (hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + (hostile / "lark-cli").chmod(0o755) + with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]), mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False): + self.assertEqual(feishu_bug_intake.resolve_lark_cli(), safe) + safe.chmod(0o775) + with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]): + with self.assertRaises(feishu_bug_intake.IntakeError): + feishu_bug_intake.resolve_lark_cli() + help_result = self.invoke("--help") + self.assertEqual(help_result.returncode, 0) + self.assertNotIn("--lark-cli", help_result.stdout + help_result.stderr) + self.assertNotIn("--executable", help_result.stdout + help_result.stderr) + self.assertNotIn("executable_override", READER.read_text(encoding="utf-8")) + + def test_fail_closed_envelopes_pagination_and_missing_trusted_cli(self) -> None: + for mode in ("okfalse", "code", "ambiguous", "noprog"): + result = self.invoke("fetch", mode=mode) + self.assertNotEqual(result.returncode, 0, mode) + self.assertEqual(result.stdout, "", mode) + with mock.patch.object(feishu_bug_intake, "MAX_PAGES", 2): + result = self.invoke("fetch", mode="max") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", side_effect=feishu_bug_intake.IntakeError("missing trusted")): + result = self.invoke("fetch", resolver=feishu_bug_intake.IntakeError("missing trusted")) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + + def test_reference_template_example_contract_signals(self) -> None: + paths = [ROOT / "skills/ack/references/feishu-bug-intake.md", ROOT / "skills/ack/templates/tasks.template.yaml", ROOT / "skills/ack/examples/tasks.example.yaml"] + content = "\n".join(path.read_text(encoding="utf-8") for path in paths) + self.assertIn("feishu-base:sha256:", content) + self.assertIn('npm install --global --prefix "$HOME/.local" @larksuite/cli@latest', content) + self.assertIn("profile add", content) + self.assertIn("--brand feishu", content) + self.assertTrue("scope" in content) + self.assertIn("不要把 `lark-cli auth check`", content) + self.assertNotIn("--lark-cli", content) + self.assertNotIn("--executable", content) + + def test_plan_organizes_stable_sources_against_a_real_task_board(self) -> None: + first = json.loads(self.invoke("fetch").stdout)["records"][0]["sourceRef"] + second = json.loads(self.invoke("fetch").stdout)["records"][0]["sourceRef"] + self.assertRegex(first, r"^feishu-base:sha256:[0-9a-f]{64}$") + self.assertEqual(first, second) + empty_plan = json.loads(self.invoke("plan").stdout) + self.assertEqual(empty_plan["actions"], [{ + "sourceRef": first, + "recordId": "recBug1", + "action": "create", + }]) + + base = self.board.read_text(encoding="utf-8") + + def write_existing(status: str, updated_at: str, *, duplicate: bool = False) -> None: + task = f"""tasks: + - id: BUG-1 + title: Existing imported bug + status: {status} + source: + kind: feishu-base + ref: "{first}" + recordId: recBug1 + updatedAt: "{updated_at}" +""" + if duplicate: + task += f""" - id: BUG-2 + title: Duplicate imported bug + status: open + source: + kind: feishu-base + ref: "{first}" + recordId: recBug1 + updatedAt: "{updated_at}" +""" + self.board.write_text(base.replace("tasks: []\n", task), encoding="utf-8") + + write_existing("open", "2026-07-31T12:00:00Z") + validator = ROOT / "skills/ack/scripts/validate_tasks.py" + validated = subprocess.run( + [sys.executable, str(validator), str(self.board)], + text=True, capture_output=True, check=False, + ) + self.assertEqual(validated.returncode, 0, validated.stderr) + self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "refresh") + + write_existing("verified", "2026-07-31T12:00:00Z") + self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "drift") + + write_existing("verified", "2026-08-01T12:00:00Z") + self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "unchanged") + + write_existing("open", "2026-07-31T12:00:00Z", duplicate=True) + duplicate_result = self.invoke("plan") + self.assertNotEqual(duplicate_result.returncode, 0) + self.assertEqual(duplicate_result.stdout, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ack_feishu_intake_unit.py b/tests/test_ack_feishu_intake_unit.py new file mode 100644 index 0000000..278b132 --- /dev/null +++ b/tests/test_ack_feishu_intake_unit.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ACK_SCRIPTS = REPO_ROOT / "skills" / "ack" / "scripts" +sys.path.insert(0, str(ACK_SCRIPTS)) +import feishu_bug_intake # noqa: E402 + + +BOARD = """version: 1 +project: + name: demo + bugIntake: + provider: feishu-base + profile: tenant-b + baseToken: bascnDemo + tableId: tblDemo + viewId: vewReady + fields: + title: fldTitle + actual: fldActual + expected: fldExpected + stepsToReproduce: fldSteps + acceptance: fldAcceptance + priority: fldPriority + attachments: fldAttachments + updatedAt: fldUpdated +tasks: [] +""" + + +class FeishuBugIntakeUnitTests(unittest.TestCase): + def make_fake_cli(self, root: Path) -> tuple[Path, Path]: + log_path = root / "calls.jsonl" + fake = root / "lark-cli" + fake.write_text( + "#!" + sys.executable + "\n" + "import json, pathlib, sys\n" + f"log = pathlib.Path({str(log_path)!r})\n" + "args = sys.argv[1:]\n" + "with log.open('a') as f: f.write(json.dumps(args) + '\\n')\n" + "if args[:2] == ['profile', 'list']:\n" + " if '--format' in args: raise SystemExit(8)\n" + " print(json.dumps([{'name':'tenant-a','appId':'cli_a','brand':'feishu','active':True},{'name':'tenant-b','appId':'cli_b','brand':'feishu','active':False}]))\n" + "elif args[:2] == ['base', '+record-list']:\n" + " print(json.dumps({'code': 0, 'data': {'fields': ['fldTitle','fldActual','fldExpected','fldSteps','fldAcceptance','fldPriority','fldAttachments','fldUpdated'], 'record_id_list': ['recA'], 'data': [[' Bug\\n title ', ' actual ', 'expected', ['one', 'two'], 'accept', 'P1', [{'file_token':'fileA','name':'shot.png','type':'image/png','size':3}], '2026-08-01']]}}))\n" + "elif args[:2] == ['base', '+record-download-attachment']:\n" + " out = pathlib.Path(args[args.index('--output') + 1]); out.mkdir(parents=True, exist_ok=True); (out / 'shot.png').write_bytes(b'png'); print(json.dumps({'ok': True, 'data': {}}))\n" + "else: raise SystemExit(2)\n", + encoding="utf-8", + ) + fake.chmod(0o755) + return fake, log_path + + def invoke(self, argv: list[str], executable: Path) -> tuple[int, str, str]: + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=executable), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = feishu_bug_intake.main(argv) + return code, stdout.getvalue(), stderr.getvalue() + + def test_reader_exposes_check_and_fetch_commands(self) -> None: + with self.assertRaises(SystemExit) as exited, contextlib.redirect_stdout(io.StringIO()): + feishu_bug_intake.main(["--help"]) + self.assertEqual(exited.exception.code, 0) + + def test_fetch_uses_mocked_trusted_executable_and_official_wire_shapes(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + board_path = temp / "tasks.yaml" + board_path.write_text(BOARD, encoding="utf-8") + fake, log_path = self.make_fake_cli(temp) + hostile = temp / "hostile" + hostile.mkdir() + (hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + (hostile / "lark-cli").chmod(0o755) + + with mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False): + code, output, error = self.invoke(["fetch", str(board_path), "--output-dir", str(temp / "downloads")], fake) + + self.assertEqual(code, 0, error) + payload = json.loads(output) + self.assertEqual(payload["records"][0]["title"], "Bug title") + self.assertEqual(payload["records"][0]["steps"], "one\ntwo") + self.assertRegex(payload["records"][0]["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$") + self.assertNotIn("bascnDemo", output) + self.assertNotIn("fileA", output) + self.assertNotIn("token", payload["records"][0]["attachments"][0]) + self.assertTrue(Path(payload["records"][0]["attachments"][0]["localPath"]).is_file()) + calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()] + self.assertEqual(calls[0], ["profile", "list"]) + for call in calls: + if call[:1] == ["base"]: + self.assertEqual(call[call.index("--profile") + 1], "tenant-b") + + def test_resolver_ignores_hostile_path_and_rejects_unsafe_target(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + trusted, hostile = temp / "trusted", temp / "hostile" + trusted.mkdir() + hostile.mkdir() + safe = trusted / "lark-cli" + safe.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + safe.chmod(0o755) + (hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + (hostile / "lark-cli").chmod(0o755) + with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]), mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False): + self.assertEqual(feishu_bug_intake.resolve_lark_cli(), safe) + safe.chmod(0o775) + with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]): + with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "trusted"): + feishu_bug_intake.resolve_lark_cli() + + def test_resolver_accepts_only_the_official_npm_wrapper_shape(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + trusted = temp / "trusted" + scripts = temp / "node_modules" / "@larksuite" / "cli" / "scripts" + trusted.mkdir() + scripts.mkdir(parents=True) + wrapper = scripts / "run.js" + wrapper.write_text( + "#!" + sys.executable + "\nimport json\nprint(json.dumps({'ok': True}))\n", + encoding="utf-8", + ) + wrapper.chmod(0o755) + manifest = scripts.parent / "package.json" + manifest.write_text(json.dumps({ + "name": "@larksuite/cli", + "bin": {"lark-cli": "scripts/run.js"}, + }), encoding="utf-8") + native = scripts.parent / "bin" / "lark-cli" + native.parent.mkdir() + native.write_text( + "#!" + sys.executable + "\nimport json\nprint(json.dumps({'ok': True}))\n", + encoding="utf-8", + ) + native.chmod(0o755) + (trusted / "lark-cli").symlink_to(wrapper) + + with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]): + self.assertEqual(feishu_bug_intake.resolve_lark_cli(), native) + self.assertEqual(feishu_bug_intake.run_cli(["probe"]), {"ok": True}) + + manifest.write_text(json.dumps({ + "name": "lookalike", + "bin": {"lark-cli": "scripts/run.js"}, + }), encoding="utf-8") + with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]): + with self.assertRaises(feishu_bug_intake.IntakeError): + feishu_bug_intake.resolve_lark_cli() + + def test_cli_environment_drops_credential_and_runtime_overrides(self) -> None: + hostile = { + "LARKSUITE_CLI_APP_ID": "wrong-app", + "LARKSUITE_CLI_APP_SECRET": "wrong-secret", + "LARKSUITE_CLI_CONFIG_DIR": "/tmp/wrong-config", + "LARKSUITE_CLI_BRAND": "lark", + "FEISHU_APP_SECRET": "wrong-feishu-secret", + "NODE_OPTIONS": "--require=/tmp/inject.js", + "PYTHONPATH": "/tmp/inject", + } + with mock.patch.dict(os.environ, hostile, clear=False): + environment = feishu_bug_intake.cli_environment() + with tempfile.TemporaryDirectory() as temp_dir: + fake = Path(temp_dir) / "lark-cli" + fake.write_text( + "#!" + sys.executable + "\n" + "import json, os\n" + "keys = ['LARKSUITE_CLI_APP_ID','LARKSUITE_CLI_APP_SECRET','LARKSUITE_CLI_CONFIG_DIR','LARKSUITE_CLI_BRAND','FEISHU_APP_SECRET','NODE_OPTIONS','PYTHONPATH']\n" + "print(json.dumps({'ok': True, 'data': {key: os.environ.get(key) for key in keys}}))\n", + encoding="utf-8", + ) + fake.chmod(0o755) + with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=fake): + child = feishu_bug_intake.run_cli(["probe"]) + self.assertEqual(set(environment) - {"HOME", "PATH"}, set(environment) & {"LANG", "LC_ALL", "LC_CTYPE"}) + for name in hostile: + self.assertNotIn(name, environment) + self.assertIsNone(child["data"][name]) + + def test_run_cli_rejects_error_and_ambiguous_envelopes(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + fake = temp / "lark-cli" + fake.write_text( + "#!" + sys.executable + "\nimport json, sys\nprint(sys.argv[1])\n", + encoding="utf-8", + ) + fake.chmod(0o755) + with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=fake): + for response in ('{"ok":false}', '{"code":7}', '{"data":{}}'): + with self.assertRaises(feishu_bug_intake.IntakeError): + feishu_bug_intake.run_cli([response]) + + def test_matrix_accepts_official_ok_envelope_and_data_rows(self) -> None: + fields = ["fldTitle"] + response = { + "ok": True, + "data": {"fields": fields, "record_id_list": ["recA"], "data": [["Bug"]]}, + } + self.assertEqual(feishu_bug_intake.matrix_from_response(response, fields), (["recA"], [["Bug"]])) + + def test_attachments_and_required_text_are_resource_bounded_and_strict(self) -> None: + with self.assertRaises(feishu_bug_intake.IntakeError): + feishu_bug_intake.attachment_items([{ + "file_token": "fileA", + "name": "huge.png", + "type": "image/png", + "size": feishu_bug_intake.MAX_ATTACHMENT_BYTES + 1, + }]) + too_many = [ + {"file_token": f"file{index}", "name": f"{index}.png", "size": 1} + for index in range(feishu_bug_intake.MAX_ATTACHMENTS_PER_RECORD + 1) + ] + with self.assertRaises(feishu_bug_intake.IntakeError): + feishu_bug_intake.attachment_items(too_many) + with self.assertRaises(feishu_bug_intake.IntakeError): + feishu_bug_intake.text({"unexpected": "value"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 20eac4b..5556b10 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -64,13 +64,23 @@ class AckSkillContentTests(unittest.TestCase): "references/delivery.md", ): self.assertTrue((ack_dir / relative_path).is_file(), relative_path) - self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.11.0") + version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip() + self.assertEqual(version, "0.12.0") + self.assertIn( + f'ackVersion: "{version}"', + (ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"), + ) + self.assertIn( + f"ack v{version}", + (ack_dir / "examples" / "project.example.md").read_text(encoding="utf-8"), + ) self.assertIn( 'ackVersion: "<接入时的 ack skill 版本>"', (ack_dir / "templates" / "tasks.template.yaml").read_text( encoding="utf-8" ), ) + self.assertTrue((ack_dir / "references" / "feishu-bug-intake.md").is_file()) if __name__ == "__main__": diff --git a/tests/test_ack_tasks_validation.py b/tests/test_ack_tasks_validation.py index bb803f2..78ea71f 100644 --- a/tests/test_ack_tasks_validation.py +++ b/tests/test_ack_tasks_validation.py @@ -148,6 +148,39 @@ class AckTaskValidationTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("任务板校验通过", result.stdout) + def test_legacy_sources_remain_open_while_feishu_sources_are_strict(self) -> None: + for legacy_source in ( + "manual", + {"kind": "jira", "ref": "JIRA-123", "project": "OPS"}, + ): + with self.subTest(legacy_source=legacy_source): + board = valid_knowledge_board() + board["tasks"][0]["source"] = legacy_source + self.assert_board_accepted_in_all_modes(board) + + valid_ref = "feishu-base:sha256:" + "a" * 64 + valid = valid_knowledge_board() + valid["tasks"][0]["source"] = { + "kind": "feishu-base", + "ref": valid_ref, + "recordId": "recA", + "updatedAt": "2026-08-01T12:00:00Z", + } + self.assert_board_accepted_in_all_modes(valid) + + raw = copy.deepcopy(valid) + raw["tasks"][0]["source"]["ref"] = "feishu-base:tenant:base-secret:recA" + self.assert_board_rejected_in_all_modes( + raw, + "必须是不透明 feishu-base SHA-256 引用", + ) + + duplicate = copy.deepcopy(valid) + second = copy.deepcopy(duplicate["tasks"][0]) + second["id"] = "T-2" + duplicate["tasks"].append(second) + self.assert_board_rejected_in_all_modes(duplicate, "来源引用重复") + def test_v010_requires_structured_routing_but_v009_remains_readable(self) -> None: current = valid_knowledge_board() current["ackVersion"] = "0.10.0"