fix: use frontend ssh bridge without backend-core

This commit is contained in:
AgentRun Artificer
2026-06-11 18:26:15 +08:00
parent 9f85274da0
commit 1b96c82b40
2 changed files with 141 additions and 2 deletions
+94 -2
View File
@@ -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<number> {
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<SshCaptureResult> {
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<SshCaptureResult> {
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<number> {
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] ?? "";
@@ -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<typeof sshCaptureBackendPlan>[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<typeof sshCaptureBackendPlan>[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",
],
}));