diff --git a/docs/reference/spec-v01-agentrun-runner.md b/docs/reference/spec-v01-agentrun-runner.md index 9e0af64..4a07cd5 100644 --- a/docs/reference/spec-v01-agentrun-runner.md +++ b/docs/reference/spec-v01-agentrun-runner.md @@ -77,7 +77,7 @@ gitbundle skill 聚合规则: workspace `tools/` 装配规则: -- runner 把 workspace `tools/` 追加到 `PATH`;repo 内的短命令文件本身承担 wrapper 语义。 +- runner 把 workspace `tools/` 暴露到 `PATH`;如 runtime 需要稳定 bin 目录,只能安装执行原始 workspace tool 的 shim,不能复制 tool 文件导致相对路径语义改变。repo 内的短命令文件本身承担 wrapper 语义。 - `tools/` 顶层 `.ts` 脚本必须带 shebang;带 shebang 的脚本会被 `chmod +x`。 - Event/result 只输出工具文件名、hash、bytes、shebang 摘要和 count,不输出脚本文本。 diff --git a/docs/reference/spec-v01-runtime-assembly.md b/docs/reference/spec-v01-runtime-assembly.md index 700b3fb..3aef12b 100644 --- a/docs/reference/spec-v01-runtime-assembly.md +++ b/docs/reference/spec-v01-runtime-assembly.md @@ -166,7 +166,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 #### tools 目录 -runner 对 workspace `tools/` 做统一装配:顶层带 shebang 的脚本会被 `chmod +x`,`tools/` 目录会追加到 `PATH`。非 shebang 文件是随 bundle 复制的源码、测试或辅助文件,不作为可执行工具发现,也不触发 schema-invalid。短命令名称来自 repo 内真实文件,例如 `tools/hwpod`,不再由 runner 生成 wrapper。 +runner 对 workspace `tools/` 做统一装配:顶层带 shebang 的脚本会被 `chmod +x`,`tools/` 会暴露到 `PATH`。如果 runtime 配置了单独的 `AGENTRUN_RESOURCE_BIN_PATH`,runner 只能在该目录写入执行原始 workspace tool 的 shim,不能复制 tool 文件导致 `dirname "$0"`、相对 import 或辅助源码解析到 bin 目录。非 shebang 文件是随 bundle 复制的源码、测试或辅助文件,不作为可执行工具发现,也不触发 schema-invalid。短命令名称来自 repo 内真实文件,例如 `tools/hwpod`,repo tool 本身承担业务 wrapper 语义。 AgentRun 自身仓库必须提供 `tools/tran` 与 `tools/trans`,用于承接 UniDesk frontend `/ws/ssh` 的 scoped client-token 透传。runner 只通过 `executionPolicy.secretScope.toolCredentials[]` 投影 `UNIDESK_SSH_CLIENT_TOKEN`,并通过 `transientEnv` 注入非敏感 `UNIDESK_MAIN_SERVER_IP` / `UNIDESK_FRONTEND_URL`;工具不得读取 provider token、主 server SSH key 或完整 frontend 登录态。`tran --help` 必须输出 JSON,并列出当前支持的最小开发面:host/host workspace `script`、`argv`、普通 ssh-like 命令、`k3s kubectl`、`k3s script` 和 k3s workload `argv/script`。未实现的 `apply-patch`、`upload`、`download` 和 Windows route 必须显式 `unsupported-operation`,不能静默改走不受控 shell 拼接或 token fallback。 diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index 795481b..921c4e7 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { chmod, cp, mkdir, readdir, readFile, rm, stat } from "node:fs/promises"; +import { chmod, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { AgentRunError } from "../common/errors.js"; import { redactText } from "../common/redaction.js"; @@ -193,6 +193,14 @@ function optionalNonEmpty(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function installedToolShim(sourcePath: string): string { + return `#!/usr/bin/env sh\nexec ${shellSingleQuote(sourcePath)} "$@"\n`; +} + async function prepareGitBundleTools(workspacePath: string, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> { const sourceBinPath = path.join(workspacePath, "tools"); const installedBinPath = optionalNonEmpty(env.AGENTRUN_RESOURCE_BIN_PATH); @@ -217,7 +225,7 @@ async function prepareGitBundleTools(workspacePath: string, env: NodeJS.ProcessE if (installedBinPath) { const targetPath = path.join(installedBinPath, entry.name); if (targetPath !== filePath) { - await cp(filePath, targetPath, { force: true, dereference: false }); + await writeFile(targetPath, installedToolShim(filePath), "utf8"); await chmod(targetPath, 0o755); } } diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index f6b3f05..c99f639 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -228,8 +228,10 @@ async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle 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, "tools", "src"), { recursive: true }); + await writeFile(path.join(repo, "tools", "hwpod"), "#!/usr/bin/env sh\nexec bun \"$(dirname \"$0\")/hwpod-cli.ts\" \"$@\"\n", "utf8"); + await writeFile(path.join(repo, "tools", "hwpod-cli.ts"), "import { hwpodSelftestName } from './src/hwpod-harness-lib.ts';\nconsole.log(JSON.stringify({ ok: true, cli: hwpodSelftestName(), argv: process.argv.slice(2) }));\n", "utf8"); + await writeFile(path.join(repo, "tools", "src", "hwpod-harness-lib.ts"), "export function hwpodSelftestName() { return 'hwpod-selftest'; }\n", "utf8"); await writeFile(path.join(repo, "tools", "hwpod-node.test.ts"), "console.log('test-only source file without shebang');\n", "utf8"); await mkdir(path.join(repo, "internal", "agent", "prompts"), { recursive: true }); await writeFile(path.join(repo, "internal", "agent", "prompts", "hwlab-v02-runtime.md"), [ @@ -259,7 +261,7 @@ async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle "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", "tools/hwpod-cli.mjs", "tools/hwpod-node.test.ts", "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.ts", "tools/src/hwpod-harness-lib.ts", "tools/hwpod-node.test.ts", "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() };