diff --git a/AGENTS.md b/AGENTS.md index c9996d91..17c70c85 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 ...`:UniDesk 仓库自带的 Playwright 指挥手测 wrapper,默认 headless,可用 `--session ` 复用 storageState,适合截图、打开页面和一次性 JS 取值;它不实现长驻浏览器 daemon、element ref `click/fill/snapshot` 会返回结构化 unsupported 和 `xvfb-run`/headless 下一步,规则见 `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 94611ea9..9716d0db 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -136,6 +136,20 @@ UniDesk 仓库自带 `scripts/playwright-cli.ts` 作为 host commander 浏览器 外部 agent skill `~/.agents/skills/playwright` 是另一个 source of truth。当前宿主上它可能仍是 `npx playwright` passthrough,但 `SKILL.md` 里描述了更丰富的 `--session`、`snapshot` 和 element-ref 操作。外部 skill 分发更新前,UniDesk/HWLAB 指挥手测应使用本仓库 wrapper;不要把外部 skill 文档当成 daemon/session 能力已经可用的证据。 +### Playwright Trans Passthrough + +跨 host 浏览器验收优先使用 `trans playwright`,标准形态是不带 workspace 的 host route,例如: + +```bash +trans D601 playwright --local-dir /tmp <<'PW' +playwright-cli screenshot https://example.com "$UNIDESK_PLAYWRIGHT_SCREENSHOT" --full-page +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 执行和产物回传,不提供长驻浏览器 daemon。需要多步交互时,把步骤写在同一个 heredoc 内;需要保留远端证据时显式加 `--keep-remote`。 + ## Async Job State 长时操作采用 Fire-and-Forget 模式:CLI 创建 `.state/jobs/{jobId}.json`,后台进程执行真实命令,并将 stdout、stderr 分别写入 `.state/jobs/{jobId}.stdout.log` 与 `.state/jobs/{jobId}.stderr.log`。调用者通过 `bun scripts/cli.ts job status ` 查询进度和尾部输出。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 06e88f25..36d2be1d 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -165,6 +165,7 @@ export function sshHelp(): unknown { "trans :/absolute/workspace apply-patch < patch.diff", "trans upload ", "trans download ", + "trans playwright [--local-dir /tmp] <<'PW'", "trans apply-patch-v1 [--allow-loose] < patch.diff", "trans py [script-args...] < script.py", "trans script [--shell sh|bash] [script-args...] <<'SCRIPT'", @@ -209,6 +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.", "`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-file-transfer.ts b/scripts/src/ssh-file-transfer.ts index 370f4e59..4f1edac9 100644 --- a/scripts/src/ssh-file-transfer.ts +++ b/scripts/src/ssh-file-transfer.ts @@ -66,6 +66,23 @@ interface SshFileTransferDownloadResult { remainingBytes: number; } +export interface SshVerifiedDownloadResult { + remotePath: string; + localPath: string; + bytes: number; + sha256: string; + verified: true; + verification: Record; + transfer: { + strategy: SshFileTransferDownloadResult["strategy"]; + transport: SshFileTransferDownloadResult["transport"]; + chunks: number; + elapsedMs: number; + throughputBytesPerSecond: number; + remainingBytes: number; + }; +} + class SshFileTransferError extends Error { constructor(message: string, public readonly details: Record = {}) { super(message); @@ -117,11 +134,7 @@ export async function runSshFileTransferOperation( return 0; } - const read = await downloadRemoteFileVerified(invocation, executor, builders, options.remotePath, localPath, options.inactivityTimeoutMs); - const verification = buildTransferVerification( - { side: "remote", path: options.remotePath, ...read.remote }, - { side: "local", path: localPath, ...read.local }, - ); + const download = await downloadSshFileVerified(invocation, executor, builders, options.remotePath, localPath, options.inactivityTimeoutMs); process.stdout.write(`${JSON.stringify({ ok: true, command: "ssh download", @@ -129,6 +142,31 @@ export async function runSshFileTransferOperation( providerId: invocation.providerId, remotePath: options.remotePath, localPath, + bytes: download.bytes, + sha256: download.sha256, + verified: true, + verification: download.verification, + transfer: download.transfer, + }, null, 2)}\n`); + return 0; +} + +export async function downloadSshFileVerified( + invocation: ParsedSshInvocation, + executor: SshRemoteCommandExecutor, + builders: SshFileTransferCommandBuilders, + remotePath: string, + localPath: string, + inactivityTimeoutMs?: number, +): Promise { + const read = await downloadRemoteFileVerified(invocation, executor, builders, remotePath, localPath, inactivityTimeoutMs); + const verification = buildTransferVerification( + { side: "remote", path: remotePath, ...read.remote }, + { side: "local", path: localPath, ...read.local }, + ); + return { + remotePath, + localPath, bytes: read.remote.bytes, sha256: read.remote.sha256, verified: true, @@ -141,8 +179,7 @@ export async function runSshFileTransferOperation( throughputBytesPerSecond: read.throughputBytesPerSecond, remainingBytes: read.remainingBytes, }, - }, null, 2)}\n`); - return 0; + }; } function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptions { diff --git a/scripts/src/ssh-playwright.ts b/scripts/src/ssh-playwright.ts new file mode 100644 index 00000000..4c77220b --- /dev/null +++ b/scripts/src/ssh-playwright.ts @@ -0,0 +1,412 @@ +import { randomBytes } from "node:crypto"; +import { basename, join, resolve } from "node:path"; +import { + downloadSshFileVerified, + type SshFileTransferCommandBuilders, + type SshRemoteCommandExecutor, + type SshVerifiedDownloadResult, +} from "./ssh-file-transfer"; +import type { ParsedSshInvocation, ParsedSshRoute, SshCaptureResult } from "./ssh"; + +interface SshPlaywrightOptions { + localDir: string; + remoteDir: string | null; + keepRemote: boolean; + inactivityTimeoutMs?: number; +} + +interface RemotePlaywrightManifest { + runId: string; + exitCode: number; + remoteDir: string; + stdoutPath: string; + stderrPath: string; + cliMode: string | null; + cliCwd: string | null; + cliBin: string | null; + defaultScreenshot: string | null; + stdoutTail: string; + stderrTail: string; + artifacts: Array<{ + remotePath: string; + bytes: number | null; + sha256: string | null; + }>; +} + +const manifestBegin = "__UNIDESK_PLAYWRIGHT_MANIFEST_BEGIN__"; +const manifestEnd = "__UNIDESK_PLAYWRIGHT_MANIFEST_END__"; + +export function isSshPlaywrightOperation(args: string[]): boolean { + return (args[0] ?? "") === "playwright"; +} + +export async function runSshPlaywrightOperation( + invocation: ParsedSshInvocation, + args: string[], + executor: SshRemoteCommandExecutor, + builders: SshFileTransferCommandBuilders, +): Promise { + if (invocation.route.plane === "win") { + throw new Error(`ssh ${invocation.route.raw} playwright is not supported for Windows routes; use a POSIX host/workspace route`); + } + if (invocation.route.plane === "k3s" && (invocation.route.namespace === null || invocation.route.resource === null)) { + throw new Error(`ssh ${invocation.route.raw} playwright requires a workload route, or use a host workspace route`); + } + const options = parseSshPlaywrightOptions(args); + const userScript = await readAllStdin(); + if (userScript.trim().length === 0) { + throw new Error("ssh playwright requires a stdin heredoc script; example: trans D601 playwright <<'PW'\nplaywright-cli screenshot https://example.com \"$UNIDESK_PLAYWRIGHT_SCREENSHOT\"\nPW"); + } + + 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 artifacts: Array = []; + let downloadFailure: Record | null = null; + + for (const artifact of manifest.artifacts) { + const localPath = join(localDir, `${runId}-${safePathSegment(basename(artifact.remotePath) || "artifact")}`); + try { + const download = await downloadSshFileVerified( + invocation, + executor, + builders, + artifact.remotePath, + localPath, + options.inactivityTimeoutMs, + ); + artifacts.push({ ...download, manifestBytes: artifact.bytes, manifestSha256: artifact.sha256 }); + } catch (error) { + downloadFailure = { + remotePath: artifact.remotePath, + message: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : undefined, + }; + break; + } + } + + const cleanup = await cleanupRemoteDir(invocation.route, executor, builders, remoteDir, options.keepRemote); + const ok = manifest.exitCode === 0 && downloadFailure === null; + const payload = { + ok, + command: "ssh playwright", + route: invocation.route.raw, + providerId: invocation.providerId, + runId, + localDir, + remote: { + exitCode: manifest.exitCode, + remoteDir: manifest.remoteDir, + stdoutPath: manifest.stdoutPath, + stderrPath: manifest.stderrPath, + stdoutTail: manifest.stdoutTail, + stderrTail: manifest.stderrTail, + cliMode: manifest.cliMode, + cliCwd: manifest.cliCwd, + cliBin: manifest.cliBin, + defaultScreenshot: manifest.defaultScreenshot, + }, + artifacts, + artifactCount: artifacts.length, + expectedArtifactCount: manifest.artifacts.length, + cleanup, + downloadFailure, + remoteCommand: { + exitCode: result.exitCode, + stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), + stderrBytes: Buffer.byteLength(result.stderr, "utf8"), + }, + }; + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + if (ok) return 0; + return manifest.exitCode === 0 ? 1 : manifest.exitCode; +} + +function parseSshPlaywrightOptions(args: string[]): SshPlaywrightOptions { + const options: SshPlaywrightOptions = { + localDir: "/tmp", + remoteDir: null, + keepRemote: false, + }; + for (let index = 1; index < args.length; index += 1) { + const arg = args[index] ?? ""; + const next = args[index + 1]; + if (arg === "--help" || arg === "-h" || arg === "help") { + process.stdout.write(`${JSON.stringify(playwrightHelp(), null, 2)}\n`); + process.exit(0); + } + if (arg === "--keep-remote") { + options.keepRemote = true; + continue; + } + if (arg === "--local-dir") { + options.localDir = requireValue(arg, next); + index += 1; + continue; + } + if (arg.startsWith("--local-dir=")) { + options.localDir = requireValue("--local-dir", arg.slice("--local-dir=".length)); + continue; + } + if (arg === "--remote-dir") { + options.remoteDir = requireAbsoluteRemotePath(arg, next); + index += 1; + continue; + } + if (arg.startsWith("--remote-dir=")) { + options.remoteDir = requireAbsoluteRemotePath("--remote-dir", arg.slice("--remote-dir=".length)); + continue; + } + if (arg === "--inactivity-timeout-ms") { + options.inactivityTimeoutMs = parsePositiveInteger(arg, next); + index += 1; + continue; + } + if (arg.startsWith("--inactivity-timeout-ms=")) { + options.inactivityTimeoutMs = parsePositiveInteger("--inactivity-timeout-ms", arg.slice("--inactivity-timeout-ms=".length)); + continue; + } + throw new Error(`unsupported ssh playwright option: ${arg}`); + } + return options; +} + +function playwrightHelp(): Record { + return { + ok: true, + command: "ssh playwright", + usage: [ + "trans playwright [--local-dir /tmp] <<'PW'", + "playwright-cli screenshot https://example.com \"$UNIDESK_PLAYWRIGHT_SCREENSHOT\" --full-page", + "PW", + "trans :/absolute/workspace playwright [--local-dir /tmp] <<'PW'", + "playwright-cli screenshot http://127.0.0.1:18081/ \"$UNIDESK_PLAYWRIGHT_SCREENSHOT\"", + "PW", + ], + 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.", + "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: { + "--local-dir ": "Local directory for returned artifacts. Default: /tmp.", + "--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.", + }, + }; +} + +function requireValue(name: string, value: string | undefined): string { + if (value === undefined || value.length === 0) throw new Error(`ssh playwright ${name} requires a non-empty value`); + return value; +} + +function requireAbsoluteRemotePath(name: string, value: string | undefined): string { + const pathValue = requireValue(name, value); + if (!pathValue.startsWith("/")) throw new Error(`ssh playwright ${name} must be an absolute POSIX path`); + return pathValue; +} + +function parsePositiveInteger(name: string, value: string | undefined): number { + const parsed = Number(requireValue(name, value)); + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`ssh playwright ${name} must be a positive integer`); + return parsed; +} + +async function readAllStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function remotePlaywrightRunnerScript(remoteDir: string): string { + return [ + "set -eu", + `remote_dir=${shellQuote(remoteDir)}`, + '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"', + "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", + " if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi", + " printf 'missing sha256 tool\\n' >&2; return 127", + "}", + "resolve_playwright_cli() {", + " 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", + " return 42", + "}", + "if ! resolve_playwright_cli; then", + " printf 'unable to find playwright-cli wrapper: expected ./scripts/playwright-cli.ts, ~/.agents/skills/playwright*/scripts/playwright-cli.ts, or playwright-cli in PATH\\n' >&2", + " exit 42", + "fi", + "export UNIDESK_PLAYWRIGHT_CLI_MODE UNIDESK_PLAYWRIGHT_CLI_CWD UNIDESK_PLAYWRIGHT_CLI_BIN", + 'cat > "$remote_dir/bin/playwright-cli" <<\'UNIDESK_PLAYWRIGHT_WRAPPER\'', + "#!/bin/sh", + "set -eu", + "case \"${UNIDESK_PLAYWRIGHT_CLI_MODE:-}\" in", + " repo|skill) cd \"$UNIDESK_PLAYWRIGHT_CLI_CWD\"; exec bun scripts/playwright-cli.ts \"$@\" ;;", + " bin) exec \"$UNIDESK_PLAYWRIGHT_CLI_BIN\" \"$@\" ;;", + " *) printf 'UNIDESK playwright wrapper is not resolved\\n' >&2; exit 42 ;;", + "esac", + "UNIDESK_PLAYWRIGHT_WRAPPER", + 'chmod 700 "$remote_dir/bin/playwright-cli"', + "if command -v npx >/dev/null 2>&1; then", + " cat > \"$remote_dir/bin/npx.cmd\" <<'UNIDESK_NPX_CMD_WRAPPER'", + "#!/bin/sh", + "exec npx \"$@\"", + "UNIDESK_NPX_CMD_WRAPPER", + " chmod 700 \"$remote_dir/bin/npx.cmd\"", + "fi", + 'export PATH="$remote_dir/bin:$PATH"', + 'export UNIDESK_PLAYWRIGHT_REMOTE_DIR="$remote_dir"', + 'export UNIDESK_PLAYWRIGHT_SCREENSHOT="$remote_dir/screenshot.png"', + "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', + ' case "$file" in *.png|*.jpg|*.jpeg|*.webp|*.pdf)', + ' bytes=$(wc -c < "$file" | tr -d "[:space:]")', + ' digest=$(sha256_file "$file")', + ' printf "artifact\\t%s\\t%s\\t%s\\n" "$bytes" "$digest" "$file" >> "$artifacts_file"', + " ;;", + " 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\"", + "printf 'remote_dir\\t%s\\n' \"$remote_dir\"", + "printf 'stdout_path\\t%s\\n' \"$stdout_file\"", + "printf 'stderr_path\\t%s\\n' \"$stderr_file\"", + "printf 'cli_mode\\t%s\\n' \"$UNIDESK_PLAYWRIGHT_CLI_MODE\"", + "printf 'cli_cwd\\t%s\\n' \"$UNIDESK_PLAYWRIGHT_CLI_CWD\"", + "printf 'cli_bin\\t%s\\n' \"$UNIDESK_PLAYWRIGHT_CLI_BIN\"", + "printf 'default_screenshot\\t%s\\n' \"$UNIDESK_PLAYWRIGHT_SCREENSHOT\"", + "printf 'stdout_tail_b64\\t%s\\n' \"$(b64_tail \"$stdout_file\")\"", + "printf 'stderr_tail_b64\\t%s\\n' \"$(b64_tail \"$stderr_file\")\"", + 'cat "$artifacts_file"', + `printf '%s\\n' ${shellQuote(manifestEnd)}`, + 'exit "$user_rc"', + ].join("\n"); +} + +function parseRemoteManifest(result: SshCaptureResult, fallbackRemoteDir: string, fallbackRunId: string): RemotePlaywrightManifest { + const start = result.stdout.indexOf(manifestBegin); + const end = result.stdout.indexOf(manifestEnd, start < 0 ? 0 : start); + if (start < 0 || end < 0) { + throw new Error(`ssh playwright did not emit a manifest; exitCode=${result.exitCode}; stdoutTail=${JSON.stringify(result.stdout.slice(-1000))}; stderrTail=${JSON.stringify(result.stderr.slice(-1000))}`); + } + const body = result.stdout.slice(start + manifestBegin.length, end).trim(); + const manifest: RemotePlaywrightManifest = { + runId: fallbackRunId, + exitCode: result.exitCode, + remoteDir: fallbackRemoteDir, + stdoutPath: join(fallbackRemoteDir, "stdout.log"), + stderrPath: join(fallbackRemoteDir, "stderr.log"), + cliMode: null, + cliCwd: null, + cliBin: null, + defaultScreenshot: null, + stdoutTail: "", + stderrTail: "", + artifacts: [], + }; + for (const rawLine of body.split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line.length === 0) continue; + const parts = line.split("\t"); + const key = parts[0] ?? ""; + if (key === "artifact") { + const bytes = Number(parts[1] ?? ""); + const sha256 = parts[2] ?? ""; + const remotePath = parts.slice(3).join("\t"); + if (remotePath.length > 0) { + manifest.artifacts.push({ + remotePath, + bytes: Number.isSafeInteger(bytes) && bytes >= 0 ? bytes : null, + sha256: /^[0-9a-f]{64}$/u.test(sha256) ? sha256 : null, + }); + } + continue; + } + const value = parts.slice(1).join("\t"); + if (key === "run_id" && value.length > 0) manifest.runId = value; + else if (key === "exit_code") { + const exitCode = Number(value); + manifest.exitCode = Number.isInteger(exitCode) ? exitCode : result.exitCode; + } else if (key === "remote_dir" && value.length > 0) manifest.remoteDir = value; + else if (key === "stdout_path" && value.length > 0) manifest.stdoutPath = value; + else if (key === "stderr_path" && value.length > 0) manifest.stderrPath = value; + else if (key === "cli_mode") manifest.cliMode = value || null; + else if (key === "cli_cwd") manifest.cliCwd = value || null; + else if (key === "cli_bin") manifest.cliBin = value || null; + else if (key === "default_screenshot") manifest.defaultScreenshot = value || null; + else if (key === "stdout_tail_b64") manifest.stdoutTail = decodeBase64(value); + else if (key === "stderr_tail_b64") manifest.stderrTail = decodeBase64(value); + } + return manifest; +} + +async function cleanupRemoteDir( + route: ParsedSshRoute, + executor: SshRemoteCommandExecutor, + builders: SshFileTransferCommandBuilders, + remoteDir: string, + keepRemote: boolean, +): Promise> { + if (keepRemote) return { attempted: false, kept: true, remoteDir }; + const command = builders.buildRouteCommand(route, ["sh", "-c", "rm -rf -- \"$1\"", "unidesk-playwright-cleanup", remoteDir]); + const result = await executor.runRemoteCommand(command); + return { + attempted: true, + kept: false, + remoteDir, + exitCode: result.exitCode, + ok: result.exitCode === 0, + stderrTail: result.stderr.slice(-1000), + }; +} + +function decodeBase64(value: string): string { + if (value.length === 0) return ""; + try { + return Buffer.from(value, "base64").toString("utf8"); + } catch { + return ""; + } +} + +function safePathSegment(value: string): string { + const cleaned = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return cleaned.length > 0 ? cleaned.slice(0, 120) : "artifact"; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index b2554f29..07a06d88 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -19,6 +19,10 @@ import { type SshRemoteCommandExecutor, type SshRemoteCommandStreamHandlers, } from "./ssh-file-transfer"; +import { + isSshPlaywrightOperation, + runSshPlaywrightOperation, +} from "./ssh-playwright"; export interface ParsedSshArgs { remoteCommand: string | null; @@ -938,6 +942,9 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { if (subcommand === "py") { return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" }; } + if (subcommand === "playwright") { + return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" }; + } if (subcommand === "script" || subcommand === "sh") { return buildShellCommand(args.slice(1)); } @@ -1126,6 +1133,9 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs invocationKind: "helper", }; } + if (operation === "playwright") { + return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" }; + } if (operation === "ps" || operation === "powershell" || operation === "powershell.exe") { const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1); if (commandArgs.length >= 2 && (commandArgs[0] === "-File" || commandArgs[0] === "-file")) { @@ -3314,6 +3324,16 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation, }); } + if (isSshPlaywrightOperation(normalizedArgs)) { + const executor: SshRemoteCommandExecutor = { + runRemoteCommand: (remoteCommand, input) => runSshCaptureRemoteCommand(config, invocation, remoteCommand, input), + streamRemoteCommand: (remoteCommand, handlers, input, options) => runSshStreamRemoteCommand(config, invocation, remoteCommand, handlers, input, options), + }; + return await runSshPlaywrightOperation(invocation, normalizedArgs, executor, { + buildRouteCommand: remoteCommandForRoute, + buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation, + }); + } if (operationName === "apply-patch") { const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1)); const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win"