From 7b3c0ea584fde4731c946595a84eba65661a2607 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 6 Jun 2026 17:59:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=20ResourceBundleRef?= =?UTF-8?q?=20workspaceFiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec-v01-hwlab-manual-dispatch.md | 5 +-- docs/reference/spec-v01-runtime-assembly.md | 20 +++++++---- src/common/types.ts | 5 +++ src/common/validation.ts | 31 +++++++++++++++++ src/mgr/result.ts | 1 + src/mgr/store.ts | 1 + src/runner/resource-bundle.ts | 34 +++++++++++++++++++ .../cases/50-hwlab-manual-dispatch.ts | 21 ++++++++++-- 8 files changed, 108 insertions(+), 10 deletions(-) diff --git a/docs/reference/spec-v01-hwlab-manual-dispatch.md b/docs/reference/spec-v01-hwlab-manual-dispatch.md index d51bae5..c4f01db 100644 --- a/docs/reference/spec-v01-hwlab-manual-dispatch.md +++ b/docs/reference/spec-v01-hwlab-manual-dispatch.md @@ -91,13 +91,14 @@ HWLAB canary 创建 run 时应使用以下字段口径: | `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。 | | `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 清单和工具入口不写入 command payload,而是通过 `ResourceBundleRef.promptRefs`、`skillRefs` 和 `toolAliases` 装配。`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 清单、工具入口和 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。 ## 需要补齐的能力 @@ -239,6 +240,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-`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/`,codex app-server 自己落盘;HWLAB 原入口已验证 runner pod 删除后同 session/thread/PVC 可以恢复,仍禁止 fake 续接。 | -| ResourceBundleRef | 已实现 Git-only materialization/promptRefs/skillRefs 装配 | run 可携带 `repoUrl + full commitId`,runner checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录并记录 commit/tree/workspace 摘要;`toolAliases`、`promptRefs` thread-start 注入和 `skillRefs` registry 聚合已实现;上传文件和对象存储仍不进入 v0.1。 | +| 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。 | | 同 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 复测。 | diff --git a/docs/reference/spec-v01-runtime-assembly.md b/docs/reference/spec-v01-runtime-assembly.md index 3fdce42..03ac334 100644 --- a/docs/reference/spec-v01-runtime-assembly.md +++ b/docs/reference/spec-v01-runtime-assembly.md @@ -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` | 初始代码/文件输入,以及同一 commit 下的非敏感工具别名、初始 prompt 和 skill manifest;P0 固定 Git-only。 | 上传文件、对象存储 artifact、inline env、Secret value、会话历史。 | +| `ResourceBundleRef` | `repoUrl`、`commitId`,可选 `toolAliases`、`promptRefs`、`skillRefs`、`workspaceFiles` | 初始代码/文件输入,以及同一 commit 下的非敏感工具别名、初始 prompt、skill manifest 和有界 workspace seed 文件;P0 固定 Git-only。 | 上传文件、对象存储 artifact、inline env、Secret value、会话历史。 | P0 最小 JSON 形态: @@ -32,7 +32,8 @@ P0 最小 JSON 形态: "commitId": "", "toolAliases": [], "promptRefs": [], - "skillRefs": [] + "skillRefs": [], + "workspaceFiles": [] } } ``` @@ -155,7 +156,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 - P0 固定 Git-only,由 `repoUrl + full commitId` 决定内容身份。 - `commitId` 必须是不可变 full commit sha,不能是 branch、tag 或 `HEAD`。 -- 可选扩展只允许 `subdir`、`sparsePaths`、`submodules=false`、`lfs=false`、`credentialRef`、`toolAliases`、`promptRefs`、`skillRefs`;默认不启用。 +- 可选扩展只允许 `subdir`、`sparsePaths`、`submodules=false`、`lfs=false`、`credentialRef`、`toolAliases`、`promptRefs`、`skillRefs`、`workspaceFiles`;默认不启用。 - `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 或镜像内旧代码。 @@ -211,6 +212,12 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 - `required=true` 的 skill 缺失、不可读或 manifest 无法解析时,run/command 必须 blocked 为 `skill-unavailable`,不能让模型凭默认 Codex skill registry 猜测,也不能把用户长 prompt 当作替代 skill。 - `skillRefs` 与 `promptRefs` 必须来自同一 `repoUrl + commitId`,以避免业务 prompt、skill manifest 和工具 alias 版本漂移。 +#### workspaceFiles + +`workspaceFiles` 用于在 runner 启动 backend 前,把少量非敏感、run-local 的 UTF-8 文本文件写入 materialized workspace。每个文件只能写到 workspace 相对路径,禁止绝对路径、`..` 和目录根;数量、单文件大小和总大小必须有上限。materialization event 与 result 只输出 path、bytes、sha256、count 和 `valuesPrinted=false`,不输出文件内容。 + +`workspaceFiles` 适合承载 CaseRun 这类编排器生成的 run-local 配置,例如 `.hwlab/hwpod-spec.yaml`。它不替代 git source、promptRefs、skillRefs、toolCredentials 或 SecretRef;不得用于传递 token、provider config、历史消息、大文件 artifact 或任务答案。若业务必须让 agent 修改该文件,应由 prompt 明确说明修改目标;否则 seed file 应被视为运行环境输入,而不是 agent 需要手工创建的任务。 + #### 初始 prompt 与 session 边界 初始 prompt 装配只发生在新 thread 的首轮 turn。后续 turn 的历史上下文必须由 Codex stdio 原生 `thread/resume` 恢复;AgentRun 不得为了补 prompt、补 skill facts 或修复 stale thread 而拼接旧用户消息、旧 assistant 回复、旧 skill 列表或旧业务事实。`thread/resume` 失败时按 [spec-v01-backend-codex.md](spec-v01-backend-codex.md) 直接失败,不启动替代 `thread/start`。 @@ -223,8 +230,8 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 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`:创建工具 wrapper、聚合 skill registry、读取并校验 thread-start prompt,写入有界 assembly event。 -8. Runner 启动 backend,并在 event 中记录 image digest、profile、SecretRef 名称/key、tool credential scope、sessionRef、repoUrl/commitId、promptRefs 和 skillRefs 的脱敏摘要。 +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 摘要。 任何一个要素缺失或不合法,都必须按该要素失败;不得静默 fallback。 @@ -266,6 +273,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 - run payload 不携带文件正文、env dump、Secret value 或大型 artifact。 - 若提供 `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 不得打印文件正文。 ### A5 综合验收 @@ -286,5 +294,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-`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/`,codex app-server 自己落盘;runner pod 删除后 replacement runner 仍复用同一 SessionRef/PVC/thread,禁止 copy/restore、replacement threadId 和 fake resume。 | -| `ResourceBundleRef` | 已实现 Git-only materialization/promptRefs/skillRefs 装配 | `repoUrl + full commitId` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 commit/tree/workspace 摘要;`toolAliases`、`promptRefs` thread-start 注入和 `skillRefs` registry 聚合已实现。 | +| `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` 写入已实现。 | | `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` 绕过。 | diff --git a/src/common/types.ts b/src/common/types.ts index 6fc137b..58ffc41 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -79,6 +79,11 @@ export interface ResourceBundleRef extends JsonRecord { required?: boolean; aggregateAs?: string; }>; + workspaceFiles?: Array<{ + path: string; + content: string; + encoding?: "utf8"; + }>; submodules?: false; lfs?: false; credentialRef?: SecretRef; diff --git a/src/common/validation.ts b/src/common/validation.ts index 3e058a7..29b6b42 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -100,6 +100,7 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n if (record.toolAliases !== undefined) result.toolAliases = validateResourceToolAliases(record.toolAliases); 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; @@ -163,6 +164,31 @@ function validateResourceSkillRefs(value: unknown): NonNullable { + 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(); + 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; @@ -173,6 +199,11 @@ function validateBundleRelativePath(relativePath: string, fieldName: string): st 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 }); + return relativePath; +} + export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy { const timeout = record.timeoutMs; if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) throw new AgentRunError("schema-invalid", "executionPolicy.timeoutMs must be a positive number", { httpStatus: 400 }); diff --git a/src/mgr/result.ts b/src/mgr/result.ts index 21ff1b2..570e45a 100644 --- a/src/mgr/result.ts +++ b/src/mgr/result.ts @@ -176,6 +176,7 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord | toolAliases: run.resourceBundleRef.toolAliases ? { count: run.resourceBundleRef.toolAliases.length, names: run.resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], 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, }; } diff --git a/src/mgr/store.ts b/src/mgr/store.ts index 754f106..ae57844 100644 --- a/src/mgr/store.ts +++ b/src/mgr/store.ts @@ -737,6 +737,7 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc toolAliases: resourceBundleRef.toolAliases ? { count: resourceBundleRef.toolAliases.length, names: resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], 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, diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index f4aa500..f404350 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -62,6 +62,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl 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 initialPrompt = assembleInitialPrompt(prompts.items, skills.items); return { workspacePath, @@ -81,6 +82,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl toolAliases: toolAliases.event, skillRefs: 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 }, valuesPrinted: false, }, @@ -184,6 +186,31 @@ async function materializeSkillRefs(checkoutPath: string, workspacePath: string, }; } +async function materializeWorkspaceFiles(workspacePath: string, files: NonNullable): 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; const sections: string[] = [ @@ -293,6 +320,13 @@ function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName return resolved; } +function resolveWorkspaceFilePath(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, `'"'"'`)}'`; } diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index 0b87ebe..b342057 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -7,11 +7,12 @@ 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"] }; +type LocalBundle = { repoUrl: string; commitId: string; toolAliases?: ResourceBundleRef["toolAliases"]; promptRefs?: ResourceBundleRef["promptRefs"]; skillRefs?: ResourceBundleRef["skillRefs"]; workspaceFiles?: ResourceBundleRef["workspaceFiles"] }; const selfTest: SelfTestCase = async (context) => { const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8"); @@ -105,6 +106,21 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin assert.deepEqual(((materialized.toolAliases as JsonRecord).names), ["hwpod"]); 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 }); @@ -209,7 +225,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-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-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"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } @@ -261,6 +277,7 @@ async function createHwlabRun(client: ManagerClient, context: SelfTestContext, b const resourceBundleRef: ResourceBundleRef = { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, toolAliases, submodules: false, lfs: false }; if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs; if (bundle.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",