fix: 移除 AgentRun 远端旧 CLI 重入

This commit is contained in:
Codex
2026-07-16 13:40:05 +02:00
parent ba3b75d1f4
commit 376c5d9e12
11 changed files with 160 additions and 215 deletions
+2 -1
View File
@@ -58,7 +58,8 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts client agent-observer list --page 2 --page-
资源命令的 Target 规则:
- 未传 `--target``--node``--lane` 时,使用 `config/agentrun.yaml#controlPlane.default`
- `client.targetExecution` 决定外层 `trans` 重入的 marker、CLI 路径和目标 UniDesk 工作区
- `client.transport=target-trans` 使当前 CLI 通过选中 Target 的 k3s service proxy 请求 manager
- Artificer target task 只额外通过 `trans <Target>:<targetWorkspace>` 执行目录存在性探测,不在 Target 上重跑 UniDesk CLI
- 代码不得补 Target 路径默认值;
- 主机 direct HTTP auth 只保留独立诊断;
- Secret 固定读取 `/root/.unidesk/.env/agentrun.env`,不得回退到 worktree 或旧 `/root/.config/hwlab-*` 路径。
@@ -32,7 +32,7 @@ bun scripts/cli.ts agentrun create task \
- 标题按规范化后的完全匹配解析,重复标题必须改用内部 ID 处理歧义。
- 默认直接执行 live 单命令;不要把 `--dry-run` 作为派单前置步骤;
- live 必须先 owning YAML 选中的 Target `trans` 重入并完成最小路径 preflight
- live 必须先 owning YAML 选中的 Target 完成最小目录 preflight,再通过同一 Target 的 k3s service proxy 请求 manager
- preflight 必须验证:
- `targetWorkspace` 已显式提供为绝对路径;
- Target 上的该路径存在且为目录;
-4
View File
@@ -29,10 +29,6 @@ auth:
client:
role: render-only
transport: target-trans
targetExecution:
markerEnv: UNIDESK_AGENTRUN_TARGET_EXECUTED
executable: bun
cliPath: scripts/cli.ts
sessionPolicy:
tenantId: unidesk
projectId: default
+5 -6
View File
@@ -273,7 +273,7 @@ Artificer 标题契约:
- 默认直接执行 live 单命令,不把 `--dry-run` 作为派单前置步骤;
- 未显式传 `--lane` 时,空选择或只传 YAML 默认 node 都使用 `config/agentrun.yaml#controlPlane.default`
- live 先 Target `trans` 重入,校验显式工作区路径与 Aipod/SecretRef
- live 先 Target 上执行受控目录探测,再校验显式工作区路径与 Aipod/SecretRef
- 预检通过后在同一命令内 submit 并自动 dispatch
- 幂等重试不得重复创建 attempt 或重复 dispatch
- UniDesk 不实现 AgentRun queue 协议,也不把 task double-write 回旧 Code Queue。
@@ -311,12 +311,11 @@ AgentRun 资源原语的默认执行路径由 UniDesk `config/agentrun.yaml` 唯
- `client.transport=target-trans` 时:
- 外层 CLI 从 `controlPlane.default` 或显式 `--target` / `--node` / `--lane` 解析 Target
- 外层通过 `trans <Target>:<unideskWorkspace>` 重入目标节点
- marker、可执行文件和 CLI 相对路径来自 `client.targetExecution`
- Target 内部按选中 lane 使用 `lane-k8s-service-proxy` 进入 manager `internalBaseUrl`
- `controlPlane.nodes.<Target>.unideskWorkspace` 是 Target 上的 UniDesk 受控入口;
- 当前 CLI 按选中 lane 使用 `lane-k8s-service-proxy` 进入 manager `internalBaseUrl`
- Artificer target task 先通过 `trans <Target>:<targetWorkspace>` 执行一次 `pwd -P` 目录探测
- 目录探测只确认显式绝对路径存在并解析真实目录,不执行远端 UniDesk CLI,也不读取 Git 状态
- 代码不得补主机名、绝对路径或第二条 transport 默认值;
- dry-run 也必须走同一 Target 重入路径,不能读取调用端同名目录冒充 Target 证据;
- dry-run 也必须走同一 Target 目录探测和 lane service proxy,不能读取调用端同名目录冒充 Target 证据;
- 输出只披露 Target/lane/namespace、SecretRef、presence/fingerprint 和 `valuesPrinted=false`
- 旧兼容 group 的 direct HTTP 只保留独立诊断,不是 Artificer 派单路径;
- `agentrun control-plane ...``git-mirror ...` 仍属于 source/runtime 运维控制路径。
+32 -36
View File
@@ -17,6 +17,7 @@ import { autoDispatchTargetTask, targetTaskDispatchDryRunPlan } from "./agentrun
import {
AgentRunTargetTaskPreflightError,
preflightTargetTask,
preflightTargetTaskFromObservedWorkspace,
resolveTargetTaskIdempotency,
targetTaskIdempotencyKey,
targetTaskOperationEvidence,
@@ -202,28 +203,14 @@ describe("AgentRun render-only REST client config", () => {
expect(() => parseAgentRunClientConfigYaml(agentRunClientYaml.replace(" role: render-only", " role: proxy"), "config/agentrun.yaml")).toThrow("client.role must be render-only");
});
test("parses YAML-first target-trans execution without code-owned Target paths", () => {
test("parses YAML-first target-trans without a remote CLI worktree contract", () => {
const targetYaml = agentRunClientYaml.replace(
" transport: direct-http",
[
" transport: target-trans",
" targetExecution:",
" markerEnv: UNIDESK_AGENTRUN_TARGET_EXECUTED",
" executable: bun",
" cliPath: scripts/cli.ts",
].join("\n"),
" transport: target-trans",
);
const config = parseAgentRunClientConfigYaml(targetYaml, "config/agentrun.yaml");
expect(config.client.transport).toBe("target-trans");
expect(config.client.targetExecution).toEqual({
markerEnv: "UNIDESK_AGENTRUN_TARGET_EXECUTED",
executable: "bun",
cliPath: "scripts/cli.ts",
});
expect(() => parseAgentRunClientConfigYaml(
agentRunClientYaml.replace(" transport: direct-http", " transport: target-trans"),
"config/agentrun.yaml",
)).toThrow("client.targetExecution.markerEnv");
expect(config.client).not.toHaveProperty("targetExecution");
});
});
@@ -247,18 +234,10 @@ describe("Artificer Target one-command dispatch", () => {
"--mdtodo-id", "R5.2",
]);
if (request === null) throw new Error("expected Target request");
const markerEnv = "UNIDESK_AGENTRUN_TARGET_EXECUTED_PATH_TEST";
const previous = process.env[markerEnv];
process.env[markerEnv] = request.target;
try {
expect(() => preflightTargetTask(request, markerEnv)).toThrow("Target 工作区不存在或不是目录");
} finally {
if (previous === undefined) delete process.env[markerEnv];
else process.env[markerEnv] = previous;
}
expect(() => preflightTargetTask(request)).toThrow("Target 工作区不存在或不是目录");
});
test("requires trans re-entry but admits dirty, ahead, behind, and mismatched Git state", () => {
test("admits dirty, ahead, behind, and mismatched Git state", () => {
const workspace = mkdtempSync(join(tmpdir(), "unidesk-agentrun-target-"));
const request = targetTaskRequestFromArgs([
"--title", "修复 SelfMedia 的 OCR 规划器",
@@ -270,12 +249,7 @@ describe("Artificer Target one-command dispatch", () => {
]);
expect(request).not.toBeNull();
if (request === null) throw new Error("expected Target request");
const markerEnv = "UNIDESK_AGENTRUN_TARGET_EXECUTED_TEST";
const previous = process.env[markerEnv];
delete process.env[markerEnv];
try {
expect(() => preflightTargetTask(request, markerEnv)).toThrow("只能在外层 trans 重入 NC01 后执行");
gitFixture(workspace, ["init", "-b", "actual/ref"]);
gitFixture(workspace, ["config", "user.email", "test@example.com"]);
gitFixture(workspace, ["config", "user.name", "AgentRun Test"]);
@@ -294,8 +268,7 @@ describe("Artificer Target one-command dispatch", () => {
.toMatch(/^1\s+1$/u);
expect(gitFixture(workspace, ["status", "--porcelain"])).not.toBe("");
process.env[markerEnv] = request.target;
const preflight = preflightTargetTask(request, markerEnv);
const preflight = preflightTargetTask(request);
expect(preflight).toMatchObject({
targetWorkspace: workspace,
workspaceRoot: workspace,
@@ -310,8 +283,6 @@ describe("Artificer Target one-command dispatch", () => {
expect(preflight).not.toHaveProperty("verifiedCommit");
expect(preflight).not.toHaveProperty("clean");
} finally {
if (previous === undefined) delete process.env[markerEnv];
else process.env[markerEnv] = previous;
rmSync(workspace, { recursive: true, force: true });
}
const preflight = {
@@ -336,6 +307,31 @@ describe("Artificer Target one-command dispatch", () => {
expect(targetTaskIdempotencyKey(preflight, { ...operation, prompt: "不同 payload" })).not.toBe(targetTaskIdempotencyKey(preflight, operation));
});
test("accepts a Target-observed absolute directory without inspecting Git or a remote CLI checkout", () => {
const request = targetTaskRequestFromArgs([
"--title", "验证 AgentRun 脏工作区派单",
"--target", "NC01",
"--target-workspace", "/root/.worktrees/unidesk/dirty-canary",
"--repo", "pikasTech/unidesk",
"--ref", "fix/dirty-canary",
"--mdtodo-id", "R13",
]);
if (request === null) throw new Error("expected Target request");
expect(preflightTargetTaskFromObservedWorkspace(request, "/root/.worktrees/unidesk/dirty-canary")).toMatchObject({
target: "NC01",
workspaceRoot: "/root/.worktrees/unidesk/dirty-canary",
sourceAuthority: {
kind: "explicit-target-context",
gitStateInspected: false,
},
});
const source = readFileSync(new URL("./agentrun/target-execution.ts", import.meta.url), "utf8");
expect(source).toContain('["argv", "pwd", "-P"]');
expect(source).not.toContain("cliPath");
expect(source).not.toMatch(/git\s/u);
});
test("scopes post-task create replay to one complete logical operation", () => {
const base = {
title: "修复 UniDesk 的 Artificer 派单",
+12 -33
View File
@@ -77,20 +77,6 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/
if (transport !== "direct-http" && transport !== "target-trans") {
throw new Error(`${sourcePath}: client.transport must be direct-http or target-trans`);
}
const targetExecutionInput = record(client.targetExecution);
const targetExecution = transport === "target-trans"
? {
markerEnv: environmentVariableField(
stringFieldFromRecord(targetExecutionInput, "markerEnv", "client.targetExecution"),
`${sourcePath}: client.targetExecution.markerEnv`,
),
executable: stringFieldFromRecord(targetExecutionInput, "executable", "client.targetExecution"),
cliPath: relativePathField(
stringFieldFromRecord(targetExecutionInput, "cliPath", "client.targetExecution"),
`${sourcePath}: client.targetExecution.cliPath`,
),
}
: null;
const sessionPolicy = readAgentRunSessionPolicyConfig(client, sourcePath);
const publicExposure = readAgentRunPublicExposureConfig(input.publicExposure, baseUrl.replace(/\/+$/u, "/"), sourcePath);
return {
@@ -108,7 +94,6 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/
client: {
role,
transport,
targetExecution,
sessionPolicy,
},
publicExposure,
@@ -348,13 +333,24 @@ export function readAgentRunLaneLookupConfig(configPath: string): { defaultNode:
}
export function agentRunDryRunPlan(action: string, pathValue: string, body: Record<string, unknown>, confirmCommand: string, method: AgentRunHttpMethod = "POST", extra: Record<string, unknown> = {}): Record<string, unknown> {
const laneTarget = activeAgentRunRestTarget;
return {
ok: true,
action,
dryRun: true,
mutation: false,
transport: "direct-http",
transport: laneTarget === null ? "direct-http" : "lane-k8s-service-proxy",
clientRole: "render-only",
...(laneTarget === null ? {} : {
target: {
node: laneTarget.spec.nodeId,
lane: laneTarget.spec.lane,
kubeRoute: laneTarget.spec.nodeKubeRoute,
namespace: laneTarget.spec.runtime.namespace,
managerDeployment: laneTarget.spec.runtime.managerDeployment,
valuesPrinted: false,
},
}),
request: {
method,
path: pathValue,
@@ -775,18 +771,6 @@ export function booleanFieldFromRecord(obj: Record<string, unknown>, key: string
return value;
}
function environmentVariableField(value: string, pathValue: string): string {
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${pathValue} must be an uppercase environment variable name`);
return value;
}
function relativePathField(value: string, pathValue: string): string {
if (value.startsWith("/") || value.includes("..") || value.includes("\0")) {
throw new Error(`${pathValue} must be a repository-relative path without ..`);
}
return value;
}
export function cloneJsonRecord(value: Record<string, unknown>): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
@@ -806,11 +790,6 @@ export interface AgentRunClientConfig {
client: {
role: string;
transport: "direct-http" | "target-trans";
targetExecution: {
markerEnv: string;
executable: string;
cliPath: string;
} | null;
sessionPolicy: AgentRunSessionPolicyConfig;
};
publicExposure: AgentRunPublicExposure | null;
+2 -2
View File
@@ -100,7 +100,7 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
],
resources: ["task/qt", "attempt", "run", "command/cmd", "runnerjob/rjob", "session/ses", "aipodspec/aps"],
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and YAML client.targetExecution makes resource verbs re-enter the selected Target through trans.",
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and target-trans uses the selected Target k3s service proxy plus a host directory probe for Artificer target tasks.",
legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain compatibility groups; new commander work should use get/describe/events/logs/result/ack/cancel/retry/dispatch/create/apply/send. sessions turn/steer are removed; use send only.",
cicdBoundary: "NC01 PaC consumer 仅由 GitHub PR merge 自动触发;默认帮助只给 status/history。legacy 与平台维护写入口只在显式 scoped help 中展示。",
};
@@ -418,6 +418,6 @@ export function agentRunHelpText(args: string[]): string {
"Machine/debug output:",
" -o json|yaml emits a stable UniDesk resource object without the global JSON envelope.",
" --raw emits the direct AgentRun REST envelope.",
" --target/--node/--lane selects a YAML Target; resource commands re-enter it through target-trans.",
" --target/--node/--lane selects a YAML Target; resource commands use its k3s service proxy, and target tasks probe targetWorkspace through trans.",
].join("\n");
}
+5 -6
View File
@@ -41,9 +41,9 @@ import { agentRunExplain, arrayRecords, shortId } from "./options";
import { agentRunCancelAuthorityDisclosure, agentRunCancelCascadeScope, agentRunCancelFencingDisclosure, agentRunCancelRunnerAbortDisclosure, compactAipodSpecDescriptionPayload, compactTaskDescriptionPayload, innerData, normalizeAipodSpecItems, normalizeAttemptResources, normalizeEventItems, normalizeLogItems, normalizeQueueAttemptItems, normalizeRunnerJobItems, normalizeSessionItems, normalizeSingleCommand, normalizeTaskItems, parseResourceKind, parseResourceRef, renderAipodSpecDescription, renderDescribe, renderEventLike, renderGenericDescription, renderMachine, renderMutationSummary, renderQueueAttemptList, renderQueueRetrySummary, renderResourceResult, renderResultSummary, renderTaskDescription, renderedCliResult, requiredContext, rerunWithoutDryRun, resolveAgentRunCancelPolicyTarget, resourceApply, resourceCreate, resourceDispatch, resourceSessionPromptCommand, taskInputDescriptionPayload, taskListState, unwrapTaskDetail } from "./render";
import { AgentRunRestError, renderAgentRunRestError, resolveAgentRunRestTarget, runAgentRunRestCommand, withAgentRunRestTarget } from "./rest-bridge";
import { renderSessionSendError } from "./session-send-render";
import { runAgentRunResourceThroughTarget } from "./target-execution";
import { prepareAgentRunTargetTaskPreflight } from "./target-execution";
import { runnerJobObservationScript, type RunnerJobObservationIdentity } from "./runner-observation";
import { isChineseTaskTitle, validateChineseTaskTitle } from "./target-task";
import { isChineseTaskTitle, validateChineseTaskTitle, withAgentRunTargetTaskPreflight } from "./target-task";
import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, record, stringOrNull } from "./utils";
export function agentRunGetKindHelp(kindRaw: string): string {
@@ -75,9 +75,8 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
}
const bridgeActionArgs = stripAgentRunResourceWrapperArgs(actionArgs);
try {
const targetResult = await runAgentRunResourceThroughTarget(config, command, canonicalArgs, options);
if (targetResult !== null) return targetResult;
return await withAgentRunRestTarget(resolveAgentRunRestTarget(config, options), async () => {
const targetTaskPreflight = await prepareAgentRunTargetTaskPreflight(config, canonicalArgs, options);
return await withAgentRunTargetTaskPreflight(targetTaskPreflight, async () => await withAgentRunRestTarget(resolveAgentRunRestTarget(config, options), async () => {
if (verb === "explain") return renderedCliResult(true, command, agentRunExplain(action ?? "task", resourceArgs, options));
if (verb === "get") return await resourceGet(config, command, action, bridgeActionArgs, options);
if (verb === "describe") return await resourceDescribe(config, command, action, bridgeActionArgs, options);
@@ -92,7 +91,7 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
if (verb === "apply") return await resourceApply(config, command, bridgeActionArgs, options);
if (verb === "send") return await resourceSessionPromptCommand(config, command, action, bridgeActionArgs, options);
return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`);
});
}));
} catch (error) {
if (error instanceof AgentRunRestError) {
if (verb === "send") return renderSessionSendError(command, error.toPayload(command), options);
+10 -5
View File
@@ -45,7 +45,7 @@ import { parseResourceOptions, stripAgentRunResourceWrapperArgs } from "./resour
import { AGENTRUN_GIT_MIRROR_RETRY_MAX_ATTEMPTS, agentRunGitMirrorRetryDelayMs, agentRunGitMirrorRetrySummary, agentRunGitMirrorRetryableFailure, createYamlLaneJobScript, yamlLaneGitMirrorJobManifest, yamlLaneGitMirrorStatusScript, yamlLaneJobProbeScript } from "./secrets";
import {
AgentRunTargetTaskPreflightError,
preflightTargetTask,
currentAgentRunTargetTaskPreflight,
resolveTargetTaskIdempotency,
targetTaskDisclosure,
targetTaskPrompt,
@@ -687,15 +687,20 @@ function targetTaskPreflightFromArgs(args: string[], aipod: string): AgentRunTar
);
}
const client = readAgentRunClientConfig();
const execution = client.client.targetExecution;
if (client.client.transport !== "target-trans" || execution === null) {
if (client.client.transport !== "target-trans") {
throw new AgentRunTargetTaskPreflightError(
"target-execution",
`${client.sourcePath} 必须声明 client.transport=target-trans 和 client.targetExecution`,
`${client.sourcePath} 必须声明 client.transport=target-trans。`,
{ configPath: client.sourcePath, transport: client.client.transport },
);
}
return preflightTargetTask(request, execution.markerEnv);
const observed = currentAgentRunTargetTaskPreflight(request);
if (observed !== null) return observed;
throw new AgentRunTargetTaskPreflightError(
"target-execution",
`Target 预检只能在受控目录探测 ${request.target}:${request.targetWorkspace} 后执行。`,
{ target: request.target, targetWorkspace: request.targetWorkspace },
);
} catch (error) {
if (!(error instanceof AgentRunTargetTaskPreflightError)) throw error;
throw targetTaskRestError(error);
+44 -112
View File
@@ -1,128 +1,60 @@
import { readFileSync } from "node:fs";
import type { UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { resolveAgentRunLaneTarget } from "../agentrun-lanes";
import { runSshCommandCapture } from "../ssh";
import { readAgentRunClientConfig } from "./config";
import { renderMachine, renderedCliResult } from "./render";
import {
AgentRunTargetTaskPreflightError,
preflightTargetTaskFromObservedWorkspace,
targetTaskRequestFromArgs,
type AgentRunTargetTaskPreflight,
} from "./target-task";
import type { AgentRunResourceOptions } from "./utils";
export async function runAgentRunResourceThroughTarget(
export async function prepareAgentRunTargetTaskPreflight(
config: UniDeskConfig | null,
command: string,
canonicalArgs: string[],
options: AgentRunResourceOptions,
): Promise<RenderedCliResult | null> {
): Promise<AgentRunTargetTaskPreflight | null> {
const request = targetTaskRequestFromArgs(canonicalArgs);
if (request === null) return null;
const client = readAgentRunClientConfig();
if (client.client.transport !== "target-trans") return null;
if (config === null) return null;
const execution = client.client.targetExecution;
if (execution === null) throw new Error(`${client.sourcePath}: client.targetExecution is required for target-trans`);
const { configPath, spec } = resolveAgentRunLaneTarget({ node: options.node, lane: options.lane });
const activeTarget = process.env[execution.markerEnv] ?? null;
if (activeTarget === spec.nodeId) return null;
if (activeTarget !== null) {
throw new Error(`${execution.markerEnv}=${activeTarget} cannot re-enter a second Target ${spec.nodeId}`);
if (client.client.transport !== "target-trans") {
throw new AgentRunTargetTaskPreflightError(
"target-execution",
`${client.sourcePath} 必须声明 client.transport=target-trans`,
{ configPath: client.sourcePath, transport: client.client.transport },
);
}
const route = `${spec.nodeRoute}:${spec.nodeUnideskWorkspace}`;
const targetArgs = [...canonicalArgs];
if (!hasOption(targetArgs, "target")) targetArgs.push("--target", spec.nodeId);
if (!hasOption(targetArgs, "lane")) targetArgs.push("--lane", spec.lane);
const stdin = targetExecutionStdin(targetArgs);
const capture = await runSshCommandCapture(config, route, [
"argv",
"env",
`${execution.markerEnv}=${spec.nodeId}`,
execution.executable,
execution.cliPath,
"agentrun",
...targetArgs,
], stdin);
const disclosure = {
transport: "target-trans",
target: spec.nodeId,
lane: spec.lane,
route,
configPath,
unideskWorkspace: spec.nodeUnideskWorkspace,
markerEnv: execution.markerEnv,
mutation: false,
valuesPrinted: false,
};
if (capture.exitCode !== 0) {
const detail = boundedRemoteFailure(capture.stderr || capture.stdout);
return renderedCliResult(
false,
command,
[
`ERROR target-trans/${spec.nodeId}`,
`route: ${route}`,
`exitCode: ${capture.exitCode}`,
`message: ${detail || "Target CLI did not return output"}`,
].join("\n"),
if (config === null) {
throw new AgentRunTargetTaskPreflightError(
"target-execution",
"Artificer Target 派单需要 UniDesk 配置以执行受控目录探测。",
{ target: request.target, targetWorkspace: request.targetWorkspace },
);
}
const { spec } = resolveAgentRunLaneTarget({ node: options.node, lane: options.lane });
if (request.target !== spec.nodeId) {
throw new AgentRunTargetTaskPreflightError(
"target-context",
`--target ${request.target} 与选中 AgentRun lane 的 Target ${spec.nodeId} 不一致。`,
{ target: request.target, selectedTarget: spec.nodeId, lane: spec.lane },
);
}
const output = capture.stdout.trimEnd();
if (options.raw || options.output === "json") {
const payload = parseTargetMachineOutput(output, "json");
return renderMachine(command, attachTargetExecution(payload, disclosure), "json", machineOutputOk(payload));
const route = `${spec.nodeRoute}:${request.targetWorkspace}`;
const capture = await runSshCommandCapture(config, route, ["argv", "pwd", "-P"]);
const workspaceRoot = capture.stdout.trim();
if (capture.exitCode !== 0 || workspaceRoot.length === 0) {
throw new AgentRunTargetTaskPreflightError(
"workspace",
`Target 工作区不存在或不是目录:${request.target}:${request.targetWorkspace}`,
{
target: request.target,
targetWorkspace: request.targetWorkspace,
route,
exitCode: capture.exitCode,
},
);
}
if (options.output === "yaml") {
const payload = parseTargetMachineOutput(output, "yaml");
return renderMachine(command, attachTargetExecution(payload, disclosure), "yaml", machineOutputOk(payload));
}
return renderedCliResult(
true,
command,
[`TargetExecution: trans ${route} lane=${spec.lane}`, output].filter(Boolean).join("\n"),
);
}
function hasOption(args: string[], name: string): boolean {
const flag = `--${name}`;
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
}
function targetExecutionStdin(args: string[]): string | undefined {
const stdinFlag = args.some((arg) => arg === "--prompt-stdin" || arg === "--stdin"
|| arg === "--json-stdin" || arg === "--yaml-stdin");
const stdinFile = optionIsStdin(args, "prompt-file") || optionIsStdin(args, "json-file")
|| optionIsStdin(args, "yaml-file") || optionIsStdin(args, "file") || optionIsStdin(args, "filename")
|| args.some((arg, index) => arg === "-f" && args[index + 1] === "-");
return stdinFlag || stdinFile ? readFileSync(0, "utf8") : undefined;
}
function optionIsStdin(args: string[], name: string): boolean {
const flag = `--${name}`;
return args.some((arg, index) => (arg === flag && args[index + 1] === "-") || arg === `${flag}=-`);
}
function parseTargetMachineOutput(output: string, mode: "json" | "yaml"): unknown {
if (output.length === 0) throw new Error(`Target CLI returned empty ${mode} output`);
try {
return mode === "json" ? JSON.parse(output) : Bun.YAML.parse(output);
} catch (error) {
throw new Error(`Target CLI returned invalid ${mode} output: ${error instanceof Error ? error.message : String(error)}`);
}
}
function attachTargetExecution(value: unknown, disclosure: Record<string, unknown>): Record<string, unknown> {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return { ...(value as Record<string, unknown>), targetExecution: disclosure };
}
return { data: value, targetExecution: disclosure };
}
function machineOutputOk(value: unknown): boolean {
return !(typeof value === "object" && value !== null && (value as Record<string, unknown>).ok === false);
}
function boundedRemoteFailure(value: string): string {
return value
.replace(/https?:\/\/[^@\s]+@/gu, "https://[redacted]@")
.replace(/(token|password|authorization)[=:][^\s]+/giu, "$1=[redacted]")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 600);
return preflightTargetTaskFromObservedWorkspace(request, workspaceRoot);
}
+47 -9
View File
@@ -88,14 +88,7 @@ export function targetTaskRequestFromArgs(args: string[]): AgentRunTargetTaskReq
};
}
export function preflightTargetTask(request: AgentRunTargetTaskRequest, markerEnv: string): AgentRunTargetTaskPreflight {
if (process.env[markerEnv] !== request.target) {
throw new AgentRunTargetTaskPreflightError(
"target-execution",
`Target 预检只能在外层 trans 重入 ${request.target} 后执行;当前缺少受控执行标记 ${markerEnv}`,
{ target: request.target, markerEnv, targetRoute: `${request.target}:${request.targetWorkspace}` },
);
}
export function preflightTargetTask(request: AgentRunTargetTaskRequest): AgentRunTargetTaskPreflight {
let workspaceRoot: string;
try {
if (!statSync(request.targetWorkspace).isDirectory()) throw new Error("not a directory");
@@ -108,6 +101,20 @@ export function preflightTargetTask(request: AgentRunTargetTaskRequest, markerEn
);
}
return preflightTargetTaskFromObservedWorkspace(request, workspaceRoot);
}
export function preflightTargetTaskFromObservedWorkspace(
request: AgentRunTargetTaskRequest,
workspaceRoot: string,
): AgentRunTargetTaskPreflight {
if (!workspaceRoot.startsWith("/") || workspaceRoot.includes("\n") || workspaceRoot.includes("\0")) {
throw new AgentRunTargetTaskPreflightError(
"workspace",
`Target 工作区返回了无效的绝对目录:${request.target}:${request.targetWorkspace}`,
{ target: request.target, targetWorkspace: request.targetWorkspace },
);
}
return {
...request,
targetRoute: `${request.target}:${request.targetWorkspace}`,
@@ -121,9 +128,40 @@ export function preflightTargetTask(request: AgentRunTargetTaskRequest, markerEn
};
}
let activeTargetTaskPreflight: AgentRunTargetTaskPreflight | null = null;
export async function withAgentRunTargetTaskPreflight<T>(
preflight: AgentRunTargetTaskPreflight | null,
action: () => Promise<T>,
): Promise<T> {
const previous = activeTargetTaskPreflight;
activeTargetTaskPreflight = preflight;
try {
return await action();
} finally {
activeTargetTaskPreflight = previous;
}
}
export function currentAgentRunTargetTaskPreflight(request: AgentRunTargetTaskRequest): AgentRunTargetTaskPreflight | null {
if (activeTargetTaskPreflight === null) return null;
const matches = activeTargetTaskPreflight.title === request.title
&& activeTargetTaskPreflight.target === request.target
&& activeTargetTaskPreflight.targetWorkspace === request.targetWorkspace
&& activeTargetTaskPreflight.repository === request.repository
&& activeTargetTaskPreflight.ref === request.ref
&& activeTargetTaskPreflight.mdtodoId === request.mdtodoId;
if (matches) return activeTargetTaskPreflight;
throw new AgentRunTargetTaskPreflightError(
"target-context",
"Target 目录探测结果与当前 Artificer 派单上下文不一致。",
{ target: request.target, targetWorkspace: request.targetWorkspace },
);
}
export function targetTaskPrompt(preflight: AgentRunTargetTaskPreflight, userPrompt: string | null): string {
const context = [
"任务目标上下文(由用户显式提供,并由 UniDesk CLI 在 Target trans 重入后校验目录存在):",
"任务目标上下文(由用户显式提供,并由 UniDesk CLI 在 Target 上受控探测目录存在):",
`- 中文任务标题: ${preflight.title}`,
`- MDTODO ID: ${preflight.mdtodoId}`,
`- Target: ${preflight.target}`,