fix(cli): guide ssh argv usage

This commit is contained in:
Codex
2026-05-23 12:32:43 +00:00
parent ca1e2544f0
commit 15d074424c
8 changed files with 171 additions and 23 deletions
@@ -1,4 +1,4 @@
import { buildProviderTriageResult, type ProviderTriageSignal } from "./src/provider-triage";
import { buildProviderTriageResult, providerTriageRecommendedCrossChecks, type ProviderTriageSignal } from "./src/provider-triage";
import { codexTaskQuery } from "./src/code-queue";
import { classifyRunnerError } from "../src/components/microservices/code-queue/src/runner-error-classifier";
@@ -58,6 +58,11 @@ export function runProviderRunnerTriageContract(): JsonRecord {
assertCondition(result.decision === "retryable-transient", "single path provider offline should be retryable transient", result);
assertCondition(result.retryable === true, "single path provider offline should be retryable", result);
assertCondition(result.contract.singlePathProviderOfflineIsGlobalBlocker === false, "triage contract should reject single-path global blocker", result);
assertCondition(result.recommendedCrossChecks.includes("bun scripts/cli.ts ssh D601 argv true"), "provider triage result must recommend argv Host SSH cross-check", result.recommendedCrossChecks);
const crossChecks = providerTriageRecommendedCrossChecks("D601");
assertCondition(crossChecks.includes("bun scripts/cli.ts ssh D601 argv true"), "provider triage recommendedCrossChecks must keep ssh argv true", crossChecks);
assertCondition(crossChecks.includes("bun scripts/cli.ts debug dispatch D601 host.ssh --wait-ms 15000"), "provider triage recommendedCrossChecks must keep host.ssh dispatch probe", crossChecks);
const rateLimitTriage = buildProviderTriageResult("D601", [
signal("observed-runner-429", "external-provider", "failed", false),
@@ -102,6 +107,7 @@ export function runProviderRunnerTriageContract(): JsonRecord {
"runner error classifier separates runner-local/provider-gateway/registry/k3s/scheduler/unknown",
"each single runner error classification has globalBlocker=false",
"provider triage keeps single provider is not online as retryable-transient, not global-blocker",
"provider triage recommendedCrossChecks keeps host.ssh dispatch and ssh argv true probes",
"external OpenAI/model provider 429 is explicit retryable backoff evidence, not Code Queue infra outage",
"codex task --detail preserves runnerErrorClassification in compact attempt output",
],
+4
View File
@@ -45,6 +45,7 @@ const syntaxFiles = [
"scripts/microservice-health-output-contract-test.ts",
"scripts/code-queue-supervisor-disclosure-contract-test.ts",
"scripts/code-queue-commander-view-contract-test.ts",
"scripts/ssh-argv-guidance-contract-test.ts",
"src/components/frontend/src/index.ts",
"src/components/frontend/src/app.tsx",
"src/components/frontend/src/decision-center.tsx",
@@ -334,6 +335,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"),
fileItem("scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"),
fileItem("scripts/provider-runner-triage-contract-test.ts"),
fileItem("scripts/ssh-argv-guidance-contract-test.ts"),
fileItem("scripts/src/provider-triage.ts"),
fileItem("src/components/microservices/code-queue/src/runner-error-classifier.ts"),
fileItem("scripts/src/ci.ts"),
@@ -384,6 +386,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000));
items.push(commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000));
items.push(commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000));
items.push(commandItem("ssh:argv-guidance-contract", ["bun", "scripts/ssh-argv-guidance-contract-test.ts"], 30_000));
items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 30_000));
items.push(commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000));
@@ -423,6 +426,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("host-codex-commander:no-daemon-smoke-contract", "host Codex commander no-daemon smoke contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("provider:runner-triage-contract", "Provider runner triage contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("ssh:argv-guidance-contract", "SSH argv guidance and failure hint contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("deploy:artifact-matrix-contract", "deploy artifact matrix contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("decision-center:desired-state-contract", "Decision Center desired-state drift contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:liveness-diagnostics-fixtures", "Code Queue liveness diagnostics fixtures are opt-in with script checks", "--scripts-typecheck or --full"));
+5 -3
View File
@@ -18,13 +18,13 @@ export function rootHelp(): unknown {
{ command: "server cleanup plan [--min-age-hours N] [--limit N]", description: "Dry-run Docker image cleanup plan only: list active/protected images, stale candidates older than the default 24h threshold, risk, estimated reclaim, and manual review commands without deleting anything." },
{ 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: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge with built-in remote helper tools in PATH." },
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; prefer `ssh <providerId> argv ...` for non-interactive remote commands." },
{ command: "ssh <providerId> apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
{ command: "ssh <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
{ command: "ssh <providerId> skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." },
{ command: "ssh <providerId> find <path...> [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." },
{ command: "ssh <providerId> glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." },
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a remote command with each argv token shell-quoted by UniDesk before SSH passthrough." },
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `argv bash -lc '<command>'` when shell features are required." },
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
{ command: "microservice health <id> [--compact|--raw|--full]", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy; default output is compact, and code-queue uses a commander-safe liveness summary unless raw/full is requested." },
@@ -142,6 +142,7 @@ export function sshHelp(): unknown {
usage: [
"bun scripts/cli.ts ssh <providerId>",
"bun scripts/cli.ts ssh <providerId> argv <command> [args...]",
"bun scripts/cli.ts ssh D601 argv bash -lc '<command>'",
"bun scripts/cli.ts ssh <providerId> apply-patch < patch.diff",
"bun scripts/cli.ts ssh <providerId> py [script-args...] < script.py",
"bun scripts/cli.ts ssh <providerId> skills [--scope all|wsl|windows] [--limit N]",
@@ -150,7 +151,8 @@ export function sshHelp(): unknown {
],
notes: [
"ssh --help and ssh <providerId> --help print this JSON help and never open an interactive session.",
"Use argv when nested shell quoting would be fragile.",
"For non-interactive remote commands, prefer argv: bun scripts/cli.ts ssh D601 argv bash -lc '<command>'.",
"If an ssh-like remote command fails with timeout/kex/exit-255 friction, stderr includes one low-noise UNIDESK_SSH_HINT JSON line with the argv retry command.",
"Use -- before a remote command that intentionally starts with a dash.",
],
};
+5 -2
View File
@@ -3,7 +3,7 @@ import { type UniDeskConfig } from "./config";
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices";
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
import { formatSshFailureHint, isSshSkillDiscoveryArgs, parseSshArgs, sshFailureHint } from "./ssh";
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync } from "./code-queue";
import { runDecisionCenterCommandAsync } from "./decision-center";
import {
@@ -862,7 +862,10 @@ async function runRemoteSshOverFrontend(session: FrontendSession, providerId: st
if (stderr.length > 0) process.stderr.write(stderr);
if (task?.status !== "succeeded") {
if (stdout.length === 0 && stderr.length === 0) process.stderr.write(`${JSON.stringify({ taskId, task }, null, 2)}\n`);
return typeof result.exitCode === "number" ? result.exitCode : 255;
const exitCode = typeof result.exitCode === "number" ? result.exitCode : 255;
const hint = sshFailureHint(providerId, parsed, exitCode, stderr.length > 0 ? stderr : String(task?.message ?? ""));
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
return exitCode;
}
return typeof result.exitCode === "number" ? result.exitCode : 0;
}
+86 -11
View File
@@ -4,6 +4,20 @@ import { type UniDeskConfig, repoRoot } from "./config";
export interface ParsedSshArgs {
remoteCommand: string | null;
requiresStdin: boolean;
invocationKind: SshInvocationKind;
}
export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like";
export interface SshFailureHint {
code: "ssh-like-command-friction";
providerId: string;
trigger: "timeout-or-kex" | "exit-255";
exitCode: number;
message: string;
try: string;
triage: string;
note: string;
}
const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
@@ -479,28 +493,28 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
const subcommand = args[0] ?? "";
if (isSshSkillDiscoveryArgs(args)) {
const toolArgs = subcommand === "skill" ? ["skill-discover", ...args.slice(2)] : ["skill-discover", ...args.slice(1)];
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false };
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false, invocationKind: "helper" };
}
if (subcommand === "apply-patch" || subcommand === "patch") {
const toolArgs = ["apply_patch", ...args.slice(1)];
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true };
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true, invocationKind: "helper" };
}
if (subcommand === "py") {
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true };
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
}
if (subcommand === "argv" || subcommand === "exec") {
const toolArgs = args.slice(1);
if (toolArgs.length === 0) throw new Error(`ssh ${subcommand} requires a command`);
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false };
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false, invocationKind: "argv" };
}
if (subcommand === "find") {
return { remoteCommand: buildFindCommand(args.slice(1)), requiresStdin: false };
return { remoteCommand: buildFindCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" };
}
if (subcommand === "glob") {
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false };
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper" };
}
if (argvQuotedSshSubcommands.has(subcommand)) {
return { remoteCommand: shellArgv(args), requiresStdin: false };
return { remoteCommand: shellArgv(args), requiresStdin: false, invocationKind: "argv" };
}
const remote: string[] = [];
let remoteStarted = false;
@@ -521,7 +535,11 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
remoteStarted = true;
remote.push(arg);
}
return { remoteCommand: remote.length === 0 ? null : remote.join(" "), requiresStdin: false };
return {
remoteCommand: remote.length === 0 ? null : remote.join(" "),
requiresStdin: false,
invocationKind: remote.length === 0 ? "interactive" : "ssh-like",
};
}
function shellArgv(args: string[]): string {
@@ -658,6 +676,48 @@ export function wrapSshRemoteCommand(command: string | null): string {
return `${bootstrap}; stty -echo 2>/dev/null || true; ${command}`;
}
function safeProviderId(providerId: string): string {
return /^[A-Za-z0-9_.-]{1,64}$/u.test(providerId) ? providerId : "<provider>";
}
function classifySshLikeFailure(exitCode: number, stderrText: string): SshFailureHint["trigger"] | null {
const normalized = stderrText.toLowerCase();
if (
normalized.includes("kex_exchange_identification")
|| normalized.includes("ssh_exchange_identification")
|| normalized.includes("connection closed by remote host")
|| normalized.includes("connection reset by peer")
|| normalized.includes("connection timed out")
|| normalized.includes("operation timed out")
|| normalized.includes("timed out waiting for provider session")
|| normalized.includes("the operation was aborted")
) {
return "timeout-or-kex";
}
return exitCode === 255 ? "exit-255" : null;
}
export function sshFailureHint(providerId: string, parsed: ParsedSshArgs, exitCode: number, stderrText: string): SshFailureHint | null {
if (parsed.invocationKind !== "ssh-like") return null;
const trigger = classifySshLikeFailure(exitCode, stderrText);
if (trigger === null) return null;
const shownProviderId = safeProviderId(providerId);
return {
code: "ssh-like-command-friction",
providerId: shownProviderId,
trigger,
exitCode,
message: "ssh-like remote command failed before proving Host SSH is globally unavailable; prefer argv form for non-interactive commands.",
try: `bun scripts/cli.ts ssh ${shownProviderId} argv bash -lc '<command>'`,
triage: `bun scripts/cli.ts provider triage ${shownProviderId} --observed-scope ssh --observed-error '<ssh-like timeout or kex failure>'`,
note: "This hint intentionally does not echo the original remote command.",
};
}
export function formatSshFailureHint(hint: SshFailureHint): string {
return `UNIDESK_SSH_HINT ${JSON.stringify(hint)}\n`;
}
function brokerSource(): string {
return String.raw`
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
@@ -831,8 +891,16 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
if (rawMode) process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.pipe(child.stdin);
let stderrTail = "";
const appendStderrTail = (chunk: Buffer | string): void => {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
stderrTail = (stderrTail + text).slice(-16_384);
};
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
child.stderr.on("data", (chunk: Buffer) => {
appendStderrTail(chunk);
process.stderr.write(chunk);
});
return await new Promise<number>((resolve) => {
const restore = (): void => {
@@ -841,12 +909,19 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
};
child.on("error", (error) => {
restore();
process.stderr.write(`unidesk ssh failed to start broker: ${error.message}\n`);
const message = `unidesk ssh failed to start broker: ${error.message}\n`;
appendStderrTail(message);
process.stderr.write(message);
const hint = sshFailureHint(providerId, parsed, 255, stderrTail);
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
resolve(255);
});
child.on("close", (code) => {
restore();
resolve(code ?? 255);
const exitCode = code ?? 255;
const hint = sshFailureHint(providerId, parsed, exitCode, stderrTail);
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
resolve(exitCode);
});
});
}
@@ -0,0 +1,54 @@
import { sshHelp } from "./src/help";
import { providerTriageRecommendedCrossChecks } from "./src/provider-triage";
import { formatSshFailureHint, parseSshArgs, sshFailureHint } from "./src/ssh";
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
export function runSshArgvGuidanceContract(): JsonRecord {
const argv = parseSshArgs(["argv", "bash", "-lc", "echo ok"]);
assertCondition(argv.invocationKind === "argv", "argv subcommand must be classified as argv", argv);
assertCondition(argv.remoteCommand === "'bash' '-lc' 'echo ok'", "argv command must shell-quote each token", argv);
assertCondition(argv.requiresStdin === false, "argv command must not require stdin", argv);
assertCondition(sshFailureHint("D601", argv, 255, "kex_exchange_identification: Connection closed by remote host") === null, "argv failures must not produce ssh-like friction hint", argv);
const shortcut = parseSshArgs(["pwd"]);
assertCondition(shortcut.invocationKind === "argv", "safe command shortcuts must use argv quoting", shortcut);
assertCondition(shortcut.remoteCommand === "'pwd'", "safe command shortcut should be shell-quoted", shortcut);
const sshLike = parseSshArgs(["echo hello"]);
const hint = sshFailureHint("D601", sshLike, 255, "kex_exchange_identification: Connection closed by remote host");
assertCondition(hint !== null, "ssh-like kex failure must produce a hint", sshLike);
assertCondition(hint?.try === "bun scripts/cli.ts ssh D601 argv bash -lc '<command>'", "hint must provide canonical argv retry", hint);
assertCondition(hint?.triage.includes("provider triage D601"), "hint must provide provider triage command", hint);
const formatted = formatSshFailureHint(hint!);
assertCondition(formatted.startsWith("UNIDESK_SSH_HINT "), "formatted hint must have structured prefix", formatted);
assertCondition(!formatted.includes("echo hello"), "formatted hint must not echo the original remote command", formatted);
const timeoutHint = sshFailureHint("D601", sshLike, 255, "unidesk ssh bridge timed out waiting for provider session");
assertCondition(timeoutHint?.trigger === "timeout-or-kex", "provider session timeout must map to timeout-or-kex", timeoutHint);
const helpText = JSON.stringify(sshHelp());
assertCondition(helpText.includes("ssh D601 argv bash -lc '<command>'"), "ssh help must recommend argv bash -lc for non-interactive commands", helpText);
assertCondition(helpText.includes("UNIDESK_SSH_HINT"), "ssh help must document structured failure hint", helpText);
const crossChecks = providerTriageRecommendedCrossChecks("D601");
assertCondition(crossChecks.includes("bun scripts/cli.ts ssh D601 argv true"), "provider triage cross-checks must keep argv true", crossChecks);
return {
ok: true,
checks: [
"argv form is classified and quoted as the success path for non-interactive commands",
"ssh-like timeout/kex failures emit one structured argv retry hint",
"help text documents argv bash -lc and UNIDESK_SSH_HINT",
"provider triage recommendedCrossChecks keeps ssh D601 argv true",
],
};
}
if (import.meta.main) {
process.stdout.write(`${JSON.stringify(runSshArgvGuidanceContract(), null, 2)}\n`);
}