diff --git a/docs/reference/spec-v01-aipod-spec.md b/docs/reference/spec-v01-aipod-spec.md index 9b758d2..e5e791b 100644 --- a/docs/reference/spec-v01-aipod-spec.md +++ b/docs/reference/spec-v01-aipod-spec.md @@ -97,7 +97,9 @@ imageRef: - UniDesk repo 提供 primary source workspace 与 `unidesk-*` skills; - AgentRun repo 提供 `tools/trans`、`tools/tran`、`tools/apply_patch` 等 runner tool shim; - `agent_skills` repo 提供 `dad-dev`、`cli-spec`、`docs-spec`、`git-spec`; - - `tools/tran` 只委派 primary UniDesk workspace 的 `scripts/tran`,Windows、host 与 k3s route 解析和 frontend transport 均由 UniDesk 共享实现负责; + - runner 通过 `AGENTRUN_WORKSPACE_PATH` 显式传递已物化的 primary workspace; + - `tools/tran` 只从该路径委派 primary UniDesk workspace 的 `scripts/tran`,不得根据安装 shim 的 `$0` 反推源码目录; + - Windows、host 与 k3s route 解析和 frontend transport 均由 UniDesk 共享实现负责; - runner 物化阶段自动把 GitHub URL 改写到 G14 git mirror,Artificer 规格本身不声明 mirror 开关或 mirror base URL。 - primary source workspace 与受控远端 tool target 是不同边界: - Agent 修改的源码仍以 `ResourceBundleRef` 物化出的 primary workspace 为唯一来源; diff --git a/docs/reference/spec-v01-runtime-assembly.md b/docs/reference/spec-v01-runtime-assembly.md index a382064..1680089 100644 --- a/docs/reference/spec-v01-runtime-assembly.md +++ b/docs/reference/spec-v01-runtime-assembly.md @@ -206,10 +206,26 @@ runner 对 managed overlay 的逻辑 `tools/` 做统一装配: - 顶层带 shebang 的脚本会被 `chmod +x`,并通过 `PATH` 暴露; - runtime 配置 `AGENTRUN_RESOURCE_BIN_PATH` 时,只能在该目录写入执行原始 overlay tool 的 shim; - 禁止复制 tool 文件到 bin 目录后改变 `dirname "$0"`、相对 import 或辅助源码解析语义; +- runner 必须通过 `AGENTRUN_WORKSPACE_PATH` 向 backend tool 显式传递已物化的 primary workspace; +- runtime tool 不得根据安装 shim 的 `$0` 反推 primary workspace; - 非 shebang 文件只作为 bundle 源码、测试或辅助文件,不作为可执行工具发现; - 短命令名称来自 repo 内真实文件,例如 `tools/hwpod`,repo tool 本身承担业务 wrapper 语义。 -AgentRun 自身仓库必须提供 `tools/tran`、`tools/trans` 与 `tools/apply_patch`,用于承接 UniDesk frontend `/ws/ssh` 的 scoped client-token 透传。runner 只通过 `executionPolicy.secretScope.toolCredentials[]` 投影 `UNIDESK_SSH_CLIENT_TOKEN`,并通过调用方 `transientEnv` 或 manager 受控默认值注入非敏感 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`;工具不得读取 provider token、主 server SSH key 或完整 frontend 登录态。`tran --help` 必须输出 JSON,并列出当前支持的最小开发面:host/host workspace `script`、`argv`、普通 ssh-like 命令、host workspace `apply-patch`、`k3s kubectl`、`k3s script`、k3s workload `argv/script` 和 k3s workload `apply-patch`。runner 侧 `apply-patch` 只把 stdin patch 和相邻 `tools/apply_patch` helper 传到目标 route 执行,不接触或回显 Secret 值;`upload`、`download` 和 Windows route 未实现时必须显式 `unsupported-operation`,不能静默改走不受控 shell 拼接或 token fallback。 +AgentRun 自身仓库必须提供 `tools/tran`、`tools/trans` 与 `tools/apply_patch`: + +- 这些工具用于承接 UniDesk frontend `/ws/ssh` 的 scoped client-token 透传; +- runner 只通过 `executionPolicy.secretScope.toolCredentials[]` 投影 `UNIDESK_SSH_CLIENT_TOKEN`; +- 非敏感 frontend 地址只由受控入口注入: + - 可用字段是 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`; + - 注入来源是调用方 `transientEnv` 或 manager 受控默认值; +- 工具不得读取 provider token、主 server SSH key 或完整 frontend 登录态; +- `tools/tran` 只委派 `AGENTRUN_WORKSPACE_PATH` 指向的 primary UniDesk workspace 中的 `scripts/tran`; +- route 与 transport 职责保持单一: + - Windows、host 与 k3s route 解析和 frontend transport 均由 UniDesk 共享实现负责; + - AgentRun 不维护第二套 parser 或 fallback; +- runner 侧 `apply-patch` 保持最小职责: + - 只把 stdin patch 和相邻 `tools/apply_patch` helper 传到目标 route 执行; + - 不接触或回显 Secret 值。 #### promptRefs diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index d19a1f3..cd66c10 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -226,7 +226,7 @@ function resourceMaterializationEnv(env: NodeJS.ProcessEnv, runId: string, attem function resourceEnvForMaterialized(env: NodeJS.ProcessEnv, materialized: Awaited>): NodeJS.ProcessEnv | undefined { if (!materialized) return undefined; - const sanitized = { ...env }; + const sanitized = { ...env, AGENTRUN_WORKSPACE_PATH: materialized.workspacePath }; delete sanitized.AGENTRUN_RESOURCE_CREDENTIAL_PATH; let next: NodeJS.ProcessEnv | undefined = sanitized; if (materialized.binPath) { diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index e5f554f..e06cfe6 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -196,6 +196,8 @@ process.exit(1); assert.equal(assemblyRunnerResult.commandsProcessed, 2); const assemblyInputs = (await readTextIfExists(assemblyInputFile)).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as JsonRecord); assert.equal(assemblyInputs.length, 2); + assert.equal(assemblyInputs[0]?.workspacePath, assemblyInputs[0]?.cwd, "backend tool env must identify the materialized primary workspace explicitly"); + assert.equal(assemblyInputs[1]?.workspacePath, assemblyInputs[1]?.cwd, "continued turns must preserve the primary workspace env contract"); const firstAssemblyInput = turnInputText(assemblyInputs[0]); const secondAssemblyInput = turnInputText(assemblyInputs[1]); assert.match(firstAssemblyInput, /HWLAB v0\.2 runtime prompt self-test/u); diff --git a/src/selftest/cases/90-runner-image-tools.ts b/src/selftest/cases/90-runner-image-tools.ts index ba72fa6..3109ebd 100644 --- a/src/selftest/cases/90-runner-image-tools.ts +++ b/src/selftest/cases/90-runner-image-tools.ts @@ -31,12 +31,32 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(applyPatch.startsWith("#!/bin/sh\n"), true, "tools/apply_patch must be a shebang helper copied with runner tools"); assert.equal(agentrunGit.startsWith("#!/usr/bin/env bun\n"), true, "tools/agentrun-git must be a shebang executable discovered by gitbundle tools"); assert.equal(tran.includes("scripts/tran"), true, "tools/tran must delegate to the primary UniDesk workspace implementation"); + assert.equal(tran.includes("AGENTRUN_WORKSPACE_PATH"), true, "tools/tran must use the explicit primary workspace contract"); assert.equal(tran.includes("UNIDESK_TRAN_REPO_ROOT"), true, "tools/tran must bind the shared implementation to the primary workspace"); + assert.equal(tran.includes("dirname -- \"$0\""), false, "tools/tran must not infer the primary workspace from an installed shim path"); assert.equal(tran.includes("parseRoute"), false, "tools/tran must not carry a second route parser"); assert.equal(tran.includes("does not support Windows routes"), false, "tools/tran must not reject shared Windows routes"); assert.equal(agentrunGit.includes("GIT_TERMINAL_PROMPT"), true, "tools/agentrun-git must disable interactive git prompts"); assert.equal(agentrunGit.includes("AGENTRUN_GIT_DIRECT_HOSTS"), true, "tools/agentrun-git must expose direct host policy"); + const primaryWorkspace = path.join(context.tmp, "tran-primary-workspace"); + const primaryScripts = path.join(primaryWorkspace, "scripts"); + await mkdir(primaryScripts, { recursive: true }); + await writeFile(path.join(primaryScripts, "ssh-cli.ts"), "// UniDesk shared tran entrypoint\n", "utf8"); + await writeFile(path.join(primaryScripts, "tran"), "#!/bin/sh\nprintf '%s\\n' \"$UNIDESK_TRAN_REPO_ROOT\" \"$*\"\n", "utf8"); + await chmod(path.join(primaryScripts, "tran"), 0o755); + const installedBin = path.join(context.tmp, "installed-resource-bin"); + await mkdir(installedBin, { recursive: true }); + const installedTran = path.join(installedBin, "tran"); + await writeFile(installedTran, `#!/bin/sh\nexec '${path.join(context.root, "tools/tran")}' \"$@\"\n`, "utf8"); + await chmod(installedTran, 0o755); + const delegatedTran = await execFileAsync(installedTran, ["TEST:win", "cmd", "cd"], { cwd: context.tmp, env: { ...process.env, AGENTRUN_WORKSPACE_PATH: primaryWorkspace } }); + assert.deepEqual(delegatedTran.stdout.trim().split("\n"), [primaryWorkspace, "TEST:win cmd cd"]); + await assert.rejects( + () => execFileAsync(installedTran, ["--help"], { cwd: context.tmp, env: { ...process.env, AGENTRUN_WORKSPACE_PATH: undefined } }), + (error) => error instanceof Error && "stderr" in error && String(error.stderr).includes("runner did not provide an absolute primary workspace path"), + ); + const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 }); assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true); const agentrunGitHelp = await execFileAsync(path.join(context.root, "tools/agentrun-git"), ["--help"], { cwd: context.root, timeout: 10_000 }); diff --git a/src/selftest/fake-codex-app-server.ts b/src/selftest/fake-codex-app-server.ts index 2229ea5..b9432e1 100644 --- a/src/selftest/fake-codex-app-server.ts +++ b/src/selftest/fake-codex-app-server.ts @@ -75,7 +75,7 @@ for await (const line of rl) { continue; } if (message.method === "turn/start") { - if (process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE) appendFileSync(process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE, `${JSON.stringify({ threadId: message.params?.threadId ?? null, input: message.params?.input ?? null, model: message.params?.model ?? null, effort: message.params?.effort ?? null })}\n`); + if (process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE) appendFileSync(process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE, `${JSON.stringify({ threadId: message.params?.threadId ?? null, input: message.params?.input ?? null, model: message.params?.model ?? null, effort: message.params?.effort ?? null, workspacePath: process.env.AGENTRUN_WORKSPACE_PATH ?? null, cwd: process.cwd() })}\n`); if (mode === "reject-unexpected-model" && (observedThreadModel || Object.hasOwn(message.params ?? {}, "model"))) { respond(message.id, null, { code: -32000, message: "turn/start unexpectedly included model" }); continue; diff --git a/tools/tran b/tools/tran index dc0fd7e..2ebc6bb 100755 --- a/tools/tran +++ b/tools/tran @@ -1,11 +1,20 @@ #!/bin/sh set -eu -self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -primary_workspace=$(CDPATH= cd -- "$self_dir/../../.." && pwd) -shared_tran="$primary_workspace/scripts/tran" +primary_workspace=${AGENTRUN_WORKSPACE_PATH:-} -if [ ! -x "$shared_tran" ]; then +case "$primary_workspace" in + /*) ;; + *) + printf '%s\n' '{"ok":false,"failureKind":"tool-unavailable","message":"runner did not provide an absolute primary workspace path","source":"primary-workspace","valuesPrinted":false}' >&2 + exit 2 + ;; +esac + +shared_tran="$primary_workspace/scripts/tran" +shared_cli="$primary_workspace/scripts/ssh-cli.ts" + +if [ ! -x "$shared_tran" ] || [ ! -f "$shared_cli" ]; then printf '%s\n' '{"ok":false,"failureKind":"tool-unavailable","message":"primary UniDesk workspace does not provide executable scripts/tran","source":"primary-workspace","valuesPrinted":false}' >&2 exit 2 fi