From 1b96c82b405cb11c80e70400ab9c3f519fdf0ef9 Mon Sep 17 00:00:00 2001 From: AgentRun Artificer Date: Thu, 11 Jun 2026 18:26:15 +0800 Subject: [PATCH] fix: use frontend ssh bridge without backend-core --- scripts/src/ssh.ts | 96 ++++++++++++++++++++- scripts/ssh-capture-bridge-contract-test.ts | 47 ++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 scripts/ssh-capture-bridge-contract-test.ts diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index ef8042c2..ca06368c 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -50,6 +50,19 @@ export interface SshCaptureResult { stderr: string; } +export type SshCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket"; + +export interface SshCaptureBackendPlan { + backend: SshCaptureBackend; + remoteHost: string | null; + reason: string; + localBackendCore: { + dockerExecutable: boolean; + backendCoreContainer: boolean; + error: string | null; + }; +} + export interface SshFailureHint { code: "ssh-like-command-friction"; providerId: string; @@ -2830,6 +2843,19 @@ async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: Par }); } +async function runRemoteSsh(config: UniDeskConfig, host: string, providerId: string, args: string[]): Promise { + const { runRemoteCli } = await import("./remote"); + return await runRemoteCli({ + host, + user: "root", + port: 22, + projectRoot: repoRoot, + identityFile: null, + transport: "frontend", + args: ["ssh", providerId, ...args], + }, config); +} + export async function runSshCommandCapture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise { const invocation = parseSshInvocation(target, args); const parsed = invocation.parsed; @@ -2837,7 +2863,69 @@ export async function runSshCommandCapture(config: UniDeskConfig, target: string const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined ? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}` : input; - return await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin); + const plan = sshCaptureBackendPlan(config, process.env); + if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { + return await runRemoteSshCapture(config, plan.remoteHost, target, args, stdin); + } + const local = await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin); + const fallbackHost = sshCaptureRemoteHost(config, process.env); + if (local.exitCode !== 0 && fallbackHost !== null && isLocalSshCaptureBackendUnavailable(local)) { + return await runRemoteSshCapture(config, fallbackHost, target, args, stdin); + } + return local; +} + +async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string): Promise { + const { runRemoteSshCommandCapture } = await import("./remote"); + return await runRemoteSshCommandCapture(config, host, target, args, input); +} + +export function sshCaptureBackendPlan(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan { + const localBackendCore = detectLocalBackendCoreStatus(); + const remoteHost = sshCaptureRemoteHost(config, 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 sshCaptureRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv): string | null { + return normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_IP) + ?? normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_HOST) + ?? normalizeRemoteHost(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST) + ?? normalizeRemoteHost(config.network.publicHost); +} + +function normalizeRemoteHost(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 isRunnerEnvironment(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 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 }; + 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"}`, + }; +} + +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); } function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void { @@ -2852,6 +2940,10 @@ function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void { export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise { const normalizedArgs = normalizeSshOperationArgs(args); process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs)); + const plan = sshCaptureBackendPlan(config, process.env); + if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { + return await runRemoteSsh(config, plan.remoteHost, providerId, normalizedArgs); + } const invocation = parseSshInvocation(providerId, normalizedArgs); const parsed = invocation.parsed; const operationName = normalizedArgs[0] ?? ""; diff --git a/scripts/ssh-capture-bridge-contract-test.ts b/scripts/ssh-capture-bridge-contract-test.ts new file mode 100644 index 00000000..c8a98ede --- /dev/null +++ b/scripts/ssh-capture-bridge-contract-test.ts @@ -0,0 +1,47 @@ +import { sshCaptureBackendPlan } from "./src/ssh"; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +const baseConfig = { + network: { publicHost: "74.48.78.17" }, +} as Parameters[0]; + +const runnerPlan = sshCaptureBackendPlan(baseConfig, { + KUBERNETES_SERVICE_HOST: "10.43.0.1", +}); + +assertCondition( + runnerPlan.backend === "remote-frontend-websocket" && runnerPlan.remoteHost === "74.48.78.17" && runnerPlan.reason === "runner-environment", + "ssh capture should use frontend websocket in runner environments", + runnerPlan, +); + +const explicitHostPlan = sshCaptureBackendPlan(baseConfig, { + UNIDESK_MAIN_SERVER_IP: "http://74.48.78.17:18081/", + KUBERNETES_SERVICE_HOST: "10.43.0.1", +}); + +assertCondition( + explicitHostPlan.backend === "remote-frontend-websocket" && explicitHostPlan.remoteHost === "http://74.48.78.17:18081", + "ssh capture should prefer explicit main server host hints and trim trailing slash", + explicitHostPlan, +); + +const localOnlyPlan = sshCaptureBackendPlan({ network: { publicHost: "127.0.0.1" } } as Parameters[0], {}); + +assertCondition( + localOnlyPlan.backend === "local-backend-core-broker" && localOnlyPlan.remoteHost === null, + "ssh capture should keep local backend-core broker mode when no remote host is configured", + localOnlyPlan, +); + +console.log(JSON.stringify({ + ok: true, + checks: [ + "runner environments use remote frontend websocket capture", + "explicit main server host hints are preferred and normalized", + "local-only environments keep local backend-core broker mode", + ], +}));