fix: honor trans backend config for ssh

This commit is contained in:
Codex
2026-06-29 14:56:01 +00:00
parent 350aa68174
commit 1cdcf3c9a8
4 changed files with 202 additions and 41 deletions
+9 -36
View File
@@ -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 {
+57
View File
@@ -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;
+61 -5
View File
@@ -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);
}
+75
View File
@@ -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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function optionalRecord(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> | 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<string, unknown>;
}
function optionalString(obj: Record<string, unknown>, 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;
}