Compare commits

..
Author SHA1 Message Date
bincxz fe7f5029af fix(ai): use neutral MCP text for silent terminal_execute success
Codex P2 on #2724: ok:true empty serial/raw PTY results no longer fall
back to "Error: Operation failed". Prefer Catty's "Command completed
(no output)" and keep operational failures / non-zero exits unchanged.
Also prefer non-empty OpenCode tool error/output when formatting tool
results.
2026-08-05 11:10:48 +08:00
bincxz 1d0762d250 fix(ai): stop OpenCode turns dying on terminal tool failures
OpenCode MCP terminal_execute treated non-zero exits as bare
"Error: Operation failed" (dropping stdout), and the OpenCode driver
aborted the whole turn on tool status=error. Preserve command output
and surface tool errors as tool results so multi-step agent runs can
continue.

Closes #2718
2026-08-05 10:58:39 +08:00
15 changed files with 350 additions and 640 deletions

No files matched your search

+19 -74
View File
@@ -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;
-8
View File
@@ -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",
-67
View File
@@ -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'] }),
-43
View File
@@ -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: [
+2 -24
View File
@@ -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),
+4 -29
View File
@@ -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
View File
@@ -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;
}