From ac4ae5079ccb8276302488281cf5113dfa193fb1 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 14 Jun 2026 01:53:34 +0000 Subject: [PATCH] fix: harden trans playwright passthrough --- AGENTS.md | 2 +- docs/reference/cli.md | 4 +- scripts/src/help.ts | 2 +- scripts/src/ssh-playwright.ts | 142 ++++++++++++++++++++++++++++++---- 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 17c70c85..b0810ef0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,7 +255,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts codex judge --attempt [--dry-run]`:按指定 task/attempt 用与队列 worker 相同的上下文构建和 MiniMax judge 调用路径单步复现完成判定;`--dry-run` 只输出 prompt/payload 诊断。 - `bun scripts/cli.ts codex steer [prompt|--prompt-file path|--prompt-stdin] [--steer-id id] [--dry-run] [--no-retry|--retry-attempts N]` / `codex steer-confirm --steer-id `:向运行中的 active turn 注入纠偏提示并用 `steerId` 做幂等/trace 确认;真实输出不回显 prompt,遇到 `deliveryUnconfirmed` 先查确认命令,不重复发送同一纠偏。 - `bun scripts/cli.ts codex interrupt|cancel `:通过 Code Queue 私有代理中断运行任务或取消 queued/retry_wait 任务,规则见 `docs/reference/cli.md`。 -- `bun scripts/playwright-cli.ts screenshot|open|eval ...` / `trans D601 playwright <<'PW' ...`:UniDesk 仓库自带的 Playwright 指挥手测 wrapper 与跨 host 透传入口,默认 headless,支持 heredoc 执行并把截图等产物回传到本机 `/tmp`;不实现长驻浏览器 daemon,规则见 `docs/reference/cli.md`。 +- `bun scripts/playwright-cli.ts screenshot|open|eval ...` / `trans D601 playwright <<'PW' ...`:UniDesk 仓库自带的 Playwright 指挥手测 wrapper 与跨 host 透传入口,默认 headless,支持 heredoc 后台执行、短轮询和截图等产物回传到本机 `/tmp`;不实现长驻浏览器 daemon,规则见 `docs/reference/cli.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list [--limit N]` / `bun scripts/cli.ts job status latest [--tail-bytes N]`:分页查询 `.state/jobs/` 中的异步任务状态,状态输出只读日志尾部并保留完整日志路径,job 机制见 `docs/reference/cli.md`。 - `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9716d0db..a891afb6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -146,9 +146,9 @@ playwright-cli screenshot https://example.com "$UNIDESK_PLAYWRIGHT_SCREENSHOT" - PW ``` -`playwright` operation 读取 stdin heredoc,在目标 POSIX host/workload 上临时注入 `playwright-cli` wrapper 到 `PATH`。wrapper 优先使用远端用户的 `~/.agents/skills/playwright*/scripts/playwright-cli.ts`,其次使用 route workspace 中的 `./scripts/playwright-cli.ts`,最后才使用远端 `PATH` 中的 `playwright-cli`。命令会设置 `UNIDESK_PLAYWRIGHT_REMOTE_DIR` 和 `UNIDESK_PLAYWRIGHT_SCREENSHOT`,把远端 run 目录中的 `png/jpg/jpeg/webp/pdf` 产物回传到本机 `--local-dir`,默认 `/tmp`,并返回本地路径、远端路径、字节数、SHA-256、stdout/stderr tail 和 transfer verification。 +`playwright` operation 读取 stdin heredoc,在目标 POSIX host/workload 上临时注入 `playwright-cli` wrapper 到 `PATH`。wrapper 优先使用 route workspace 或目标 host 上已知 UniDesk workspace 的 `./scripts/playwright-cli.ts`,其次使用远端用户的 `~/.agents/skills/playwright*/scripts/playwright-cli.ts`,最后才使用远端 `PATH` 中的 `playwright-cli`。命令会设置 `UNIDESK_PLAYWRIGHT_REMOTE_DIR` 和 `UNIDESK_PLAYWRIGHT_SCREENSHOT`,把远端 run 目录中的 `png/jpg/jpeg/webp/pdf` 产物回传到本机 `--local-dir`,默认 `/tmp`,并返回本地路径、远端路径、字节数、SHA-256、stdout/stderr tail 和 transfer verification。 -该入口只负责短生命周期 Playwright 执行和产物回传,不提供长驻浏览器 daemon。需要多步交互时,把步骤写在同一个 heredoc 内;需要保留远端证据时显式加 `--keep-remote`。 +该入口只负责短生命周期 Playwright 执行和产物回传,不提供长驻浏览器 daemon。需要多步交互时,把步骤写在同一个 heredoc 内;helper 会在远端后台提交 job,并用短连接轮询 manifest,避免单次 SSH 透传超过 60 秒硬限制。需要保留远端证据时显式加 `--keep-remote`。 ## Async Job State diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 36d2be1d..6bd35772 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -210,7 +210,7 @@ export function sshHelp(): unknown { "For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.", "`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Plain multi-file Update File patches on POSIX host/k3s and Windows workspace routes use bulk read/write operations to avoid per-file SSH round trips. Its stdout follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.", "`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and return `verification.automatic=true`, `verification.verified=true`, and `verification.match.{bytes,sha256}=true`; this JSON is the transfer integrity proof, so callers do not need a separate manual `sha256sum` check. Downloads stream over `host.ssh.tcp-pool`, emit progress JSON, and may receive a caller-supplied `--inactivity-timeout-ms` from async artifact/deploy jobs so active large transfers are not killed by the generic short-command budget.", - "`playwright` runs a stdin heredoc on the target POSIX host/workload with a temporary `playwright-cli` wrapper in PATH, sets `UNIDESK_PLAYWRIGHT_SCREENSHOT` under a remote `/tmp` run directory, then downloads image/pdf artifacts to local `/tmp` by default with the same verified SHA-256 transfer path. Canonical syntax is `trans D601 playwright <<'PW' ... PW`; workspace routes are optional and only affect wrapper discovery.", + "`playwright` runs a stdin heredoc on the target POSIX host/workload with a temporary `playwright-cli` wrapper in PATH, submits the remote script as a background job, polls short status commands for the manifest, then downloads image/pdf artifacts to local `/tmp` by default with the same verified SHA-256 transfer path. Canonical syntax is `trans D601 playwright <<'PW' ... PW`; workspace and known UniDesk host workspaces are preferred for wrapper discovery before external skill passthroughs.", "`apply-patch-v1` is the only legacy fallback entry: it rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.", "script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; it is for host/k3s POSIX shell only. Use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.", "Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `:/absolute/workspace`; WSL providers can use `:win ps` for Windows PowerShell and `:win cmd` for Windows cmd.exe, with `:win/c/test ...` mapping the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use `:k3s` for the control plane and `:k3s::[:]` for a workload/container. In k3s routes, `:` is the distributed route separator; `/...` is only an in-container filesystem cwd and never selects a container. Prefer operation `--cwd /path` when a container is also specified.", diff --git a/scripts/src/ssh-playwright.ts b/scripts/src/ssh-playwright.ts index 4c77220b..c0f6438c 100644 --- a/scripts/src/ssh-playwright.ts +++ b/scripts/src/ssh-playwright.ts @@ -13,6 +13,8 @@ interface SshPlaywrightOptions { remoteDir: string | null; keepRemote: boolean; inactivityTimeoutMs?: number; + pollIntervalMs: number; + waitTimeoutMs: number; } interface RemotePlaywrightManifest { @@ -62,9 +64,12 @@ export async function runSshPlaywrightOperation( const runId = `unidesk-playwright-${safePathSegment(invocation.providerId)}-${Date.now()}-${randomBytes(4).toString("hex")}`; const remoteDir = options.remoteDir ?? `/tmp/${runId}`; const localDir = resolve(options.localDir); - const remoteCommand = builders.buildRouteCommand(invocation.route, ["sh", "-c", remotePlaywrightRunnerScript(remoteDir), "unidesk-playwright"], { stdin: true }); - const result = await executor.runRemoteCommand(remoteCommand, userScript); - const manifest = parseRemoteManifest(result, remoteDir, runId); + const submitCommand = builders.buildRouteCommand(invocation.route, ["sh", "-c", remotePlaywrightSubmitScript(remoteDir), "unidesk-playwright-submit"], { stdin: true }); + const submit = await executor.runRemoteCommand(submitCommand, userScript); + if (submit.exitCode !== 0) { + throw new Error(`ssh playwright submit failed: exitCode=${submit.exitCode}; stdoutTail=${JSON.stringify(submit.stdout.slice(-1000))}; stderrTail=${JSON.stringify(submit.stderr.slice(-1000))}`); + } + const manifest = await pollRemoteManifest(invocation.route, executor, builders, remoteDir, runId, options); const artifacts: Array = []; let downloadFailure: Record | null = null; @@ -117,9 +122,10 @@ export async function runSshPlaywrightOperation( cleanup, downloadFailure, remoteCommand: { - exitCode: result.exitCode, - stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), - stderrBytes: Buffer.byteLength(result.stderr, "utf8"), + exitCode: manifest.exitCode, + submitExitCode: submit.exitCode, + submitStdoutBytes: Buffer.byteLength(submit.stdout, "utf8"), + submitStderrBytes: Buffer.byteLength(submit.stderr, "utf8"), }, }; process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); @@ -132,6 +138,8 @@ function parseSshPlaywrightOptions(args: string[]): SshPlaywrightOptions { localDir: "/tmp", remoteDir: null, keepRemote: false, + pollIntervalMs: 2_000, + waitTimeoutMs: 180_000, }; for (let index = 1; index < args.length; index += 1) { const arg = args[index] ?? ""; @@ -171,6 +179,24 @@ function parseSshPlaywrightOptions(args: string[]): SshPlaywrightOptions { options.inactivityTimeoutMs = parsePositiveInteger("--inactivity-timeout-ms", arg.slice("--inactivity-timeout-ms=".length)); continue; } + if (arg === "--poll-interval-ms") { + options.pollIntervalMs = parsePositiveInteger(arg, next); + index += 1; + continue; + } + if (arg.startsWith("--poll-interval-ms=")) { + options.pollIntervalMs = parsePositiveInteger("--poll-interval-ms", arg.slice("--poll-interval-ms=".length)); + continue; + } + if (arg === "--wait-timeout-ms") { + options.waitTimeoutMs = parsePositiveInteger(arg, next); + index += 1; + continue; + } + if (arg.startsWith("--wait-timeout-ms=")) { + options.waitTimeoutMs = parsePositiveInteger("--wait-timeout-ms", arg.slice("--wait-timeout-ms=".length)); + continue; + } throw new Error(`unsupported ssh playwright option: ${arg}`); } return options; @@ -190,7 +216,8 @@ function playwrightHelp(): Record { ], behavior: [ "Reads a POSIX shell heredoc from stdin and runs it on the target route.", - "Prepends a temporary playwright-cli wrapper to PATH. The wrapper prefers ~/.agents/skills/playwright*/scripts/playwright-cli.ts, then ./scripts/playwright-cli.ts in the route workspace, then a playwright-cli binary.", + "Prepends a temporary playwright-cli wrapper to PATH. The wrapper prefers ./scripts/playwright-cli.ts in the route workspace or known UniDesk host workspaces, then ~/.agents/skills/playwright*/scripts/playwright-cli.ts, then a playwright-cli binary.", + "Submits the remote script as a background job and polls short status commands for the manifest, so multi-step Playwright flows do not occupy one SSH connection past the 60s trans runtime limit.", "Sets UNIDESK_PLAYWRIGHT_REMOTE_DIR and UNIDESK_PLAYWRIGHT_SCREENSHOT. Files created under that remote dir with image/pdf extensions are downloaded to --local-dir with byte and sha256 verification.", ], options: { @@ -198,6 +225,8 @@ function playwrightHelp(): Record { "--remote-dir ": "Remote artifact directory. Default: /tmp/unidesk-playwright---.", "--keep-remote": "Do not remove the remote artifact directory after the transfer.", "--inactivity-timeout-ms ": "Forwarded to verified artifact download when large screenshots are returned.", + "--wait-timeout-ms ": "Maximum wall-clock time to wait for the remote Playwright job. Default: 180000.", + "--poll-interval-ms ": "Short-query polling interval for the remote job. Default: 2000.", }, }; } @@ -227,17 +256,94 @@ async function readAllStdin(): Promise { return Buffer.concat(chunks).toString("utf8"); } +async function pollRemoteManifest( + route: ParsedSshRoute, + executor: SshRemoteCommandExecutor, + builders: SshFileTransferCommandBuilders, + remoteDir: string, + runId: string, + options: SshPlaywrightOptions, +): Promise { + const deadline = Date.now() + options.waitTimeoutMs; + let lastStatus: SshCaptureResult | null = null; + while (Date.now() <= deadline) { + const command = builders.buildRouteCommand(route, ["sh", "-c", remotePlaywrightStatusScript(), "unidesk-playwright-status", remoteDir]); + const status = await executor.runRemoteCommand(command); + lastStatus = status; + if (status.exitCode === 0 && status.stdout.includes(manifestEnd)) return parseRemoteManifest(status, remoteDir, runId); + if (status.exitCode !== 0 && !/status\t(?:pending|running)/u.test(status.stdout)) { + throw new Error(`ssh playwright status failed: exitCode=${status.exitCode}; stdoutTail=${JSON.stringify(status.stdout.slice(-1000))}; stderrTail=${JSON.stringify(status.stderr.slice(-1000))}`); + } + await sleep(options.pollIntervalMs); + } + throw new Error(`ssh playwright timed out waiting for remote job after ${options.waitTimeoutMs}ms; lastStdoutTail=${JSON.stringify(lastStatus?.stdout.slice(-1000) ?? "")}; lastStderrTail=${JSON.stringify(lastStatus?.stderr.slice(-1000) ?? "")}`); +} + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +function remotePlaywrightSubmitScript(remoteDir: string): string { + const runner = Buffer.from(remotePlaywrightRunnerScript(remoteDir), "utf8").toString("base64"); + return [ + "set -eu", + `remote_dir=${shellQuote(remoteDir)}`, + 'mkdir -p -- "$remote_dir"', + 'user_script="$remote_dir/user-script.sh"', + 'runner_script="$remote_dir/runner.sh"', + 'pid_file="$remote_dir/pid"', + 'submit_stdout="$remote_dir/submit.out"', + 'submit_stderr="$remote_dir/submit.err"', + 'if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" >/dev/null 2>&1; then', + ' printf "status\\trunning\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"', + " exit 0", + "fi", + 'cat > "$user_script"', + 'chmod 700 "$user_script"', + `printf %s ${shellQuote(runner)} | base64 -d >"$runner_script"`, + 'chmod 700 "$runner_script"', + 'nohup sh "$runner_script" "$user_script" >"$submit_stdout" 2>"$submit_stderr" < /dev/null &', + 'pid=$!', + 'printf "%s\\n" "$pid" >"$pid_file"', + 'printf "status\\tsubmitted\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$pid"', + ].join("\n"); +} + +function remotePlaywrightStatusScript(): string { + return [ + "set -eu", + 'remote_dir="$1"', + 'manifest="$remote_dir/manifest.tsv"', + 'pid_file="$remote_dir/pid"', + 'if [ -f "$manifest" ]; then cat "$manifest"; exit 0; fi', + 'if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" >/dev/null 2>&1; then', + ' printf "status\\trunning\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"', + " exit 1", + "fi", + 'if [ -f "$pid_file" ]; then', + ' printf "status\\texited-without-manifest\\nremote_dir\\t%s\\npid\\t%s\\n" "$remote_dir" "$(cat "$pid_file")"', + ' if [ -f "$remote_dir/submit.err" ]; then tail -c 1000 "$remote_dir/submit.err" >&2; fi', + ' if [ -f "$remote_dir/stderr.log" ]; then tail -c 1000 "$remote_dir/stderr.log" >&2; fi', + " exit 2", + "fi", + 'printf "status\\tpending\\nremote_dir\\t%s\\n" "$remote_dir"', + 'if [ -f "$remote_dir/submit.err" ]; then tail -c 1000 "$remote_dir/submit.err" >&2; fi', + 'if [ -f "$remote_dir/stderr.log" ]; then tail -c 1000 "$remote_dir/stderr.log" >&2; fi', + "exit 1", + ].join("\n"); +} + function remotePlaywrightRunnerScript(remoteDir: string): string { return [ "set -eu", `remote_dir=${shellQuote(remoteDir)}`, + 'if [ "$#" -lt 1 ]; then printf "missing user script path\\n" >&2; exit 2; fi', + 'user_script="$1"', 'mkdir -p -- "$remote_dir/bin"', - 'user_script="$remote_dir/user-script.sh"', 'stdout_file="$remote_dir/stdout.log"', 'stderr_file="$remote_dir/stderr.log"', 'artifacts_file="$remote_dir/artifacts.tsv"', - 'cat > "$user_script"', - 'chmod 700 "$user_script"', + 'manifest_file="$remote_dir/manifest.tsv"', "sha256_file() {", " if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi", " if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi", @@ -245,14 +351,21 @@ function remotePlaywrightRunnerScript(remoteDir: string): string { " printf 'missing sha256 tool\\n' >&2; return 127", "}", "resolve_playwright_cli() {", + " if command -v bun >/dev/null 2>&1; then", + " if [ -f ./scripts/playwright-cli.ts ]; then", + " UNIDESK_PLAYWRIGHT_CLI_MODE=repo; UNIDESK_PLAYWRIGHT_CLI_CWD=$(pwd); UNIDESK_PLAYWRIGHT_CLI_BIN=; return 0", + " fi", + " for dir in \"$HOME/workspace/unidesk-dev\" \"$HOME/unidesk\" \"/home/ubuntu/workspace/unidesk-dev\" \"/root/unidesk\"; do", + " if [ -f \"$dir/scripts/playwright-cli.ts\" ]; then", + " UNIDESK_PLAYWRIGHT_CLI_MODE=repo; UNIDESK_PLAYWRIGHT_CLI_CWD=$dir; UNIDESK_PLAYWRIGHT_CLI_BIN=; return 0", + " fi", + " done", + " fi", " for dir in \"$HOME/.agents/skills/playwright\" \"$HOME/.agents/skills/playwright-cli\" \"$HOME/.codex/skills/playwright\" \"$HOME/.codex/skills/playwright-cli\"; do", " if command -v bun >/dev/null 2>&1 && [ -f \"$dir/scripts/playwright-cli.ts\" ]; then", " UNIDESK_PLAYWRIGHT_CLI_MODE=skill; UNIDESK_PLAYWRIGHT_CLI_CWD=$dir; UNIDESK_PLAYWRIGHT_CLI_BIN=; return 0", " fi", " done", - " if command -v bun >/dev/null 2>&1 && [ -f ./scripts/playwright-cli.ts ]; then", - " UNIDESK_PLAYWRIGHT_CLI_MODE=repo; UNIDESK_PLAYWRIGHT_CLI_CWD=$(pwd); UNIDESK_PLAYWRIGHT_CLI_BIN=; return 0", - " fi", " if command -v playwright-cli >/dev/null 2>&1; then", " UNIDESK_PLAYWRIGHT_CLI_MODE=bin; UNIDESK_PLAYWRIGHT_CLI_CWD=; UNIDESK_PLAYWRIGHT_CLI_BIN=$(command -v playwright-cli); return 0", " fi", @@ -286,7 +399,6 @@ function remotePlaywrightRunnerScript(remoteDir: string): string { "set +e", 'sh "$user_script" >"$stdout_file" 2>"$stderr_file"', "user_rc=$?", - "set -e", ': > "$artifacts_file"', 'find "$remote_dir" -type f | while IFS= read -r file; do', ' case "$file" in "$user_script"|"$stdout_file"|"$stderr_file"|"$artifacts_file"|"$remote_dir/bin/"*) continue ;; esac', @@ -298,6 +410,7 @@ function remotePlaywrightRunnerScript(remoteDir: string): string { " esac", "done", "b64_tail() { if [ -f \"$1\" ]; then tail -c 4000 \"$1\" | base64 | tr -d '\\n'; fi; }", + `{`, `printf '%s\\n' ${shellQuote(manifestBegin)}`, "printf 'run_id\\t%s\\n' \"${remote_dir##*/}\"", "printf 'exit_code\\t%s\\n' \"$user_rc\"", @@ -312,6 +425,7 @@ function remotePlaywrightRunnerScript(remoteDir: string): string { "printf 'stderr_tail_b64\\t%s\\n' \"$(b64_tail \"$stderr_file\")\"", 'cat "$artifacts_file"', `printf '%s\\n' ${shellQuote(manifestEnd)}`, + `} > "$manifest_file"`, 'exit "$user_rc"', ].join("\n"); }