From 0d0b3e21f3bac93abf7ed31b7c5ea5c46e40010b Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Thu, 11 Jun 2026 01:57:17 +0000 Subject: [PATCH] fix: route AgentRun bridge from runners --- docs/reference/agentrun.md | 2 + docs/reference/cli.md | 1 + scripts/agentrun-cli-contract-test.ts | 17 +++ scripts/src/agentrun.ts | 181 +++++++++++++++++++++++++- scripts/src/remote.ts | 22 ++++ 5 files changed, 217 insertions(+), 6 deletions(-) diff --git a/docs/reference/agentrun.md b/docs/reference/agentrun.md index 311bd77b..442ceba8 100644 --- a/docs/reference/agentrun.md +++ b/docs/reference/agentrun.md @@ -113,6 +113,8 @@ AgentRun `v0.1` 的指挥官任务面已经按 AgentRun issue #105 完成真实 UniDesk 指挥官新任务入口固定使用 `bun scripts/cli.ts agentrun queue|sessions`。该入口是 G14 `/root/agentrun-v01` 中官方 `./scripts/agentrun --manager-url auto` CLI 的直接 bridge;日常派单、dispatch、turn 和 steer 优先用 `--json-stdin`、`--prompt-stdin`、`--runner-json-stdin` 或 `--*-file -` 的 quoted heredoc/stdin 形式,stdin 会通过管道直通官方 CLI,不先落 dump 文件。`--json-file`、`--prompt-file` 和 `--runner-json-file` 只用于已审阅且可复用的受控文件,bridge 会将其 materialize 到 G14 临时文件后传给官方 CLI。UniDesk 不实现 AgentRun queue 协议,也不把任务 double-write 回旧 Code Queue。 +`agentrun control-plane ...` 与 `agentrun queue|runs|commands|runner|sessions ...` 共用同一 UniDesk SSH capture bridge。主 server 本机可继续使用本地 backend-core broker;AgentRun runner、Artificer 或其他没有本地 Docker / `unidesk-backend-core` 容器的环境会自动改走既有 frontend `/ws/ssh` WebSocket backend,并在输出的 `bridge.capture.backend`、`reason` 和 `localBackendCore` 中披露选择依据。本地 `unidesk-backend-core` 容器不是 runner 环境使用这些 AgentRun CLI 入口的隐式前置条件。若所有 capture backend 都不可用,CLI 必须返回 `failureKind=bridge-execution-environment` 与 `capture-backend-unavailable` 或 `bridge-execution-environment-unavailable`,并给出受控恢复入口;AgentRun 官方 CLI 自身返回的 run/command/schema 错误不得被改写成 bridge 失败。 + AgentRun Queue 任务如果需要调用 UniDesk 维护桥,例如 `trans` / `unidesk-ssh`,长期契约以 AgentRun 仓库 `docs/reference/spec-v01-runtime-assembly.md` 和 `docs/reference/spec-v01-secret-distribution.md` 为准:调用方通过 `executionPolicy.secretScope.toolCredentials[].tool=unidesk-ssh` 请求 `UNIDESK_SSH_CLIENT_TOKEN` SecretRef;非敏感 endpoint 由 runner-job `transientEnv` 显式提供,或由 manager 受控默认值自动补齐。UniDesk bridge 提交 Queue payload 时不得在 prompt、payload 或 `transientEnv` 中携带 token,也不得使用 HWLAB runtime Web 入口冒充 UniDesk frontend。若 dispatcher 已正确请求 `unidesk-ssh` 但 trace 的 `runner-job-created.transientEnv.names` 没有 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`,归为 AgentRun assembly 问题;若 endpoint env 已存在但 route denied/timeout,再按 UniDesk frontend/token scope 或 provider session 排查。 旧 UniDesk Code Queue 只保留历史归档、只读排障和残留旧任务停止入口。`codex submit/enqueue`、`codex steer`、`codex resume`、`codex queue create/merge`、`codex move`、旧 Web 提交表单、旧队列管理和旧 workdir 管理都必须返回冻结状态或禁用;`codex task/tasks/output/read/unread/queues` 可继续读取历史,`codex interrupt|cancel` 只用于停止残留旧任务。旧 Code Queue history 不迁移到 AgentRun,也不提供 adapter、legacy mode、fallback 或双写路径。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index fe168c05..6e68cb8c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -94,6 +94,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 - `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule list`、`schedule get`、`schedule runs --limit N` 和 `schedule runs --limit N` 是只读观察入口;`schedule run`、`schedule retry-run`、`schedule delete` 和 `schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global` 和 `scheduleId=null`;`schedule runs --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId` 和 `observeCommand`;`schedule retry-run ` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId`、`scheduleId`、`newRunId` 和 `observeCommand`。当 backend-core 目标容器缺失或只观察到 verify-only 容器时,schedule/microservice 命令必须以非零退出并返回 `failureKind=target-stack-not-running`、`runnerDisposition=infra-blocked`、`readOnlyCommands` 和 `authorizationRequiredForRecovery`,不得把 Docker 的 `No such container` 当成成功的空历史。 - `codex deploy ` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 - `agentrun queue|sessions` 是当前指挥官新任务和 AgentRun session 控制入口。UniDesk CLI 通过 G14 `/root/agentrun-v01` 中官方 `./scripts/agentrun --manager-url auto` 执行:`queue commander` 查看指挥官队列,`queue submit --json-stdin` 创建新任务,`queue dispatch --json-stdin` 派发,`queue read/cancel` 标记和取消队列任务;`sessions trace/output/read/steer/cancel` 读取和控制已创建 session。日常一次性 JSON、prompt 和 runner JSON 输入优先用 quoted heredoc/stdin;`--json-file`、`--prompt-file`、`--runner-json-file` 只用于已审阅且可复用的受控文件。本地 bridge 对 stdin 直通官方 AgentRun CLI,不先落 dump 文件;它不是旧 Code Queue adapter,不做双写,也不迁移旧历史。 +- `agentrun control-plane ...` 与 `agentrun queue|runs|commands|runner|sessions ...` 共用 UniDesk SSH capture bridge。主 server 本机可使用本地 backend-core broker;Artificer/AgentRun runner 等没有本地 Docker 或 `unidesk-backend-core` 容器的环境会自动使用 frontend `/ws/ssh` WebSocket backend,并在 `bridge.capture.backend`、`reason`、`localBackendCore` 中披露选择依据。本地 `unidesk-backend-core` 不是 runner 环境的隐式前置条件;若 capture backend 不可用,错误必须归类为 `failureKind=bridge-execution-environment` 并给出受控恢复入口。 - `codex submit/enqueue`、`codex steer`、`codex resume`、`codex queue create`、`codex queue merge`、`codex move`、旧 Web 提交表单、旧队列管理和旧 workdir 管理是冻结的 legacy Code Queue 写入口。CLI 必须返回 `ok=false`、`frozen=true`、`degradedReason=legacy-code-queue-frozen` 和 AgentRun 替代命令;服务端旧 API 写入口必须返回 410。新任务、steer、trace/output、read 和 cancel 走 AgentRun Queue/Sessions。 - 旧 Code Queue 只保留历史归档、只读排障和残留任务停止。`codex task/tasks/output/read/unread/queues` 继续通过 backend-core 私有代理读取旧 PostgreSQL 历史;`codex interrupt|cancel ` 只用于停止旧运行面残留任务。旧 `steer-confirm` 只作为历史 trace confirmation 查询,不是新任务控制入口。 - `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N] [--full|--raw]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。默认输出是紧凑 commander 视图,显式分出 `schedulerPreflight` 与 `activeRunnerPrCapability`,并附带 `commands` 和 `disclosure`,方便先看 scheduler auth 缺口、再看当前 runner/dev container 的 `gh auth status` 与 `gh pr create --dry-run` 能力;`--full` 或 `--raw` 才展开完整 `preflight`、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测和观测原文。只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。当 auth-broker 配置存在时,`tokenCoverage.source="auth-broker"`、`credentialSource="broker-issued-token"` 且 runner env token 不是成功前提;当仅 env token 存在时,`credentialSource="env-token"` 且 `authBroker.nextAction="use-env-token-until-auth-broker-live"`;两者都缺失时顶层 `ok=false`、`runnerDisposition=infra-blocked`、`degradedReason=auth-broker-needed`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`,并输出 `authBroker.source="broker/auth-broker-needed"`、`capability.source="missing-token"`。该 `auth-missing` 的 scope 是 `scheduler-runner-env`,不能简化成“当前 active runner/dev container 不能创建 PR”;默认视图必须带 `scopeBoundary` 和 `activeRunnerPrCapability`。GitHub DNS/API 连接失败应归类为 `failureKind=github-transient`、`degradedReason=github-dns-api-transient`,并带 `retryable=true`、`commanderAction=retry-backoff-or-keep-running-if-heartbeat-fresh` 和有界 `githubTransient.failedProbes`;调用方应重试/退避,且在任务 heartbeat/trace 新鲜时继续监督,不把它当成 auth 缺失或 PR 语义失败。`prCapability` 是 runner-facing 合同摘要,必须包含目标分支、token/auth 来源、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff、真实 PR 创建需要 commander 授权,以及 guarded `gh pr merge --dry-run` 预检路径;系统 `gh` binary 缺失只进入 `tools.systemGhBinary`,不得误判为 UniDesk REST `gh` CLI 不可用。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=control-plane-missing` / `degradedReason=remote-control-plane-unreachable`,而不是把本地 `backend-core-container-missing` 当作最终阻塞。`--pr-create-dry-run` 不 POST GitHub,只证明 runner 内 PR body 生成、`scripts/cli.ts gh pr create --dry-run` 和 branch 参数形态可用;服务端创建权限仍以 token/auth broker、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。 diff --git a/scripts/agentrun-cli-contract-test.ts b/scripts/agentrun-cli-contract-test.ts index e349bc52..663d58d6 100644 --- a/scripts/agentrun-cli-contract-test.ts +++ b/scripts/agentrun-cli-contract-test.ts @@ -85,6 +85,21 @@ assertCondition( "AgentRun control-plane status must degrade empty runtime JSON snippets instead of failing the whole status probe", ); +assertCondition( + agentRunSource.includes('type AgentRunBridgeCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket"') + && agentRunSource.includes('reason: "runner-environment"') + && agentRunSource.includes('degradedReason: "capture-backend-unavailable"') + && agentRunSource.includes('"agentrun-cli-returned-failure"') + && agentRunSource.includes('failureKind: "bridge-execution-environment"') + && agentRunSource.includes('key === "nextActions"'), + "AgentRun CLI bridge must use the remote frontend backend in runner/no-Docker environments and classify bridge failures separately", +); + +assertCondition( + !agentRunSource.includes('degradedReason: "agentrun-cli-bridge-failed"'), + "AgentRun CLI bridge must not collapse official AgentRun failures into bridge failures", +); + console.log(JSON.stringify({ ok: true, checks: [ @@ -96,5 +111,7 @@ console.log(JSON.stringify({ "AgentRun command help presents heredoc/stdin before reusable file fallbacks", "global help indexes AgentRun v0.1 entrypoints", "AgentRun control-plane status degrades empty runtime JSON snippets", + "AgentRun CLI bridge selects remote frontend backend in runner/no-Docker environments", + "AgentRun CLI bridge keeps AgentRun failures distinct from bridge failures", ], })); diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index d60e95f1..8685473a 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -1,6 +1,8 @@ import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import type { UniDeskConfig } from "./config"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; +import { runRemoteSshCommandCapture } from "./remote"; import { startJob } from "./jobs"; const g14SourceRoute = "G14:/root/agentrun-v01"; @@ -1608,9 +1610,9 @@ type AgentRunOfficialCliBridgeGroup = "queue" | "sessions" | "aipod-specs" | "ai async function runOfficialAgentRunCli(config: UniDeskConfig, group: AgentRunOfficialCliBridgeGroup, args: string[]): Promise> { const prepared = prepareOfficialAgentRunCliArgs([group, ...args]); const command = `agentrun ${prepared.args.join(" ")}`.trim(); - const bridge = agentRunQueueBridgeMetadata(prepared.materializedFiles, prepared.stdinPayload); const script = officialAgentRunCliScript(prepared); const result = await capture(config, g14SourceRoute, ["script", "--", script]); + const bridge = agentRunQueueBridgeMetadata(prepared.materializedFiles, prepared.stdinPayload, captureBridgeMetadata(result)); const payload = captureJsonPayload(result); if (result.exitCode === 0 && Object.keys(payload).length > 0) { return { @@ -1628,10 +1630,14 @@ async function runOfficialAgentRunCli(config: UniDeskConfig, group: AgentRunOffi remote: compactCapture(result, { full: true, stdoutTailChars: 4000, stderrTailChars: 2000 }), }; } + const bridgeFailureKind = bridgeExecutionFailureKind(result); + const agentrunFailureKind = stringOrNull(payload.failureKind) ?? stringOrNull(record(payload.error).failureKind); return { ok: false, command, - degradedReason: "agentrun-cli-bridge-failed", + degradedReason: bridgeFailureKind === null ? "agentrun-cli-returned-failure" : bridgeFailureKind.degradedReason, + ...(bridgeFailureKind === null ? {} : { failureKind: bridgeFailureKind.failureKind, recoveryActions: bridgeFailureKind.recoveryActions }), + ...(bridgeFailureKind === null && agentrunFailureKind !== null ? { failureKind: agentrunFailureKind } : {}), bridge, agentrun: payload, remote: compactCapture(result, { full: true, stdoutTailChars: 8000, stderrTailChars: 4000 }), @@ -1668,7 +1674,7 @@ function rewriteOfficialCommandFieldValue(value: unknown): unknown { } function shouldRewriteOfficialCommandField(key: string): boolean { - return key === "pollCommands" || key === "drillDownCommands" || key === "recoveryActions" || key === "logPath"; + return key === "pollCommands" || key === "drillDownCommands" || key === "recoveryActions" || key === "nextActions" || key === "logPath"; } function rewriteOfficialCommandString(value: string): string { @@ -1804,7 +1810,7 @@ function parentDir(pathValue: string): string { return index > 0 ? pathValue.slice(0, index) : "."; } -function agentRunQueueBridgeMetadata(materializedFiles: AgentRunCliMaterializedFile[], stdinPayload: AgentRunCliForwardedStdin | null): Record { +function agentRunQueueBridgeMetadata(materializedFiles: AgentRunCliMaterializedFile[], stdinPayload: AgentRunCliForwardedStdin | null, captureBridge: Record | null): Record { return { route: g14SourceRoute, sourceWorktree: "/root/agentrun-v01", @@ -1813,6 +1819,7 @@ function agentRunQueueBridgeMetadata(materializedFiles: AgentRunCliMaterializedF officialCli: "./scripts/agentrun", mode: "direct-official-cli", compatibility: "no-code-queue-adapter-no-double-write", + capture: captureBridge, stdinForwarded: stdinPayload ? { flag: stdinPayload.flag, source: stdinPayload.source, @@ -1827,8 +1834,162 @@ function agentRunQueueBridgeMetadata(materializedFiles: AgentRunCliMaterializedF }; } -async function capture(config: UniDeskConfig, target: string, args: string[]): Promise { - return await runSshCommandCapture(config, target, args); +type AgentRunBridgeCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket"; + +interface LocalBackendCoreStatus { + dockerExecutable: boolean; + backendCoreContainer: boolean; + error: string | null; +} + +interface AgentRunBridgeCapturePlan { + backend: AgentRunBridgeCaptureBackend; + route: string; + reason: string; + remoteHost: string | null; + localBackendCore: LocalBackendCoreStatus; +} + +type AgentRunBridgeCaptureResult = SshCaptureResult & { bridgeExecution?: AgentRunBridgeCapturePlan }; + +let localBackendCoreStatusCache: LocalBackendCoreStatus | null = null; + +async function capture(config: UniDeskConfig, target: string, args: string[]): Promise { + const plan = agentRunBridgeCapturePlan(config, target); + if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { + return await captureRemote(config, plan, target, args); + } + const local = attachBridgeExecution(await runSshCommandCapture(config, target, args), plan); + const fallbackHost = agentRunBridgeRemoteHost(config); + if (local.exitCode !== 0 && fallbackHost !== null && bridgeExecutionFailureKind(local)?.degradedReason === "capture-backend-unavailable") { + const fallbackPlan: AgentRunBridgeCapturePlan = { + ...plan, + backend: "remote-frontend-websocket", + remoteHost: fallbackHost, + reason: "local-capture-backend-unavailable", + }; + return await captureRemote(config, fallbackPlan, target, args); + } + return local; +} + +async function captureRemote(config: UniDeskConfig, plan: AgentRunBridgeCapturePlan, target: string, args: string[]): Promise { + if (plan.remoteHost === null) return attachBridgeExecution(remoteBridgeCaptureFailure(new Error("remote host is not configured")), plan); + try { + return attachBridgeExecution(await runRemoteSshCommandCapture(config, plan.remoteHost, target, args), plan); + } catch (error) { + return attachBridgeExecution(remoteBridgeCaptureFailure(error), plan); + } +} + +function remoteBridgeCaptureFailure(error: unknown): SshCaptureResult { + const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + return { + exitCode: 255, + stdout: "", + stderr: `unidesk remote frontend ssh bridge failed: ${message}\n`, + }; +} + +function attachBridgeExecution(result: SshCaptureResult, plan: AgentRunBridgeCapturePlan): AgentRunBridgeCaptureResult { + return { ...result, bridgeExecution: plan }; +} + +function agentRunBridgeCapturePlan(config: UniDeskConfig, target: string, env: NodeJS.ProcessEnv = process.env): AgentRunBridgeCapturePlan { + const localBackendCore = detectLocalBackendCoreStatus(); + const remoteHost = agentRunBridgeRemoteHost(config, env); + const runnerEnv = isAgentRunRunnerEnvironment(env); + if (runnerEnv && remoteHost !== null) { + return { backend: "remote-frontend-websocket", route: target, reason: "runner-environment", remoteHost, localBackendCore }; + } + if (!localBackendCore.backendCoreContainer && remoteHost !== null) { + return { backend: "remote-frontend-websocket", route: target, reason: "local-backend-core-unavailable", remoteHost, localBackendCore }; + } + return { backend: "local-backend-core-broker", route: target, reason: "main-server-local-backend-core", remoteHost: null, localBackendCore }; +} + +function detectLocalBackendCoreStatus(): LocalBackendCoreStatus { + if (localBackendCoreStatusCache !== null) return localBackendCoreStatusCache; + const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf8", timeout: 2000 }); + if (result.error !== undefined) { + localBackendCoreStatusCache = { + dockerExecutable: false, + backendCoreContainer: false, + error: result.error.message, + }; + return localBackendCoreStatusCache; + } + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + localBackendCoreStatusCache = { + dockerExecutable: result.status === 0, + backendCoreContainer: result.status === 0 && String(result.stdout ?? "").split(/\r?\n/u).includes("unidesk-backend-core"), + error: result.status === 0 ? null : output || `docker ps exited ${result.status ?? "unknown"}`, + }; + return localBackendCoreStatusCache; +} + +function isAgentRunRunnerEnvironment(env: NodeJS.ProcessEnv): boolean { + return Boolean( + env.AGENTRUN_BOOT_MODE + || env.AGENTRUN_RUN_ID + || env.AGENTRUN_K8S_JOB_NAME + || env.CODE_QUEUE_SERVICE_ROLE + || env.CODE_QUEUE_INSTANCE_ID + || env.KUBERNETES_SERVICE_HOST, + ); +} + +function agentRunBridgeRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): string | null { + return normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_IP) + ?? normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_HOST) + ?? normalizeRemoteHostHint(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST) + ?? normalizeRemoteHostHint(config.network.publicHost); +} + +function normalizeRemoteHostHint(raw: string | undefined): string | null { + const value = raw?.trim() ?? ""; + if (value.length === 0 || value === "localhost" || value === "127.0.0.1" || value === "::1") return null; + return value.replace(/\/+$/u, ""); +} + +function captureBridgeMetadata(result: SshCaptureResult): Record | null { + const bridgeExecution = (result as AgentRunBridgeCaptureResult).bridgeExecution; + if (bridgeExecution === undefined) return null; + return { + backend: bridgeExecution.backend, + route: bridgeExecution.route, + reason: bridgeExecution.reason, + remoteHost: bridgeExecution.remoteHost, + localBackendCore: bridgeExecution.localBackendCore, + }; +} + +function bridgeExecutionFailureKind(result: SshCaptureResult): { degradedReason: string; failureKind: string; recoveryActions: string[] } | null { + if (result.exitCode === 0) return null; + const stderr = result.stderr; + if (/No such container: unidesk-backend-core|failed to start broker|Executable not found.*"docker"|docker: not found|Cannot connect to the Docker daemon/iu.test(stderr)) { + return { + degradedReason: "capture-backend-unavailable", + failureKind: "bridge-execution-environment", + recoveryActions: [ + "Run the same command from a healthy UniDesk main-server CLI, or provide UNIDESK_MAIN_SERVER_IP/UNIDESK_MAIN_SERVER_HOST so the bridge can use the frontend WebSocket SSH backend.", + "For AgentRun runner jobs, request the unidesk-ssh tool credential SecretRef instead of embedding secret values in prompts or payloads.", + "After restoring the bridge backend, retry the same bun scripts/cli.ts agentrun ... command; do not use direct kubectl or GitHub API bypasses.", + ], + }; + } + if (/frontend login failed|remote frontend ssh bridge|websocket error|timed out waiting for provider session|route-not-allowed/iu.test(stderr)) { + return { + degradedReason: "bridge-execution-environment-unavailable", + failureKind: "bridge-execution-environment", + recoveryActions: [ + "Verify the UniDesk frontend/backend-core SSH bridge is reachable from this runner and that UNIDESK_MAIN_SERVER_IP or UNIDESK_MAIN_SERVER_HOST points at the main server.", + "Verify scoped UNIDESK_SSH_CLIENT_TOKEN route allowlist includes the requested AgentRun route, without printing token values.", + "Retry with the same bun scripts/cli.ts agentrun ... command after the controlled bridge path is restored.", + ], + }; + } + return null; } function compactCapture(result: SshCaptureResult, options: { full?: boolean; stdoutTailChars?: number; stderrTailChars?: number } = {}): Record { @@ -1842,6 +2003,14 @@ function compactCapture(result: SshCaptureResult, options: { full?: boolean; std stdoutTailOmitted: !full && result.exitCode === 0, stderrTailOmitted: !full && result.exitCode === 0, }; + const bridgeExecution = captureBridgeMetadata(result); + if (bridgeExecution !== null) payload.bridgeExecution = bridgeExecution; + const failureKind = bridgeExecutionFailureKind(result); + if (failureKind !== null) { + payload.degradedReason = failureKind.degradedReason; + payload.failureKind = failureKind.failureKind; + payload.recoveryActions = failureKind.recoveryActions; + } if (full || result.exitCode !== 0) { payload.stdoutTail = tail(result.stdout, stdoutTailChars); payload.stderrTail = tail(result.stderr, stderrTailChars); diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 07bcd812..99845869 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -535,6 +535,28 @@ function scopedSshFrontendSession(host: string, config: UniDeskConfig, token: st return { baseUrl: frontendBaseUrl(host, config), cookie: "", sshClientToken: token }; } +export async function runRemoteSshCommandCapture( + config: UniDeskConfig, + host: string, + target: string, + args: string[], + input?: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const token = sshClientTokenFromEnv(env); + const session = token === null + ? await loginFrontend(host, config) + : scopedSshFrontendSession(host, config, token); + const normalizedArgs = normalizeSshOperationArgs(args); + const invocation = parseSshInvocation(target, normalizedArgs); + const parsed = invocation.parsed; + if (parsed.remoteCommand === null) throw new Error(`remote ssh ${target} capture requires a non-interactive operation`); + const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined + ? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}` + : input; + return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, parsed.remoteCommand, stdin); +} + async function frontendJson(session: FrontendSession, path: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise { const headers = new Headers(init?.headers); headers.set("cookie", session.cookie);