feat: add provider ssh tcp data pool

This commit is contained in:
Codex
2026-06-07 02:02:38 +00:00
parent d4b7fc95f9
commit fe1b91dcbd
32 changed files with 988 additions and 350 deletions
@@ -16,7 +16,8 @@ assertCondition(source.includes("downloadRemoteFile(options, remoteArchive, loca
assertCondition(source.includes("runRemoteScriptBackground(options, remoteScript"), "remote docker save must run as a background job");
assertCondition(source.includes('runRemoteScriptBackground(options, deployScript, Math.max(options.timeoutMs, 420_000), "d601-k3s-deploy")'), "D601 k3s deploy must use background polling");
assertCondition(source.includes('"ssh",\n options.providerId,\n "download"'), "download helper must route through UniDesk ssh download");
assertCondition(downloadRemoteFileSource.includes('"--chunk-bytes",\n "45000"'), "artifact ssh download chunk must keep base64 stdout below the provider bridge truncation boundary");
assertCondition(downloadRemoteFileSource.includes('"--chunk-bytes",\n "1048576"'), "artifact ssh download chunk should use MiB-scale blocks after ssh data moved to tcp-pool");
assertCondition(!downloadRemoteFileSource.includes('"45000"'), "artifact ssh download must not preserve the old provider bridge truncation boundary");
assertCondition(downloadRemoteFileSource.includes("tee -a") && downloadRemoteFileSource.includes("UNIDESK_JOB_STDERR_FILE"), "artifact ssh download must stream progress stderr into async job logs");
assertCondition(source.includes("UNIDESK_SSH_CLIENT_TOKEN") && source.includes("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST"), "dev frontend artifact deploy must sync scoped ssh runtime keys");
@@ -27,7 +28,7 @@ console.log(JSON.stringify({
"no docker-save stdout stream over ssh",
"compose artifact uses verified ssh download",
"remote docker save and k3s deploy use background polling",
"artifact downloads keep base64 stdout below the provider bridge truncation boundary",
"artifact downloads use MiB-scale tcp-pool chunks instead of the old bridge truncation boundary",
"artifact download progress is visible in async job stderr",
"dev frontend artifact deploy syncs scoped ssh runtime keys"
]
@@ -298,6 +298,7 @@ async function main(): Promise<void> {
devFrontend: { port: 18083, containerPort: 8080 },
database: { port: 15432, containerPort: 5432 },
providerIngress: { port: 18082, containerPort: 8081 },
providerData: { port: 18084, containerPort: 8082 },
},
database: { user: "unidesk", password: "<redacted-test-password>", name: "unidesk", volume: "unidesk_pgdata_10gb", volumeSize: "15GB" },
providerGateway: {
+1 -1
View File
@@ -1678,7 +1678,7 @@ function downloadRemoteFile(options: ArtifactRegistryOptions, remotePath: string
options.providerId,
"download",
"--chunk-bytes",
"45000",
"1048576",
remotePath,
localPath,
];
+2
View File
@@ -13,6 +13,7 @@ export interface UniDeskConfig {
devFrontend: { port: number; containerPort: number };
database: { port: number; containerPort: number };
providerIngress: { port: number; containerPort: number };
providerData: { port: number; containerPort: number };
restrictedHostAccess?: { bindHost: string; allowedSourceCidrs: string[] };
};
auth: { username: string; password: string; sessionSecret: string; sessionTtlSeconds: number };
@@ -276,6 +277,7 @@ export function readConfig(): UniDeskConfig {
devFrontend: portPair(network, "devFrontend"),
database: portPair(network, "database"),
providerIngress: portPair(network, "providerIngress"),
providerData: portPair(network, "providerData"),
restrictedHostAccess: optionalRestrictedHostAccess(network),
},
database: {
+7
View File
@@ -154,6 +154,10 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
UNIDESK_DATABASE_PORT: String(config.network.database.port),
UNIDESK_DATABASE_BIND_HOST: runtimeSecretWithDefault("UNIDESK_DATABASE_BIND_HOST", restrictedHostBind, "127.0.0.1"),
UNIDESK_PROVIDER_INGRESS_PORT: String(config.network.providerIngress.port),
UNIDESK_PROVIDER_DATA_PORT: String(config.network.providerData.port),
UNIDESK_PROVIDER_DATA_POOL_SIZE: runtimeSecret("UNIDESK_PROVIDER_DATA_POOL_SIZE") || "10",
UNIDESK_PROVIDER_DATA_CONNECT_TIMEOUT_MS: runtimeSecret("UNIDESK_PROVIDER_DATA_CONNECT_TIMEOUT_MS") || "5000",
UNIDESK_BACKEND_CORE_CARGO_BUILD_JOBS: runtimeSecret("UNIDESK_BACKEND_CORE_CARGO_BUILD_JOBS") || "1",
UNIDESK_DATABASE_USER: config.database.user,
UNIDESK_DATABASE_PASSWORD: config.database.password,
UNIDESK_DATABASE_NAME: config.database.name,
@@ -397,6 +401,7 @@ function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number;
{ name: "frontend", port: config.network.frontend.port, listening: isPortListening(config.network.frontend.port) },
{ name: "dev-frontend", port: config.network.devFrontend.port, listening: isPortListening(config.network.devFrontend.port) },
{ name: "provider-ingress", port: config.network.providerIngress.port, listening: isPortListening(config.network.providerIngress.port) },
{ name: "provider-data", port: config.network.providerData.port, listening: isPortListening(config.network.providerData.port) },
];
}
@@ -570,6 +575,7 @@ export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
frontend: await probe(`http://127.0.0.1:${config.network.frontend.port}/health`),
devFrontend: await probe(`http://127.0.0.1:${config.network.devFrontend.port}/health`),
providerIngress: await probe(`http://127.0.0.1:${config.network.providerIngress.port}/health`),
providerDataPortListening: isPortListening(config.network.providerData.port),
database: dockerExec(config, "unidesk-database", ["pg_isready", "-U", config.database.user, "-d", config.database.name]),
overview,
},
@@ -577,6 +583,7 @@ export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
frontend: `http://${config.network.publicHost}:${config.network.frontend.port}`,
devFrontend: `http://${config.network.publicHost}:${config.network.devFrontend.port}`,
providerIngress: `ws://${config.network.publicHost}:${config.network.providerIngress.port}/ws/provider`,
providerData: `tcp://${config.network.publicHost}:${config.network.providerData.port}`,
internalCore: `http://backend-core:${config.network.core.containerPort}`,
internalDatabase: `postgres://${config.database.user}:***@database:${config.network.database.containerPort}/${config.database.name}`,
},
+3 -3
View File
@@ -12,7 +12,7 @@ export function rootHelp(): unknown {
{ command: "help", description: "List supported commands." },
{ command: "--main-server-ip <ip> <command>", description: "Run selected commands through the public frontend API; use --main-server-key only for legacy SSH transport." },
{ command: "config show", description: "Validate and print config.json as the single source of truth." },
{ command: "check [--full|--files|--scripts-typecheck|--components|--compose|--logs|--recovery-guardrails|--rust] | check recovery-guardrails", description: "Run the lightweight default syntax/config gate or the low-noise read-only D601 recovery guardrails; Rust is opt-in and only allowed from D601 CI/dev execution." },
{ command: "check [--full|--files|--scripts-typecheck|--components|--compose|--logs|--recovery-guardrails|--rust] | check recovery-guardrails", description: "Run the lightweight default syntax/config gate or the low-noise read-only D601 recovery guardrails; check --rust stays D601-guarded, while backend-core main-server online uses the separate constrained rebuild path." },
{ command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server user services." },
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
@@ -22,7 +22,7 @@ export function rootHelp(): unknown {
{ command: "gc plan|run|db-trace|policy|remote [--confirm] [--logs-keep-days N] [--include-browser-cache]", description: "One-time main-server or remote provider disk relief and low-risk anti-bloat policy for logs, journald, allowlisted /tmp artifacts, scoped core dumps and explicit trace telemetry retention; plan is read-only and run requires --confirm." },
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." },
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
{ command: "trans <route> [operation args...] (alias of ssh <route> ...)", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; `trans` is the short alias for this ssh passthrough path." },
{ command: "trans <route> [operation args...] (alias of ssh <route> ...)", description: "Open a Host SSH / WSL SSH maintenance session; provider WebSocket carries control and host.ssh.tcp-pool carries stdin/stdout/stderr data." },
{ command: "trans gh:/owner/repo[/pr|/issue][/number[/1]] ls|cat|rg|patch-apply", description: "Treat GitHub PRs/issues as virtual text directories; `ls --full` shows state/floors/body length, and `patch-apply` updates first-floor `body.md` through UniDesk gh plus apply-patch v2." },
{ command: "trans <route> apply-patch < patch.diff", description: "Default remote text patch entry: apply a standard patch with the local TypeScript v2 engine while the remote route only reads and writes files." },
{ command: "trans <route> upload <local-file> <remote-file> | trans <route> download <remote-file> <local-file>", description: "Transfer whole files through SSH passthrough with remote temp files, automatic endpoint byte/SHA-256 verification, and client-side chunk fallback." },
@@ -133,7 +133,7 @@ export function serverHelp(action: string | undefined = undefined): unknown {
providerIngress: "provider-gateway WebSocket ingress",
},
rustBoundary: {
masterServer: "do not use server rebuild backend-core for Rust iteration; it would build locally",
masterServer: "do not use server rebuild backend-core for routine Rust iteration; only reviewed backend-core main-server online may use the constrained rebuild path",
d601: "use deploy apply --env dev --service backend-core and CI for Rust build/check",
},
maintenanceOnly: {
+24 -16
View File
@@ -50,12 +50,13 @@ class SshFileTransferError extends Error {
}
}
const fileTransferReadBlockBytes = 45_000;
const fileTransferReadBlockBytes = 1_048_576;
const fileTransferWriteB64ArgvLimit = 48_000;
const fileTransferWriteB64ChunkChars = 12_000;
const fileTransferWriteRawChunkBytes = 1_048_576;
const fileTransferWriteB64ChunkChars = 1_398_104;
const fileTransferReadBlockMaxAttempts = 12;
const fileTransferReadEmptyRetryDelayMs = 250;
const fileTransferProgressEveryChunks = 64;
const fileTransferProgressEveryChunks = 16;
export function isSshFileTransferOperation(args: string[]): boolean {
const subcommand = args[0] ?? "";
@@ -163,7 +164,7 @@ function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptio
function boundedTransferChunkBytes(raw: string, option: string): number {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${option} must be a positive integer`);
return Math.min(96_000, Math.max(1024, value));
return Math.min(4 * 1024 * 1024, Math.max(1024, value));
}
async function writeRemoteFileVerified(
@@ -173,26 +174,33 @@ async function writeRemoteFileVerified(
remotePath: string,
content: Buffer,
): Promise<SshFileTransferWriteResult> {
const encoded = content.toString("base64");
const expectedBytes = String(content.length);
const expectedSha256 = sha256HexBuffer(content);
const encoded = content.length <= fileTransferWriteB64ArgvLimit
? content.toString("base64")
: "";
if (invocation.route.plane !== "win" && encoded.length <= fileTransferWriteB64ArgvLimit) {
await checkedFileTransfer(invocation, executor, builders, "write-b64-argv", [remotePath, expectedBytes, expectedSha256, ...chunkString(encoded, fileTransferWriteB64ChunkChars)]);
return { strategy: "argv", chunks: encoded.length === 0 ? 0 : Math.ceil(encoded.length / fileTransferWriteB64ChunkChars) };
}
try {
await checkedFileTransfer(invocation, executor, builders, "write-b64-stdin", [remotePath, expectedBytes, expectedSha256], encoded);
return { strategy: "stdin", chunks: 1 };
} catch {
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
const chunks = chunkString(encoded, fileTransferWriteB64ChunkChars);
await checkedFileTransfer(invocation, executor, builders, "write-b64-begin", [remotePath, token]);
for (const chunk of chunks) {
await checkedFileTransfer(invocation, executor, builders, "write-b64-append-stdin", [remotePath, token], chunk);
if (content.length <= fileTransferWriteRawChunkBytes) {
try {
await checkedFileTransfer(invocation, executor, builders, "write-b64-stdin", [remotePath, expectedBytes, expectedSha256], content.toString("base64"));
return { strategy: "stdin", chunks: 1 };
} catch {
// Fall through to chunked upload with per-block base64 encoding.
}
await checkedFileTransfer(invocation, executor, builders, "write-b64-commit", [remotePath, token, expectedBytes, expectedSha256]);
return { strategy: "chunked-stdin", chunks: encoded.length === 0 ? 0 : chunks.length };
}
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
let chunks = 0;
await checkedFileTransfer(invocation, executor, builders, "write-b64-begin", [remotePath, token]);
for (let offset = 0; offset < content.length; offset += fileTransferWriteRawChunkBytes) {
const encodedChunk = content.subarray(offset, Math.min(content.length, offset + fileTransferWriteRawChunkBytes)).toString("base64");
await checkedFileTransfer(invocation, executor, builders, "write-b64-append-stdin", [remotePath, token], encodedChunk);
chunks += 1;
}
await checkedFileTransfer(invocation, executor, builders, "write-b64-commit", [remotePath, token, expectedBytes, expectedSha256]);
return { strategy: "chunked-stdin", chunks };
}
async function readRemoteFileVerified(
@@ -0,0 +1,51 @@
import { readFileSync } from "node:fs";
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
const rustBridge = readFileSync("src/components/backend-core/src/ssh_bridge.rs", "utf8");
const rustData = readFileSync("src/components/backend-core/src/ssh_data_channel.rs", "utf8");
const rustMain = readFileSync("src/components/backend-core/src/main.rs", "utf8");
const rustState = readFileSync("src/components/backend-core/src/state.rs", "utf8");
const rustProviderRegistry = readFileSync("src/components/backend-core/src/provider_registry.rs", "utf8");
const provider = readFileSync("src/components/provider-gateway/src/index.ts", "utf8");
const providerRegistryTs = readFileSync("src/components/backend-core/src/provider-registry.ts", "utf8");
const providerPackage = JSON.parse(readFileSync("src/components/provider-gateway/package.json", "utf8")) as { version?: unknown };
const shared = readFileSync("src/components/shared/src/index.ts", "utf8");
const compose = readFileSync("docker-compose.yml", "utf8");
const config = readFileSync("config.json", "utf8");
const backendCoreDockerfile = readFileSync("src/components/backend-core/Dockerfile", "utf8");
assertCondition(rustBridge.includes('"host.ssh.tcp-pool"'), "backend-core ssh bridge must require host.ssh.tcp-pool");
assertCondition(rustBridge.includes("provider-gateway-upgrade-required"), "old provider must fail with upgrade-required classification");
assertCondition(rustBridge.includes("provider-data-pool-exhausted"), "tcp pool exhaustion must be visible");
assertCondition(!rustBridge.includes('"host_ssh_input"'), "ssh input must not fall back to provider control websocket");
assertCondition(!rustBridge.includes('"host_ssh_eof"'), "ssh eof must not fall back to provider control websocket");
assertCondition(!rustBridge.includes('"host_ssh_close"'), "ssh close must not fall back to provider control websocket");
assertCondition(rustBridge.includes('json!({ "type": "input"') && rustBridge.includes('json!({ "type": "eof"'), "ssh bridge must send stdin/eof over data frames");
assertCondition(!rustProviderRegistry.includes('"host_ssh_opened"') && !rustProviderRegistry.includes('"host_ssh_data"') && !rustProviderRegistry.includes('"host_ssh_exit"'), "rust provider registry must not accept old websocket ssh data messages");
assertCondition(rustData.includes("TcpListener") && rustData.includes("read_data_frame") && rustData.includes("write_data_frame"), "backend-core must expose raw TCP data frame listener");
assertCondition(rustData.includes("SSH_DATA_PROTOCOL") && rustData.includes("unidesk-host-ssh-tcp-pool-v1"), "tcp data protocol must be explicit");
assertCondition(rustData.includes("base64::engine::general_purpose::STANDARD.encode(payload)"), "only client-facing websocket payload should remain base64 encoded");
assertCondition(rustMain.includes("providerDataTcpUrl") && rustMain.includes("spawn_ssh_data_listener"), "backend-core startup must expose provider data listener visibility");
assertCondition(rustState.includes("active_ssh_data_channels") && rustState.includes("data_channel_id"), "backend state must track data channels separately from provider control socket");
assertCondition(provider.includes("createConnection") && provider.includes("sshDataChannels"), "provider-gateway must use direct TCP sockets for ssh data pool");
assertCondition(provider.includes("PROVIDER_DATA_POOL_SIZE") && provider.includes("providerGatewaySshDataPoolReady"), "provider-gateway must expose pool config and heartbeat labels");
assertCondition(provider.includes('capabilities.push("host.ssh", "host.ssh.tcp-pool")'), "provider-gateway must declare tcp-pool capability only with host ssh");
assertCondition(provider.includes("acquireSshDataChannel") && provider.includes("releaseSshDataChannel"), "provider-gateway must claim/release one data channel per ssh session");
assertCondition(provider.includes("writeSshDataFrame(dataChannel") && provider.includes("sendSshDataSessionFrame"), "provider-gateway ssh output must use tcp data frames");
assertCondition(!provider.includes('parsed.type === "host_ssh_input"') && !provider.includes('parsed.type === "host_ssh_close"'), "provider-gateway must not retain old websocket ssh input handlers");
assertCondition(providerPackage.version === "0.2.28", "provider-gateway behavior change must bump package version", providerPackage);
assertCondition(shared.includes('transport: "tcp-pool"') && shared.includes("dataChannelId: string"), "shared host_ssh_open contract must require tcp-pool fields");
assertCondition(!shared.includes("CoreHostSshInputMessage") && !shared.includes("ProviderHostSshDataMessage"), "shared protocol must remove old websocket ssh data contracts");
assertCondition(!providerRegistryTs.includes("host_ssh_data") && !providerRegistryTs.includes("host_ssh_exit"), "typescript backend registry must not forward old websocket ssh data messages");
assertCondition(compose.includes("UNIDESK_PROVIDER_DATA_PORT") && compose.includes("PROVIDER_DATA_POOL_SIZE"), "compose must wire provider data port and pool size");
assertCondition(config.includes('"providerData"') && config.includes('"port": 18084') && config.includes('"containerPort": 8082'), "config.json must declare providerData port pair");
assertCondition(backendCoreDockerfile.includes("ARG CARGO_BUILD_JOBS=1") && backendCoreDockerfile.includes('cargo build --release --jobs "${CARGO_BUILD_JOBS}"'), "backend-core main-server online build must keep cargo concurrency constrained");
assertCondition(compose.includes("UNIDESK_BACKEND_CORE_CARGO_BUILD_JOBS") && compose.includes("CARGO_BUILD_JOBS"), "compose must pass backend-core cargo build concurrency explicitly");
console.log(JSON.stringify({ ok: true, test: "ssh-data-tcp-pool-contract" }));