支持 gitbundle 资源装配
This commit is contained in:
@@ -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 提供。
|
||||
|
||||
RuntimeAssembly P0 中 `SessionRef` 可以显式为 `null`,runner 不得把完整 `CODEX_HOME`、Secret projection 或节点 host path 当作 session store。`ResourceBundleRef` P0 收敛为 Git-only;runner 已支持把 `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 直接挂载)
|
||||
|
||||
@@ -64,16 +64,22 @@ volumeMounts:
|
||||
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`。
|
||||
- 聚合只允许 symlink 或 copy 当前 bundle 内的 skill root,不允许引用 `/app/skills`、host path、Secret volume 或用户上传临时目录。
|
||||
- Event/result 只输出 skill name、manifest path 摘要、hash、bytes、version/commit metadata、required 和 aggregate target。
|
||||
- 聚合只允许 copy `bundles[]` 指定的 Git 子树,不允许引用 `/app/skills`、host path、Secret volume 或用户上传临时目录。
|
||||
- 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` 装配规则:
|
||||
|
||||
@@ -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 状态并中止 backend;backend 不支持精确 interrupt 时终止受控进程组。 |
|
||||
| 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。 |
|
||||
| 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。 |
|
||||
| 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 装配
|
||||
|
||||
阅读本文和 [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-invalid,required 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 控制。 |
|
||||
| claim/lease/report client | 已实现/已通过 stale lease recovery 复测 | 已拆出 runner manager API client,覆盖 register、claim、lease heartbeat、poll command、ack、append event、command status 和必要 run status;replacement runner 遇到旧 lease 时会等待 stale lease 并重试 claim,live runtime 通过 manager 写入 Postgres durable store。 |
|
||||
| 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 后再启动 backend;per-session PVC 直接挂载已通过 HWLAB v0.2 原入口恢复复测,`toolAliases` 已实现,`promptRefs` 和 `skillRefs` 装配按本规格待补齐。 |
|
||||
| SessionRef/ResourceBundleRef 消费 | 已实现最小闭环 | runner 会使用 run 中的 SessionRef threadId 执行 resume,并 materialize `kind="gitbundle"` ResourceBundleRef 到隔离 workspace 后再启动 backend;per-session PVC 直接挂载已通过 HWLAB v0.2 原入口恢复复测,`tools/` PATH、`promptRefs` 和 `.agents/skills` 装配已实现。 |
|
||||
| 同 run/runner 多 turn | 已实现最小闭环 | runner 在同一 Job 中 materialize bundle 一次后循环 poll command;普通 turn completed 只终结 command,run 保持可继续接后续 turn,直到 idle timeout 或 run terminal。 |
|
||||
| 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` 切换联调。 |
|
||||
|
||||
@@ -20,7 +20,7 @@ codex app-server --listen stdio://
|
||||
|
||||
Adapter 通过 stdin 写入换行分隔 JSON-RPC 请求,通过 stdout 逐行读取 JSON-RPC response 和 notification,stderr 只作为有界诊断日志。最小请求序列是 `initialize`、`thread/start` 或 `thread/resume`、`turn/start`;response 中必须提取 thread/turn identity,notification 和后续输出必须归一化为 `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。对新 thread,adapter 在首个 `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 shape,events 只记录 prompt/skill 的 path/hash/bytes/injected 状态,不输出全文。
|
||||
若 run 的 `ResourceBundleRef` 包含 `promptRefs` 或 gitbundle `.agents/skills`,Codex adapter 只能消费 runner 已装配好的有界 `initialPrompt`、skill summary 和 skill registry path。对新 thread,adapter 在首个 `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 shape,events 只记录 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。
|
||||
|
||||
@@ -37,7 +37,7 @@ Adapter 通过 stdin 写入换行分隔 JSON-RPC 请求,通过 stdout 逐行
|
||||
|
||||
这些参考用于协议和质量标准,不复制 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 定义
|
||||
|
||||
@@ -147,9 +147,9 @@ Run 的 `executionPolicy.secretScope` 应引用与 `backendProfile` 匹配的 pr
|
||||
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,不改装配。
|
||||
|
||||
### 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 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。 |
|
||||
| 真实 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 已通过主闭环。 |
|
||||
|
||||
@@ -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 bundle;runner 内每 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) |
|
||||
| 初始业务 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) |
|
||||
| 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) |
|
||||
@@ -88,17 +88,16 @@ HWLAB canary 创建 run 时应使用以下字段口径:
|
||||
| `providerId` | `G14`,只表示目标 provider,不授予 HWLAB 业务权限。 |
|
||||
| `backendProfile` | `deepseek`、`codex` 或 `minimax-m3`,由 HWLAB 或调度方显式选择;缺少 matching SecretRef 必须失败,不 fallback。 |
|
||||
| `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.skillRefs[]` | 用于承接 HWLAB required skills,例如 `hwpod-cli`、`hwpod-ctl`;必须来自同一 full commit 的 `SKILL.md`,required skill 缺失时 blocked,不能让模型默认 skill 列表替代。 |
|
||||
| `resourceBundleRef.toolAliases[]` | 用于暴露短入口,例如 `hwpod`;缺 alias 时修 runner/bundle,不改走长路径 wrapper。 |
|
||||
| `resourceBundleRef.workspaceFiles[]` | 用于承接 HWLAB 编排器生成的非敏感 run-local workspace 文件,例如 CaseRun 预装 `.hwlab/hwpod-spec.yaml`;runner 启动 backend 前写入,result/event 只输出 path、bytes、sha256,不把文件正文并入 prompt。 |
|
||||
| `resourceBundleRef.kind` | 必须是 `gitbundle`。 |
|
||||
| `resourceBundleRef.bundles[]` | 用于承接 HWLAB 固定工具和 skill 子树,默认 `tools -> tools`、`skills -> .agents/skills`;旧字段不得再发送。 |
|
||||
| `resourceBundleRef.promptRefs[]` | 用于承接 HWLAB 稳定初始 prompt,例如 `hwlab-v02-runtime`;必须来自同一 full commit,`inject=thread-start`,新 thread 首轮注入,resume 不注入。 |
|
||||
| `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。 |
|
||||
| `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 覆盖。
|
||||
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
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
|
||||
{
|
||||
"resourceBundleRef": {
|
||||
"kind": "git",
|
||||
"kind": "gitbundle",
|
||||
"repoUrl": "git@github.com:pikasTech/HWLAB.git",
|
||||
"commitId": "<full commit sha>",
|
||||
"toolAliases": [
|
||||
{ "name": "hwpod", "path": "tools/hwpod-cli.ts", "kind": "bun-script" }
|
||||
"bundles": [
|
||||
{ "name": "hwlab-tools", "subpath": "tools", "target_path": "tools" },
|
||||
{ "name": "hwlab-agent-skills", "subpath": "skills", "target_path": ".agents/skills" }
|
||||
],
|
||||
"promptRefs": [
|
||||
{ "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" }
|
||||
{ "name": "hwlab-v02-runtime", "path": "internal/agent/prompts/hwlab-v02-runtime.md", "inject": "thread-start", "required": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -187,7 +182,7 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须迁移为 `ResourceB
|
||||
| 2 | trace/result 元语 | 标准 event 子集、terminal result envelope、bounded output metadata | HWLAB 可由 events 稳定生成 result/trace;partial 不误报 completed。 |
|
||||
| 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。 |
|
||||
| 5 | Resource prompt/skill assembly | `promptRefs` thread-start 注入、`skillRefs` registry 聚合、required blocker、hash/bytes 可见 | 简短 HWLAB prompt 能看到业务 instruction 和 required skill;resume 不重复注入;缺失直接 blocked。 |
|
||||
| 5 | Resource prompt/skill assembly | `promptRefs` thread-start 注入、gitbundle skillDirs 发现、hash/bytes 可见 | 简短 HWLAB prompt 能看到业务 instruction 和 gitbundle skills;resume 不重复注入;旧字段直接拒绝。 |
|
||||
| 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 授权。 |
|
||||
|
||||
@@ -219,17 +214,17 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须迁移为 `ResourceB
|
||||
|
||||
### 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 真实验收
|
||||
|
||||
阅读本文、[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。
|
||||
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 API;pending cancel 会阻止新 runner Job,running runner 通过轮询触发 backend abort,终态写入 event、command state 和 run status。 |
|
||||
| SessionRef | 已实现最小持久化 | run 可携带 `sessionRef`,manager 保存 session/thread,runner 会按 threadId resume,result 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 续接。 |
|
||||
| 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 不终结 run,bundle 只 materialize 一次,command result 按 commandId 独立聚合。 |
|
||||
| HWLAB v0.2 canary | 已实现/已通过 HWLAB v0.2 原入口复测 | HWLAB dispatcher adapter 已调 AgentRun 手动调度 API,并能转换 result/trace;MiniMax-M3 显式 session、provider profile 继承和 runner pod 删除后的同 session resume 已通过原入口 CLI 复测。 |
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| `BackendImageRef` | `image` | digest-pinned backend/runner 镜像。 | API KEY、profile config、用户代码、session 文件。 |
|
||||
| `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。 |
|
||||
| `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 下的稳定初始 prompt;P0 固定 Git-only gitbundle。 | 上传文件、对象存储 artifact、inline env、Secret value、会话历史、旧 inline seed。 |
|
||||
|
||||
P0 最小 JSON 形态:
|
||||
|
||||
@@ -28,12 +28,14 @@ P0 最小 JSON 形态:
|
||||
},
|
||||
"sessionRef": null,
|
||||
"resourceBundleRef": {
|
||||
"kind": "gitbundle",
|
||||
"repoUrl": "git@github.com:pikasTech/unidesk.git",
|
||||
"commitId": "<full commit sha>",
|
||||
"toolAliases": [],
|
||||
"promptRefs": [],
|
||||
"skillRefs": [],
|
||||
"workspaceFiles": []
|
||||
"bundles": [
|
||||
{ "name": "tools", "subpath": "tools", "target_path": "tools" },
|
||||
{ "name": "skills", "subpath": "skills", "target_path": ".agents/skills" }
|
||||
],
|
||||
"promptRefs": []
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -154,16 +156,17 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
|
||||
|
||||
### ResourceBundleRef
|
||||
|
||||
- P0 固定 Git-only,由 `repoUrl + full commitId` 决定内容身份。
|
||||
- P0 固定 `kind="gitbundle"`,由 `repoUrl + full commitId + bundles[]` 决定内容身份。
|
||||
- `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。
|
||||
- 不支持上传文件、对象存储 artifact、任意 ConfigMap 文件袋或 inline env;后续需要时另写版本规格。
|
||||
- 面向 HWLAB 手动调度 canary,runner materialization 必须把 Git-only bundle checkout 到允许 workspace 前缀,并在 event/result 中记录 repo、full commit、checkout path 和 tree 摘要;不得隐式使用 manager Pod、host path 或镜像内旧代码。
|
||||
- 不支持上传文件、对象存储 artifact、任意 ConfigMap 文件袋、inline seed 或旧字段;旧 `toolAliases`、`skillRefs`、`workspaceFiles`、`subdir`、`sparsePaths` 输入必须直接 schema-invalid。
|
||||
- 面向 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
|
||||
|
||||
@@ -189,34 +192,9 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
|
||||
- `promptRefs` 不得读取 Secret、env、token、profile config 或 session 文件;需要 credential 的内容必须通过 `ProfileRef`、`toolCredentials` 或 `transientEnv` 的正式路径装配。
|
||||
- `promptRefs` 缺失且 `required=true` 时,run/command 必须 blocked;不得 fallback 到用户 prompt、旧硬编码 prompt、模型默认 system prompt 或历史上下文拼接。
|
||||
|
||||
#### skillRefs
|
||||
#### skill 目录
|
||||
|
||||
`skillRefs` 用于按同一 `ResourceBundleRef` checkout 装配 skill manifest。它只描述非敏感 skill 文件、聚合方式和 required 语义;运行时 credential 仍走 `toolCredentials` 或其他 SecretRef 路径。
|
||||
|
||||
最小形态:
|
||||
|
||||
```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 需要手工创建的任务。
|
||||
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`。
|
||||
|
||||
#### 初始 prompt 与 session 边界
|
||||
|
||||
@@ -229,9 +207,9 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
|
||||
3. Manager 或 runner Job render 只使用解析后的 image、SecretRef、sessionRef、Git commit 和 projection intent。
|
||||
4. Runner materialize profile Secret 到 writable runtime home。
|
||||
5. Runner materialize tool credential 到该 run 允许的 env/file projection;未实现的 tool scope 必须显式 failed/blocked,不能静默跳过后让 agent 自己猜凭据。
|
||||
6. Runner materialize Git-only resource bundle 到 workspace;P0 未实现时必须显式记录为 deferred 或 null,不能猜测 host path。
|
||||
7. Runner 在 materialized bundle 内解析 `toolAliases`、`promptRefs`、`skillRefs` 和 `workspaceFiles`:创建工具 wrapper、聚合 skill registry、读取并校验 thread-start prompt、写入 workspace seed 文件,写入有界 assembly event。
|
||||
8. Runner 启动 backend,并在 event 中记录 image digest、profile、SecretRef 名称/key、tool credential scope、sessionRef、repoUrl/commitId、promptRefs、skillRefs 和 workspace file hash/bytes 摘要。
|
||||
6. Runner materialize `kind="gitbundle"` resource bundle 到 workspace;P0 未实现时必须显式 blocked,不能猜测 host path。
|
||||
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、bundles、promptRefs、tools 和 skillDirs 摘要。
|
||||
|
||||
任何一个要素缺失或不合法,都必须按该要素失败;不得静默 fallback。
|
||||
|
||||
@@ -267,13 +245,13 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
|
||||
|
||||
### A4 ResourceBundleRef 验收
|
||||
|
||||
- P0 ResourceBundle 只能是 Git-only:`repoUrl + full commitId`。
|
||||
- P0 ResourceBundle 只能是 `kind="gitbundle"`:`repoUrl + full commitId + bundles[]`。
|
||||
- `commitId` 不是 branch/tag/HEAD。
|
||||
- checkout 只能进入允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。
|
||||
- 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`。
|
||||
- 若提供 `skillRefs`,必须能看到 skill registry 聚合摘要、required skill 名称和 manifest hash;required skill 缺失必须 blocked,不能显示模型默认 skill 列表当作业务 skill。
|
||||
- 若提供 `workspaceFiles`,必须能看到每个文件的相对路径、bytes、sha256 和 materialized count;backend 启动前 workspace 内应已存在这些文件,event/result 不得打印文件正文。
|
||||
- 若 bundle 复制了 `.agents/skills`,必须能看到 skillDirs 聚合摘要、skill 名称和 manifest hash;不能显示模型默认 skill 列表当作业务 skill。
|
||||
|
||||
### A5 综合验收
|
||||
|
||||
@@ -283,7 +261,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
|
||||
2. 用哪一个 profile 和 SecretRef。
|
||||
3. 是否使用 session;若不用,必须明确为 `null`/deferred。
|
||||
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。
|
||||
|
||||
## 实现状态
|
||||
@@ -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。 |
|
||||
| `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`,run 创建会解析既有 session,runner 按 threadId resume;session 不保存 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。 |
|
||||
| `ResourceBundleRef` | 已实现 Git-only materialization/promptRefs/skillRefs/workspaceFiles 装配 | `repoUrl + full commitId` 已进入 run schema 和 runner checkout,workspace 受 `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 checkout,workspace 受 `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 进入 runner;v0.1 支持 `tool=github` 与 `tool=unidesk-ssh`、`projection.kind=env`,runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 |
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- Postgres adapter:migration、事务、run/command/event round-trip、重启后可查询。
|
||||
- Secret 分发:SecretRef schema、missing secret failure、redaction。
|
||||
- AgentRun Queue:task 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。
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
6. manager 可查询 command state、append-only events、terminal_status 和 redacted logPath/job identity。
|
||||
7. 重启 `agentrun-mgr` 后,run、command、events 和 terminal_status 仍可从 Postgres 查询。
|
||||
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 交互联调标准
|
||||
|
||||
@@ -105,8 +105,8 @@ CLI 与 RESTful API 可以复用同一个真实 run 做联调。若两者观察
|
||||
| cancel | 对 pending/running/terminal 分别调用 cancel | cancel 幂等,pending 不再启动 runner,running 收敛为 cancelled 或既有 terminal,events/result 可见。 |
|
||||
| SessionRef | 连续两轮使用同一 sessionRef 或 conversation/session/thread 摘要 | 第二轮可 resume backend thread;session 不包含 credential 文件或完整 CODEX_HOME。 |
|
||||
| ResourceBundleRef | 使用 `repoUrl + full commitId` 启动 runner | runner checkout 到允许 workspace,event/result 能回答 repo、commit、workspace 摘要;不使用 branch/tag/HEAD 或 host path。 |
|
||||
| Resource prompt/skill assembly | 使用同一 `ResourceBundleRef` 指定 `promptRefs`、`skillRefs` 和 `toolAliases` | 新 thread 首轮注入 initial prompt 和 skill facts;resume 不重复注入;required prompt/skill 缺失 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` 正向链路。 |
|
||||
| Resource prompt/skill assembly | 使用同一 `ResourceBundleRef.kind="gitbundle"` 指定 `bundles[]` 和 `promptRefs` | 新 thread 首轮注入 initial prompt 和 gitbundle skill facts;resume 不重复注入;required prompt 缺失 blocked;不使用用户长 prompt、旧硬编码 prompt、镜像 `/app/skills` 或默认 Codex skill registry 替代。 |
|
||||
| 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 值。 |
|
||||
| bounded output | 触发工具/命令输出摘要 | result/event 只含摘要、字节数、截断标记和必要引用,不把大 stdout/stderr 塞入单个 JSON 响应。 |
|
||||
|
||||
|
||||
+10
-19
@@ -56,34 +56,25 @@ export interface SecretRef extends JsonRecord {
|
||||
mountPath?: string;
|
||||
}
|
||||
|
||||
export interface GitBundleItemRef extends JsonRecord {
|
||||
name?: string;
|
||||
repoUrl?: string;
|
||||
commitId?: string;
|
||||
subpath: string;
|
||||
targetPath: string;
|
||||
}
|
||||
|
||||
export interface ResourceBundleRef extends JsonRecord {
|
||||
kind: "git";
|
||||
kind: "gitbundle";
|
||||
repoUrl: string;
|
||||
commitId: string;
|
||||
subdir?: string;
|
||||
sparsePaths?: string[];
|
||||
toolAliases?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "node-script" | "bun-script" | "sh-script" | "executable";
|
||||
}>;
|
||||
bundles: GitBundleItemRef[];
|
||||
promptRefs?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
inject?: "thread-start";
|
||||
required?: boolean;
|
||||
}>;
|
||||
skillRefs?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
required?: boolean;
|
||||
aggregateAs?: string;
|
||||
}>;
|
||||
workspaceFiles?: Array<{
|
||||
path: string;
|
||||
content: string;
|
||||
encoding?: "utf8";
|
||||
}>;
|
||||
submodules?: false;
|
||||
lfs?: false;
|
||||
credentialRef?: SecretRef;
|
||||
|
||||
+39
-76
@@ -83,26 +83,13 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
|
||||
if (value === undefined || value === null) return null;
|
||||
const record = asRecord(value, "resourceBundleRef");
|
||||
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 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 });
|
||||
const result: ResourceBundleRef = { kind: "git", repoUrl, commitId };
|
||||
const subdir = optionalString(record.subdir);
|
||||
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);
|
||||
validateCommitId(commitId, "resourceBundleRef.commitId");
|
||||
rejectLegacyResourceBundleFields(record);
|
||||
const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, commitId, bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId) };
|
||||
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.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;
|
||||
@@ -111,24 +98,39 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateResourceToolAliases(value: unknown): NonNullable<ResourceBundleRef["toolAliases"]> {
|
||||
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.toolAliases must be an array", { httpStatus: 400 });
|
||||
if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.toolAliases must contain at most 16 entries", { httpStatus: 400 });
|
||||
function rejectLegacyResourceBundleFields(record: JsonRecord): void {
|
||||
for (const field of ["toolAliases", "skillRefs", "workspaceFiles", "subdir", "sparsePaths"] as const) {
|
||||
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>();
|
||||
return value.map((entry, index) => {
|
||||
const record = asRecord(entry, `resourceBundleRef.toolAliases[${index}]`);
|
||||
const name = requiredString(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 (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases name ${name} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(name);
|
||||
const aliasPath = requiredString(record, "path");
|
||||
if (aliasPath.startsWith("/") || aliasPath.includes("..")) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].path must stay within the checkout`, { httpStatus: 400 });
|
||||
const kind = requiredString(record, "kind");
|
||||
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"] } });
|
||||
return { name, path: aliasPath, kind };
|
||||
const record = asRecord(entry, `resourceBundleRef.bundles[${index}]`);
|
||||
const name = optionalString(record.name);
|
||||
if (name) validateResourceName(name, `resourceBundleRef.bundles[${index}].name`);
|
||||
const repoUrl = optionalString(record.repoUrl) ?? defaultRepoUrl;
|
||||
const commitId = optionalString(record.commitId) ?? defaultCommitId;
|
||||
validateCommitId(commitId, `resourceBundleRef.bundles[${index}].commitId`);
|
||||
const subpath = validateBundleSubpath(requiredString(record, "subpath"), `resourceBundleRef.bundles[${index}].subpath`);
|
||||
const rawTargetPath = typeof record.targetPath === "string" ? record.targetPath : record.target_path;
|
||||
if (typeof rawTargetPath !== "string" || rawTargetPath.trim().length === 0) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path is required`, { httpStatus: 400 });
|
||||
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"]> {
|
||||
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 });
|
||||
@@ -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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -172,11 +172,12 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord |
|
||||
kind: run.resourceBundleRef.kind,
|
||||
repoUrl: run.resourceBundleRef.repoUrl,
|
||||
commitId: run.resourceBundleRef.commitId,
|
||||
subdir: run.resourceBundleRef.subdir ?? null,
|
||||
toolAliases: run.resourceBundleRef.toolAliases ? { count: run.resourceBundleRef.toolAliases.length, names: run.resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
|
||||
bundles: {
|
||||
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 },
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+5
-5
@@ -732,12 +732,12 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
|
||||
kind: resourceBundleRef.kind,
|
||||
repoUrl: resourceBundleRef.repoUrl,
|
||||
commitId: resourceBundleRef.commitId,
|
||||
subdir: resourceBundleRef.subdir ?? null,
|
||||
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
|
||||
toolAliases: resourceBundleRef.toolAliases ? { count: resourceBundleRef.toolAliases.length, names: resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
|
||||
bundles: {
|
||||
count: resourceBundleRef.bundles.length,
|
||||
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 },
|
||||
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,
|
||||
lfs: resourceBundleRef.lfs ?? false,
|
||||
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
|
||||
|
||||
+135
-125
@@ -1,6 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
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 { AgentRunError } from "../common/errors.js";
|
||||
import { redactText } from "../common/redaction.js";
|
||||
@@ -40,70 +40,135 @@ interface MaterializedSkillRef {
|
||||
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> {
|
||||
if (!resourceBundleRef) return null;
|
||||
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));
|
||||
await mkdir(checkoutPath, { recursive: true });
|
||||
await git(["init"], checkoutPath);
|
||||
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
|
||||
await git(["remote", "add", "origin", resourceBundleRef.repoUrl], checkoutPath);
|
||||
if (resourceBundleRef.sparsePaths && resourceBundleRef.sparsePaths.length > 0) {
|
||||
await git(["config", "core.sparseCheckout", "true"], checkoutPath);
|
||||
await mkdir(path.join(checkoutPath, ".git", "info"), { recursive: true });
|
||||
await writeFile(path.join(checkoutPath, ".git", "info", "sparse-checkout"), `${resourceBundleRef.sparsePaths.join("\n")}\n`, "utf8");
|
||||
}
|
||||
await git(["fetch", "--depth", "1", "origin", resourceBundleRef.commitId], checkoutPath);
|
||||
await git(["checkout", "--detach", resourceBundleRef.commitId], checkoutPath);
|
||||
const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim();
|
||||
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 } });
|
||||
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
|
||||
const workspacePath = resolveWorkspacePath(checkoutPath, resourceBundleRef.subdir);
|
||||
const toolAliases = await materializeToolAliases(checkoutPath, resourceBundleRef.toolAliases ?? [], env);
|
||||
const skills = await materializeSkillRefs(checkoutPath, workspacePath, resourceBundleRef.skillRefs ?? []);
|
||||
const prompts = await materializePromptRefs(checkoutPath, resourceBundleRef.promptRefs ?? []);
|
||||
const workspaceFiles = await materializeWorkspaceFiles(workspacePath, resourceBundleRef.workspaceFiles ?? []);
|
||||
const runScope = env.AGENTRUN_RUN_ID ?? env.AGENTRUN_ATTEMPT_ID ?? "standalone";
|
||||
const assemblyRoot = path.join(workspaceRoot, `gitbundle-${stableHash({ runScope, resourceBundleRef }).slice(0, 16)}`);
|
||||
const checkoutRoot = path.join(assemblyRoot, "checkouts");
|
||||
const workspacePath = path.join(assemblyRoot, "workspace");
|
||||
await rm(assemblyRoot, { recursive: true, force: true });
|
||||
await mkdir(checkoutRoot, { recursive: true });
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const checkoutCache = new Map<string, Promise<GitCheckout>>();
|
||||
const checkoutFor = (repoUrl: string, commitId: string) => {
|
||||
const key = stableHash({ repoUrl, commitId });
|
||||
let checkout = checkoutCache.get(key);
|
||||
if (!checkout) {
|
||||
checkout = checkoutGitCommit(checkoutRoot, repoUrl, commitId);
|
||||
checkoutCache.set(key, checkout);
|
||||
}
|
||||
return checkout;
|
||||
};
|
||||
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, checkoutFor);
|
||||
const defaultCheckout = await checkoutFor(resourceBundleRef.repoUrl, resourceBundleRef.commitId);
|
||||
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);
|
||||
return {
|
||||
workspacePath,
|
||||
...(toolAliases.binPath ? { binPath: toolAliases.binPath } : {}),
|
||||
...(tools.binPath ? { binPath: tools.binPath } : {}),
|
||||
...(skills.skillsDir ? { skillsDir: skills.skillsDir } : {}),
|
||||
...(initialPrompt ? { initialPrompt } : {}),
|
||||
event: {
|
||||
phase: "resource-bundle-materialized",
|
||||
kind: "git",
|
||||
kind: "gitbundle",
|
||||
repoUrl: resourceBundleRef.repoUrl,
|
||||
commitId: resourceBundleRef.commitId,
|
||||
treeId,
|
||||
checkoutPath: pathSummary(checkoutPath),
|
||||
treeId: defaultCheckout.treeId,
|
||||
checkoutPath: pathSummary(defaultCheckout.checkoutPath),
|
||||
workspacePath: pathSummary(workspacePath),
|
||||
subdir: resourceBundleRef.subdir ?? null,
|
||||
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
|
||||
toolAliases: toolAliases.event,
|
||||
skillRefs: skills.event,
|
||||
bundles: {
|
||||
count: materializedBundles.length,
|
||||
items: materializedBundles.map((item) => ({ ...item, valuesPrinted: false })),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
tools: tools.event,
|
||||
skillDirs: skills.event,
|
||||
promptRefs: prompts.event,
|
||||
workspaceFiles: workspaceFiles.event,
|
||||
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillRefCount: skills.items.length, valuesPrinted: false },
|
||||
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeToolAliases(checkoutPath: string, aliases: NonNullable<ResourceBundleRef["toolAliases"]>, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> {
|
||||
if (aliases.length === 0) return { event: { count: 0, names: [], binPath: null, valuesPrinted: false } };
|
||||
const binPath = path.resolve(env.AGENTRUN_RESOURCE_BIN_PATH ?? path.join(path.dirname(checkoutPath), ".bin"));
|
||||
await mkdir(binPath, { recursive: true });
|
||||
const names: string[] = [];
|
||||
for (const alias of aliases) {
|
||||
const target = resolveBundlePath(checkoutPath, alias.path, `toolAliases.${alias.name}.path`);
|
||||
const wrapper = path.join(binPath, alias.name);
|
||||
const content = aliasWrapper(alias.kind, target);
|
||||
await assertAliasWrapperWritable(wrapper, alias.name);
|
||||
await writeFile(wrapper, content, "utf8");
|
||||
await chmod(wrapper, 0o755);
|
||||
names.push(alias.name);
|
||||
async function checkoutGitCommit(checkoutRoot: string, repoUrl: string, commitId: string): Promise<GitCheckout> {
|
||||
const checkoutPath = path.join(checkoutRoot, stableHash({ repoUrl, commitId }).slice(0, 16));
|
||||
await mkdir(checkoutPath, { recursive: true });
|
||||
await git(["init"], checkoutPath);
|
||||
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
|
||||
await git(["remote", "add", "origin", repoUrl], checkoutPath);
|
||||
await git(["fetch", "--depth", "1", "origin", commitId], checkoutPath);
|
||||
await git(["checkout", "--detach", commitId], checkoutPath);
|
||||
const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim();
|
||||
if (actualCommit !== commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: commitId, actualCommit } });
|
||||
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
|
||||
return { repoUrl, commitId, checkoutPath, treeId };
|
||||
}
|
||||
|
||||
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 }> {
|
||||
@@ -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 }> {
|
||||
if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } };
|
||||
async function discoverGitBundleSkills(workspacePath: string): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
||||
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 eventItems: JsonRecord[] = [];
|
||||
for (const ref of refs) {
|
||||
const manifestPath = resolveBundlePath(checkoutPath, ref.path, `skillRefs.${ref.name}.path`);
|
||||
const required = ref.required === true;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const aggregateAs = entry.name;
|
||||
const manifestPath = path.join(skillsDir, aggregateAs, "SKILL.md");
|
||||
let manifestText: string;
|
||||
try {
|
||||
manifestText = await readFile(manifestPath, "utf8");
|
||||
} 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: ref.name, path: ref.path, required, aggregateAs: ref.aggregateAs ?? ref.name, status: "missing", valuesPrinted: false });
|
||||
eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, required: true, aggregateAs, status: "missing", error: fileErrorSummary(error), valuesPrinted: false });
|
||||
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 sha = sha256Text(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 });
|
||||
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 });
|
||||
items.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, registryPath: manifestPath, manifestBytes: bytes, manifestSha256: sha, summary });
|
||||
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 {
|
||||
items,
|
||||
skillsDir,
|
||||
event: {
|
||||
count: refs.length,
|
||||
count: entries.filter((entry) => entry.isDirectory()).length,
|
||||
materializedCount: items.length,
|
||||
names: items.map((item) => item.name),
|
||||
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 }> {
|
||||
if (files.length === 0) return { event: { count: 0, materializedCount: 0, items: [], totalBytes: 0, valuesPrinted: false } };
|
||||
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;
|
||||
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skills: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
|
||||
if (promptRefs.length === 0 && skills.length === 0) return undefined;
|
||||
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) {
|
||||
sections.push([`## Resource Prompt: ${prompt.name}`, `path: ${prompt.path}`, prompt.text].join("\n"));
|
||||
}
|
||||
if (skillRefs.length > 0) {
|
||||
if (skills.length > 0) {
|
||||
const lines = [
|
||||
"## Resource Skills",
|
||||
"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"));
|
||||
}
|
||||
const text = sections.join("\n\n");
|
||||
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 {
|
||||
text,
|
||||
summary: {
|
||||
@@ -238,8 +276,8 @@ function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: M
|
||||
sha256: sha256Text(text),
|
||||
promptRefCount: promptRefs.length,
|
||||
promptRefNames: promptRefs.map((item) => item.name),
|
||||
skillRefCount: skillRefs.length,
|
||||
skillRefNames: skillRefs.map((item) => item.name),
|
||||
skillCount: skills.length,
|
||||
skillNames: skills.map((item) => item.name),
|
||||
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 };
|
||||
}
|
||||
|
||||
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 }> {
|
||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
@@ -308,11 +327,6 @@ async function git(args: string[], cwd: string, options: { allowFailure?: boolea
|
||||
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 {
|
||||
const resolved = path.resolve(checkoutPath, relativePath);
|
||||
const root = path.resolve(checkoutPath);
|
||||
@@ -320,17 +334,13 @@ function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName
|
||||
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 root = path.resolve(workspacePath);
|
||||
if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped workspace`, { httpStatus: 400 });
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function shellArg(value: string): string {
|
||||
return `'${value.replace(/'/gu, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function pathSummary(value: string): JsonRecord {
|
||||
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 };
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
if (!materializationAttempted) {
|
||||
materializationAttempted = true;
|
||||
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) {
|
||||
workspacePath = materialized.workspacePath;
|
||||
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 {
|
||||
if (!materialized) return undefined;
|
||||
let next: NodeJS.ProcessEnv | undefined;
|
||||
|
||||
@@ -7,12 +7,11 @@ import { startManagerServer } from "../../mgr/server.js";
|
||||
import { ManagerClient } from "../../mgr/client.js";
|
||||
import { MemoryAgentRunStore } from "../../mgr/store.js";
|
||||
import { runOnce } from "../../runner/run-once.js";
|
||||
import { stableHash } from "../../common/validation.js";
|
||||
import type { JsonRecord, ResourceBundleRef } from "../../common/types.js";
|
||||
import { assertNoSecretLeak, type SelfTestCase, type SelfTestContext } from "../harness.js";
|
||||
|
||||
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 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 = {
|
||||
...bundle,
|
||||
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 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;
|
||||
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("AGENTRUN_BOOT_COMMIT"));
|
||||
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 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"), AGENTRUN_RESOURCE_BIN_PATH: resourceBin }, 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") }, oneShot: true });
|
||||
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");
|
||||
assert.equal(session?.threadId, "thread_selftest_1");
|
||||
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.sessionRef as JsonRecord).threadId), "thread_selftest_1");
|
||||
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);
|
||||
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);
|
||||
|
||||
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 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 });
|
||||
@@ -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 assemblyResource = assemblyEnvelope.resourceBundleRef as JsonRecord;
|
||||
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;
|
||||
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);
|
||||
assertNoSecretLeak(assemblyEnvelope);
|
||||
|
||||
@@ -162,11 +137,6 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
assert.equal(missingPromptResult.terminalStatus, "blocked");
|
||||
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 resumedRun = await client.get(`/api/v1/runs/${resumed.runId}`) as JsonRecord;
|
||||
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;
|
||||
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 {
|
||||
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 writeFile(path.join(repo, "README.md"), "HWLAB bundle self-test\n", "utf8");
|
||||
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 mkdir(path.join(repo, "internal", "agent", "prompts"), { recursive: true });
|
||||
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.",
|
||||
].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 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 });
|
||||
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo });
|
||||
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 }> {
|
||||
const toolAliases = bundle.toolAliases ?? [{ name: "hwpod", path: "tools/hwpod-cli.mjs", kind: "node-script" }];
|
||||
const resourceBundleRef: ResourceBundleRef = { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, toolAliases, submodules: false, lfs: false };
|
||||
const resourceBundleRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: bundle.repoUrl, commitId: bundle.commitId, bundles: bundle.bundles ?? defaultGitBundles(), submodules: false, lfs: false };
|
||||
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", {
|
||||
tenantId: "hwlab",
|
||||
projectId: "pikasTech/HWLAB",
|
||||
@@ -299,6 +267,13 @@ async function createHwlabRun(client: ManagerClient, context: SelfTestContext, b
|
||||
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> {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
@@ -167,7 +167,7 @@ async function assertResourceBundleFailure(client: ManagerClient, context: SelfT
|
||||
const repo = await createLocalGitRepo(context);
|
||||
const run = await client.post("/api/v1/runs", {
|
||||
...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 };
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user