Merge pull request #99 from pikasTech/fix/issue1037-tree-bundle

支持 gitbundle 资源装配
This commit is contained in:
Lyon
2026-06-08 11:27:58 +08:00
committed by GitHub
13 changed files with 287 additions and 364 deletions
+16 -10
View File
@@ -30,7 +30,7 @@ Runner Secret 只能通过 Kubernetes Secret projection、ServiceAccount/RBAC
Kubernetes Job runner 必须把 credential source 与 runtime home 分开:Secret volume 只读挂在 `/var/run/agentrun/secrets/...``/home/agentrun``emptyDir` 提供可写空间,`CODEX_HOME` 指向当前 run/profile 的 writable runtime home`AGENTRUN_CODEX_SECRET_HOME` 指向当前 `backendProfile` 对应的只读 projection。runner/backend 在启动 provider 前只复制授权文件,不打印内容。`codex``deepseek``minimax-m3` profile 不得共享同一个可写 runtime home,除非它们运行在不同的 per-run Kubernetes Job 且该目录由 Job 独占 emptyDir 提供。 Kubernetes Job runner 必须把 credential source 与 runtime home 分开:Secret volume 只读挂在 `/var/run/agentrun/secrets/...``/home/agentrun``emptyDir` 提供可写空间,`CODEX_HOME` 指向当前 run/profile 的 writable runtime home`AGENTRUN_CODEX_SECRET_HOME` 指向当前 `backendProfile` 对应的只读 projection。runner/backend 在启动 provider 前只复制授权文件,不打印内容。`codex``deepseek``minimax-m3` profile 不得共享同一个可写 runtime home,除非它们运行在不同的 per-run Kubernetes Job 且该目录由 Job 独占 emptyDir 提供。
RuntimeAssembly P0 中 `SessionRef` 可以显式为 `null`runner 不得把完整 `CODEX_HOME`、Secret projection 或节点 host path 当作 session store。`ResourceBundleRef` P0 收敛为 Git-onlyrunner 已支持把 `repoUrl + full commitId` checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录,并记录 commit/tree 摘要,不能把用户上传文件或 env dump 混入 Git-only bundle。`toolAliases``promptRefs``skillRefs` 都属于该 Git-only bundle 的非敏感子资源:runner 必须在同一 checkout 中解析、校验和装配,不能从镜像默认目录、host path、旧 prompt 常量或用户长 prompt 中补齐 RuntimeAssembly P0 中 `SessionRef` 可以显式为 `null`runner 不得把完整 `CODEX_HOME`、Secret projection 或节点 host path 当作 session store。`ResourceBundleRef` P0 收敛为 `kind="gitbundle"`runner 已支持把 `repoUrl + full commitId + bundles[]` checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录,`subpath -> target_path` 复制到 workspace,并记录 commit/tree/bundles 摘要。工具来自 workspace `tools/`skill 来自 workspace `.agents/skills`,不能把用户上传文件、inline seed、旧字段或 env dump 混入 gitbundle
### v0.1.1 Session state 持久化(per-session RWO PVC 直接挂载) ### v0.1.1 Session state 持久化(per-session RWO PVC 直接挂载)
@@ -64,16 +64,22 @@ volumeMounts:
mountPath: /home/agentrun/.codex-<profile>/<codex_rollout_subdir> # <-- 唯一改这一行 mountPath: /home/agentrun/.codex-<profile>/<codex_rollout_subdir> # <-- 唯一改这一行
``` ```
### ResourceBundle 子资源装配 ### ResourceBundle gitbundle 装配
Runner materialize `ResourceBundleRef` 后必须按固定顺序处理资源:先创建 `toolAliases` wrapper,再装配 `skillRefs` registry,最后读取 `promptRefs` 并生成当前 command 的 assembled initial prompt 摘要。任何子资源缺失或非法都必须按对应 failureKind 阻塞当前 command,不得继续运行后让模型自行猜测 Runner materialize `ResourceBundleRef.kind="gitbundle"` 后必须按固定顺序处理资源:先 checkout repo/commit,再按 `bundles[]` 复制文件或目录,再准备 workspace `tools/`,再发现 `.agents/skills`,最后读取 `promptRefs` 并生成当前 command 的 assembled initial prompt 摘要。旧字段输入必须由 schema 校验直接拒绝,不得在 runner 里兼容
`skillRefs` 聚合规则: gitbundle skill 聚合规则:
- 聚合目录默认在 materialized workspace 下的 `.agents/skills`,每个 skill 使用稳定目录名,例如 `.agents/skills/<aggregateAs || name>/SKILL.md` - 聚合目录固定在 materialized workspace 下的 `.agents/skills`,每个 skill 使用稳定目录名,例如 `.agents/skills/<name>/SKILL.md`
- Runner 必须把该聚合目录通过环境或 backend option 暴露给 Codex runtime;对于需要兼容 Codex skill discovery 的实现,应设置 `AGENTRUN_SKILLS_DIRS` 或等价字段,并可同步设置业务方约定的 `<PROJECT>_CODE_AGENT_SKILLS_DIRS` - Runner 必须把该聚合目录通过环境或 backend option 暴露给 Codex runtime;对于需要兼容 Codex skill discovery 的实现,应设置 `AGENTRUN_SKILLS_DIRS` 或等价字段,并可同步设置业务方约定的 `<PROJECT>_CODE_AGENT_SKILLS_DIRS`
- 聚合只允许 symlink 或 copy 当前 bundle 内的 skill root,不允许引用 `/app/skills`、host path、Secret volume 或用户上传临时目录。 - 聚合只允许 copy `bundles[]` 指定的 Git 子树,不允许引用 `/app/skills`、host path、Secret volume 或用户上传临时目录。
- Event/result 只输出 skill name、manifest path 摘要、hash、bytes、version/commit metadata、required 和 aggregate target。 - Event/result 只输出 skill name、manifest path 摘要、hash、bytes、summary 和 target。
workspace `tools/` 装配规则:
- runner 把 workspace `tools/` 追加到 `PATH`;repo 内的短命令文件本身承担 wrapper 语义。
- `tools/` 顶层 `.ts` 脚本必须带 shebang;带 shebang 的脚本会被 `chmod +x`
- Event/result 只输出工具文件名、hash、bytes、shebang 摘要和 count,不输出脚本文本。
`promptRefs` 装配规则: `promptRefs` 装配规则:
@@ -92,7 +98,7 @@ Runner 承接的是 HWLAB v0.2 原有 Code Agent 的执行层经验,不承接
| cancel/interrupt | `internal/cloud/server-code-agent-http.ts``internal/cloud/codex-stdio-session.ts` | runner 必须轮询 manager cancel 状态并中止 backendbackend 不支持精确 interrupt 时终止受控进程组。 | | cancel/interrupt | `internal/cloud/server-code-agent-http.ts``internal/cloud/codex-stdio-session.ts` | runner 必须轮询 manager cancel 状态并中止 backendbackend 不支持精确 interrupt 时终止受控进程组。 |
| runnerTrace 事件可见性 | `internal/cloud/code-agent-trace-store.ts` | backend 输出必须转成 manager events;每个 terminal/错误/取消都要有事件和 final status。 | | runnerTrace 事件可见性 | `internal/cloud/code-agent-trace-store.ts` | backend 输出必须转成 manager events;每个 terminal/错误/取消都要有事件和 final status。 |
| workspace-write 边界 | `internal/cloud/code-agent-contract.ts` | runner 只使用 ResourceBundleRef materialized workspace,不猜 HWLAB Pod 的 `/workspace/hwlab` 或 host path。 | | workspace-write 边界 | `internal/cloud/code-agent-contract.ts` | runner 只使用 ResourceBundleRef materialized workspace,不猜 HWLAB Pod 的 `/workspace/hwlab` 或 host path。 |
| prompt/skill 装配 | `internal/cloud/codex-stdio-session.ts``internal/cloud/codex-stdio-session-helpers.ts` | HWLAB 旧业务 prompt 与 skill discovery 经验迁入 ResourceBundleRef `promptRefs`/`skillRefs`runner 只按 Git commit/path 装配,不内建 HWLAB 业务。 | | prompt/skill 装配 | `internal/cloud/codex-stdio-session.ts``internal/cloud/codex-stdio-session-helpers.ts` | HWLAB 旧业务 prompt 与 skill discovery 经验迁入 ResourceBundleRef `promptRefs` 和 gitbundle `.agents/skills`runner 只按 Git commit/path 装配,不内建 HWLAB 业务。 |
| Secret 与 writable CODEX_HOME 分离 | `internal/cloud/code-agent-contract.ts``docs/reference/code-agent-chat-readiness.md` | profile Secret 只读投影,复制到当前 run/profile writable runtime home;不同 profile 不共享 runtime home。 | | Secret 与 writable CODEX_HOME 分离 | `internal/cloud/code-agent-contract.ts``docs/reference/code-agent-chat-readiness.md` | profile Secret 只读投影,复制到当前 run/profile writable runtime home;不同 profile 不共享 runtime home。 |
| bounded stdout/stderr | `docs/reference/code-agent-chat-readiness.md` | `command_output` 记录摘要、字节数、截断标记和必要引用;不得把大输出直接塞进单个 event/result。 | | bounded stdout/stderr | `docs/reference/code-agent-chat-readiness.md` | `command_output` 记录摘要、字节数、截断标记和必要引用;不得把大输出直接塞进单个 event/result。 |
@@ -196,7 +202,7 @@ HWLAB v0.2 原有 Code Agent 在 cloud-api 进程内执行,失败时依赖本
### T7 Resource prompt/skill 装配 ### T7 Resource prompt/skill 装配
阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后创建一个带 `ResourceBundleRef.promptRefs``skillRefs``toolAliases` 的真实或自测试 run。确认 runner 从同一 full commit checkout 装配 prompt、skill 和工具 alias;新 thread 首轮显示 `initialPromptInjected=true`assistant 能看见 required skill 摘要;第二轮 resume 显示 `initialPromptInjected=false`,且没有拼接第一轮历史 prompt。删除 required skill 或 prompt 后,command 必须 blocked 为 `skill-unavailable` `prompt-unavailable` 阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后创建一个带 `ResourceBundleRef.kind="gitbundle"``bundles[]``promptRefs` 的真实或自测试 run。确认 runner 从同一 full commit checkout 装配 prompt、workspace tools 和 `.agents/skills`;新 thread 首轮显示 `initialPromptInjected=true`assistant 能看见 gitbundle skill 摘要;第二轮 resume 显示 `initialPromptInjected=false`,且没有拼接第一轮历史 prompt。旧字段请求必须 schema-invalidrequired prompt 缺失必须 blocked 为 `prompt-unavailable`
## 规格的实现情况 ## 规格的实现情况
@@ -207,7 +213,7 @@ HWLAB v0.2 原有 Code Agent 在 cloud-api 进程内执行,失败时依赖本
| host process runner | 已实现 | `runner start``src/runner/main.ts` 进入同一套 `runOnce`,可通过 manager register/claim/poll/report 执行自测试,并支持 `--one-shot` 或 idle timeout 控制。 | | host process runner | 已实现 | `runner start``src/runner/main.ts` 进入同一套 `runOnce`,可通过 manager register/claim/poll/report 执行自测试,并支持 `--one-shot` 或 idle timeout 控制。 |
| claim/lease/report client | 已实现/已通过 stale lease recovery 复测 | 已拆出 runner manager API client,覆盖 register、claim、lease heartbeat、poll command、ack、append event、command status 和必要 run statusreplacement runner 遇到旧 lease 时会等待 stale lease 并重试 claimlive runtime 通过 manager 写入 Postgres durable store。 | | claim/lease/report client | 已实现/已通过 stale lease recovery 复测 | 已拆出 runner manager API client,覆盖 register、claim、lease heartbeat、poll command、ack、append event、command status 和必要 run statusreplacement runner 遇到旧 lease 时会等待 stale lease 并重试 claimlive runtime 通过 manager 写入 Postgres durable store。 |
| cancel observation | 已实现最小闭环 | runner 在 backend 执行期间轮询 run/command cancel,触发 AbortController 中止 Codex stdio backend,并按 `cancelled` 上报 command/run 终态。 | | cancel observation | 已实现最小闭环 | runner 在 backend 执行期间轮询 run/command cancel,触发 AbortController 中止 Codex stdio backend,并按 `cancelled` 上报 command/run 终态。 |
| SessionRef/ResourceBundleRef 消费 | 已实现最小闭环/待 promptRefs 与 skillRefs | runner 会使用 run 中的 SessionRef threadId 执行 resume,并 materialize Git-only ResourceBundleRef 到隔离 workspace 后再启动 backendper-session PVC 直接挂载已通过 HWLAB v0.2 原入口恢复复测,`toolAliases` 已实现,`promptRefs``skillRefs` 装配按本规格待补齐。 | | SessionRef/ResourceBundleRef 消费 | 已实现最小闭环 | runner 会使用 run 中的 SessionRef threadId 执行 resume,并 materialize `kind="gitbundle"` ResourceBundleRef 到隔离 workspace 后再启动 backendper-session PVC 直接挂载已通过 HWLAB v0.2 原入口恢复复测,`tools/` PATH、`promptRefs``.agents/skills` 装配已实现。 |
| 同 run/runner 多 turn | 已实现最小闭环 | runner 在同一 Job 中 materialize bundle 一次后循环 poll command;普通 turn completed 只终结 commandrun 保持可继续接后续 turn,直到 idle timeout 或 run terminal。 | | 同 run/runner 多 turn | 已实现最小闭环 | runner 在同一 Job 中 materialize bundle 一次后循环 poll command;普通 turn completed 只终结 commandrun 保持可继续接后续 turn,直到 idle timeout 或 run terminal。 |
| runner redaction | 已实现主路径 | runner/backend event 和 Job 输出使用 redaction;复杂审计仍按 [spec-v01-validation.md](spec-v01-validation.md) 的人工验收抽查。 | | runner redaction | 已实现主路径 | runner/backend event 和 Job 输出使用 redaction;复杂审计仍按 [spec-v01-validation.md](spec-v01-validation.md) 的人工验收抽查。 |
| `deepseek` profile runner selection | 已实现/已通过主闭环 | Runner Job 和 host runner 已按 run `backendProfile` 选择 matching SecretRef、projection、`CODEX_HOME` 和 backend metadata;真实 Kubernetes Job 已完成 `codex -> deepseek -> codex` 切换联调。 | | `deepseek` profile runner selection | 已实现/已通过主闭环 | Runner Job 和 host runner 已按 run `backendProfile` 选择 matching SecretRef、projection、`CODEX_HOME` 和 backend metadata;真实 Kubernetes Job 已完成 `codex -> deepseek -> codex` 切换联调。 |
+5 -5
View File
@@ -20,7 +20,7 @@ codex app-server --listen stdio://
Adapter 通过 stdin 写入换行分隔 JSON-RPC 请求,通过 stdout 逐行读取 JSON-RPC response 和 notificationstderr 只作为有界诊断日志。最小请求序列是 `initialize``thread/start``thread/resume``turn/start`response 中必须提取 thread/turn identitynotification 和后续输出必须归一化为 `backend_status``assistant_message``tool_call``command_output``error``terminal_status` events。运行中 steer 使用同一 app-server 进程的 `turn/steer` JSON-RPC 方法,参数为 `threadId``expectedTurnId` 和文本 `input` 数组;取消/中断使用 `turn/interrupt`,参数为 `threadId``turnId`。已有 `SessionRef.threadId` 时只能执行 Codex stdio 原生 `thread/resume` 后接 `turn/start`;当 `thread/resume` 返回 `no rollout found for thread id` 或任何其他协议错误时,adapter 必须输出 `thread-resume-failed` 并终止当前 turn。adapter 不得启动替代 `thread/start`、拼接历史 prompt、回写新 threadId 或用其他上下文模拟继续会话。 Adapter 通过 stdin 写入换行分隔 JSON-RPC 请求,通过 stdout 逐行读取 JSON-RPC response 和 notificationstderr 只作为有界诊断日志。最小请求序列是 `initialize``thread/start``thread/resume``turn/start`response 中必须提取 thread/turn identitynotification 和后续输出必须归一化为 `backend_status``assistant_message``tool_call``command_output``error``terminal_status` events。运行中 steer 使用同一 app-server 进程的 `turn/steer` JSON-RPC 方法,参数为 `threadId``expectedTurnId` 和文本 `input` 数组;取消/中断使用 `turn/interrupt`,参数为 `threadId``turnId`。已有 `SessionRef.threadId` 时只能执行 Codex stdio 原生 `thread/resume` 后接 `turn/start`;当 `thread/resume` 返回 `no rollout found for thread id` 或任何其他协议错误时,adapter 必须输出 `thread-resume-failed` 并终止当前 turn。adapter 不得启动替代 `thread/start`、拼接历史 prompt、回写新 threadId 或用其他上下文模拟继续会话。
若 run 的 `ResourceBundleRef` 包含 `promptRefs``skillRefs`Codex adapter 只能消费 runner 已装配好的有界 `initialPrompt`、skill summary 和 skill registry path。对新 threadadapter 在首个 `turn/start` 中把 `initialPrompt` 和 skill facts 放在用户 message 之前;对 `thread/resume`adapter 不重复注入 `initialPrompt`,只发送当前 command 的用户 message。当前 Codex app-server 若只有 `input: [{ type: "text", text }]`,允许使用结构化文本前缀承载 initial prompt;若后续 app-server 支持 developer/runtime instruction item,优先映射到该标准 item。无论哪种 wire shapeevents 只记录 prompt/skill 的 path/hash/bytes/injected 状态,不输出全文。 若 run 的 `ResourceBundleRef` 包含 `promptRefs`gitbundle `.agents/skills`Codex adapter 只能消费 runner 已装配好的有界 `initialPrompt`、skill summary 和 skill registry path。对新 threadadapter 在首个 `turn/start` 中把 `initialPrompt` 和 skill facts 放在用户 message 之前;对 `thread/resume`adapter 不重复注入 `initialPrompt`,只发送当前 command 的用户 message。当前 Codex app-server 若只有 `input: [{ type: "text", text }]`,允许使用结构化文本前缀承载 initial prompt;若后续 app-server 支持 developer/runtime instruction item,优先映射到该标准 item。无论哪种 wire shapeevents 只记录 prompt/skill 的 path/hash/bytes/injected 状态,不输出全文。
不得把以下路径作为 `v0.1` Codex stdio backend 的正式实现或综合联调通过证据:直接 Responses HTTP 代理、OpenAI SDK wrapper、`codex exec` 一次性命令输出、fake provider、固定文本回复、只读 shortcut 或本地 shell 模拟。裸 HTTP 或 `codex exec --json` 可以作为 provider/upstream 诊断,但最终通过必须来自 app-server stdio turn。 不得把以下路径作为 `v0.1` Codex stdio backend 的正式实现或综合联调通过证据:直接 Responses HTTP 代理、OpenAI SDK wrapper、`codex exec` 一次性命令输出、fake provider、固定文本回复、只读 shortcut 或本地 shell 模拟。裸 HTTP 或 `codex exec --json` 可以作为 provider/upstream 诊断,但最终通过必须来自 app-server stdio turn。
@@ -37,7 +37,7 @@ Adapter 通过 stdin 写入换行分隔 JSON-RPC 请求,通过 stdout 逐行
这些参考用于协议和质量标准,不复制 UniDesk/HWLAB 的业务 prompt、硬件路径、tenant policy、hostPath Secret 做法或任何明文密钥。 这些参考用于协议和质量标准,不复制 UniDesk/HWLAB 的业务 prompt、硬件路径、tenant policy、hostPath Secret 做法或任何明文密钥。
业务 prompt 与 skill 迁移必须通过 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md) 的 `ResourceBundleRef.promptRefs``skillRefs` 完成。Codex backend 不内建 HWLAB 或 UniDesk 的业务文本;缺少 required prompt/skill 时必须由 runner/manager 返回装配 blocker,不能落到 Codex 默认 skill 列表、用户长 prompt 或文本 fallback。 业务 prompt 与 skill 必须通过 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md) 的 `ResourceBundleRef.promptRefs`gitbundle `.agents/skills` 完成。Codex backend 不内建 HWLAB 或 UniDesk 的业务文本;缺少 required prompt 时必须由 runner/manager 返回装配 blocker,不能落到 Codex 默认 skill 列表、用户长 prompt 或文本 fallback。
## v0.1 Profile 定义 ## v0.1 Profile 定义
@@ -147,9 +147,9 @@ Run 的 `executionPolicy.secretScope` 应引用与 `backendProfile` 匹配的 pr
5. `AGENTRUN_SESSION_PVC_NAME` 未设 + `thread/resume` 失败:仍走 `thread-resume-failed`,不升级为 `session-store-evicted` 5. `AGENTRUN_SESSION_PVC_NAME` 未设 + `thread/resume` 失败:仍走 `thread-resume-failed`,不升级为 `session-store-evicted`
6. `codex_rollout_subdir` 走 env `AGENTRUN_CODEX_ROLLOUT_SUBDIR`,默认 `sessions`codex CLI 改子目录时只改 env,不改装配。 6. `codex_rollout_subdir` 走 env `AGENTRUN_CODEX_ROLLOUT_SUBDIR`,默认 `sessions`codex CLI 改子目录时只改 env,不改装配。
### T8 Initial prompt and skill refs ### T8 Initial prompt and gitbundle skills
阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后用 fake app-server 或真实 Codex stdio run 验证 `ResourceBundleRef.promptRefs``skillRefs`。首轮无 threadId 时,`turn/start` input 必须包含 initial prompt 与 skill facts,并记录 `initialPromptInjected=true`;第二轮带同一 `threadId` resume 时,`turn/start` input 只能包含当前用户 message,记录 `initialPromptInjected=false`,且不得拼接第一轮 prompt、assistant 回复或旧 skill facts。required prompt/skill 缺失时不得调用 Codex provider。 阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后用 fake app-server 或真实 Codex stdio run 验证 `ResourceBundleRef.promptRefs`gitbundle `.agents/skills`。首轮无 threadId 时,`turn/start` input 必须包含 initial prompt 与 skill facts,并记录 `initialPromptInjected=true`;第二轮带同一 `threadId` resume 时,`turn/start` input 只能包含当前用户 message,记录 `initialPromptInjected=false`,且不得拼接第一轮 prompt、assistant 回复或旧 skill facts。required prompt 缺失时不得调用 Codex provider。
## 规格的实现情况 ## 规格的实现情况
@@ -157,7 +157,7 @@ Run 的 `executionPolicy.secretScope` 应引用与 `backendProfile` 匹配的 pr
| --- | --- | --- | | --- | --- | --- |
| Codex stdio backend/profile 规格 | 已定义 | 本文为 v0.1 Codex app-server stdio backend kind 和 profile 权威。 | | Codex stdio backend/profile 规格 | 已定义 | 本文为 v0.1 Codex app-server stdio backend kind 和 profile 权威。 |
| Codex Secret projection | 已实现/已通过主闭环 | runner Job 使用只读 Secret projection 和 writable `CODEX_HOME`Codex 测试凭据来自 `agentrun-v01-provider-codex``auth.json`/`config.toml`。 | | Codex Secret projection | 已实现/已通过主闭环 | runner Job 使用只读 Secret projection 和 writable `CODEX_HOME`Codex 测试凭据来自 `agentrun-v01-provider-codex``auth.json`/`config.toml`。 |
| Codex adapter | 已实现主路径/待 initial prompt skill refs 接入 | 当前代码已实现受控 `codex app-server --listen stdio://``initialize`/`thread/start`/`thread/resume`/`turn/start` response 校验、stale rollout `thread-resume-failed`、stderr 有界诊断、spawn/JSON parse/response invalid/timeout/provider 5xx/invalid tool-call availability failureKind,以及包含 retry error notification 的 fake app-server 自测试;`ResourceBundleRef.promptRefs` thread-start 注入和 `skillRefs` facts/registry 消费待实现。 | | Codex adapter | 已实现主路径 initial prompt/gitbundle skills 接入 | 当前代码已实现受控 `codex app-server --listen stdio://``initialize`/`thread/start`/`thread/resume`/`turn/start` response 校验、stale rollout `thread-resume-failed`、stderr 有界诊断、spawn/JSON parse/response invalid/timeout/provider 5xx/invalid tool-call availability failureKind,以及包含 retry error notification 的 fake app-server 自测试;`ResourceBundleRef.promptRefs` thread-start 注入和 gitbundle skill facts/registry 消费已接入。 |
| 错误可观测与脱敏 | 已实现主路径 | child env、cwd、workspace 和 Codex home 只输出摘要;stderr tail 有界且标记截断;事件和 failure 统一走 redaction。 | | 错误可观测与脱敏 | 已实现主路径 | child env、cwd、workspace 和 Codex home 只输出摘要;stderr tail 有界且标记截断;事件和 failure 统一走 redaction。 |
| 真实 provider turn | 已通过主闭环 | 真实 Codex provider turn 已经通过 RESTful API 和 CLI 综合联调;每次发布仍按 [spec-v01-validation.md](spec-v01-validation.md) 手动复验。 | | 真实 provider turn | 已通过主闭环 | 真实 Codex provider turn 已经通过 RESTful API 和 CLI 综合联调;每次发布仍按 [spec-v01-validation.md](spec-v01-validation.md) 手动复验。 |
| `deepseek` profile | 已实现/已通过主闭环 | 代码已支持 `agentrun-v01-provider-deepseek`、独立 `CODEX_HOME`、同一 `codex app-server --listen stdio://` 协议和 profile metadata;真实 Kubernetes SecretRef、runner Job 和 Codex stdio turn 已通过主闭环。 | | `deepseek` profile | 已实现/已通过主闭环 | 代码已支持 `agentrun-v01-provider-deepseek`、独立 `CODEX_HOME`、同一 `codex app-server --listen stdio://` 协议和 profile metadata;真实 Kubernetes SecretRef、runner Job 和 Codex stdio turn 已通过主闭环。 |
@@ -50,7 +50,7 @@ AgentRun `v0.1` 承接 HWLAB v0.2 时,只吸收原有 Code Agent 的通用执
| conversation/session/thread 复用 | `internal/cloud/codex-stdio-session.ts``internal/cloud/code-agent-session-registry.ts` | `SessionRef` 保存 session/thread 摘要;同一 run/runner Job 处理后续 command,不重新 materialize bundlerunner 内每 turn 有 thread 则 resume,无 thread 则 start | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | | conversation/session/thread 复用 | `internal/cloud/codex-stdio-session.ts``internal/cloud/code-agent-session-registry.ts` | `SessionRef` 保存 session/thread 摘要;同一 run/runner Job 处理后续 command,不重新 materialize bundlerunner 内每 turn 有 thread 则 resume,无 thread 则 start | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) |
| 固定 repo workspace 执行 | `internal/cloud/code-agent-contract.ts``docs/reference/code-agent-chat-readiness.md` | `ResourceBundleRef` 使用 Git-only `repoUrl + full commitId` checkout 到隔离 workspace | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | | 固定 repo workspace 执行 | `internal/cloud/code-agent-contract.ts``docs/reference/code-agent-chat-readiness.md` | `ResourceBundleRef` 使用 Git-only `repoUrl + full commitId` checkout 到隔离 workspace | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) |
| 初始业务 prompt 注入 | `internal/cloud/codex-stdio-session.ts` 的 boundary instructions、`internal/cloud/codex-stdio-session-helpers.ts``buildCodexUserPrompt()` | HWLAB 把稳定业务 instruction 文件放入同一 Git bundle,并通过 `ResourceBundleRef.promptRefs` 指定;AgentRun 只在新 thread 首轮注入,resume 不重复注入 | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-backend-codex.md](spec-v01-backend-codex.md) | | 初始业务 prompt 注入 | `internal/cloud/codex-stdio-session.ts` 的 boundary instructions、`internal/cloud/codex-stdio-session-helpers.ts``buildCodexUserPrompt()` | HWLAB 把稳定业务 instruction 文件放入同一 Git bundle,并通过 `ResourceBundleRef.promptRefs` 指定;AgentRun 只在新 thread 首轮注入,resume 不重复注入 | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-backend-codex.md](spec-v01-backend-codex.md) |
| skill discovery 与 skill facts | `internal/cloud/skills-store.ts``internal/cloud/codex-stdio-session-helpers.ts``discoverSkillsForStdio()``codexSidecarSkillsPrompt()` | HWLAB 把 required skill manifest 放入同一 Git bundle,并通过 `ResourceBundleRef.skillRefs` 指定;AgentRun 聚合到 runner workspace skill registry 并向 Codex 暴露有界 skill facts | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | | skill discovery 与 skill facts | `internal/cloud/skills-store.ts``internal/cloud/codex-stdio-session-helpers.ts``discoverSkillsForStdio()``codexSidecarSkillsPrompt()` | HWLAB 把完整 `skills/` 子树通过 `kind="gitbundle"` 复制到 workspace `.agents/skills`AgentRun 发现多文件 skill 并向 Codex 暴露有界 skill facts | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) |
| provider profile 隔离和 Secret 不泄露 | `internal/cloud/code-agent-contract.ts``docs/reference/code-agent-chat-readiness.md` | `ProfileRef/SecretRef` profile-scoped 投影、缺失为 `secret-unavailable`、禁止 fallback 和泄露值 | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-backend-adapter.md](spec-v01-backend-adapter.md) | | provider profile 隔离和 Secret 不泄露 | `internal/cloud/code-agent-contract.ts``docs/reference/code-agent-chat-readiness.md` | `ProfileRef/SecretRef` profile-scoped 投影、缺失为 `secret-unavailable`、禁止 fallback 和泄露值 | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-backend-adapter.md](spec-v01-backend-adapter.md) |
| HWPOD/HWLAB runtime 短期 env 注入 | HWLAB Cloud API 的 Code Agent env assembly(历史实现名可能含 device-pod | `runner-jobs.transientEnv` 只在本次 Kubernetes Job env 中生效;只记录 name/count,不保存或输出 value | [spec-v01-agentrun-mgr.md](spec-v01-agentrun-mgr.md)、[spec-v01-secret-distribution.md](spec-v01-secret-distribution.md) | | HWPOD/HWLAB runtime 短期 env 注入 | HWLAB Cloud API 的 Code Agent env assembly(历史实现名可能含 device-pod | `runner-jobs.transientEnv` 只在本次 Kubernetes Job env 中生效;只记录 name/count,不保存或输出 value | [spec-v01-agentrun-mgr.md](spec-v01-agentrun-mgr.md)、[spec-v01-secret-distribution.md](spec-v01-secret-distribution.md) |
| UniDesk SSH passthrough | HWLAB Code Agent 通过 `tran` 访问 G14/D601/HWLAB/GitHub 维护面 | `toolCredentials[].tool=unidesk-ssh` 注入 `UNIDESK_SSH_CLIENT_TOKEN``transientEnv` 只注入非敏感 `UNIDESK_MAIN_SERVER_IP`UniDesk frontend 负责 route allowlist | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-secret-distribution.md](spec-v01-secret-distribution.md) | | UniDesk SSH passthrough | HWLAB Code Agent 通过 `tran` 访问 G14/D601/HWLAB/GitHub 维护面 | `toolCredentials[].tool=unidesk-ssh` 注入 `UNIDESK_SSH_CLIENT_TOKEN``transientEnv` 只注入非敏感 `UNIDESK_MAIN_SERVER_IP`UniDesk frontend 负责 route allowlist | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-secret-distribution.md](spec-v01-secret-distribution.md) |
@@ -88,17 +88,16 @@ HWLAB canary 创建 run 时应使用以下字段口径:
| `providerId` | `G14`,只表示目标 provider,不授予 HWLAB 业务权限。 | | `providerId` | `G14`,只表示目标 provider,不授予 HWLAB 业务权限。 |
| `backendProfile` | `deepseek``codex``minimax-m3`,由 HWLAB 或调度方显式选择;缺少 matching SecretRef 必须失败,不 fallback。 | | `backendProfile` | `deepseek``codex``minimax-m3`,由 HWLAB 或调度方显式选择;缺少 matching SecretRef 必须失败,不 fallback。 |
| `workspaceRef` | 必须引用 ResourceBundleRef 中的 Git-only full commit;不得由 runner 猜 host path。 | | `workspaceRef` | 必须引用 ResourceBundleRef 中的 Git-only full commit;不得由 runner 猜 host path。 |
| `resourceBundleRef.promptRefs[]` | 用于承接 HWLAB 稳定初始 prompt,例如 `hwlab-v02-runtime``hwpod-runtime-policy`;必须来自同一 full commit`inject=thread-start`,新 thread 首轮注入,resume 不注入。 | | `resourceBundleRef.kind` | 必须是 `gitbundle`。 |
| `resourceBundleRef.skillRefs[]` | 用于承接 HWLAB required skills,例如 `hwpod-cli``hwpod-ctl`;必须来自同一 full commit 的 `SKILL.md`required skill 缺失时 blocked,不能让模型默认 skill 列表替代。 | | `resourceBundleRef.bundles[]` | 用于承接 HWLAB 固定工具和 skill 子树,默认 `tools -> tools``skills -> .agents/skills`;旧字段不得再发送。 |
| `resourceBundleRef.toolAliases[]` | 用于暴露短入口,例如 `hwpod`;缺 alias 时修 runner/bundle,不改走长路径 wrapper。 | | `resourceBundleRef.promptRefs[]` | 用于承接 HWLAB 稳定初始 prompt,例如 `hwlab-v02-runtime`;必须来自同一 full commit`inject=thread-start`,新 thread 首轮注入,resume 不注入。 |
| `resourceBundleRef.workspaceFiles[]` | 用于承接 HWLAB 编排器生成的非敏感 run-local workspace 文件,例如 CaseRun 预装 `.hwlab/hwpod-spec.yaml`runner 启动 backend 前写入,result/event 只输出 path、bytes、sha256,不把文件正文并入 prompt。 |
| `executionPolicy` | sandbox、network、timeout、secretScope 必须显式,不得由 HWLAB 扩大 AgentRun Secret 范围。 | | `executionPolicy` | sandbox、network、timeout、secretScope 必须显式,不得由 HWLAB 扩大 AgentRun Secret 范围。 |
| `executionPolicy.secretScope.toolCredentials[]` | 需要 UniDesk SSH passthrough 时必须声明 `tool=unidesk-ssh``purpose=ssh-passthrough`、SecretRef `agentrun-v01-tool-unidesk-ssh`、projection env `UNIDESK_SSH_CLIENT_TOKEN`;不得把 token 放入 command payload 或 runner-job transientEnv。 | | `executionPolicy.secretScope.toolCredentials[]` | 需要 UniDesk SSH passthrough 时必须声明 `tool=unidesk-ssh``purpose=ssh-passthrough`、SecretRef `agentrun-v01-tool-unidesk-ssh`、projection env `UNIDESK_SSH_CLIENT_TOKEN`;不得把 token 放入 command payload 或 runner-job transientEnv。 |
| `traceSink` | 可指向 HWLAB trace adapter;为 `null` 时 HWLAB 仍可通过 AgentRun events 轮询。 | | `traceSink` | 可指向 HWLAB trace adapter;为 `null` 时 HWLAB 仍可通过 AgentRun events 轮询。 |
`tenantId` / `projectId` 是 AgentRun manager 的 policy 边界,不是 HWLAB Workbench project。HWLAB adapter 可以把业务 project/workspace 写入 `metadata.hwlabProjectId``metadata.hwlabWorkspaceId``workspaceRef`,但不得覆盖 AgentRun `projectId=pikasTech/HWLAB`;否则 manager 必须按 `tenant-policy-denied` 拒绝。`providerProfile` 是 HWLAB 入口字段,进入 AgentRun 后必须映射为 `backendProfile`;同一个 HWLAB session 的后续 turn 应继承 session provider profile,不能被 stale workspace provider 覆盖。 `tenantId` / `projectId` 是 AgentRun manager 的 policy 边界,不是 HWLAB Workbench project。HWLAB adapter 可以把业务 project/workspace 写入 `metadata.hwlabProjectId``metadata.hwlabWorkspaceId``workspaceRef`,但不得覆盖 AgentRun `projectId=pikasTech/HWLAB`;否则 manager 必须按 `tenant-policy-denied` 拒绝。`providerProfile` 是 HWLAB 入口字段,进入 AgentRun 后必须映射为 `backendProfile`;同一个 HWLAB session 的后续 turn 应继承 session provider profile,不能被 stale workspace provider 覆盖。
Command 第一阶段要求 `type=turn``type=steer``turn` 保存用户原始 prompt、conversation metadata、profile 选择和 HWLAB trace correlation;稳定业务 prompt、skill 清单工具入口和 run-local workspace 文件不写入 command payload,而是通过 `ResourceBundleRef.promptRefs``skillRefs``toolAliases` `workspaceFiles` 装配。`steer` 保存运行中引导文本,并由 runner 在同 run active turn 期间转发到 backend。业务 cancel 仍走 run/command cancel API,不用 `steer` 伪装。不得把 cookie、session token、provider credential、HWPOD internal token、Secret value、历史消息或大段 skill manifest 写入 payload。 Command 第一阶段要求 `type=turn``type=steer``turn` 保存用户原始 prompt、conversation metadata、profile 选择和 HWLAB trace correlation;稳定业务 prompt、skill 清单工具入口不写入 command payload,而是通过 `ResourceBundleRef.kind="gitbundle"``bundles[]` `promptRefs` 装配。`steer` 保存运行中引导文本,并由 runner 在同 run active turn 期间转发到 backend。业务 cancel 仍走 run/command cancel API,不用 `steer` 伪装。不得把 cookie、session token、provider credential、HWPOD internal token、Secret value、历史消息或大段 skill manifest 写入 payload。
## 需要补齐的能力 ## 需要补齐的能力
@@ -148,28 +147,24 @@ AgentRun 需要提供 durable cancel 能力,建议形态为 `POST /api/v1/runs
### P1 ResourceBundleRef / bundle materialization ### P1 ResourceBundleRef / bundle materialization
`ResourceBundleRef` 必须按 Git-only 模型落地:`repoUrl + full commitId` 是唯一内容身份。runner 只能 checkout 到允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。第一阶段支持 `subdir``sparsePaths``submodules=false``lfs=false``credentialRef` 的最小字段即可。HWLAB canary 只需要 `pikasTech/HWLAB` 固定 full commit 的普通 checkout;用户上传文件和对象存储 artifact 不进入 `v0.1` `ResourceBundleRef` 必须按 `kind="gitbundle"` 模型落地:`repoUrl + full commitId + bundles[]` 是唯一内容身份。runner 只能 checkout 到允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。HWLAB canary 默认只复制 `tools -> tools``skills -> .agents/skills`;用户上传文件、inline seed、对象存储 artifact 和旧字段不进入 `v0.1`
### P1 Resource prompt/skill assembly ### P1 Resource prompt/skill assembly
HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须迁移`ResourceBundleRef` 子资源,而不是继续依赖 cloud-api 进程内硬编码 prompt、`/app/skills` 镜像目录、用户长 prompt 或 Codex 默认 skill registry。HWLAB dispatcher 创建 run 时应指定: HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须收敛`gitbundle` 子树和 `promptRefs`,而不是继续依赖 cloud-api 进程内硬编码 prompt、`/app/skills` 镜像目录、用户长 prompt、旧 seed 或 Codex 默认 skill registry。HWLAB dispatcher 创建 run 时应指定:
```json ```json
{ {
"resourceBundleRef": { "resourceBundleRef": {
"kind": "git", "kind": "gitbundle",
"repoUrl": "git@github.com:pikasTech/HWLAB.git", "repoUrl": "git@github.com:pikasTech/HWLAB.git",
"commitId": "<full commit sha>", "commitId": "<full commit sha>",
"toolAliases": [ "bundles": [
{ "name": "hwpod", "path": "tools/hwpod-cli.ts", "kind": "bun-script" } { "name": "hwlab-tools", "subpath": "tools", "target_path": "tools" },
{ "name": "hwlab-agent-skills", "subpath": "skills", "target_path": ".agents/skills" }
], ],
"promptRefs": [ "promptRefs": [
{ "name": "hwlab-v02-runtime", "path": "internal/agent/prompts/hwlab-v02-runtime.md", "inject": "thread-start", "required": true }, { "name": "hwlab-v02-runtime", "path": "internal/agent/prompts/hwlab-v02-runtime.md", "inject": "thread-start", "required": true }
{ "name": "hwpod-runtime-policy", "path": "internal/agent/prompts/hwpod-runtime-policy.md", "inject": "thread-start", "required": true }
],
"skillRefs": [
{ "name": "hwpod-cli", "path": "skills/hwpod-cli/SKILL.md", "required": true, "aggregateAs": "hwpod-cli" },
{ "name": "hwpod-ctl", "path": "skills/hwpod-ctl/SKILL.md", "required": true, "aggregateAs": "hwpod-ctl" }
] ]
} }
} }
@@ -187,7 +182,7 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须迁移为 `ResourceB
| 2 | trace/result 元语 | 标准 event 子集、terminal result envelope、bounded output metadata | HWLAB 可由 events 稳定生成 result/tracepartial 不误报 completed。 | | 2 | trace/result 元语 | 标准 event 子集、terminal result envelope、bounded output metadata | HWLAB 可由 events 稳定生成 result/tracepartial 不误报 completed。 |
| 3 | cancel 闭环 | durable cancel API、runner cancel poll、backend interrupt/process group stop | pending/running/terminal 后 cancel 均幂等且可见。 | | 3 | cancel 闭环 | durable cancel API、runner cancel poll、backend interrupt/process group stop | pending/running/terminal 后 cancel 均幂等且可见。 |
| 4 | ResourceBundleRef materialization | Git-only checkout、workspace 前缀、commit/tree 摘要、failureKind | 使用 full commit;不接受 branch/tag/HEAD;不覆盖 Secret/session/runtime home。 | | 4 | ResourceBundleRef materialization | Git-only checkout、workspace 前缀、commit/tree 摘要、failureKind | 使用 full commit;不接受 branch/tag/HEAD;不覆盖 Secret/session/runtime home。 |
| 5 | Resource prompt/skill assembly | `promptRefs` thread-start 注入、`skillRefs` registry 聚合、required blocker、hash/bytes 可见 | 简短 HWLAB prompt 能看到业务 instruction 和 required skillresume 不重复注入;缺失直接 blocked。 | | 5 | Resource prompt/skill assembly | `promptRefs` thread-start 注入、gitbundle skillDirs 发现、hash/bytes 可见 | 简短 HWLAB prompt 能看到业务 instruction 和 gitbundle skillsresume 不重复注入;旧字段直接拒绝。 |
| 6 | SessionRef 持久化与 runner 多 turn | session record/store、thread resume、runner command loop、TTL/GC、profile 隔离 | 同一 conversation 连续两轮复用同一 run/runner Job;第二轮不重新 materialize bundle、不重复注入 initial prompt;不同 profile 不污染。 | | 6 | SessionRef 持久化与 runner 多 turn | session record/store、thread resume、runner command loop、TTL/GC、profile 隔离 | 同一 conversation 连续两轮复用同一 run/runner Job;第二轮不重新 materialize bundle、不重复注入 initial prompt;不同 profile 不污染。 |
| 7 | HWLAB v0.2 canary | HWLAB dispatcher adapter、traceId 映射、result/trace 转换 | 普通自然语言最短 turn 真实 completed 且 reply 非空;HWPOD 仍由 HWLAB 授权。 | | 7 | HWLAB v0.2 canary | HWLAB dispatcher adapter、traceId 映射、result/trace 转换 | 普通自然语言最短 turn 真实 completed 且 reply 非空;HWPOD 仍由 HWLAB 授权。 |
@@ -219,17 +214,17 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须迁移为 `ResourceB
### T7 HWLAB prompt/skill 装配 ### T7 HWLAB prompt/skill 装配
阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后用 HWLAB canary `ResourceBundleRef` 指定 `promptRefs``skillRefs``toolAliases`。首轮 Web/CLI 简短 prompt 只写“编译 D601-F103-V2”或等价自然语言,确认 Codex turn 能看到 HWLAB runtime prompt、`hwpod-cli`/`hwpod-ctl` skill facts 和 `hwpod` alias;第二轮 continuation 使用同一 thread resume,确认 `initialPromptInjected=false`,没有手工拼接历史;删除 required skill/prompt 后确认 blocked 而不是 fallback 到默认 Codex 5 个系统 skill 阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后用 HWLAB canary `ResourceBundleRef.kind="gitbundle"` 指定 `bundles[]``promptRefs`。首轮 Web/CLI 简短 prompt 只写“编译 D601-F103-V2”或等价自然语言,确认 Codex turn 能看到 HWLAB runtime prompt、`.agents/skills` 中的 `hwpod-cli`/`hwpod-ctl` skill facts 和 `tools/hwpod` 命令;第二轮 continuation 使用同一 thread resume,确认 `initialPromptInjected=false`,没有手工拼接历史;旧字段请求必须 schema-invalid
### T8 DS 短 prompt 真实验收 ### T8 DS 短 prompt 真实验收
阅读本文、[spec-v01-backend-codex.md](spec-v01-backend-codex.md) 和 HWLAB `spec-v02-hwlab-cli.md`,然后必须用正式 HWLAB CLI/Web 等价短连接入口提交真实 `backendProfile=deepseek` 的短 prompt 到 AgentRun runtime,不允许用长提示词把规则补给模型。验收至少包含三条 prompt: 阅读本文、[spec-v01-backend-codex.md](spec-v01-backend-codex.md) 和 HWLAB `spec-v02-hwlab-cli.md`,然后必须用正式 HWLAB CLI/Web 等价短连接入口提交真实 `backendProfile=deepseek` 的短 prompt 到 AgentRun runtime,不允许用长提示词把规则补给模型。验收至少包含三条 prompt:
1. “不调用工具的情况下,你可见的 skill 有哪些?”确认回复包含 `ResourceBundleRef.skillRefs` 中的 HWLAB required skill,例如 `hwpod-cli``hwpod-ctl`,并且不只返回 Codex 默认 5 个系统 skill。 1. “不调用工具的情况下,你可见的 skill 有哪些?”确认回复包含 gitbundle `.agents/skills` 中的 HWLAB skill,例如 `hwpod-cli``hwpod-ctl`,并且不只返回 Codex 默认 5 个系统 skill。
2. “你当前 HWLAB 初始规则里,D601-F103-V2 应该走哪个标准入口?请只回答入口和禁止路径。”确认回复能说出 `hwpod``hwpod-cli`/`hwpod-ctl`、assembled runtime env、禁止旧 Device Pod/profile/device-pod-cli fallback、禁止 session-token fallback 和禁止长路径 wrapper。 2. “你当前 HWLAB 初始规则里,D601-F103-V2 应该走哪个标准入口?请只回答入口和禁止路径。”确认回复能说出 `hwpod``hwpod-cli`/`hwpod-ctl`、assembled runtime env、禁止旧 Device Pod/profile/device-pod-cli fallback、禁止 session-token fallback 和禁止长路径 wrapper。
3. “编译 D601-F103-V2。”确认短 prompt 能按注入规则触发 `hwpod-cli -> hwpod-compiler-cli -> /v1/hwpod-node-ops -> hwpod-node` 正向链路,而不是要求用户补充长 prompt。 3. “编译 D601-F103-V2。”确认短 prompt 能按注入规则触发 `hwpod-cli -> hwpod-compiler-cli -> /v1/hwpod-node-ops -> hwpod-node` 正向链路,而不是要求用户补充长 prompt。
上述三条必须来自真实 DS/DeepSeek profile 的 Codex stdio `thread/start`/`turn/start` 和后续 `thread/resume` 事件;trace/result 必须显示 `initialPromptInjected=true` 的首轮、skill refs 摘要、真实 provider profile、terminal status,以及 continuation 时 `initialPromptInjected=false`。如果回复只列出 Codex 默认系统 skill、不能识别 HWLAB 初始规则或需要用户长 prompt 才能触发 `hwpod`,验收失败。 上述三条必须来自真实 DS/DeepSeek profile 的 Codex stdio `thread/start`/`turn/start` 和后续 `thread/resume` 事件;trace/result 必须显示 `initialPromptInjected=true` 的首轮、gitbundle skillDirs 摘要、真实 provider profile、terminal status,以及 continuation 时 `initialPromptInjected=false`。如果回复只列出 Codex 默认系统 skill、不能识别 HWLAB 初始规则或需要用户长 prompt 才能触发 `hwpod`,验收失败。
## 实现状态 ## 实现状态
@@ -240,6 +235,6 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须迁移为 `ResourceB
| cancel | 已实现最小闭环 | 已提供 run/command cancel APIpending cancel 会阻止新 runner Jobrunning runner 通过轮询触发 backend abort,终态写入 event、command state 和 run status。 | | cancel | 已实现最小闭环 | 已提供 run/command cancel APIpending cancel 会阻止新 runner Jobrunning runner 通过轮询触发 backend abort,终态写入 event、command state 和 run status。 |
| SessionRef | 已实现最小持久化 | run 可携带 `sessionRef`manager 保存 session/threadrunner 会按 threadId resumeresult envelope 暴露脱敏 session 摘要;TTL/GC 仍按后续运维策略细化。 | | SessionRef | 已实现最小持久化 | run 可携带 `sessionRef`manager 保存 session/threadrunner 会按 threadId resumeresult envelope 暴露脱敏 session 摘要;TTL/GC 仍按后续运维策略细化。 |
| SessionRef | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | 在「metadata-only 最小持久化」基础上把 session 真实持久化:每个 session 绑 RWO PVC`agentrun-v01-session-<sessionId>`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/<codex_rollout_subdir>`codex app-server 自己落盘;HWLAB 原入口已验证 runner pod 删除后同 session/thread/PVC 可以恢复,仍禁止 fake 续接。 | | SessionRef | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | 在「metadata-only 最小持久化」基础上把 session 真实持久化:每个 session 绑 RWO PVC`agentrun-v01-session-<sessionId>`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/<codex_rollout_subdir>`codex app-server 自己落盘;HWLAB 原入口已验证 runner pod 删除后同 session/thread/PVC 可以恢复,仍禁止 fake 续接。 |
| ResourceBundleRef | 已实现 Git-only materialization/promptRefs/skillRefs/workspaceFiles 装配 | run 可携带 `repoUrl + full commitId`runner checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录并记录 commit/tree/workspace 摘要;`toolAliases``promptRefs` thread-start 注入`skillRefs` registry 聚合和有界 workspace seed 文件写入已实现;上传文件和对象存储不进入 v0.1。 | | ResourceBundleRef | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs 装配 | run 可携带 `repoUrl + full commitId + bundles[]`runner checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录并记录 commit/tree/workspace/bundles 摘要;`tools/` PATH`promptRefs` thread-start 注入`.agents/skills` 目录发现已实现;上传文件、inline seed 和对象存储不进入 v0.1。 |
| 同 run/runner 多 turn | 已实现最小闭环 | runner Job 在 idle timeout 内持续 poll 同一 run 的后续 command;普通 turn completed 不终结 runbundle 只 materialize 一次,command result 按 commandId 独立聚合。 | | 同 run/runner 多 turn | 已实现最小闭环 | runner Job 在 idle timeout 内持续 poll 同一 run 的后续 command;普通 turn completed 不终结 runbundle 只 materialize 一次,command result 按 commandId 独立聚合。 |
| HWLAB v0.2 canary | 已实现/已通过 HWLAB v0.2 原入口复测 | HWLAB dispatcher adapter 已调 AgentRun 手动调度 API,并能转换 result/traceMiniMax-M3 显式 session、provider profile 继承和 runner pod 删除后的同 session resume 已通过原入口 CLI 复测。 | | HWLAB v0.2 canary | 已实现/已通过 HWLAB v0.2 原入口复测 | HWLAB dispatcher adapter 已调 AgentRun 手动调度 API,并能转换 result/traceMiniMax-M3 显式 session、provider profile 继承和 runner pod 删除后的同 session resume 已通过原入口 CLI 复测。 |
+24 -46
View File
@@ -13,7 +13,7 @@
| `BackendImageRef` | `image` | digest-pinned backend/runner 镜像。 | API KEY、profile config、用户代码、session 文件。 | | `BackendImageRef` | `image` | digest-pinned backend/runner 镜像。 | API KEY、profile config、用户代码、session 文件。 |
| `ProfileRef` | `profile``secretRef` | provider profile 和 API KEY/配置 SecretRef。 | backend 镜像、session、repo 文件、GitHub/业务工具 credential。 | | `ProfileRef` | `profile``secretRef` | provider profile 和 API KEY/配置 SecretRef。 | backend 镜像、session、repo 文件、GitHub/业务工具 credential。 |
| `SessionRef` | `sessionId``null` | backend 会话文件持久化引用;P0 可以为 `null`。 | API KEY、完整 `CODEX_HOME`、Git workspace。 | | `SessionRef` | `sessionId``null` | backend 会话文件持久化引用;P0 可以为 `null`。 | API KEY、完整 `CODEX_HOME`、Git workspace。 |
| `ResourceBundleRef` | `repoUrl``commitId`,可选 `toolAliases``promptRefs``skillRefs``workspaceFiles` | 初始代码/文件输入,以及同一 commit 下的非敏感工具别名、初始 prompt、skill manifest 和有界 workspace seed 文件P0 固定 Git-only。 | 上传文件、对象存储 artifact、inline env、Secret value、会话历史。 | | `ResourceBundleRef` | `kind="gitbundle"``repoUrl``commitId``bundles[]`、可选 `promptRefs` | 初始代码/文件输入、工具目录、skill 目录和同一 commit 下的稳定初始 promptP0 固定 Git-only gitbundle。 | 上传文件、对象存储 artifact、inline env、Secret value、会话历史、旧 inline seed。 |
P0 最小 JSON 形态: P0 最小 JSON 形态:
@@ -28,12 +28,14 @@ P0 最小 JSON 形态:
}, },
"sessionRef": null, "sessionRef": null,
"resourceBundleRef": { "resourceBundleRef": {
"kind": "gitbundle",
"repoUrl": "git@github.com:pikasTech/unidesk.git", "repoUrl": "git@github.com:pikasTech/unidesk.git",
"commitId": "<full commit sha>", "commitId": "<full commit sha>",
"toolAliases": [], "bundles": [
"promptRefs": [], { "name": "tools", "subpath": "tools", "target_path": "tools" },
"skillRefs": [], { "name": "skills", "subpath": "skills", "target_path": ".agents/skills" }
"workspaceFiles": [] ],
"promptRefs": []
} }
} }
``` ```
@@ -154,16 +156,17 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
### ResourceBundleRef ### ResourceBundleRef
- P0 固定 Git-only,由 `repoUrl + full commitId` 决定内容身份。 - P0 固定 `kind="gitbundle"`,由 `repoUrl + full commitId + bundles[]` 决定内容身份。
- `commitId` 必须是不可变 full commit sha,不能是 branch、tag 或 `HEAD` - `commitId` 必须是不可变 full commit sha,不能是 branch、tag 或 `HEAD`
- 可选扩展只允许 `subdir``sparsePaths``submodules=false``lfs=false``credentialRef``toolAliases``promptRefs``skillRefs``workspaceFiles`;默认不启用 - `bundles[]` 每一项只允许 `{ name?, repoUrl?, commitId?, subpath, target_path }`;缺省 repo/commit 继承顶层
- `subpath` 必须留在 checkout 内,`target_path` 必须留在 runner workspace 内;runner 按 `subpath -> target_path` 复制文件或目录。
- `credentialRef` 只用于拉取私有 Git repo,不等同于 backend API KEY。 - `credentialRef` 只用于拉取私有 Git repo,不等同于 backend API KEY。
- 不支持上传文件、对象存储 artifact、任意 ConfigMap 文件袋或 inline env;后续需要时另写版本规格 - 不支持上传文件、对象存储 artifact、任意 ConfigMap 文件袋、inline seed 或旧字段;旧 `toolAliases``skillRefs``workspaceFiles``subdir``sparsePaths` 输入必须直接 schema-invalid
- 面向 HWLAB 手动调度 canaryrunner materialization 必须把 Git-only bundle checkout 到允许 workspace 前缀,并在 event/result 记录 repo、full commit、checkout path 和 tree 摘要;不得隐式使用 manager Pod、host path 或镜像内旧代码 - 面向 HWLAB 手动调度 canary默认 bundle 把 repo 的 `tools/` 复制到 workspace `tools/`,把 `skills/` 复制到 workspace `.agents/skills/`event/result 必须记录 repo、full commit、checkout tree、bundle 列表、workspace 摘要、tools 和 skillDirs 摘要
#### toolAliases #### tools 目录
`toolAliases` 用于把 bundle 内的受控脚本暴露为 runner PATH 中的短命令。每个 alias 只能指向当前 checkout 内的相对路径,不能覆盖 runner 镜像里已有非 AgentRun wrapper 命令;materialization event 只输出 alias 名称、kind、目标 path hash 和 wrapper 摘要,不输出脚本全文。缺少 required 工具入口时必须返回 `resource-tool-unavailable` 或等价 blocker,不能要求业务 prompt 改走长路径 wrapper。 runner 对 workspace `tools/` 做统一装配:顶层带 shebang 的脚本会被 `chmod +x``tools/` 目录会追加到 `PATH``.ts` 工具脚本必须以 `#!/usr/bin/env bun` 或等价 shebang 开头;缺 shebang 必须 blocked 为 schema-invalid。短命令名称来自 repo 内真实文件,例如 `tools/hwpod`,不再由 runner 生成 wrapper。
#### promptRefs #### promptRefs
@@ -189,34 +192,9 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
- `promptRefs` 不得读取 Secret、env、token、profile config 或 session 文件;需要 credential 的内容必须通过 `ProfileRef``toolCredentials``transientEnv` 的正式路径装配。 - `promptRefs` 不得读取 Secret、env、token、profile config 或 session 文件;需要 credential 的内容必须通过 `ProfileRef``toolCredentials``transientEnv` 的正式路径装配。
- `promptRefs` 缺失且 `required=true` 时,run/command 必须 blocked;不得 fallback 到用户 prompt、旧硬编码 prompt、模型默认 system prompt 或历史上下文拼接。 - `promptRefs` 缺失且 `required=true` 时,run/command 必须 blocked;不得 fallback 到用户 prompt、旧硬编码 prompt、模型默认 system prompt 或历史上下文拼接。
#### skillRefs #### skill 目录
`skillRefs` 用于按同一 `ResourceBundleRef` checkout 装配 skill manifest。它只描述非敏感 skill 文件、聚合方式和 required 语义;运行时 credential 仍走 `toolCredentials` 或其他 SecretRef 路径 skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills/<name>/SKILL.md`。runner 发现直接子目录中的 `SKILL.md` 后,把 `.agents/skills` 暴露给 backend,并在 initial prompt 中加入有界 skill facts。多文件 skill 的 `references/``scripts/` 和后续新增文件跟随目录一起复制;event/result 只输出 skill name、manifest path 摘要、hash、bytes 和 summary,不输出大段 manifest 正文。runner 不读取镜像默认 skill、host path、ConfigMap、用户长 prompt 或旧 `skillRefs`
最小形态:
```json
{
"name": "hwpod-cli",
"path": "skills/hwpod-cli/SKILL.md",
"required": true,
"aggregateAs": "hwpod-cli"
}
```
规则:
- `path` 必须指向当前 checkout 内的 `SKILL.md`,或后续规格显式允许的 skill root;不能引用 runner 镜像、host path、ConfigMap 或外部 URL。
- runner 必须把 skill 聚合到当前 workspace 的标准 skill registry,例如 `$WORKSPACE/.agents/skills/<aggregateAs>/SKILL.md`,并设置 backend 可见的 skill dirs 环境或等价配置。
- skill discovery fact 只输出 skill name、summary、manifest path、hash、version/commit metadata 和 count;不得输出大段 manifest 正文,除非 agent 在 turn 中按需读取文件。
- `required=true` 的 skill 缺失、不可读或 manifest 无法解析时,run/command 必须 blocked 为 `skill-unavailable`,不能让模型凭默认 Codex skill registry 猜测,也不能把用户长 prompt 当作替代 skill。
- `skillRefs``promptRefs` 必须来自同一 `repoUrl + commitId`,以避免业务 prompt、skill manifest 和工具 alias 版本漂移。
#### workspaceFiles
`workspaceFiles` 用于在 runner 启动 backend 前,把少量非敏感、run-local 的 UTF-8 文本文件写入 materialized workspace。每个文件只能写到 workspace 相对路径,禁止绝对路径、`..` 和目录根;数量、单文件大小和总大小必须有上限。materialization event 与 result 只输出 path、bytes、sha256、count 和 `valuesPrinted=false`,不输出文件内容。
`workspaceFiles` 适合承载 CaseRun 这类编排器生成的 run-local 配置,例如 `.hwlab/hwpod-spec.yaml`。它不替代 git source、promptRefs、skillRefs、toolCredentials 或 SecretRef;不得用于传递 token、provider config、历史消息、大文件 artifact 或任务答案。若业务必须让 agent 修改该文件,应由 prompt 明确说明修改目标;否则 seed file 应被视为运行环境输入,而不是 agent 需要手工创建的任务。
#### 初始 prompt 与 session 边界 #### 初始 prompt 与 session 边界
@@ -229,9 +207,9 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
3. Manager 或 runner Job render 只使用解析后的 image、SecretRef、sessionRef、Git commit 和 projection intent。 3. Manager 或 runner Job render 只使用解析后的 image、SecretRef、sessionRef、Git commit 和 projection intent。
4. Runner materialize profile Secret 到 writable runtime home。 4. Runner materialize profile Secret 到 writable runtime home。
5. Runner materialize tool credential 到该 run 允许的 env/file projection;未实现的 tool scope 必须显式 failed/blocked,不能静默跳过后让 agent 自己猜凭据。 5. Runner materialize tool credential 到该 run 允许的 env/file projection;未实现的 tool scope 必须显式 failed/blocked,不能静默跳过后让 agent 自己猜凭据。
6. Runner materialize Git-only resource bundle 到 workspaceP0 未实现时必须显式记录为 deferred 或 null,不能猜测 host path。 6. Runner materialize `kind="gitbundle"` resource bundle 到 workspaceP0 未实现时必须显式 blocked,不能猜测 host path。
7. Runner 在 materialized bundle 内解析 `toolAliases``promptRefs``skillRefs``workspaceFiles`:创建工具 wrapper、聚合 skill registry、读取并校验 thread-start prompt、写入 workspace seed 文件,写入有界 assembly event。 7. Runner `bundles[]` 复制目录或文件,准备 workspace `tools/`、发现 `.agents/skills`,读取并校验 `promptRefs`,写入有界 assembly event。
8. Runner 启动 backend,并在 event 中记录 image digest、profile、SecretRef 名称/key、tool credential scope、sessionRef、repoUrl/commitId、promptRefs、skillRefs 和 workspace file hash/bytes 摘要。 8. Runner 启动 backend,并在 event 中记录 image digest、profile、SecretRef 名称/key、tool credential scope、sessionRef、repoUrl/commitId、bundles、promptRefs、tools 和 skillDirs 摘要。
任何一个要素缺失或不合法,都必须按该要素失败;不得静默 fallback。 任何一个要素缺失或不合法,都必须按该要素失败;不得静默 fallback。
@@ -267,13 +245,13 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
### A4 ResourceBundleRef 验收 ### A4 ResourceBundleRef 验收
- P0 ResourceBundle 只能是 Git-only`repoUrl + full commitId` - P0 ResourceBundle 只能是 `kind="gitbundle"``repoUrl + full commitId + bundles[]`
- `commitId` 不是 branch/tag/HEAD。 - `commitId` 不是 branch/tag/HEAD。
- checkout 只能进入允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。 - checkout 只能进入允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。
- run payload 不携带文件正文、env dump、Secret value 或大型 artifact。 - run payload 不携带文件正文、env dump、Secret value 或大型 artifact。
- 若提供 `bundles[]`,必须能看到每个 `subpath -> target_path` 的复制摘要;旧字段输入必须 schema-invalid。
- 若提供 `promptRefs`,必须能看到每个 prompt 的 `name/path/sha256/bytes/inject`,新 thread 首轮 `initialPromptInjected=true`resume turn `initialPromptInjected=false` - 若提供 `promptRefs`,必须能看到每个 prompt 的 `name/path/sha256/bytes/inject`,新 thread 首轮 `initialPromptInjected=true`resume turn `initialPromptInjected=false`
-提供 `skillRefs`,必须能看到 skill registry 聚合摘要、required skill 名称和 manifest hashrequired skill 缺失必须 blocked不能显示模型默认 skill 列表当作业务 skill。 - bundle 复制了 `.agents/skills`,必须能看到 skillDirs 聚合摘要、skill 名称和 manifest hash不能显示模型默认 skill 列表当作业务 skill。
- 若提供 `workspaceFiles`,必须能看到每个文件的相对路径、bytes、sha256 和 materialized countbackend 启动前 workspace 内应已存在这些文件,event/result 不得打印文件正文。
### A5 综合验收 ### A5 综合验收
@@ -283,7 +261,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
2. 用哪一个 profile 和 SecretRef。 2. 用哪一个 profile 和 SecretRef。
3. 是否使用 session;若不用,必须明确为 `null`/deferred。 3. 是否使用 session;若不用,必须明确为 `null`/deferred。
4. 使用哪一个 Git repo 和 full commit;若 P0 尚未 materialize,必须明确为 deferred,不能隐式使用 host path。 4. 使用哪一个 Git repo 和 full commit;若 P0 尚未 materialize,必须明确为 deferred,不能隐式使用 host path。
5. 是否装配 tool aliases、初始 prompt 和 skill refs;若提供,必须能回答 name/path/hash/inject/required 和是否注入,不能只依赖模型默认 prompt 或默认 skill registry。 5. 是否装配 gitbundle bundles、workspace tools、初始 prompt 和 skillDirs;若提供,必须能回答 name/path/hash/inject/required 和是否注入,不能只依赖模型默认 prompt 或默认 skill registry。
6. 是否装配 tool credential;若需要 GitHub PR 能力,必须能回答 tool、purpose、SecretRef 和 projection kind,不能只在运行时 shell 中偶然存在 token。 6. 是否装配 tool credential;若需要 GitHub PR 能力,必须能回答 tool、purpose、SecretRef 和 projection kind,不能只在运行时 shell 中偶然存在 token。
## 实现状态 ## 实现状态
@@ -294,5 +272,5 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
| `ProfileRef` | 已实现/已通过 HWLAB v0.2 原入口复测 | `codex``deepseek``minimax-m3` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证;MiniMax-M3 已通过 HWLAB 显式 session 原入口复测,后续只允许作为 profile/config/SecretRef 选择,不新增直连 backend。 | | `ProfileRef` | 已实现/已通过 HWLAB v0.2 原入口复测 | `codex``deepseek``minimax-m3` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证;MiniMax-M3 已通过 HWLAB 显式 session 原入口复测,后续只允许作为 profile/config/SecretRef 选择,不新增直连 backend。 |
| `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`run 创建会解析既有 sessionrunner 按 threadId resumesession 不保存 credential 文件,TTL/GC 后续细化。 | | `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`run 创建会解析既有 sessionrunner 按 threadId resumesession 不保存 credential 文件,TTL/GC 后续细化。 |
| `SessionRef` | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | manager 持久化 `sessionId/conversationId/threadId` + 每个 session 绑 RWO PVC`agentrun-v01-session-<sessionId>`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/<codex_rollout_subdir>`codex app-server 自己落盘;runner pod 删除后 replacement runner 仍复用同一 SessionRef/PVC/thread,禁止 copy/restore、replacement threadId 和 fake resume。 | | `SessionRef` | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | manager 持久化 `sessionId/conversationId/threadId` + 每个 session 绑 RWO PVC`agentrun-v01-session-<sessionId>`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/<codex_rollout_subdir>`codex app-server 自己落盘;runner pod 删除后 replacement runner 仍复用同一 SessionRef/PVC/thread,禁止 copy/restore、replacement threadId 和 fake resume。 |
| `ResourceBundleRef` | 已实现 Git-only materialization/promptRefs/skillRefs/workspaceFiles 装配 | `repoUrl + full commitId` 已进入 run schema 和 runner checkoutworkspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 commit/tree/workspace 摘要;`toolAliases``promptRefs` thread-start 注入`skillRefs` registry 聚合和有界 `workspaceFiles` 写入已实现。 | | `ResourceBundleRef` | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs 装配 | `repoUrl + full commitId + bundles[]` 已进入 run schema 和 runner checkoutworkspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 commit/tree/workspace/bundles 摘要;`tools/` PATH`promptRefs` thread-start 注入`.agents/skills` 目录发现已实现。 |
| `toolCredentials` | 已实现最小 env projection | GitHub PR 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runnerv0.1 支持 `tool=github``tool=unidesk-ssh``projection.kind=env`runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 | | `toolCredentials` | 已实现最小 env projection | GitHub PR 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runnerv0.1 支持 `tool=github``tool=unidesk-ssh``projection.kind=env`runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 |
+4 -4
View File
@@ -24,7 +24,7 @@
- Postgres adaptermigration、事务、run/command/event round-trip、重启后可查询。 - Postgres adaptermigration、事务、run/command/event round-trip、重启后可查询。
- Secret 分发:SecretRef schema、missing secret failure、redaction。 - Secret 分发:SecretRef schema、missing secret failure、redaction。
- AgentRun Queuetask schema、attempt 状态机、summary/stats/read cursor、Queue 与 Session 引用边界、旧 MiniMax/OpenCode 直连入口废弃和 redaction。 - AgentRun Queuetask schema、attempt 状态机、summary/stats/read cursor、Queue 与 Session 引用边界、旧 MiniMax/OpenCode 直连入口废弃和 redaction。
- HWLAB v0.2 基线承接:可以用 fake backend/临时 manager 做组件自测试,覆盖 event contract、result completed 防误判、bounded output、runner job status、SessionRef profile 隔离、ResourceBundleRef 失败分类、`promptRefs`/`skillRefs` 装配和 backend preflight redaction;这些自测试不能替代真实 `agentrun-v01` CLI 交互验收。 - HWLAB v0.2 基线承接:可以用 fake backend/临时 manager 做组件自测试,覆盖 event contract、result completed 防误判、bounded output、runner job status、SessionRef profile 隔离、ResourceBundleRef 失败分类、`promptRefs`/gitbundle skillDirs 装配和 backend preflight redaction;这些自测试不能替代真实 `agentrun-v01` CLI 交互验收。
自测试应使用 Bun + TypeScript 运行,Codex 相关自测试可以使用 fake app-server JSON-RPC client 模拟 `initialize``thread/start``thread/resume``turn/start`、assistant 输出、协议错误、timeout 和 transport close。 自测试应使用 Bun + TypeScript 运行,Codex 相关自测试可以使用 fake app-server JSON-RPC client 模拟 `initialize``thread/start``thread/resume``turn/start`、assistant 输出、协议错误、timeout 和 transport close。
@@ -59,7 +59,7 @@
6. manager 可查询 command state、append-only events、terminal_status 和 redacted logPath/job identity。 6. manager 可查询 command state、append-only events、terminal_status 和 redacted logPath/job identity。
7. 重启 `agentrun-mgr` 后,run、command、events 和 terminal_status 仍可从 Postgres 查询。 7. 重启 `agentrun-mgr` 后,run、command、events 和 terminal_status 仍可从 Postgres 查询。
8. 日志、event、CLI 输出和 health 中没有 provider credential、DSN password、token 或 URL credential 明文。 8. 日志、event、CLI 输出和 health 中没有 provider credential、DSN password、token 或 URL credential 明文。
9. 若变更涉及 RuntimeAssembly,必须能追溯 `BackendImageRef``ProfileRef``SessionRef``ResourceBundleRef` 的装配状态;未提供 session/resource 时必须显式为 `null`,提供时必须能查到 session/thread 和 Git commit/tree/workspace 摘要,不能由 runner 隐式猜测。若提供 `promptRefs``skillRefs`,必须能查到 name/path/hash/bytes/required/injected 摘要;required 缺失必须 blocked,不能 fallback 到模型默认 prompt 或默认 skill registry。 9. 若变更涉及 RuntimeAssembly,必须能追溯 `BackendImageRef``ProfileRef``SessionRef``ResourceBundleRef` 的装配状态;未提供 session/resource 时必须显式为 `null`,提供时必须能查到 session/thread 和 Git commit/tree/workspace/bundles 摘要,不能由 runner 隐式猜测。若提供 `promptRefs`gitbundle skills,必须能查到 name/path/hash/bytes/injected 摘要;required prompt 缺失必须 blocked,不能 fallback 到模型默认 prompt 或默认 skill registry。
### CLI 交互联调标准 ### CLI 交互联调标准
@@ -105,8 +105,8 @@ CLI 与 RESTful API 可以复用同一个真实 run 做联调。若两者观察
| cancel | 对 pending/running/terminal 分别调用 cancel | cancel 幂等,pending 不再启动 runnerrunning 收敛为 cancelled 或既有 terminalevents/result 可见。 | | cancel | 对 pending/running/terminal 分别调用 cancel | cancel 幂等,pending 不再启动 runnerrunning 收敛为 cancelled 或既有 terminalevents/result 可见。 |
| SessionRef | 连续两轮使用同一 sessionRef 或 conversation/session/thread 摘要 | 第二轮可 resume backend threadsession 不包含 credential 文件或完整 CODEX_HOME。 | | SessionRef | 连续两轮使用同一 sessionRef 或 conversation/session/thread 摘要 | 第二轮可 resume backend threadsession 不包含 credential 文件或完整 CODEX_HOME。 |
| ResourceBundleRef | 使用 `repoUrl + full commitId` 启动 runner | runner checkout 到允许 workspaceevent/result 能回答 repo、commit、workspace 摘要;不使用 branch/tag/HEAD 或 host path。 | | ResourceBundleRef | 使用 `repoUrl + full commitId` 启动 runner | runner checkout 到允许 workspaceevent/result 能回答 repo、commit、workspace 摘要;不使用 branch/tag/HEAD 或 host path。 |
| Resource prompt/skill assembly | 使用同一 `ResourceBundleRef` 指定 `promptRefs``skillRefs``toolAliases` | 新 thread 首轮注入 initial prompt 和 skill factsresume 不重复注入;required prompt/skill 缺失 blocked;不使用用户长 prompt、旧硬编码 prompt、镜像 `/app/skills` 或默认 Codex skill registry 替代。 | | Resource prompt/skill assembly | 使用同一 `ResourceBundleRef.kind="gitbundle"` 指定 `bundles[]``promptRefs` | 新 thread 首轮注入 initial prompt 和 gitbundle skill factsresume 不重复注入;required prompt 缺失 blocked;不使用用户长 prompt、旧硬编码 prompt、镜像 `/app/skills` 或默认 Codex skill registry 替代。 |
| DS 短 prompt 探测 | 通过正式 CLI/Web 等价入口向真实 `backendProfile=deepseek` 发送短 prompt | “可见 skill”回复包含业务 `skillRefs` 而不只是 Codex 默认系统 skill;“当前 HWLAB 初始规则”能回答 hwpod/HWPOD 四要素/禁止路径;“编译 D601-F103-V2”能触发 `hwpod-cli -> hwpod-compiler-cli -> /v1/hwpod-node-ops -> hwpod-node` 正向链路。 | | DS 短 prompt 探测 | 通过正式 CLI/Web 等价入口向真实 `backendProfile=deepseek` 发送短 prompt | “可见 skill”回复包含 gitbundle `.agents/skills` 中的业务 skill 而不只是 Codex 默认系统 skill;“当前 HWLAB 初始规则”能回答 hwpod/HWPOD 四要素/禁止路径;“编译 D601-F103-V2”能触发 `hwpod -> hwpod-cli -> hwpod-compiler-cli -> /v1/hwpod-node-ops -> hwpod-node` 正向链路。 |
| ProfileRef/SecretRef | 分别验证 `codex``deepseek``minimax-m3` profile | 只使用当前 profile SecretRef;缺失时 `secret-unavailable`,不 fallback,不泄露 Secret 值。 | | ProfileRef/SecretRef | 分别验证 `codex``deepseek``minimax-m3` profile | 只使用当前 profile SecretRef;缺失时 `secret-unavailable`,不 fallback,不泄露 Secret 值。 |
| bounded output | 触发工具/命令输出摘要 | result/event 只含摘要、字节数、截断标记和必要引用,不把大 stdout/stderr 塞入单个 JSON 响应。 | | bounded output | 触发工具/命令输出摘要 | result/event 只含摘要、字节数、截断标记和必要引用,不把大 stdout/stderr 塞入单个 JSON 响应。 |
+10 -19
View File
@@ -56,34 +56,25 @@ export interface SecretRef extends JsonRecord {
mountPath?: string; mountPath?: string;
} }
export interface GitBundleItemRef extends JsonRecord {
name?: string;
repoUrl?: string;
commitId?: string;
subpath: string;
targetPath: string;
}
export interface ResourceBundleRef extends JsonRecord { export interface ResourceBundleRef extends JsonRecord {
kind: "git"; kind: "gitbundle";
repoUrl: string; repoUrl: string;
commitId: string; commitId: string;
subdir?: string; bundles: GitBundleItemRef[];
sparsePaths?: string[];
toolAliases?: Array<{
name: string;
path: string;
kind: "node-script" | "bun-script" | "sh-script" | "executable";
}>;
promptRefs?: Array<{ promptRefs?: Array<{
name: string; name: string;
path: string; path: string;
inject?: "thread-start"; inject?: "thread-start";
required?: boolean; required?: boolean;
}>; }>;
skillRefs?: Array<{
name: string;
path: string;
required?: boolean;
aggregateAs?: string;
}>;
workspaceFiles?: Array<{
path: string;
content: string;
encoding?: "utf8";
}>;
submodules?: false; submodules?: false;
lfs?: false; lfs?: false;
credentialRef?: SecretRef; credentialRef?: SecretRef;
+39 -76
View File
@@ -83,26 +83,13 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
if (value === undefined || value === null) return null; if (value === undefined || value === null) return null;
const record = asRecord(value, "resourceBundleRef"); const record = asRecord(value, "resourceBundleRef");
const kind = requiredString(record, "kind"); const kind = requiredString(record, "kind");
if (kind !== "git") throw new AgentRunError("schema-invalid", "resourceBundleRef.kind must be git in v0.1", { httpStatus: 400 }); if (kind !== "gitbundle") throw new AgentRunError("schema-invalid", "resourceBundleRef.kind must be gitbundle", { httpStatus: 400 });
const repoUrl = requiredString(record, "repoUrl"); const repoUrl = requiredString(record, "repoUrl");
const commitId = requiredString(record, "commitId"); const commitId = requiredString(record, "commitId");
if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", "resourceBundleRef.commitId must be a full 40-character git commit sha", { httpStatus: 400 }); validateCommitId(commitId, "resourceBundleRef.commitId");
const result: ResourceBundleRef = { kind: "git", repoUrl, commitId }; rejectLegacyResourceBundleFields(record);
const subdir = optionalString(record.subdir); const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, commitId, bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId) };
if (subdir) {
if (subdir.startsWith("/") || subdir.includes("..")) throw new AgentRunError("schema-invalid", "resourceBundleRef.subdir must stay within the checkout", { httpStatus: 400 });
result.subdir = subdir;
}
if (record.sparsePaths !== undefined) {
if (!Array.isArray(record.sparsePaths) || !record.sparsePaths.every((item) => typeof item === "string" && item.length > 0 && !item.startsWith("/") && !item.includes(".."))) {
throw new AgentRunError("schema-invalid", "resourceBundleRef.sparsePaths must be relative path strings", { httpStatus: 400 });
}
result.sparsePaths = record.sparsePaths as string[];
}
if (record.toolAliases !== undefined) result.toolAliases = validateResourceToolAliases(record.toolAliases);
if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs); if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs);
if (record.skillRefs !== undefined) result.skillRefs = validateResourceSkillRefs(record.skillRefs);
if (record.workspaceFiles !== undefined) result.workspaceFiles = validateResourceWorkspaceFiles(record.workspaceFiles);
if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 }); if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 });
if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 }); if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 });
if (record.submodules === false) result.submodules = false; if (record.submodules === false) result.submodules = false;
@@ -111,24 +98,39 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
return result; return result;
} }
function validateResourceToolAliases(value: unknown): NonNullable<ResourceBundleRef["toolAliases"]> { function rejectLegacyResourceBundleFields(record: JsonRecord): void {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.toolAliases must be an array", { httpStatus: 400 }); for (const field of ["toolAliases", "skillRefs", "workspaceFiles", "subdir", "sparsePaths"] as const) {
if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.toolAliases must contain at most 16 entries", { httpStatus: 400 }); if (record[field] !== undefined) throw new AgentRunError("schema-invalid", `resourceBundleRef.${field} is removed; use resourceBundleRef.bundles[] with kind=gitbundle`, { httpStatus: 400 });
}
}
function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defaultCommitId: string): ResourceBundleRef["bundles"] {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must be an array", { httpStatus: 400 });
if (value.length === 0) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at least one entry", { httpStatus: 400 });
if (value.length > 64) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at most 64 entries", { httpStatus: 400 });
const seen = new Set<string>(); const seen = new Set<string>();
return value.map((entry, index) => { return value.map((entry, index) => {
const record = asRecord(entry, `resourceBundleRef.toolAliases[${index}]`); const record = asRecord(entry, `resourceBundleRef.bundles[${index}]`);
const name = requiredString(record, "name"); const name = optionalString(record.name);
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].name must be a lowercase command name`, { httpStatus: 400 }); if (name) validateResourceName(name, `resourceBundleRef.bundles[${index}].name`);
if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases name ${name} is duplicated`, { httpStatus: 400 }); const repoUrl = optionalString(record.repoUrl) ?? defaultRepoUrl;
seen.add(name); const commitId = optionalString(record.commitId) ?? defaultCommitId;
const aliasPath = requiredString(record, "path"); validateCommitId(commitId, `resourceBundleRef.bundles[${index}].commitId`);
if (aliasPath.startsWith("/") || aliasPath.includes("..")) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].path must stay within the checkout`, { httpStatus: 400 }); const subpath = validateBundleSubpath(requiredString(record, "subpath"), `resourceBundleRef.bundles[${index}].subpath`);
const kind = requiredString(record, "kind"); const rawTargetPath = typeof record.targetPath === "string" ? record.targetPath : record.target_path;
if (kind !== "node-script" && kind !== "bun-script" && kind !== "sh-script" && kind !== "executable") throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].kind is not supported in v0.1`, { httpStatus: 400, details: { allowedKinds: ["node-script", "bun-script", "sh-script", "executable"] } }); if (typeof rawTargetPath !== "string" || rawTargetPath.trim().length === 0) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path is required`, { httpStatus: 400 });
return { name, path: aliasPath, kind }; const targetPath = validateWorkspaceRelativePath(rawTargetPath.trim(), `resourceBundleRef.bundles[${index}].target_path`);
const identity = `${repoUrl}\0${commitId}\0${subpath}\0${targetPath}`;
if (seen.has(identity)) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}] duplicates an earlier bundle target`, { httpStatus: 400 });
seen.add(identity);
return { ...(name ? { name } : {}), ...(repoUrl === defaultRepoUrl ? {} : { repoUrl }), ...(commitId === defaultCommitId ? {} : { commitId }), subpath, targetPath };
}); });
} }
function validateCommitId(commitId: string, fieldName: string): void {
if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", `${fieldName} must be a full 40-character git commit sha`, { httpStatus: 400 });
}
function validateResourcePromptRefs(value: unknown): NonNullable<ResourceBundleRef["promptRefs"]> { function validateResourcePromptRefs(value: unknown): NonNullable<ResourceBundleRef["promptRefs"]> {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must be an array", { httpStatus: 400 }); if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must be an array", { httpStatus: 400 });
if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must contain at most 16 entries", { httpStatus: 400 }); if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must contain at most 16 entries", { httpStatus: 400 });
@@ -147,62 +149,23 @@ function validateResourcePromptRefs(value: unknown): NonNullable<ResourceBundleR
}); });
} }
function validateResourceSkillRefs(value: unknown): NonNullable<ResourceBundleRef["skillRefs"]> {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.skillRefs must be an array", { httpStatus: 400 });
if (value.length > 32) throw new AgentRunError("schema-invalid", "resourceBundleRef.skillRefs must contain at most 32 entries", { httpStatus: 400 });
const seen = new Set<string>();
return value.map((entry, index) => {
const record = asRecord(entry, `resourceBundleRef.skillRefs[${index}]`);
const name = validateResourceName(requiredString(record, "name"), `resourceBundleRef.skillRefs[${index}].name`);
if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs name ${name} is duplicated`, { httpStatus: 400 });
seen.add(name);
const skillPath = validateBundleRelativePath(requiredString(record, "path"), `resourceBundleRef.skillRefs[${index}].path`);
if (!skillPath.endsWith("SKILL.md")) throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs[${index}].path must point to SKILL.md in v0.1`, { httpStatus: 400 });
const required = record.required === undefined ? false : record.required;
if (typeof required !== "boolean") throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs[${index}].required must be boolean`, { httpStatus: 400 });
const aggregateAs = optionalString(record.aggregateAs);
if (aggregateAs) validateResourceName(aggregateAs, `resourceBundleRef.skillRefs[${index}].aggregateAs`);
return { name, path: skillPath, required, ...(aggregateAs ? { aggregateAs } : {}) };
});
}
const maxWorkspaceFiles = Number.MAX_SAFE_INTEGER;
const maxWorkspaceFileBytes = 128 * 1024;
const maxWorkspaceFilesTotalBytes = 4 * 1024 * 1024;
function validateResourceWorkspaceFiles(value: unknown): NonNullable<ResourceBundleRef["workspaceFiles"]> {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.workspaceFiles must be an array", { httpStatus: 400 });
if (value.length > maxWorkspaceFiles) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles must contain at most ${maxWorkspaceFiles} entries`, { httpStatus: 400 });
const seen = new Set<string>();
let totalBytes = 0;
return value.map((entry, index) => {
const record = asRecord(entry, `resourceBundleRef.workspaceFiles[${index}]`);
const filePath = validateWorkspaceRelativePath(requiredString(record, "path"), `resourceBundleRef.workspaceFiles[${index}].path`);
if (seen.has(filePath)) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles path ${filePath} is duplicated`, { httpStatus: 400 });
seen.add(filePath);
if (typeof record.content !== "string") throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}].content must be a utf8 string`, { httpStatus: 400 });
const encoding = optionalString(record.encoding) ?? "utf8";
if (encoding !== "utf8") throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}].encoding must be utf8`, { httpStatus: 400 });
const bytes = Buffer.byteLength(record.content, "utf8");
if (bytes > maxWorkspaceFileBytes) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}] exceeds the per-file size limit`, { httpStatus: 400, details: { path: filePath, bytes, maxWorkspaceFileBytes, valuesPrinted: false } });
totalBytes += bytes;
if (totalBytes > maxWorkspaceFilesTotalBytes) throw new AgentRunError("schema-invalid", "resourceBundleRef.workspaceFiles exceeds the total size limit", { httpStatus: 400, details: { totalBytes, maxWorkspaceFilesTotalBytes, valuesPrinted: false } });
return { path: filePath, content: record.content, encoding: "utf8" as const };
});
}
function validateResourceName(name: string, fieldName: string): string { function validateResourceName(name: string, fieldName: string): string {
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be a lowercase resource name`, { httpStatus: 400 }); if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be a lowercase resource name`, { httpStatus: 400 });
return name; return name;
} }
function validateBundleSubpath(relativePath: string, fieldName: string): string {
if (relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 });
return relativePath;
}
function validateBundleRelativePath(relativePath: string, fieldName: string): string { function validateBundleRelativePath(relativePath: string, fieldName: string): string {
if (relativePath.startsWith("/") || relativePath.includes("..")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 }); if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 });
return relativePath; return relativePath;
} }
function validateWorkspaceRelativePath(relativePath: string, fieldName: string): string { function validateWorkspaceRelativePath(relativePath: string, fieldName: string): string {
if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the workspace`, { httpStatus: 400 }); if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the workspace`, { httpStatus: 400 });
return relativePath; return relativePath;
} }
+5 -4
View File
@@ -172,11 +172,12 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord |
kind: run.resourceBundleRef.kind, kind: run.resourceBundleRef.kind,
repoUrl: run.resourceBundleRef.repoUrl, repoUrl: run.resourceBundleRef.repoUrl,
commitId: run.resourceBundleRef.commitId, commitId: run.resourceBundleRef.commitId,
subdir: run.resourceBundleRef.subdir ?? null, bundles: {
toolAliases: run.resourceBundleRef.toolAliases ? { count: run.resourceBundleRef.toolAliases.length, names: run.resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false }, count: run.resourceBundleRef.bundles.length,
items: run.resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? run.resourceBundleRef?.repoUrl ?? null, commitId: item.commitId ?? run.resourceBundleRef?.commitId ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
valuesPrinted: false,
},
promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false }, promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
skillRefs: run.resourceBundleRef.skillRefs ? { count: run.resourceBundleRef.skillRefs.length, names: run.resourceBundleRef.skillRefs.map((item) => item.name), required: run.resourceBundleRef.skillRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
workspaceFiles: run.resourceBundleRef.workspaceFiles ? { count: run.resourceBundleRef.workspaceFiles.length, paths: run.resourceBundleRef.workspaceFiles.map((item) => item.path), valuesPrinted: false } : { count: 0, paths: [], valuesPrinted: false },
materialized: materialized as JsonValue, materialized: materialized as JsonValue,
}; };
} }
+5 -5
View File
@@ -732,12 +732,12 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
kind: resourceBundleRef.kind, kind: resourceBundleRef.kind,
repoUrl: resourceBundleRef.repoUrl, repoUrl: resourceBundleRef.repoUrl,
commitId: resourceBundleRef.commitId, commitId: resourceBundleRef.commitId,
subdir: resourceBundleRef.subdir ?? null, bundles: {
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0, count: resourceBundleRef.bundles.length,
toolAliases: resourceBundleRef.toolAliases ? { count: resourceBundleRef.toolAliases.length, names: resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false }, items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, commitId: item.commitId ?? resourceBundleRef.commitId, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
valuesPrinted: false,
},
promptRefs: resourceBundleRef.promptRefs ? { count: resourceBundleRef.promptRefs.length, names: resourceBundleRef.promptRefs.map((item) => item.name), required: resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false }, promptRefs: resourceBundleRef.promptRefs ? { count: resourceBundleRef.promptRefs.length, names: resourceBundleRef.promptRefs.map((item) => item.name), required: resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
skillRefs: resourceBundleRef.skillRefs ? { count: resourceBundleRef.skillRefs.length, names: resourceBundleRef.skillRefs.map((item) => item.name), required: resourceBundleRef.skillRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
workspaceFiles: resourceBundleRef.workspaceFiles ? { count: resourceBundleRef.workspaceFiles.length, paths: resourceBundleRef.workspaceFiles.map((item) => item.path), valuesPrinted: false } : { count: 0, paths: [], valuesPrinted: false },
submodules: resourceBundleRef.submodules ?? false, submodules: resourceBundleRef.submodules ?? false,
lfs: resourceBundleRef.lfs ?? false, lfs: resourceBundleRef.lfs ?? false,
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null, credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
+135 -125
View File
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { chmod, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { chmod, cp, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { AgentRunError } from "../common/errors.js"; import { AgentRunError } from "../common/errors.js";
import { redactText } from "../common/redaction.js"; import { redactText } from "../common/redaction.js";
@@ -40,70 +40,135 @@ interface MaterializedSkillRef {
summary: string; summary: string;
} }
interface GitCheckout {
repoUrl: string;
commitId: string;
checkoutPath: string;
treeId: string;
}
interface MaterializedGitBundle {
name: string | null;
repoUrl: string;
commitId: string;
subpath: string;
targetPath: string;
sourceKind: "file" | "directory";
sourceBytes: number | null;
}
export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, env: NodeJS.ProcessEnv = process.env): Promise<MaterializedResourceBundle | null> { export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, env: NodeJS.ProcessEnv = process.env): Promise<MaterializedResourceBundle | null> {
if (!resourceBundleRef) return null; if (!resourceBundleRef) return null;
const workspaceRoot = path.resolve(env.AGENTRUN_WORKSPACE_ROOT ?? "/home/agentrun/workspaces"); const workspaceRoot = path.resolve(env.AGENTRUN_WORKSPACE_ROOT ?? "/home/agentrun/workspaces");
const checkoutPath = path.join(workspaceRoot, stableHash({ repoUrl: resourceBundleRef.repoUrl, commitId: resourceBundleRef.commitId }).slice(0, 16)); const runScope = env.AGENTRUN_RUN_ID ?? env.AGENTRUN_ATTEMPT_ID ?? "standalone";
await mkdir(checkoutPath, { recursive: true }); const assemblyRoot = path.join(workspaceRoot, `gitbundle-${stableHash({ runScope, resourceBundleRef }).slice(0, 16)}`);
await git(["init"], checkoutPath); const checkoutRoot = path.join(assemblyRoot, "checkouts");
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true }); const workspacePath = path.join(assemblyRoot, "workspace");
await git(["remote", "add", "origin", resourceBundleRef.repoUrl], checkoutPath); await rm(assemblyRoot, { recursive: true, force: true });
if (resourceBundleRef.sparsePaths && resourceBundleRef.sparsePaths.length > 0) { await mkdir(checkoutRoot, { recursive: true });
await git(["config", "core.sparseCheckout", "true"], checkoutPath); await mkdir(workspacePath, { recursive: true });
await mkdir(path.join(checkoutPath, ".git", "info"), { recursive: true }); const checkoutCache = new Map<string, Promise<GitCheckout>>();
await writeFile(path.join(checkoutPath, ".git", "info", "sparse-checkout"), `${resourceBundleRef.sparsePaths.join("\n")}\n`, "utf8"); const checkoutFor = (repoUrl: string, commitId: string) => {
} const key = stableHash({ repoUrl, commitId });
await git(["fetch", "--depth", "1", "origin", resourceBundleRef.commitId], checkoutPath); let checkout = checkoutCache.get(key);
await git(["checkout", "--detach", resourceBundleRef.commitId], checkoutPath); if (!checkout) {
const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim(); checkout = checkoutGitCommit(checkoutRoot, repoUrl, commitId);
if (actualCommit !== resourceBundleRef.commitId) throw new AgentRunError("infra-failed", "resource bundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: resourceBundleRef.commitId, actualCommit } }); checkoutCache.set(key, checkout);
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim(); }
const workspacePath = resolveWorkspacePath(checkoutPath, resourceBundleRef.subdir); return checkout;
const toolAliases = await materializeToolAliases(checkoutPath, resourceBundleRef.toolAliases ?? [], env); };
const skills = await materializeSkillRefs(checkoutPath, workspacePath, resourceBundleRef.skillRefs ?? []); const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, checkoutFor);
const prompts = await materializePromptRefs(checkoutPath, resourceBundleRef.promptRefs ?? []); const defaultCheckout = await checkoutFor(resourceBundleRef.repoUrl, resourceBundleRef.commitId);
const workspaceFiles = await materializeWorkspaceFiles(workspacePath, resourceBundleRef.workspaceFiles ?? []); const tools = await prepareGitBundleTools(workspacePath);
const skills = await discoverGitBundleSkills(workspacePath);
const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []);
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items); const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
return { return {
workspacePath, workspacePath,
...(toolAliases.binPath ? { binPath: toolAliases.binPath } : {}), ...(tools.binPath ? { binPath: tools.binPath } : {}),
...(skills.skillsDir ? { skillsDir: skills.skillsDir } : {}), ...(skills.skillsDir ? { skillsDir: skills.skillsDir } : {}),
...(initialPrompt ? { initialPrompt } : {}), ...(initialPrompt ? { initialPrompt } : {}),
event: { event: {
phase: "resource-bundle-materialized", phase: "resource-bundle-materialized",
kind: "git", kind: "gitbundle",
repoUrl: resourceBundleRef.repoUrl, repoUrl: resourceBundleRef.repoUrl,
commitId: resourceBundleRef.commitId, commitId: resourceBundleRef.commitId,
treeId, treeId: defaultCheckout.treeId,
checkoutPath: pathSummary(checkoutPath), checkoutPath: pathSummary(defaultCheckout.checkoutPath),
workspacePath: pathSummary(workspacePath), workspacePath: pathSummary(workspacePath),
subdir: resourceBundleRef.subdir ?? null, bundles: {
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0, count: materializedBundles.length,
toolAliases: toolAliases.event, items: materializedBundles.map((item) => ({ ...item, valuesPrinted: false })),
skillRefs: skills.event, valuesPrinted: false,
},
tools: tools.event,
skillDirs: skills.event,
promptRefs: prompts.event, promptRefs: prompts.event,
workspaceFiles: workspaceFiles.event, initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillRefCount: skills.items.length, valuesPrinted: false },
valuesPrinted: false, valuesPrinted: false,
}, },
}; };
} }
async function materializeToolAliases(checkoutPath: string, aliases: NonNullable<ResourceBundleRef["toolAliases"]>, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> { async function checkoutGitCommit(checkoutRoot: string, repoUrl: string, commitId: string): Promise<GitCheckout> {
if (aliases.length === 0) return { event: { count: 0, names: [], binPath: null, valuesPrinted: false } }; const checkoutPath = path.join(checkoutRoot, stableHash({ repoUrl, commitId }).slice(0, 16));
const binPath = path.resolve(env.AGENTRUN_RESOURCE_BIN_PATH ?? path.join(path.dirname(checkoutPath), ".bin")); await mkdir(checkoutPath, { recursive: true });
await mkdir(binPath, { recursive: true }); await git(["init"], checkoutPath);
const names: string[] = []; await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
for (const alias of aliases) { await git(["remote", "add", "origin", repoUrl], checkoutPath);
const target = resolveBundlePath(checkoutPath, alias.path, `toolAliases.${alias.name}.path`); await git(["fetch", "--depth", "1", "origin", commitId], checkoutPath);
const wrapper = path.join(binPath, alias.name); await git(["checkout", "--detach", commitId], checkoutPath);
const content = aliasWrapper(alias.kind, target); const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim();
await assertAliasWrapperWritable(wrapper, alias.name); if (actualCommit !== commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: commitId, actualCommit } });
await writeFile(wrapper, content, "utf8"); const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
await chmod(wrapper, 0o755); return { repoUrl, commitId, checkoutPath, treeId };
names.push(alias.name); }
async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, checkoutFor: (repoUrl: string, commitId: string) => Promise<GitCheckout>): Promise<MaterializedGitBundle[]> {
const items: MaterializedGitBundle[] = [];
for (const [index, bundle] of resourceBundleRef.bundles.entries()) {
const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl;
const commitId = bundle.commitId ?? resourceBundleRef.commitId;
const checkout = await checkoutFor(repoUrl, commitId);
const source = resolveBundlePath(checkout.checkoutPath, bundle.subpath, `bundles[${index}].subpath`);
const target = resolveWorkspaceTargetPath(workspacePath, bundle.targetPath, `bundles[${index}].target_path`);
let sourceStat;
try {
sourceStat = await stat(source);
} catch (error) {
throw new AgentRunError("schema-invalid", `gitbundle subpath ${bundle.subpath} is not readable`, { httpStatus: 400, details: { index, subpath: bundle.subpath, error: fileErrorSummary(error), valuesPrinted: false } });
}
await mkdir(path.dirname(target), { recursive: true });
await rm(target, { recursive: true, force: true });
await cp(source, target, { recursive: true, force: true, dereference: false });
items.push({ name: bundle.name ?? null, repoUrl, commitId, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null });
} }
return { binPath, event: { count: names.length, names, binPath: pathSummary(binPath), valuesPrinted: false } }; return items;
}
async function prepareGitBundleTools(workspacePath: string): Promise<{ binPath?: string; event: JsonRecord }> {
const binPath = path.join(workspacePath, "tools");
let entries;
try {
entries = await readdir(binPath, { withFileTypes: true });
} catch (error) {
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return { event: { count: 0, names: [], binPath: null, valuesPrinted: false } };
throw error;
}
const names: string[] = [];
const items: JsonRecord[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = path.join(binPath, entry.name);
const text = await readFile(filePath, "utf8");
const firstLine = text.split(/\r?\n/u, 1)[0] ?? "";
if (entry.name.endsWith(".ts") && !firstLine.startsWith("#!")) throw new AgentRunError("schema-invalid", `gitbundle tool ${entry.name} must start with a shebang`, { httpStatus: 400 });
if (!firstLine.startsWith("#!")) continue;
await chmod(filePath, 0o755);
names.push(entry.name);
items.push({ name: entry.name, sha256: sha256Text(text), bytes: Buffer.byteLength(text, "utf8"), shebang: firstLine.slice(0, 80), valuesPrinted: false });
}
return { binPath, event: { count: names.length, names, items, binPath: pathSummary(binPath), valuesPrinted: false } };
} }
async function materializePromptRefs(checkoutPath: string, refs: NonNullable<ResourceBundleRef["promptRefs"]>): Promise<{ items: MaterializedPromptRef[]; event: JsonRecord }> { async function materializePromptRefs(checkoutPath: string, refs: NonNullable<ResourceBundleRef["promptRefs"]>): Promise<{ items: MaterializedPromptRef[]; event: JsonRecord }> {
@@ -142,41 +207,39 @@ async function materializePromptRefs(checkoutPath: string, refs: NonNullable<Res
}; };
} }
async function materializeSkillRefs(checkoutPath: string, workspacePath: string, refs: NonNullable<ResourceBundleRef["skillRefs"]>): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> { async function discoverGitBundleSkills(workspacePath: string): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } };
const skillsDir = path.join(workspacePath, ".agents", "skills"); const skillsDir = path.join(workspacePath, ".agents", "skills");
await mkdir(skillsDir, { recursive: true }); let entries;
try {
entries = await readdir(skillsDir, { withFileTypes: true });
} catch (error) {
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } };
throw error;
}
const items: MaterializedSkillRef[] = []; const items: MaterializedSkillRef[] = [];
const eventItems: JsonRecord[] = []; const eventItems: JsonRecord[] = [];
for (const ref of refs) { for (const entry of entries) {
const manifestPath = resolveBundlePath(checkoutPath, ref.path, `skillRefs.${ref.name}.path`); if (!entry.isDirectory()) continue;
const required = ref.required === true; const aggregateAs = entry.name;
const manifestPath = path.join(skillsDir, aggregateAs, "SKILL.md");
let manifestText: string; let manifestText: string;
try { try {
manifestText = await readFile(manifestPath, "utf8"); manifestText = await readFile(manifestPath, "utf8");
} catch (error) { } catch (error) {
if (required) throw new AgentRunError("skill-unavailable", `required resource skill ${ref.name} is not readable`, { httpStatus: 400, details: { name: ref.name, path: ref.path, error: fileErrorSummary(error), valuesPrinted: false } }); eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, required: true, aggregateAs, status: "missing", error: fileErrorSummary(error), valuesPrinted: false });
eventItems.push({ name: ref.name, path: ref.path, required, aggregateAs: ref.aggregateAs ?? ref.name, status: "missing", valuesPrinted: false });
continue; continue;
} }
const aggregateAs = ref.aggregateAs ?? ref.name;
const sourceRoot = path.dirname(manifestPath);
const targetRoot = path.join(skillsDir, aggregateAs);
if (path.resolve(sourceRoot) !== path.resolve(targetRoot)) {
await rm(targetRoot, { recursive: true, force: true });
await cp(sourceRoot, targetRoot, { recursive: true, force: true, dereference: false });
}
const bytes = Buffer.byteLength(manifestText, "utf8"); const bytes = Buffer.byteLength(manifestText, "utf8");
const sha = sha256Text(manifestText); const sha = sha256Text(manifestText);
const summary = skillSummary(manifestText); const summary = skillSummary(manifestText);
items.push({ name: ref.name, path: ref.path, aggregateAs, required, registryPath: path.join(targetRoot, "SKILL.md"), manifestBytes: bytes, manifestSha256: sha, summary }); items.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, registryPath: manifestPath, manifestBytes: bytes, manifestSha256: sha, summary });
eventItems.push({ name: ref.name, path: ref.path, aggregateAs, required, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(path.join(targetRoot, "SKILL.md")), summary, valuesPrinted: false }); eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(manifestPath), summary, valuesPrinted: false });
} }
return { return {
items, items,
skillsDir, skillsDir,
event: { event: {
count: refs.length, count: entries.filter((entry) => entry.isDirectory()).length,
materializedCount: items.length, materializedCount: items.length,
names: items.map((item) => item.name), names: items.map((item) => item.name),
skillsDir: pathSummary(skillsDir), skillsDir: pathSummary(skillsDir),
@@ -186,50 +249,25 @@ async function materializeSkillRefs(checkoutPath: string, workspacePath: string,
}; };
} }
async function materializeWorkspaceFiles(workspacePath: string, files: NonNullable<ResourceBundleRef["workspaceFiles"]>): Promise<{ event: JsonRecord }> { function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skills: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
if (files.length === 0) return { event: { count: 0, materializedCount: 0, items: [], totalBytes: 0, valuesPrinted: false } }; if (promptRefs.length === 0 && skills.length === 0) return undefined;
const eventItems: JsonRecord[] = [];
let totalBytes = 0;
for (const file of files) {
const target = resolveWorkspaceFilePath(workspacePath, file.path, `workspaceFiles.${file.path}.path`);
const content = file.content;
const bytes = Buffer.byteLength(content, "utf8");
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content, "utf8");
totalBytes += bytes;
eventItems.push({ path: file.path, encoding: file.encoding ?? "utf8", status: "materialized", bytes, sha256: sha256Text(content), valuesPrinted: false });
}
return {
event: {
count: files.length,
materializedCount: eventItems.length,
paths: eventItems.map((item) => item.path),
items: eventItems,
totalBytes,
valuesPrinted: false,
},
};
}
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
if (promptRefs.length === 0 && skillRefs.length === 0) return undefined;
const sections: string[] = [ const sections: string[] = [
"AgentRun initial runtime instructions. These instructions are assembled from ResourceBundleRef promptRefs and skillRefs for the first thread-start turn only.", "AgentRun initial runtime instructions. These instructions are assembled from ResourceBundleRef promptRefs and gitbundle skill directories for the first thread-start turn only.",
]; ];
for (const prompt of promptRefs) { for (const prompt of promptRefs) {
sections.push([`## Resource Prompt: ${prompt.name}`, `path: ${prompt.path}`, prompt.text].join("\n")); sections.push([`## Resource Prompt: ${prompt.name}`, `path: ${prompt.path}`, prompt.text].join("\n"));
} }
if (skillRefs.length > 0) { if (skills.length > 0) {
const lines = [ const lines = [
"## Resource Skills", "## Resource Skills",
"The following required runtime skills are mounted in the current workspace. Use these bundle skills instead of default model skill guesses.", "The following required runtime skills are mounted in the current workspace. Use these bundle skills instead of default model skill guesses.",
...skillRefs.map((skill) => `- ${skill.name}: ${skill.summary || "No summary provided."} manifest=.agents/skills/${skill.aggregateAs}/SKILL.md source=${skill.path} required=${skill.required}`), ...skills.map((skill) => `- ${skill.name}: ${skill.summary || "No summary provided."} manifest=.agents/skills/${skill.aggregateAs}/SKILL.md source=${skill.path} required=${skill.required}`),
]; ];
sections.push(lines.join("\n")); sections.push(lines.join("\n"));
} }
const text = sections.join("\n\n"); const text = sections.join("\n\n");
const bytes = Buffer.byteLength(text, "utf8"); const bytes = Buffer.byteLength(text, "utf8");
if (bytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled initial prompt exceeds the total size limit", { httpStatus: 400, details: { bytes, maxInitialPromptBytes, promptRefCount: promptRefs.length, skillRefCount: skillRefs.length, valuesPrinted: false } }); if (bytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled initial prompt exceeds the total size limit", { httpStatus: 400, details: { bytes, maxInitialPromptBytes, promptRefCount: promptRefs.length, skillCount: skills.length, valuesPrinted: false } });
return { return {
text, text,
summary: { summary: {
@@ -238,8 +276,8 @@ function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: M
sha256: sha256Text(text), sha256: sha256Text(text),
promptRefCount: promptRefs.length, promptRefCount: promptRefs.length,
promptRefNames: promptRefs.map((item) => item.name), promptRefNames: promptRefs.map((item) => item.name),
skillRefCount: skillRefs.length, skillCount: skills.length,
skillRefNames: skillRefs.map((item) => item.name), skillNames: skills.map((item) => item.name),
valuesPrinted: false, valuesPrinted: false,
}, },
}; };
@@ -269,25 +307,6 @@ function fileErrorSummary(error: unknown): JsonRecord {
return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? redactText(record.message).slice(0, 300) : null }; return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? redactText(record.message).slice(0, 300) : null };
} }
function aliasWrapper(kind: string, target: string): string {
if (kind === "node-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec node ${shellArg(target)} "$@"\n`;
if (kind === "bun-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec bun ${shellArg(target)} "$@"\n`;
if (kind === "sh-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec sh ${shellArg(target)} "$@"\n`;
return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec ${shellArg(target)} "$@"\n`;
}
async function assertAliasWrapperWritable(wrapper: string, name: string): Promise<void> {
try {
const existing = await readFile(wrapper, "utf8");
if (existing.includes("agentrun-resource-alias-wrapper")) return;
throw new AgentRunError("schema-invalid", `resource bundle tool alias ${name} would overwrite an existing command`, { httpStatus: 409, details: { wrapper: pathSummary(wrapper) } });
} catch (error) {
if (error instanceof AgentRunError) throw error;
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return;
throw error;
}
}
async function git(args: string[], cwd: string, options: { allowFailure?: boolean } = {}): Promise<{ stdout: string; stderr: string }> { async function git(args: string[], cwd: string, options: { allowFailure?: boolean } = {}): Promise<{ stdout: string; stderr: string }> {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
let stdout = ""; let stdout = "";
@@ -308,11 +327,6 @@ async function git(args: string[], cwd: string, options: { allowFailure?: boolea
return { stdout, stderr }; return { stdout, stderr };
} }
function resolveWorkspacePath(checkoutPath: string, subdir: string | undefined): string {
if (!subdir) return checkoutPath;
return resolveBundlePath(checkoutPath, subdir, "resourceBundleRef.subdir");
}
function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName: string): string { function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName: string): string {
const resolved = path.resolve(checkoutPath, relativePath); const resolved = path.resolve(checkoutPath, relativePath);
const root = path.resolve(checkoutPath); const root = path.resolve(checkoutPath);
@@ -320,17 +334,13 @@ function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName
return resolved; return resolved;
} }
function resolveWorkspaceFilePath(workspacePath: string, relativePath: string, fieldName: string): string { function resolveWorkspaceTargetPath(workspacePath: string, relativePath: string, fieldName: string): string {
const resolved = path.resolve(workspacePath, relativePath); const resolved = path.resolve(workspacePath, relativePath);
const root = path.resolve(workspacePath); const root = path.resolve(workspacePath);
if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped workspace`, { httpStatus: 400 }); if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped workspace`, { httpStatus: 400 });
return resolved; return resolved;
} }
function shellArg(value: string): string {
return `'${value.replace(/'/gu, `'"'"'`)}'`;
}
function pathSummary(value: string): JsonRecord { function pathSummary(value: string): JsonRecord {
const parts = value.split(/[\\/]+/u).filter(Boolean); const parts = value.split(/[\\/]+/u).filter(Boolean);
return { absolute: path.isAbsolute(value), basename: parts.at(-1) ?? null, depth: parts.length, fingerprint: stableHash(value).slice(0, 16), valuePrinted: false }; return { absolute: path.isAbsolute(value), basename: parts.at(-1) ?? null, depth: parts.length, fingerprint: stableHash(value).slice(0, 16), valuePrinted: false };
+5 -1
View File
@@ -95,7 +95,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
if (!materializationAttempted) { if (!materializationAttempted) {
materializationAttempted = true; materializationAttempted = true;
try { try {
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, options.env ?? process.env); const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, resourceMaterializationEnv(options.env ?? process.env, options.runId, attemptId));
if (materialized) { if (materialized) {
workspacePath = materialized.workspacePath; workspacePath = materialized.workspacePath;
resourceEnv = resourceEnvForMaterialized(options.env ?? process.env, materialized); resourceEnv = resourceEnvForMaterialized(options.env ?? process.env, materialized);
@@ -145,6 +145,10 @@ function withResourceAssembly(options: RunnerOnceOptions, resourceEnv: NodeJS.Pr
}; };
} }
function resourceMaterializationEnv(env: NodeJS.ProcessEnv, runId: string, attemptId: string): NodeJS.ProcessEnv {
return { ...env, AGENTRUN_RUN_ID: env.AGENTRUN_RUN_ID ?? runId, AGENTRUN_ATTEMPT_ID: env.AGENTRUN_ATTEMPT_ID ?? attemptId };
}
function resourceEnvForMaterialized(env: NodeJS.ProcessEnv, materialized: Awaited<ReturnType<typeof materializeResourceBundle>>): NodeJS.ProcessEnv | undefined { function resourceEnvForMaterialized(env: NodeJS.ProcessEnv, materialized: Awaited<ReturnType<typeof materializeResourceBundle>>): NodeJS.ProcessEnv | undefined {
if (!materialized) return undefined; if (!materialized) return undefined;
let next: NodeJS.ProcessEnv | undefined; let next: NodeJS.ProcessEnv | undefined;
+21 -46
View File
@@ -7,12 +7,11 @@ import { startManagerServer } from "../../mgr/server.js";
import { ManagerClient } from "../../mgr/client.js"; import { ManagerClient } from "../../mgr/client.js";
import { MemoryAgentRunStore } from "../../mgr/store.js"; import { MemoryAgentRunStore } from "../../mgr/store.js";
import { runOnce } from "../../runner/run-once.js"; import { runOnce } from "../../runner/run-once.js";
import { stableHash } from "../../common/validation.js";
import type { JsonRecord, ResourceBundleRef } from "../../common/types.js"; import type { JsonRecord, ResourceBundleRef } from "../../common/types.js";
import { assertNoSecretLeak, type SelfTestCase, type SelfTestContext } from "../harness.js"; import { assertNoSecretLeak, type SelfTestCase, type SelfTestContext } from "../harness.js";
const execFile = promisify(execFileCallback); const execFile = promisify(execFileCallback);
type LocalBundle = { repoUrl: string; commitId: string; toolAliases?: ResourceBundleRef["toolAliases"]; promptRefs?: ResourceBundleRef["promptRefs"]; skillRefs?: ResourceBundleRef["skillRefs"]; workspaceFiles?: ResourceBundleRef["workspaceFiles"] }; type LocalBundle = { repoUrl: string; commitId: string; bundles?: ResourceBundleRef["bundles"]; promptRefs?: ResourceBundleRef["promptRefs"] };
const selfTest: SelfTestCase = async (context) => { const selfTest: SelfTestCase = async (context) => {
const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8"); const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8");
@@ -49,10 +48,6 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
const assemblyBundle: LocalBundle = { const assemblyBundle: LocalBundle = {
...bundle, ...bundle,
promptRefs: [{ name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true }], promptRefs: [{ name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true }],
skillRefs: [
{ name: "hwpod-cli", path: "skills/hwpod-cli/SKILL.md", required: true, aggregateAs: "hwpod-cli" },
{ name: "hwpod-ctl", path: "skills/hwpod-ctl/SKILL.md", required: true, aggregateAs: "hwpod-ctl" },
],
}; };
const first = await createHwlabRun(client, context, bundle, "hwlab-session-1", "hello bundle", "hwlab-command-1"); const first = await createHwlabRun(client, context, bundle, "hwlab-session-1", "hello bundle", "hwlab-command-1");
const created = await client.post(`/api/v1/runs/${first.runId}/runner-jobs`, { commandId: first.commandId, idempotencyKey: "hwlab-trace-1" }) as JsonRecord; const created = await client.post(`/api/v1/runs/${first.runId}/runner-jobs`, { commandId: first.commandId, idempotencyKey: "hwlab-trace-1" }) as JsonRecord;
@@ -66,7 +61,6 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
); );
const manifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord; const manifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord;
assert.ok(JSON.stringify(manifest).includes("AGENTRUN_RESOURCE_BUNDLE_JSON")); assert.ok(JSON.stringify(manifest).includes("AGENTRUN_RESOURCE_BUNDLE_JSON"));
assert.equal(runnerEnvValue(manifest, "AGENTRUN_RESOURCE_BIN_PATH"), "/usr/local/bin");
assert.ok(JSON.stringify(manifest).includes("/opt/agentrun/deploy/runtime/boot/agentrun-runner.sh")); assert.ok(JSON.stringify(manifest).includes("/opt/agentrun/deploy/runtime/boot/agentrun-runner.sh"));
assert.ok(JSON.stringify(manifest).includes("AGENTRUN_BOOT_COMMIT")); assert.ok(JSON.stringify(manifest).includes("AGENTRUN_BOOT_COMMIT"));
assertNoSecretLeak(created); assertNoSecretLeak(created);
@@ -84,16 +78,8 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
); );
const sessionRun = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello session", "hwlab-command-session"); const sessionRun = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello session", "hwlab-command-session");
const resourceBin = path.join(context.tmp, "resource-bin"); const runResult = await runOnce({ managerUrl: server.baseUrl, runId: sessionRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces") }, oneShot: true });
const runResult = await runOnce({ managerUrl: server.baseUrl, runId: sessionRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces"), AGENTRUN_RESOURCE_BIN_PATH: resourceBin }, oneShot: true });
assert.equal(runResult.terminalStatus, "completed"); assert.equal(runResult.terminalStatus, "completed");
const hwpod = await execFile(path.join(resourceBin, "hwpod"), ["profile", "list"]);
assert.match(hwpod.stdout, /"argv":\["profile","list"\]/u);
await writeFile(path.join(resourceBin, "blocked"), "#!/usr/bin/env sh\necho existing\n", "utf8");
const blockedRun = await createHwlabRun(client, context, { ...bundle, toolAliases: [{ name: "blocked", path: "tools/hwpod-cli.mjs", kind: "node-script" }] }, "hwlab-session-blocked-alias", "blocked alias", "hwlab-command-blocked-alias");
const blockedResult = await runOnce({ managerUrl: server.baseUrl, runId: blockedRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-blocked"), AGENTRUN_RESOURCE_BIN_PATH: resourceBin }, oneShot: true });
assert.equal(blockedResult.terminalStatus, "blocked");
assert.equal(blockedResult.failureKind, "schema-invalid");
const session = await store.getSession("hwlab-session-resume"); const session = await store.getSession("hwlab-session-resume");
assert.equal(session?.threadId, "thread_selftest_1"); assert.equal(session?.threadId, "thread_selftest_1");
const resultEnvelope = await client.get(`/api/v1/runs/${sessionRun.runId}/commands/${sessionRun.commandId}/result`) as JsonRecord; const resultEnvelope = await client.get(`/api/v1/runs/${sessionRun.runId}/commands/${sessionRun.commandId}/result`) as JsonRecord;
@@ -101,26 +87,14 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.equal(resultEnvelope.reply, "fake codex stdio reply"); assert.equal(resultEnvelope.reply, "fake codex stdio reply");
assert.equal(((resultEnvelope.sessionRef as JsonRecord).threadId), "thread_selftest_1"); assert.equal(((resultEnvelope.sessionRef as JsonRecord).threadId), "thread_selftest_1");
assert.equal(((resultEnvelope.resourceBundleRef as JsonRecord).commitId), bundle.commitId); assert.equal(((resultEnvelope.resourceBundleRef as JsonRecord).commitId), bundle.commitId);
assert.deepEqual(((resultEnvelope.resourceBundleRef as JsonRecord).toolAliases as JsonRecord).names, ["hwpod"]); assert.equal(((resultEnvelope.resourceBundleRef as JsonRecord).kind), "gitbundle");
const resultBundleTargets = (((resultEnvelope.resourceBundleRef as JsonRecord).bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath);
assert.deepEqual(resultBundleTargets, ["tools", ".agents/skills"]);
const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord); const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
assert.deepEqual(((materialized.toolAliases as JsonRecord).names), ["hwpod"]); assert.deepEqual(((materialized.tools as JsonRecord).names), ["hwpod"]);
assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
assertNoSecretLeak(resultEnvelope); assertNoSecretLeak(resultEnvelope);
const seededRun = await createHwlabRun(client, context, { ...bundle, workspaceFiles: [{ path: ".hwlab/hwpod-spec.yaml", content: "apiVersion: hwlab.dev/v0alpha1\nkind: Hwpod\n", encoding: "utf8" }] }, "hwlab-session-seeded", "inspect seeded spec", "hwlab-command-seeded");
const seededRoot = path.join(context.tmp, "workspaces-seeded");
const seededResult = await runOnce({ managerUrl: server.baseUrl, runId: seededRun.runId, commandId: seededRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: seededRoot }, oneShot: true }) as JsonRecord;
assert.equal(seededResult.terminalStatus, "completed");
const seededEnvelope = await client.get(`/api/v1/runs/${seededRun.runId}/commands/${seededRun.commandId}/result`) as JsonRecord;
const seededResource = seededEnvelope.resourceBundleRef as JsonRecord;
assert.deepEqual(((seededResource.workspaceFiles as JsonRecord).paths), [".hwlab/hwpod-spec.yaml"]);
const seededMaterialized = seededResource.materialized as JsonRecord;
const seededWorkspaceFiles = seededMaterialized.workspaceFiles as JsonRecord;
assert.equal(seededWorkspaceFiles.count, 1);
assert.deepEqual(seededWorkspaceFiles.paths, [".hwlab/hwpod-spec.yaml"]);
assert.equal(JSON.stringify(seededWorkspaceFiles).includes("apiVersion"), false);
const seededWorkspace = path.join(seededRoot, stableHash({ repoUrl: bundle.repoUrl, commitId: bundle.commitId }).slice(0, 16));
assert.equal(await readTextIfExists(path.join(seededWorkspace, ".hwlab", "hwpod-spec.yaml")), "apiVersion: hwlab.dev/v0alpha1\nkind: Hwpod\n");
const assemblyRun = await createHwlabRun(client, context, assemblyBundle, "hwlab-session-assembly", "list visible bundle skills without tools", "hwlab-command-assembly-1"); const assemblyRun = await createHwlabRun(client, context, assemblyBundle, "hwlab-session-assembly", "list visible bundle skills without tools", "hwlab-command-assembly-1");
const assemblyInputFile = path.join(context.tmp, "fake-codex-turn-input-assembly.jsonl"); const assemblyInputFile = path.join(context.tmp, "fake-codex-turn-input-assembly.jsonl");
const assemblyRunner = runOnce({ managerUrl: server.baseUrl, runId: assemblyRun.runId, commandId: assemblyRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-assembly"), AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE: assemblyInputFile }, idleTimeoutMs: 500, pollIntervalMs: 50 }); const assemblyRunner = runOnce({ managerUrl: server.baseUrl, runId: assemblyRun.runId, commandId: assemblyRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-assembly"), AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE: assemblyInputFile }, idleTimeoutMs: 500, pollIntervalMs: 50 });
@@ -150,10 +124,11 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
const assemblyEnvelope = await client.get(`/api/v1/runs/${assemblyRun.runId}/commands/${assemblyRun.commandId}/result`) as JsonRecord; const assemblyEnvelope = await client.get(`/api/v1/runs/${assemblyRun.runId}/commands/${assemblyRun.commandId}/result`) as JsonRecord;
const assemblyResource = assemblyEnvelope.resourceBundleRef as JsonRecord; const assemblyResource = assemblyEnvelope.resourceBundleRef as JsonRecord;
assert.deepEqual(((assemblyResource.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]); assert.deepEqual(((assemblyResource.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]);
assert.deepEqual(((assemblyResource.skillRefs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]); const assemblyBundleTargets = ((assemblyResource.bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath);
assert.deepEqual(assemblyBundleTargets, ["tools", ".agents/skills"]);
const assemblyMaterialized = assemblyResource.materialized as JsonRecord; const assemblyMaterialized = assemblyResource.materialized as JsonRecord;
assert.deepEqual(((assemblyMaterialized.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]); assert.deepEqual(((assemblyMaterialized.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]);
assert.deepEqual(((assemblyMaterialized.skillRefs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]); assert.deepEqual(((assemblyMaterialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
assert.equal(((assemblyMaterialized.initialPrompt as JsonRecord).available), true); assert.equal(((assemblyMaterialized.initialPrompt as JsonRecord).available), true);
assertNoSecretLeak(assemblyEnvelope); assertNoSecretLeak(assemblyEnvelope);
@@ -162,11 +137,6 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.equal(missingPromptResult.terminalStatus, "blocked"); assert.equal(missingPromptResult.terminalStatus, "blocked");
assert.equal(missingPromptResult.failureKind, "prompt-unavailable"); assert.equal(missingPromptResult.failureKind, "prompt-unavailable");
const missingSkillRun = await createHwlabRun(client, context, { ...bundle, skillRefs: [{ name: "missing-skill", path: "skills/missing-skill/SKILL.md", required: true }] }, "hwlab-session-missing-skill", "missing skill", "hwlab-command-missing-skill");
const missingSkillResult = await runOnce({ managerUrl: server.baseUrl, runId: missingSkillRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-missing-skill") }, oneShot: true }) as JsonRecord;
assert.equal(missingSkillResult.terminalStatus, "blocked");
assert.equal(missingSkillResult.failureKind, "skill-unavailable");
const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed"); const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed");
const resumedRun = await client.get(`/api/v1/runs/${resumed.runId}`) as JsonRecord; const resumedRun = await client.get(`/api/v1/runs/${resumed.runId}`) as JsonRecord;
assert.equal(((resumedRun.sessionRef as JsonRecord).threadId), "thread_selftest_1"); assert.equal(((resumedRun.sessionRef as JsonRecord).threadId), "thread_selftest_1");
@@ -225,7 +195,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
const runningResult = await running; const runningResult = await running;
assert.equal(runningResult.terminalStatus, "cancelled"); assert.equal(runningResult.terminalStatus, "cancelled");
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-bundle-materialization", "resource-bundle-tool-alias", "resource-bundle-workspace-files", "resource-prompt-skill-assembly", "resource-prompt-skill-required-blockers", "same-run-runner-multiturn", "running-steer", "running-cancel"] }; return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "same-run-runner-multiturn", "running-steer", "running-cancel"] };
} finally { } finally {
await new Promise<void>((resolve) => server.server.close(() => resolve())); await new Promise<void>((resolve) => server.server.close(() => resolve()));
} }
@@ -237,6 +207,7 @@ async function createLocalGitBundle(context: SelfTestContext): Promise<LocalBund
await execFile("git", ["init"], { cwd: repo }); await execFile("git", ["init"], { cwd: repo });
await writeFile(path.join(repo, "README.md"), "HWLAB bundle self-test\n", "utf8"); await writeFile(path.join(repo, "README.md"), "HWLAB bundle self-test\n", "utf8");
await mkdir(path.join(repo, "tools"), { recursive: true }); await mkdir(path.join(repo, "tools"), { recursive: true });
await writeFile(path.join(repo, "tools", "hwpod"), "#!/usr/bin/env bun\nconsole.log(JSON.stringify({ ok: true, cli: 'hwpod-selftest', argv: process.argv.slice(2) }));\n", "utf8");
await writeFile(path.join(repo, "tools", "hwpod-cli.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-cli-selftest', argv: process.argv.slice(2) }));\n", "utf8"); await writeFile(path.join(repo, "tools", "hwpod-cli.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-cli-selftest', argv: process.argv.slice(2) }));\n", "utf8");
await mkdir(path.join(repo, "internal", "agent", "prompts"), { recursive: true }); await mkdir(path.join(repo, "internal", "agent", "prompts"), { recursive: true });
await writeFile(path.join(repo, "internal", "agent", "prompts", "hwlab-v02-runtime.md"), [ await writeFile(path.join(repo, "internal", "agent", "prompts", "hwlab-v02-runtime.md"), [
@@ -266,18 +237,15 @@ async function createLocalGitBundle(context: SelfTestContext): Promise<LocalBund
"Use hwpod-ctl for HWPOD runtime inspection and control-plane state.", "Use hwpod-ctl for HWPOD runtime inspection and control-plane state.",
].join("\n"), "utf8"); ].join("\n"), "utf8");
await writeFile(path.join(repo, "skills", "hwpod-ctl", "scripts", "hwpod-ctl.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-ctl-skill-selftest' }));\n", "utf8"); await writeFile(path.join(repo, "skills", "hwpod-ctl", "scripts", "hwpod-ctl.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-ctl-skill-selftest' }));\n", "utf8");
await execFile("git", ["add", "README.md", "tools/hwpod-cli.mjs", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo }); await execFile("git", ["add", "README.md", "tools/hwpod", "tools/hwpod-cli.mjs", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo });
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo }); await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo });
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo }); const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo });
return { repoUrl: repo, commitId: stdout.trim() }; return { repoUrl: repo, commitId: stdout.trim() };
} }
async function createHwlabRun(client: ManagerClient, context: SelfTestContext, bundle: LocalBundle, sessionId: string, prompt: string, idempotencyKey: string, timeoutMs = 15_000): Promise<{ runId: string; commandId: string }> { async function createHwlabRun(client: ManagerClient, context: SelfTestContext, bundle: LocalBundle, sessionId: string, prompt: string, idempotencyKey: string, timeoutMs = 15_000): Promise<{ runId: string; commandId: string }> {
const toolAliases = bundle.toolAliases ?? [{ name: "hwpod", path: "tools/hwpod-cli.mjs", kind: "node-script" }]; const resourceBundleRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: bundle.repoUrl, commitId: bundle.commitId, bundles: bundle.bundles ?? defaultGitBundles(), submodules: false, lfs: false };
const resourceBundleRef: ResourceBundleRef = { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, toolAliases, submodules: false, lfs: false };
if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs; if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs;
if (bundle.skillRefs) resourceBundleRef.skillRefs = bundle.skillRefs;
if (bundle.workspaceFiles) resourceBundleRef.workspaceFiles = bundle.workspaceFiles;
const run = await client.post("/api/v1/runs", { const run = await client.post("/api/v1/runs", {
tenantId: "hwlab", tenantId: "hwlab",
projectId: "pikasTech/HWLAB", projectId: "pikasTech/HWLAB",
@@ -299,6 +267,13 @@ async function createHwlabRun(client: ManagerClient, context: SelfTestContext, b
return { runId: run.id, commandId: command.id }; return { runId: run.id, commandId: command.id };
} }
function defaultGitBundles(): ResourceBundleRef["bundles"] {
return [
{ name: "hwlab-tools", subpath: "tools", targetPath: "tools" },
{ name: "hwlab-agent-skills", subpath: "skills", targetPath: ".agents/skills" },
];
}
async function waitForCommandState(client: ManagerClient, runId: string, commandId: string, state: string): Promise<void> { async function waitForCommandState(client: ManagerClient, runId: string, commandId: string, state: string): Promise<void> {
const deadline = Date.now() + 5_000; const deadline = Date.now() + 5_000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
@@ -167,7 +167,7 @@ async function assertResourceBundleFailure(client: ManagerClient, context: SelfT
const repo = await createLocalGitRepo(context); const repo = await createLocalGitRepo(context);
const run = await client.post("/api/v1/runs", { const run = await client.post("/api/v1/runs", {
...runPayload(context, "codex", "selftest-bad-bundle-session"), ...runPayload(context, "codex", "selftest-bad-bundle-session"),
resourceBundleRef: { kind: "git", repoUrl: repo.repoUrl, commitId: "0000000000000000000000000000000000000000", submodules: false, lfs: false }, resourceBundleRef: { kind: "gitbundle", repoUrl: repo.repoUrl, commitId: "0000000000000000000000000000000000000000", bundles: [{ name: "tools", subpath: "tools", targetPath: "tools" }], submodules: false, lfs: false },
}) as { id: string }; }) as { id: string };
const command = await client.post(`/api/v1/runs/${run.id}/commands`, { type: "turn", payload: { prompt: "bad bundle" }, idempotencyKey: "selftest-bad-bundle" }) as { id: string }; const command = await client.post(`/api/v1/runs/${run.id}/commands`, { type: "turn", payload: { prompt: "bad bundle" }, idempotencyKey: "selftest-bad-bundle" }) as { id: string };
const result = await runOnce({ managerUrl, runId: run.id, commandId: command.id, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "bad-bundle-workspaces") }, oneShot: true }) as JsonRecord; const result = await runOnce({ managerUrl, runId: run.id, commandId: command.id, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "bad-bundle-workspaces") }, oneShot: true }) as JsonRecord;