diff --git a/scripts/src/agentrun/utils.ts b/scripts/src/agentrun/utils.ts index a6ff24fb..7d84f762 100644 --- a/scripts/src/agentrun/utils.ts +++ b/scripts/src/agentrun/utils.ts @@ -6,11 +6,10 @@ // Exposes AgentRun lane-scoped policy, AipodSpec SecretRef binding, cancel lifecycle, and bounded default output in the UniDesk CLI. import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { spawnSync } from "node:child_process"; import { rootPath, type UniDeskConfig } from "../config"; import type { RenderedCliResult } from "../output"; import { applyLocalCaddyManagedSite } from "../pk01-caddy"; -import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; +import { runSshCommandCapture, sshCaptureBackendPlan, type SshCaptureBackendPlan, type SshCaptureResult } from "../ssh"; import { runRemoteSshCommandCapture } from "../remote"; import { startJob } from "../jobs"; import { @@ -114,11 +113,7 @@ export interface AgentRunSessionPolicyTarget { export type AgentRunBridgeCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket"; -export interface LocalBackendCoreStatus { - dockerExecutable: boolean; - backendCoreContainer: boolean; - error: string | null; -} +export type LocalBackendCoreStatus = SshCaptureBackendPlan["localBackendCore"]; export interface AgentRunBridgeCapturePlan { backend: AgentRunBridgeCaptureBackend; @@ -174,36 +169,14 @@ export function attachBridgeExecution(result: SshCaptureResult, plan: AgentRunBr } export 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 }; -} - -export 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"}`, + const plan = sshCaptureBackendPlan(config, env); + return { + backend: plan.backend, + route: target, + reason: plan.reason, + remoteHost: plan.remoteHost, + localBackendCore: plan.localBackendCore, }; - return localBackendCoreStatusCache; } export function isAgentRunRunnerEnvironment(env: NodeJS.ProcessEnv): boolean { diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index 2a433278..aa975b65 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -1,10 +1,15 @@ import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { describe, expect, test } from "bun:test"; +import type { UniDeskConfig } from "./config"; import { createPosixApplyPatchFileSystem, createSshStdoutForwarder, formatSshStdoutTruncationHint, parseSshInvocation, + sshCaptureBackendPlan, sshStdoutStreamMaxBytes, sshStdoutTruncationHint, windowsFsReadOnlyScript, @@ -15,6 +20,12 @@ function sha256Hex(text: string): string { return createHash("sha256").update(text, "utf8").digest("hex"); } +function minimalConfig(publicHost = "203.0.113.10"): UniDeskConfig { + return { + network: { publicHost }, + } as UniDeskConfig; +} + describe("ssh windows PowerShell safety prelude", () => { test("shadows ConvertTo-Json and strips filesystem ETS metadata", () => { const prelude = windowsPowerShellScriptPrelude("C:\\test"); @@ -193,6 +204,52 @@ describe("ssh stdout bounded streaming", () => { }); }); +describe("ssh backend selection", () => { + test("uses trans YAML backend-core preference without runtime docker detection", () => { + const dir = mkdtempSync(join(tmpdir(), "unidesk-trans-config-")); + const configPath = join(dir, "trans.ymal"); + writeFileSync(configPath, [ + "version: 1", + "kind: UniDeskTransConfig", + "ssh:", + " backend: backend-core", + "", + ].join("\n")); + try { + const plan = sshCaptureBackendPlan(minimalConfig(), { UNIDESK_TRANS_CONFIG_PATH: configPath } as NodeJS.ProcessEnv); + + expect(plan.backend).toBe("local-backend-core-broker"); + expect(plan.remoteHost).toBeNull(); + expect(plan.reason).toBe(`configured:${configPath}#ssh.backend`); + expect(plan.localBackendCore.backendCoreContainer).toBe(true); + expect(plan.localBackendCore.source).toBe(`${configPath}#ssh.backend`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("uses trans YAML frontend websocket preference when configured", () => { + const dir = mkdtempSync(join(tmpdir(), "unidesk-trans-config-")); + const configPath = join(dir, "trans.ymal"); + writeFileSync(configPath, [ + "version: 1", + "kind: UniDeskTransConfig", + "ssh:", + " backend: frontend-websocket", + "", + ].join("\n")); + try { + const plan = sshCaptureBackendPlan(minimalConfig("198.51.100.4"), { UNIDESK_TRANS_CONFIG_PATH: configPath } as NodeJS.ProcessEnv); + + expect(plan.backend).toBe("remote-frontend-websocket"); + expect(plan.remoteHost).toBe("198.51.100.4"); + expect(plan.reason).toBe(`configured:${configPath}#ssh.backend`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("ssh removed shell aliases", () => { test("reports route-aware replacement examples for trans script", () => { const previousEntrypoint = process.env.UNIDESK_SSH_ENTRYPOINT; diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 58ce8d82..a36c7eed 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -23,6 +23,7 @@ import { type SshRemoteCommandStreamHandlers, } from "./ssh-file-transfer"; import { readTransHostProxyEnvRule, type TransHostProxyEnvRule } from "./trans-host-proxy"; +import { readTransSshBackendConfig, type TransSshBackendConfig } from "./trans-config"; export interface ParsedSshArgs { remoteCommand: string | null; @@ -74,6 +75,7 @@ export interface SshCaptureBackendPlan { dockerExecutable: boolean; backendCoreContainer: boolean; error: string | null; + source?: string; }; } @@ -196,6 +198,8 @@ const windowsCmdExeNativePath = "C:\\Windows\\System32\\cmd.exe"; const defaultSshSlowWarningMs = 10_000; const defaultSshRuntimeTimeoutMs = 60_000; const maxSshRuntimeTimeoutMs = 60_000; +const defaultSshBackendCoreDetectTimeoutMs = 15_000; +const maxSshBackendCoreDetectTimeoutMs = 60_000; const defaultSshStdoutStreamMaxBytes = 256 * 1024; const minSshStdoutStreamMaxBytes = 4 * 1024; const maxSshStdoutStreamMaxBytes = 16 * 1024 * 1024; @@ -3551,14 +3555,46 @@ async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: } export function sshCaptureBackendPlan(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan { - const localBackendCore = detectLocalBackendCoreStatus(); + const configuredBackend = readTransSshBackendConfig(env); const remoteHost = sshCaptureRemoteHost(config, env); + if (configuredBackend !== null) return configuredSshCaptureBackendPlan(configuredBackend, remoteHost); + const localBackendCore = detectLocalBackendCoreStatus(env); const runnerEnv = isRunnerEnvironment(env); if (runnerEnv && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "runner-environment", localBackendCore }; if (!localBackendCore.backendCoreContainer && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "local-backend-core-unavailable", localBackendCore }; return { backend: "local-backend-core-broker", remoteHost: null, reason: "main-server-local-backend-core", localBackendCore }; } +function configuredSshCaptureBackendPlan(configuredBackend: TransSshBackendConfig, remoteHost: string | null): SshCaptureBackendPlan { + if (configuredBackend.backend === "local-backend-core-broker") { + return { + backend: "local-backend-core-broker", + remoteHost: null, + reason: `configured:${configuredBackend.sourceRef}`, + localBackendCore: { + dockerExecutable: true, + backendCoreContainer: true, + error: null, + source: configuredBackend.sourceRef, + }, + }; + } + if (remoteHost === null) { + throw new Error(`${configuredBackend.sourceRef} selects ${configuredBackend.raw}, but no remote host is configured for frontend websocket SSH`); + } + return { + backend: "remote-frontend-websocket", + remoteHost, + reason: `configured:${configuredBackend.sourceRef}`, + localBackendCore: { + dockerExecutable: false, + backendCoreContainer: false, + error: null, + source: configuredBackend.sourceRef, + }, + }; +} + function sshCaptureRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv): string | null { return normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_IP) ?? normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_HOST) @@ -3583,17 +3619,37 @@ function isRunnerEnvironment(env: NodeJS.ProcessEnv): boolean { ); } -function detectLocalBackendCoreStatus(): SshCaptureBackendPlan["localBackendCore"] { - const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf8", timeout: 2000 }); - if (result.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: result.error.message }; +function detectLocalBackendCoreStatus(env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan["localBackendCore"] { + const timeout = sshBackendCoreDetectTimeoutMs(env); + const inspect = spawnSync("docker", ["inspect", "unidesk-backend-core", "--format", "{{.State.Running}}"], { encoding: "utf8", timeout }); + if (inspect.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: inspect.error.message, source: "docker-inspect" }; + const inspectOutput = `${inspect.stdout ?? ""}\n${inspect.stderr ?? ""}`.trim(); + if (inspect.status === 0) { + const running = String(inspect.stdout ?? "").trim() === "true"; + return { + dockerExecutable: true, + backendCoreContainer: running, + error: running ? null : inspectOutput || "docker inspect unidesk-backend-core reported not running", + source: "docker-inspect", + }; + } + const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf8", timeout }); + if (result.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: result.error.message, source: "docker-ps" }; const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); return { 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"}`, + error: result.status === 0 ? null : inspectOutput || output || `docker ps exited ${result.status ?? "unknown"}`, + source: "docker-ps", }; } +function sshBackendCoreDetectTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number(env.UNIDESK_SSH_BACKEND_CORE_DETECT_TIMEOUT_MS); + if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshBackendCoreDetectTimeoutMs; + return Math.min(maxSshBackendCoreDetectTimeoutMs, Math.max(2000, Math.trunc(parsed))); +} + function isLocalSshCaptureBackendUnavailable(result: SshCaptureResult): boolean { return /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(result.stderr); } diff --git a/scripts/src/trans-config.ts b/scripts/src/trans-config.ts new file mode 100644 index 00000000..5780e015 --- /dev/null +++ b/scripts/src/trans-config.ts @@ -0,0 +1,75 @@ +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { rootPath } from "./config"; + +export type TransSshBackendPreference = "local-backend-core-broker" | "remote-frontend-websocket"; + +export interface TransSshBackendConfig { + backend: TransSshBackendPreference; + raw: string; + sourceRef: string; +} + +const defaultTransConfigPath = ".env/trans.ymal"; + +export function readTransSshBackendConfig(env: NodeJS.ProcessEnv = process.env): TransSshBackendConfig | null { + const configPath = resolveTransConfigPath(env); + if (!existsSync(configPath.absolutePath)) return null; + const parsed = asRecord(Bun.YAML.parse(readFileSync(configPath.absolutePath, "utf8")) as unknown, configPath.sourceRef); + const kind = optionalString(parsed, "kind", configPath.sourceRef); + if (kind !== null && kind !== "UniDeskTransConfig") { + throw new Error(`${configPath.sourceRef}.kind must be UniDeskTransConfig`); + } + const ssh = optionalRecord(parsed, "ssh", configPath.sourceRef); + if (ssh === null) return null; + const raw = optionalString(ssh, "backend", `${configPath.sourceRef}.ssh`); + if (raw === null) return null; + const backend = normalizeTransSshBackend(raw, `${configPath.sourceRef}.ssh.backend`); + if (backend === "auto") return null; + return { + backend, + raw, + sourceRef: `${configPath.sourceRef}#ssh.backend`, + }; +} + +function resolveTransConfigPath(env: NodeJS.ProcessEnv): { absolutePath: string; sourceRef: string } { + const configured = (env.UNIDESK_TRANS_CONFIG_PATH ?? env.UNIDESK_TRANS_YAML_PATH)?.trim(); + if (configured !== undefined && configured.length > 0) { + return { + absolutePath: isAbsolute(configured) ? configured : rootPath(configured), + sourceRef: configured, + }; + } + return { + absolutePath: rootPath(defaultTransConfigPath), + sourceRef: defaultTransConfigPath, + }; +} + +function normalizeTransSshBackend(raw: string, path: string): TransSshBackendPreference | "auto" { + const normalized = raw.trim(); + if (normalized === "backend-core" || normalized === "local-backend-core-broker") return "local-backend-core-broker"; + if (normalized === "frontend-websocket" || normalized === "remote-frontend-websocket") return "remote-frontend-websocket"; + if (normalized === "auto") return "auto"; + throw new Error(`${path} must be one of: backend-core, local-backend-core-broker, frontend-websocket, remote-frontend-websocket, auto`); +} + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function optionalRecord(obj: Record, key: string, path: string): Record | null { + const value = obj[key]; + if (value === undefined) return null; + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path}.${key} must be an object`); + return value as Record; +} + +function optionalString(obj: Record, key: string, path: string): string | null { + const value = obj[key]; + if (value === undefined) return null; + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`); + return value; +}