mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7f5029af | ||
|
|
1d0762d250 |
No files matched your search
+19
-74
@@ -108,12 +108,7 @@ import {
|
||||
resumeScriptRun,
|
||||
stopScriptRun,
|
||||
} from "@/application/state/scriptAutomationCoordinator.ts";
|
||||
import {
|
||||
hasUnresolvedConnectScriptBindings,
|
||||
resolveConnectScriptsForHost,
|
||||
shouldMarkConnectAutomationConsumed,
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
} from "@/domain/hostConnectScripts.ts";
|
||||
import { resolveConnectScriptsForHost, hasUnresolvedConnectScriptBindings } from "@/domain/hostConnectScripts.ts";
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
@@ -357,16 +352,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
const pendingScriptRunIdRef = useRef<string | null>(null);
|
||||
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
||||
const pendingScriptRef = useRef(pendingScript);
|
||||
const pendingScriptIdRef = useRef(pendingScriptId);
|
||||
pendingScriptRef.current = pendingScript;
|
||||
pendingScriptIdRef.current = pendingScriptId;
|
||||
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
|
||||
if (snippet.id) {
|
||||
return pendingScriptRunIdRef.current === snippet.id;
|
||||
}
|
||||
return pendingScriptHandledRef.current === snippet;
|
||||
}, []);
|
||||
// Mosh marks status=connected during the SSH handshake so interactive
|
||||
// prompts remain reachable. Connect/pending scripts must wait until
|
||||
// mosh-client is ready (#2199). closeSession clears preload ready
|
||||
@@ -739,9 +724,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
const [connectionReuseAttemptSourceId, setConnectionReuseAttemptSourceId] = useState(
|
||||
reuseConnectionFromSessionId,
|
||||
);
|
||||
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
@@ -1060,14 +1042,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const passwordPickerEmptyText = t("terminal.passwordPicker.empty");
|
||||
const sudoHintText = t("terminal.sudoHint.pressEnter");
|
||||
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
|
||||
const reuseConnectionSourceRef = useRef(reuseConnectionFromSessionId);
|
||||
const reuseConnectionSourceAttemptedRef = useRef(false);
|
||||
const previousReuseConnectionSourcePropRef = useRef(reuseConnectionFromSessionId);
|
||||
if (previousReuseConnectionSourcePropRef.current !== reuseConnectionFromSessionId) {
|
||||
previousReuseConnectionSourcePropRef.current = reuseConnectionFromSessionId;
|
||||
reuseConnectionSourceRef.current = reuseConnectionFromSessionId;
|
||||
reuseConnectionSourceAttemptedRef.current = false;
|
||||
}
|
||||
const auth = useTerminalAuthState({
|
||||
host,
|
||||
pendingAuthRef,
|
||||
@@ -2163,29 +2137,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
knownHosts,
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
|
||||
reuseConnectionSourceAttemptedRef,
|
||||
setConnectionReuseAttemptSourceId,
|
||||
shouldUseFreshSshConnection: () => {
|
||||
const currentPendingScript = pendingScriptRef.current;
|
||||
const currentPendingScriptId = pendingScriptIdRef.current;
|
||||
const hasUnhandledPendingScript = currentPendingScript && isScriptSnippet(currentPendingScript)
|
||||
? !isPendingScriptAlreadyHandled(currentPendingScript)
|
||||
: Boolean(
|
||||
currentPendingScriptId
|
||||
&& pendingScriptRunIdRef.current !== currentPendingScriptId,
|
||||
);
|
||||
return shouldUseFreshSshConnectionForAutomation({
|
||||
host: hostRef.current,
|
||||
snippets: snippetsRef.current,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasPendingScript: hasUnhandledPendingScript,
|
||||
connectAutomationConsumed: connectScriptsConsumedRef.current,
|
||||
});
|
||||
},
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
},
|
||||
reuseConnectionFromSessionId,
|
||||
isNetworkDevice,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
@@ -2362,6 +2314,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingScriptHandledRef.current = null;
|
||||
}, [pendingScript?.id, pendingScriptId]);
|
||||
|
||||
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
|
||||
if (snippet.id) {
|
||||
return pendingScriptRunIdRef.current === snippet.id;
|
||||
}
|
||||
return pendingScriptHandledRef.current === snippet;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'connected') return;
|
||||
if (effectiveTerminalProtocol === 'mosh' && !moshShellReady) return;
|
||||
@@ -2378,22 +2337,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
|
||||
// An empty hydrated vault is a final decision for this connection. Lock it
|
||||
// before the output-settling delay so a sync arriving during that window
|
||||
// cannot inject a new connect script into an already-reused SSH session.
|
||||
if (
|
||||
!connectScriptsConsumedRef.current
|
||||
&& resolvedConnectScripts.length === 0
|
||||
&& shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone: true,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
|
||||
})
|
||||
) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
|
||||
const shouldEvaluateConnect = !connectScriptsConsumedRef.current;
|
||||
const hasPendingWork = Boolean(pendingOne);
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
@@ -2405,7 +2348,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
: resolvedConnectScripts.filter(
|
||||
: resolveConnectScriptsForHost(host, snippets).filter(
|
||||
(item) => item.id && !connectScriptsCompletedIdsRef.current.has(item.id),
|
||||
);
|
||||
|
||||
@@ -2417,17 +2360,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
scriptsToRun.push(pendingOne);
|
||||
}
|
||||
|
||||
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
|
||||
const allConnectScriptsDone = resolvedConnectScripts.length === 0
|
||||
|| resolvedConnectScripts.every(
|
||||
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
|
||||
);
|
||||
|
||||
if (scriptsToRun.length === 0) {
|
||||
if (!connectScriptsConsumedRef.current && shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
|
||||
})) {
|
||||
if (
|
||||
!connectScriptsConsumedRef.current
|
||||
&& allConnectScriptsDone
|
||||
&& isVaultInitialized()
|
||||
&& snippets.length > 0
|
||||
&& !hasUnresolvedConnectScriptBindings(host, snippets)
|
||||
) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
return;
|
||||
@@ -3235,7 +3181,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setConnectionReuseFellBack(false);
|
||||
setConnectionReuseAttemptSourceId(undefined);
|
||||
updateStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs((prev) => (
|
||||
@@ -3325,7 +3270,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSerialConnection,
|
||||
isDisconnectedDialogDismissed,
|
||||
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
|
||||
reuseConnectionFromSessionId,
|
||||
host,
|
||||
connectionReuseFellBack,
|
||||
}),
|
||||
|
||||
@@ -426,7 +426,7 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
isNetworkDevice: true,
|
||||
reuseConnectionFromSessionIdRef: { current: "source-session" },
|
||||
reuseConnectionFromSessionId: "source-session",
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
@@ -436,206 +436,6 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
|
||||
assert.equal(capturedOptions?.skipShellPidDiscovery, true);
|
||||
});
|
||||
|
||||
test("startSSH requests a fresh transport for ordinary opens with connection automation", async () => {
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured.push(options);
|
||||
return `ssh-session-${captured.length}`;
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const reuseConnectionFromSessionIdRef = { current: "source-session" as string | undefined };
|
||||
const reuseAttempts: Array<string | undefined> = [];
|
||||
let requiresFreshConnection = true;
|
||||
let committedAutomationSnapshots = 0;
|
||||
const automatedStarters = createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => requiresFreshConnection,
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
committedAutomationSnapshots += 1;
|
||||
},
|
||||
reuseConnectionFromSessionIdRef,
|
||||
setConnectionReuseAttemptSourceId: (sourceSessionId: string | undefined) => {
|
||||
reuseAttempts.push(sourceSessionId);
|
||||
},
|
||||
terminalBackend,
|
||||
}) as never);
|
||||
await automatedStarters.startSSH(createTermStub() as never);
|
||||
await automatedStarters.startSSH(createTermStub() as never);
|
||||
assert.equal(committedAutomationSnapshots, 0);
|
||||
requiresFreshConnection = false;
|
||||
await automatedStarters.startSSH(createTermStub() as never);
|
||||
assert.equal(committedAutomationSnapshots, 1);
|
||||
await createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
terminalBackend,
|
||||
}) as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(captured[0].reuseTransport, undefined);
|
||||
assert.equal(captured[0].sourceSessionId, "source-session");
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(captured[1].sourceSessionId, undefined);
|
||||
assert.equal(captured[2].reuseTransport, undefined);
|
||||
assert.equal(captured[2].sourceSessionId, undefined);
|
||||
assert.equal(captured[3].reuseTransport, undefined);
|
||||
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
|
||||
});
|
||||
|
||||
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
|
||||
let resolveStart: ((sessionId: string) => void) | undefined;
|
||||
let commitCount = 0;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: () => new Promise<string>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const startPromise = createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
commitCount += 1;
|
||||
},
|
||||
terminalBackend,
|
||||
}) as never).startSSH(createTermStub() as never);
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(commitCount, 0);
|
||||
assert.ok(resolveStart);
|
||||
resolveStart("ssh-session");
|
||||
await startPromise;
|
||||
assert.equal(commitCount, 1);
|
||||
});
|
||||
|
||||
test("startSSH rechecks the live automation policy before password fallback", async () => {
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
let requiresFreshConnection = false;
|
||||
let commitCount = 0;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured.push(options);
|
||||
if (captured.length === 1) {
|
||||
requiresFreshConnection = true;
|
||||
throw new Error("Authentication failed");
|
||||
}
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
password: "login-secret",
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
name: "Key",
|
||||
privateKey: "plain-private-key",
|
||||
publicKey: "",
|
||||
source: "embedded",
|
||||
}],
|
||||
shouldUseFreshSshConnection: () => requiresFreshConnection,
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
commitCount += 1;
|
||||
},
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(captured.length, 2);
|
||||
assert.equal(captured[0].reuseTransport, undefined);
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(commitCount, 0);
|
||||
});
|
||||
|
||||
test("startSSH keeps interactive source auth retries off unrelated pooled transports", async () => {
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured.push(options);
|
||||
if (captured.length === 1) throw new Error("Authentication failed");
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const reuseConnectionFromSessionIdRef = { current: "source-session" as string | undefined };
|
||||
const reuseConnectionSourceAttemptedRef = { current: false };
|
||||
const firstCtx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
name: "Key",
|
||||
privateKey: "plain-private-key",
|
||||
publicKey: "",
|
||||
source: "embedded",
|
||||
}],
|
||||
reuseConnectionFromSessionIdRef,
|
||||
reuseConnectionSourceAttemptedRef,
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(firstCtx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(reuseConnectionSourceAttemptedRef.current, true);
|
||||
|
||||
const retryCtx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "corrected-secret",
|
||||
},
|
||||
reuseConnectionFromSessionIdRef,
|
||||
reuseConnectionSourceAttemptedRef,
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
terminalBackend,
|
||||
});
|
||||
await createTerminalSessionStarters(retryCtx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(captured.length, 2);
|
||||
assert.equal(captured[0].sourceSessionId, "source-session");
|
||||
assert.equal(captured[0].reuseTransport, undefined);
|
||||
assert.equal(captured[1].sourceSessionId, undefined);
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(reuseConnectionSourceAttemptedRef.current, false);
|
||||
});
|
||||
|
||||
test("startSSH uses the system agent when a synced vault key cannot be decrypted", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
|
||||
@@ -529,27 +529,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
? ctx.host.identityFilePaths
|
||||
: undefined;
|
||||
|
||||
let sourceReuseAttemptedWithinStart = false;
|
||||
const startAttempt = async (attempt: {
|
||||
password?: string;
|
||||
key?: SSHKey;
|
||||
useIdentityFiles?: boolean;
|
||||
useSshAgent?: boolean;
|
||||
}): Promise<string> => {
|
||||
const sourceSessionId = ctx.reuseConnectionFromSessionIdRef?.current;
|
||||
const sourceReuseAttempted = ctx.reuseConnectionSourceAttemptedRef?.current
|
||||
?? sourceReuseAttemptedWithinStart;
|
||||
const isFallbackAfterSourceReuse = sourceReuseAttempted && !sourceSessionId;
|
||||
if (ctx.reuseConnectionFromSessionIdRef) {
|
||||
ctx.reuseConnectionFromSessionIdRef.current = undefined;
|
||||
}
|
||||
if (sourceSessionId) {
|
||||
sourceReuseAttemptedWithinStart = true;
|
||||
if (ctx.reuseConnectionSourceAttemptedRef) {
|
||||
ctx.reuseConnectionSourceAttemptedRef.current = true;
|
||||
}
|
||||
}
|
||||
ctx.setConnectionReuseAttemptSourceId?.(sourceSessionId);
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
ctx.setIsConnectionPastTcpDial?.(false);
|
||||
// Resolve keepalive per-host: a host can opt into its own values
|
||||
@@ -561,8 +546,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
globalTerminalSettings,
|
||||
);
|
||||
const connectionTimeouts = resolveHostSshConnectionTimeouts(ctx.host);
|
||||
const requiresFreshSshConnection = ctx.shouldUseFreshSshConnection?.() === true;
|
||||
const startedSessionId = await ctx.terminalBackend.startSSHSession({
|
||||
return ctx.terminalBackend.startSSHSession({
|
||||
sessionId: ctx.sessionId,
|
||||
hostLabel: ctx.host.label,
|
||||
hostname: ctx.host.hostname,
|
||||
@@ -615,23 +599,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// (issue #1204). Only honored on the very first connect attempt; the
|
||||
// bridge silently falls back to a fresh connection if the source is
|
||||
// gone, so reconnect/retry after the source closed still works.
|
||||
sourceSessionId,
|
||||
// Connect-time automation must see the complete login sequence. An
|
||||
// explicit Copy/Split keeps its source-session reuse contract, while
|
||||
// an ordinary open bypasses endpoint/idle transport reuse.
|
||||
reuseTransport: !sourceSessionId && (requiresFreshSshConnection || isFallbackAfterSourceReuse)
|
||||
? false
|
||||
: undefined,
|
||||
sourceSessionId: ctx.reuseConnectionFromSessionId,
|
||||
skipShellPidDiscovery: ctx.isNetworkDevice === true,
|
||||
});
|
||||
if (!requiresFreshSshConnection) {
|
||||
ctx.onConnectAutomationSnapshotCommitted?.();
|
||||
}
|
||||
sourceReuseAttemptedWithinStart = false;
|
||||
if (ctx.reuseConnectionSourceAttemptedRef) {
|
||||
ctx.reuseConnectionSourceAttemptedRef.current = false;
|
||||
}
|
||||
return startedSessionId;
|
||||
};
|
||||
|
||||
let id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FitAddon } from "@xterm/addon-fit";
|
||||
import type { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import type { Host, Identity, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
|
||||
import type { PromptLineBreakState } from "./promptLineBreak";
|
||||
import type {
|
||||
@@ -147,21 +147,9 @@ export type TerminalSessionStartersContext = {
|
||||
knownHosts?: KnownHost[];
|
||||
resolvedChainHosts: Host[];
|
||||
sessionId: string;
|
||||
// One-shot source session intent for Copy/Split. Consumed by the first SSH
|
||||
// attempt so later reconnects do not skip the initial login sequence.
|
||||
reuseConnectionFromSessionIdRef?: MutableRefObject<string | undefined>;
|
||||
// Persists across renderer auth retries after the one-shot source intent is
|
||||
// consumed. Cleared only after a backend session starts successfully.
|
||||
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
|
||||
// Mirrors the source actually consumed by the current SSH attempt so the UI
|
||||
// only hides its connecting dialog while Copy/Split reuse is being tried.
|
||||
setConnectionReuseAttemptSourceId?: (sourceSessionId: string | undefined) => void;
|
||||
// Connect automation and still-unhandled pending scripts need the initial
|
||||
// login output. Evaluate per attempt because pending work is one-shot.
|
||||
shouldUseFreshSshConnection?: () => boolean;
|
||||
// Commit the no-automation snapshot only after the corresponding backend
|
||||
// session actually starts, so failed auth attempts do not consume scripts.
|
||||
onConnectAutomationSnapshotCommitted?: () => void;
|
||||
// Source session id to reuse an authenticated SSH connection from when this
|
||||
// terminal was created from an existing SSH session.
|
||||
reuseConnectionFromSessionId?: string;
|
||||
isNetworkDevice?: boolean;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
|
||||
@@ -421,14 +421,6 @@ test("connection dialog keeps existing local and disconnected behavior", () => {
|
||||
});
|
||||
|
||||
test("connection reuse hides connecting dialog only while reuse is still possible", () => {
|
||||
assert.equal(
|
||||
shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId: undefined,
|
||||
host: host(),
|
||||
connectionReuseFellBack: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId: "source-session",
|
||||
|
||||
@@ -5,13 +5,10 @@ import {
|
||||
appendHostConnectScript,
|
||||
getGlobalConnectScripts,
|
||||
getHostConnectScriptIds,
|
||||
hasHostConnectAutomation,
|
||||
migrateHostConnectScriptIds,
|
||||
reorderHostConnectScript,
|
||||
removeHostConnectScript,
|
||||
resolveConnectScriptsForHost,
|
||||
shouldMarkConnectAutomationConsumed,
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
syncHostsForSnippetTargetChange,
|
||||
} from './hostConnectScripts.ts';
|
||||
|
||||
@@ -57,70 +54,6 @@ test('resolveConnectScriptsForHost runs globals before host queue and dedupes',
|
||||
assert.deepEqual(resolved.map((item) => item.id), ['global', 'both', 'host-only']);
|
||||
});
|
||||
|
||||
test('hasHostConnectAutomation covers host, global, and unresolved connect scripts', () => {
|
||||
assert.equal(
|
||||
hasHostConnectAutomation(
|
||||
{ ...host, connectScriptIds: ['host-script'] },
|
||||
[script({ id: 'host-script', targets: ['host-a'] })],
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasHostConnectAutomation(host, [script({ id: 'global', targetsAllHosts: true })]),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasHostConnectAutomation({ ...host, loginScriptId: 'not-loaded-yet' }, []),
|
||||
true,
|
||||
);
|
||||
assert.equal(hasHostConnectAutomation(host, []), false);
|
||||
});
|
||||
|
||||
test('fresh SSH automation policy is conservative before vault hydration and for pending scripts', () => {
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [],
|
||||
vaultInitialized: false,
|
||||
}), true);
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [],
|
||||
vaultInitialized: true,
|
||||
hasPendingScript: true,
|
||||
}), true);
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [],
|
||||
vaultInitialized: true,
|
||||
}), false);
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [script({ id: 'global', targetsAllHosts: true })],
|
||||
vaultInitialized: true,
|
||||
connectAutomationConsumed: true,
|
||||
}), false);
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [script({ id: 'global', targetsAllHosts: true })],
|
||||
vaultInitialized: true,
|
||||
hasPendingScript: true,
|
||||
connectAutomationConsumed: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('empty hydrated vault finalizes the current connection automation decision', () => {
|
||||
assert.equal(shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone: true,
|
||||
vaultInitialized: true,
|
||||
hasUnresolvedBindings: false,
|
||||
}), true);
|
||||
assert.equal(shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone: true,
|
||||
vaultInitialized: false,
|
||||
hasUnresolvedBindings: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('append updates host connectScriptIds order', () => {
|
||||
const snippets = [
|
||||
script({ id: 'a', targets: ['host-a'] }),
|
||||
|
||||
@@ -118,49 +118,6 @@ export function resolveConnectScriptsForHost(host: Host, snippets: Snippet[]): S
|
||||
return [...globals, ...hostScripts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether connecting this host can run automation that depends on the initial
|
||||
* login output. Missing referenced scripts still count: vault hydration or a
|
||||
* later sync may restore them after the terminal has already started.
|
||||
*/
|
||||
export function hasHostConnectAutomation(host: Host, snippets: Snippet[]): boolean {
|
||||
return hasUnresolvedConnectScriptBindings(host, snippets)
|
||||
|| resolveConnectScriptsForHost(host, snippets).length > 0;
|
||||
}
|
||||
|
||||
export function shouldUseFreshSshConnectionForAutomation(options: {
|
||||
host: Host;
|
||||
snippets: Snippet[];
|
||||
vaultInitialized: boolean;
|
||||
hasPendingScript?: boolean;
|
||||
connectAutomationConsumed?: boolean;
|
||||
}): boolean {
|
||||
return options.hasPendingScript === true
|
||||
|| (
|
||||
options.connectAutomationConsumed !== true
|
||||
&& (
|
||||
!options.vaultInitialized
|
||||
|| hasHostConnectAutomation(options.host, options.snippets)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current connection's automation decision as final once the vault
|
||||
* has hydrated, even when it hydrated to an empty script list. This prevents a
|
||||
* later sync from starting a newly arrived global script against a connection
|
||||
* whose initial login output may already have been skipped.
|
||||
*/
|
||||
export function shouldMarkConnectAutomationConsumed(options: {
|
||||
allConnectScriptsDone: boolean;
|
||||
vaultInitialized: boolean;
|
||||
hasUnresolvedBindings: boolean;
|
||||
}): boolean {
|
||||
return options.allConnectScriptsDone
|
||||
&& options.vaultInitialized
|
||||
&& !options.hasUnresolvedBindings;
|
||||
}
|
||||
|
||||
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
|
||||
const snippet = scriptById(snippets, scriptId);
|
||||
if (!snippet) return host;
|
||||
|
||||
@@ -287,8 +287,26 @@ function translateOpenCodeEvent(event, emitter, state = {}) {
|
||||
emitter.toolResult(callId, part.state.output || "", toolName);
|
||||
}
|
||||
} else if (part.state?.status === "error") {
|
||||
emitter.emitError(part.state.error || "OpenCode tool failed");
|
||||
return { idle: false, error: true, content: false };
|
||||
// Tool-level failures must not abort the whole OpenCode turn. Other
|
||||
// drivers (Cursor / Codex / Grok) surface tool errors as tool results
|
||||
// so the model can adapt and continue multi-step work (issue #2718).
|
||||
state.toolCalls = state.toolCalls || new Set();
|
||||
if (!state.toolCalls.has(callId)) {
|
||||
state.toolCalls.add(callId);
|
||||
emitter.toolCall(toolName, input, callId);
|
||||
}
|
||||
state.toolResults = state.toolResults || new Set();
|
||||
if (!state.toolResults.has(callId)) {
|
||||
state.toolResults.add(callId);
|
||||
// Prefer non-empty error, then output, then a stable default (blank
|
||||
// string error must not hide a useful output payload).
|
||||
const rawError = part.state.error || part.state.output || "OpenCode tool failed";
|
||||
const errorText = typeof rawError === "string"
|
||||
? rawError
|
||||
: (extractOpenCodeErrorMessage(rawError) || "OpenCode tool failed");
|
||||
emitter.toolResult(callId, errorText, toolName);
|
||||
}
|
||||
return { idle: false, error: false, content: true };
|
||||
}
|
||||
}
|
||||
return { idle: false, error: false, content: part.type === "tool" };
|
||||
|
||||
@@ -403,6 +403,101 @@ test("translateOpenCodeEvent emits a tool call before a result when only complet
|
||||
]);
|
||||
});
|
||||
|
||||
test("translateOpenCodeEvent maps tool status error to toolResult without failing the turn (#2718)", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = {};
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
type: "tool",
|
||||
callID: "tool-err",
|
||||
tool: "netcatty-remote-hosts_terminal_execute",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: "du /missing" },
|
||||
error: "Error: Operation failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
emitter,
|
||||
state,
|
||||
);
|
||||
|
||||
assert.equal(result.error, false);
|
||||
assert.equal(result.content, true);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
k: "toolCall",
|
||||
name: "netcatty-remote-hosts_terminal_execute",
|
||||
args: { command: "du /missing" },
|
||||
id: "tool-err",
|
||||
},
|
||||
{
|
||||
k: "toolResult",
|
||||
id: "tool-err",
|
||||
out: "Error: Operation failed",
|
||||
name: "netcatty-remote-hosts_terminal_execute",
|
||||
},
|
||||
]);
|
||||
assert.equal(events.some((event) => event.k === "error"), false);
|
||||
});
|
||||
|
||||
test("runOpenCodeTurn continues after a tool error part and reaches session.idle (#2718)", async () => {
|
||||
const { events, emitter } = collector();
|
||||
const abortController = new AbortController();
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
type: "tool",
|
||||
sessionID: "sess-1",
|
||||
callID: "t1",
|
||||
tool: "terminal_execute",
|
||||
state: { status: "error", input: { command: "false" }, error: "Error: Operation failed" },
|
||||
},
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
type: "text",
|
||||
sessionID: "sess-1",
|
||||
id: "p1",
|
||||
text: "retrying",
|
||||
},
|
||||
delta: "retrying",
|
||||
},
|
||||
};
|
||||
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
|
||||
},
|
||||
};
|
||||
const client = {
|
||||
global: { event: async () => ({ stream }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "sess-1" } }),
|
||||
promptAsync: async () => ({ data: true }),
|
||||
},
|
||||
};
|
||||
|
||||
await runOpenCodeTurn({
|
||||
prompt: "run false then continue",
|
||||
emitter,
|
||||
abortController,
|
||||
openCodeFactory: async () => ({ client, server: { close() {} } }),
|
||||
});
|
||||
|
||||
assert.ok(events.some((event) => event.k === "toolResult" && event.out === "Error: Operation failed"));
|
||||
assert.ok(events.some((event) => event.k === "text" && event.t === "retrying"));
|
||||
assert.ok(events.some((event) => event.k === "done"));
|
||||
assert.equal(events.some((event) => event.k === "error"), false);
|
||||
});
|
||||
|
||||
test("mapOpenCodeModels flattens providers", () => {
|
||||
assert.deepEqual(mapOpenCodeModels({
|
||||
providers: [
|
||||
|
||||
@@ -1108,7 +1108,7 @@ function isStrictAgentAuthFailure(options, err) {
|
||||
}
|
||||
|
||||
function canReuseExistingSession(options) {
|
||||
if (options.reuseTransport === false || !options.sourceSessionId || options.x11Forwarding) return false;
|
||||
if (!options.sourceSessionId || options.x11Forwarding) return false;
|
||||
return Boolean(findReusableSession(sessions, options.sourceSessionId, {
|
||||
hostname: options.hostname,
|
||||
port: options.port || 22,
|
||||
@@ -1300,38 +1300,16 @@ async function startSSHSessionWithRetries(event, options, pendingDialState) {
|
||||
|
||||
async function startSSHSessionWrapper(event, options) {
|
||||
const pendingDialState = { coordination: null };
|
||||
let sourcePinHolder = null;
|
||||
let sourceReuseState = options.sourceSessionId && options.reuseTransport !== false
|
||||
? { attempted: false, session: null }
|
||||
: null;
|
||||
if (options.sourceSessionId && options.reuseTransport !== false) {
|
||||
const sourceAtRequest = findReusableSession(sessions, options.sourceSessionId);
|
||||
if (sourceAtRequest?.connRef) {
|
||||
sourcePinHolder = {};
|
||||
acquireConnectionRef(sourcePinHolder, sourceAtRequest.connRef);
|
||||
sourceReuseState.session = {
|
||||
conn: sourceAtRequest.conn,
|
||||
connRef: sourceAtRequest.connRef,
|
||||
stream: sourceAtRequest.stream,
|
||||
_reuseEndpoint: sourceAtRequest._reuseEndpoint,
|
||||
};
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Main-process LAN probe so macOS Local Network TCC attributes to Netcatty
|
||||
// before the (possibly worker-hosted) SSH dial. See #2663 / TN3179.
|
||||
await ensureMacLocalNetworkAccess(options);
|
||||
return await startSSHSessionWithRetries(event, {
|
||||
...options,
|
||||
...(sourceReuseState ? { _sourceReuseState: sourceReuseState } : {}),
|
||||
}, pendingDialState);
|
||||
return await startSSHSessionWithRetries(event, options, pendingDialState);
|
||||
} catch (err) {
|
||||
if (pendingDialState.coordination) {
|
||||
failTransportDial(pendingDialState.coordination, err);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (sourcePinHolder) releaseConnectionRef(sourcePinHolder);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,91 +104,6 @@ test("simultaneous normal opens of the same host make one physical SSH dial", as
|
||||
assert.equal(sessions.get("normal-1").connRef.count, 2);
|
||||
});
|
||||
|
||||
test("reuseTransport false makes simultaneous same-host opens dial independently", async (t) => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const options = {
|
||||
hostname: "10.0.0.51",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
reuseTransport: false,
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
start({ sender: makeSender() }, { ...options, sessionId: "fresh-1" }),
|
||||
start({ sender: makeSender() }, { ...options, sessionId: "fresh-2" }),
|
||||
]);
|
||||
|
||||
assert.equal(getClientConstructCount(), 2);
|
||||
assert.notEqual(sessions.get("fresh-1").conn, sessions.get("fresh-2").conn);
|
||||
});
|
||||
|
||||
test("a sequential ordinary open with reuse disabled bypasses the live same-host transport", async (t) => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const options = {
|
||||
hostname: "10.0.0.52",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
||||
await start({ sender: makeSender() }, {
|
||||
...options,
|
||||
sessionId: "second",
|
||||
reuseTransport: false,
|
||||
});
|
||||
|
||||
assert.equal(getClientConstructCount(), 2);
|
||||
assert.notEqual(sessions.get("first").conn, sessions.get("second").conn);
|
||||
});
|
||||
|
||||
test("an ordinary open with reuse disabled bypasses an idle same-host transport", async (t) => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const options = {
|
||||
hostname: "10.0.0.53",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
||||
const first = sessions.get("first");
|
||||
const firstTransport = first.connRef;
|
||||
first.stream.emit("close");
|
||||
assert.equal(firstTransport.state, "idle");
|
||||
|
||||
await start({ sender: makeSender() }, {
|
||||
...options,
|
||||
sessionId: "second",
|
||||
reuseTransport: false,
|
||||
});
|
||||
|
||||
assert.equal(getClientConstructCount(), 2);
|
||||
assert.notEqual(firstTransport.conn, sessions.get("second").conn);
|
||||
});
|
||||
|
||||
function makeSender() {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -397,34 +312,6 @@ test("Copy Tab reuses the source connection instead of dialing fresh", async (t)
|
||||
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0, "successful reuse should not emit fallback");
|
||||
});
|
||||
|
||||
test("reuseTransport false bypasses a matching live source and connects fresh", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "fresh",
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
sourceSessionId: "source",
|
||||
reuseTransport: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
assert.equal(sourceConn.openedShells.length, 0);
|
||||
assert.notEqual(sessions.get("fresh").conn, sourceConn);
|
||||
});
|
||||
|
||||
test("Copy Tab records a distinct remote shell for each shared terminal", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
@@ -556,13 +443,8 @@ test("concurrent Copy Tab requests serialize shell discovery per connection", as
|
||||
]);
|
||||
|
||||
assert.equal(sessions.get("source").shellPid, "111");
|
||||
// The macOS network preflight runs before either request joins the shared
|
||||
// shell-open queue, so under load either copy may enqueue first. The contract
|
||||
// is that both remote shells are identified once and never collide.
|
||||
assert.deepEqual(
|
||||
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
|
||||
new Set(["222", "333"]),
|
||||
);
|
||||
assert.equal(sessions.get("copy-1").shellPid, "222");
|
||||
assert.equal(sessions.get("copy-2").shellPid, "333");
|
||||
});
|
||||
|
||||
test("concurrent copies keep distinct shell IDs when the source closes immediately", async (t) => {
|
||||
@@ -595,10 +477,8 @@ test("concurrent copies keep distinct shell IDs when the source closes immediate
|
||||
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
|
||||
await copies;
|
||||
|
||||
assert.deepEqual(
|
||||
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
|
||||
new Set(["222", "333"]),
|
||||
);
|
||||
assert.equal(sessions.get("copy-1").shellPid, "222");
|
||||
assert.equal(sessions.get("copy-2").shellPid, "333");
|
||||
});
|
||||
|
||||
test("Copy Tab preserves the server locale unless the host explicitly overrides it", async (t) => {
|
||||
@@ -711,20 +591,19 @@ test("source closed while reused shell is pending keeps the connection alive", a
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
|
||||
// Begin the copy; conn.shell() is deferred. The request pin protects the
|
||||
// explicit source across preflight, and the shell-open pin protects the
|
||||
// pending channel (count -> 3) before the real session is attached.
|
||||
// Begin the copy; conn.shell() is deferred, so the reuse path has acquired the
|
||||
// ref (count -> 2) but not yet attached the session.
|
||||
const startPromise = start(
|
||||
{ sender: makeSender() },
|
||||
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
|
||||
);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(connRef.count, 3, "request and shell-open pins protect the source connection");
|
||||
assert.equal(connRef.count, 2, "reuse pins the connection before opening the shell");
|
||||
|
||||
// Close the source tab while the copy's shell is still opening.
|
||||
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
|
||||
assert.equal(conn.ended, 0, "connection must survive for the pending copy");
|
||||
assert.equal(connRef.count, 2);
|
||||
assert.equal(connRef.count, 1);
|
||||
|
||||
// Now let the copy's shell open.
|
||||
conn.flushShell();
|
||||
@@ -799,8 +678,6 @@ test("synchronous shell failure releases the ref and falls back to fresh", async
|
||||
test("falls back to a fresh connection when the source is gone", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const otherConn = makeReusableConn();
|
||||
sessions.set("other", makeSourceSession(otherConn, { hostname: "10.0.0.1", username: "alice" }));
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
|
||||
// sourceSessionId points at a session that doesn't exist -> fresh connect.
|
||||
@@ -818,7 +695,6 @@ test("falls back to a fresh connection when the source is gone", async (t) => {
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.equal(otherConn.openedShells.length, 0, "must not substitute another matching transport");
|
||||
assert.equal(getClientConstructCount(), 1, "should attempt one fresh connection");
|
||||
assert.deepEqual(
|
||||
getConnectionReuseFallbackEvents(sender).map((m) => m.payload),
|
||||
|
||||
@@ -871,29 +871,9 @@ printf '%s\n' '${scanCompleteMarker}'`;
|
||||
// X11. For X11 hosts we deliberately skip reuse and make a fresh
|
||||
// connection so the duplicate keeps working X11 forwarding.
|
||||
const reuseEndpoint = buildConnectionReuseEndpoint(options);
|
||||
const allowTransportReuse = options.reuseTransport !== false;
|
||||
// Copy/Split reuse is source-specific. If that source disappears or its
|
||||
// channel cannot open, fall through to a fresh login instead of silently
|
||||
// borrowing another live, idle, or in-flight transport for the endpoint.
|
||||
const allowGeneralTransportReuse = allowTransportReuse && !options.sourceSessionId;
|
||||
const sourceReuseState = options._sourceReuseState;
|
||||
const canAttemptSourceReuse = Boolean(
|
||||
allowTransportReuse
|
||||
&& options.sourceSessionId
|
||||
&& !options.x11Forwarding
|
||||
&& (!sourceReuseState || sourceReuseState.attempted !== true),
|
||||
);
|
||||
|
||||
if (canAttemptSourceReuse) {
|
||||
if (sourceReuseState) sourceReuseState.attempted = true;
|
||||
const sourceSession = findReusableSession(sessions, options.sourceSessionId, reuseEndpoint)
|
||||
|| (sourceReuseState?.session
|
||||
? findReusableSession(
|
||||
new Map([[options.sourceSessionId, sourceReuseState.session]]),
|
||||
options.sourceSessionId,
|
||||
reuseEndpoint,
|
||||
)
|
||||
: null);
|
||||
if (options.sourceSessionId && !options.x11Forwarding) {
|
||||
const sourceSession = findReusableSession(sessions, options.sourceSessionId, reuseEndpoint);
|
||||
if (sourceSession) {
|
||||
try {
|
||||
return await reuseShellSession(event, options, sourceSession, sessionId, log);
|
||||
@@ -917,7 +897,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
|
||||
|
||||
// Idle-park / endpoint reuse: after the last tab returns its lease the
|
||||
// transport may still be warm. Open a new shell channel without re-auth.
|
||||
if (allowGeneralTransportReuse && !options.x11Forwarding && typeof findTransportByEndpoint === "function") {
|
||||
if (!options.x11Forwarding && typeof findTransportByEndpoint === "function") {
|
||||
// Shell park reuse requires exact agentForwarding match so disabling
|
||||
// ForwardAgent cannot reattach to a warm conn that still exposes the agent.
|
||||
const parked = findTransportByEndpoint(reuseEndpoint, { kind: "shell" });
|
||||
@@ -958,12 +938,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
|
||||
// for the same compatible endpoint can wait for this leader and then
|
||||
// open its own channel on the authenticated transport.
|
||||
let pendingDialCoordination = options._pendingDialState?.coordination || null;
|
||||
if (
|
||||
allowGeneralTransportReuse
|
||||
&& !pendingDialCoordination
|
||||
&& !options.x11Forwarding
|
||||
&& typeof beginTransportDial === "function"
|
||||
) {
|
||||
if (!pendingDialCoordination && !options.x11Forwarding && typeof beginTransportDial === "function") {
|
||||
const coordination = beginTransportDial(reuseEndpoint, { kind: "shell" });
|
||||
if (coordination.role === "reuse" || coordination.role === "join") {
|
||||
try {
|
||||
|
||||
@@ -46,19 +46,71 @@ function buildZodSchema(inputShape) {
|
||||
}
|
||||
|
||||
function formatRpcError(result) {
|
||||
return `Error: ${result?.error || "Operation failed"}`;
|
||||
if (result?.error) return `Error: ${result.error}`;
|
||||
if (result?.code) return `Error: Operation failed (${result.code})`;
|
||||
return "Error: Operation failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the RPC result carries command-run evidence (output and/or exit
|
||||
* code). Non-zero exits from PTY exec set ok:false without an error string —
|
||||
* that is still a completed command, not an operational failure.
|
||||
*/
|
||||
function hasTerminalExecuteEvidence(result) {
|
||||
if (!result || typeof result !== "object") return false;
|
||||
if (typeof result.stdout === "string" && result.stdout.length > 0) return true;
|
||||
if (typeof result.stderr === "string" && result.stderr.length > 0) return true;
|
||||
return result.exitCode != null;
|
||||
}
|
||||
|
||||
function formatTerminalExecuteResult(result) {
|
||||
const parts = [];
|
||||
if (result.stdout) parts.push(result.stdout);
|
||||
if (result.stderr) parts.push(`[stderr] ${result.stderr}`);
|
||||
if (result.exitCode != null) {
|
||||
if (result?.stdout) parts.push(result.stdout);
|
||||
if (result?.stderr) parts.push(`[stderr] ${result.stderr}`);
|
||||
if (result?.exitCode != null) {
|
||||
parts.push(`[exit code: ${result.exitCode}]`);
|
||||
}
|
||||
if (result?.error) parts.push(`[error] ${result.error}`);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Align with Catty executor wording when a command ran but produced no text.
|
||||
const TERMINAL_EXECUTE_NO_OUTPUT_TEXT = "Command completed (no output)";
|
||||
|
||||
/**
|
||||
* Format terminal_execute for MCP clients.
|
||||
* Match Catty semantics: when the command actually ran, always surface
|
||||
* stdout/stderr/exitCode so the model can judge the failure. Only pure
|
||||
* operational failures (session missing, blocked, etc.) become a bare
|
||||
* "Error: …" payload. See issue #2718.
|
||||
*
|
||||
* Successful empty results (serial/network raw PTY often returns ok:true with
|
||||
* empty stdout/stderr and exitCode:null) must NOT fall back to
|
||||
* "Error: Operation failed" — Codex P2 on PR #2724.
|
||||
*/
|
||||
function formatTerminalExecuteMcpResponse(result) {
|
||||
if (!result || typeof result !== "object") {
|
||||
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
|
||||
}
|
||||
|
||||
// Operational failure with no command evidence (session not found, busy, blocked…).
|
||||
if (result.ok === false && !hasTerminalExecuteEvidence(result)) {
|
||||
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
|
||||
}
|
||||
|
||||
// Empty successful payloads (serial/raw PTY): neutral text.
|
||||
// Never fall back to formatRpcError here — that mislabels silent successes (#2724 P2).
|
||||
// When result.error is set, formatTerminalExecuteResult already includes `[error] …`.
|
||||
const text = formatTerminalExecuteResult(result) || TERMINAL_EXECUTE_NO_OUTPUT_TEXT;
|
||||
// Mark isError only for operational/runtime failures that include an error
|
||||
// string (timeout, cancel, stream lost). Non-zero exit alone is success of
|
||||
// the tool call with a failed command — same as Catty ok:true + exitCode.
|
||||
if (result.ok === false && result.error) {
|
||||
return { content: [{ type: "text", text }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function createToolHandler(toolDef, deps) {
|
||||
const {
|
||||
rpcCall,
|
||||
@@ -76,10 +128,10 @@ function createToolHandler(toolDef, deps) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall(rpcMethod, { ...scopeParams, sessionId, command });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
|
||||
}
|
||||
if (TERMINAL_START_TOOLS.has(mcpTool)) {
|
||||
if (!result?.ok) {
|
||||
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
@@ -94,7 +146,7 @@ function createToolHandler(toolDef, deps) {
|
||||
}],
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text: formatTerminalExecuteResult(result) }] };
|
||||
return formatTerminalExecuteMcpResponse(result);
|
||||
}
|
||||
|
||||
if (TERMINAL_POLL_TOOLS.has(mcpTool) || TERMINAL_STOP_TOOLS.has(mcpTool)) {
|
||||
@@ -188,6 +240,10 @@ function registerMcpTools(server, deps) {
|
||||
module.exports = {
|
||||
buildZodSchema,
|
||||
buildZodShapeObject,
|
||||
formatRpcError,
|
||||
formatTerminalExecuteResult,
|
||||
formatTerminalExecuteMcpResponse,
|
||||
hasTerminalExecuteEvidence,
|
||||
registerMcpTools,
|
||||
SFTP_WRITE_TOOLS,
|
||||
};
|
||||
@@ -235,3 +235,129 @@ test("session_close remains available as a cleanup action in observer mode", asy
|
||||
assert.equal(result.isError, undefined);
|
||||
assert.equal(guardCalls, 0);
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response preserves stdout/exitCode on non-zero exit (#2718)", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({
|
||||
ok: false,
|
||||
stdout: "du: cannot access '/missing': No such file or directory",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
}),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
assert.ok(handler, "terminal_execute handler registered");
|
||||
const result = await handler({ sessionId: "sess-1", command: "du /missing" });
|
||||
assert.equal(result.isError, undefined);
|
||||
const text = result.content?.[0]?.text || "";
|
||||
assert.match(text, /cannot access '\/missing'/);
|
||||
assert.match(text, /\[exit code: 1\]/);
|
||||
assert.doesNotMatch(text, /Operation failed/);
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response keeps operational failures as isError", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({ ok: false, error: "Session not found" }),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
const result = await handler({ sessionId: "gone", command: "uptime" });
|
||||
assert.equal(result.isError, true);
|
||||
assert.equal(result.content?.[0]?.text, "Error: Session not found");
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response includes partial output on timeout", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({
|
||||
ok: false,
|
||||
stdout: "partial lines",
|
||||
stderr: "",
|
||||
exitCode: -1,
|
||||
error: "Command timed out (60s)",
|
||||
}),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
const result = await handler({ sessionId: "sess-1", command: "sleep 999" });
|
||||
assert.equal(result.isError, true);
|
||||
const text = result.content?.[0]?.text || "";
|
||||
assert.match(text, /partial lines/);
|
||||
assert.match(text, /\[exit code: -1\]/);
|
||||
assert.match(text, /\[error\] Command timed out \(60s\)/);
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response uses neutral text for successful empty output (#2724)", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
// Serial/network-device raw PTY success: ok true, empty streams, exitCode null.
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
}),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
const result = await handler({ sessionId: "sess-1", command: "configure terminal" });
|
||||
assert.equal(result.isError, undefined);
|
||||
assert.equal(result.content?.[0]?.text, "Command completed (no output)");
|
||||
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response keeps exit-only non-zero without isError", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
}),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
const result = await handler({ sessionId: "sess-1", command: "false" });
|
||||
assert.equal(result.isError, undefined);
|
||||
assert.equal(result.content?.[0]?.text, "[exit code: 1]");
|
||||
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
|
||||
});
|
||||
Vendored
+5
-4
@@ -165,10 +165,11 @@ declare global {
|
||||
// Skip POSIX process discovery when copying a network-device session.
|
||||
skipShellPidDiscovery?: boolean;
|
||||
/**
|
||||
* When false, terminal/SFTP opens must dial a fresh SSH connection and
|
||||
* must not borrow a live, parked, or in-flight registry transport. Used by
|
||||
* connect-time terminal automation and dedicated bulk transfers.
|
||||
* Default true (normal terminal, browse, and MFA-skip reuse).
|
||||
* When false, openSftp must dial a fresh SSH connection and must not
|
||||
* borrow a parked/shared registry transport. Used for dedicated bulk
|
||||
* transfer / restart-resume so transfers do not attach to a terminal
|
||||
* conn that may die or leave checkpoint resume half-bound.
|
||||
* Default true (browse / MFA-skip reuse of parked transports).
|
||||
*/
|
||||
reuseTransport?: boolean;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user