Merge pull request #1256 from pikasTech/fix/1250-jd01-ssh-backend-detect
fix: honor trans backend config for SSH
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user