Compare commits

..
Author SHA1 Message Date
bincxz 6a138ba0b1 fix(ai): preserve terminal replies between OSC contexts 2026-10-15 14:51:04 +08:00
23 changed files with 58 additions and 569 deletions

No files matched your search

-3
View File
@@ -1149,9 +1149,6 @@ Highlight the focused split pane:
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveSelected': 'Move to group ({count})',
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
-3
View File
@@ -1107,9 +1107,6 @@ Resalta el panel dividido enfocado:
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
'vault.hosts.connectSelected': 'Conectar ({count})',
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
'vault.hosts.empty.title': 'Configura tus hosts',
-3
View File
@@ -128,9 +128,6 @@ export const ruVaultMessages: Messages = {
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
'vault.hosts.connectSelected': 'Подключить ({count})',
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
'vault.hosts.empty.title': 'Настройте свои хосты',
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
-3
View File
@@ -766,9 +766,6 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.connectSelected': '连接 ({count})',
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
'vault.hosts.moveSelected': '移动到分组 ({count})',
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
-3
View File
@@ -768,9 +768,6 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
'vault.hosts.connectSelected': '連線 ({count})',
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
'vault.hosts.moveSelected': '移動到群組 ({count})',
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
'vault.hosts.empty.title': '設定你的主機',
+1 -14
View File
@@ -4,7 +4,7 @@ import test from "node:test";
import type { Host } from "../../domain/models";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
@@ -171,16 +171,3 @@ test("host session factories snapshot plugin connection configuration", () => {
assert.deepEqual(workspace.pluginConnection, pluginConnection);
assert.notEqual(workspace.pluginConnection, pluginConnection);
});
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
const source = createHostTerminalSession("original-bash", original);
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
assert.equal(copied.localShell, "/bin/bash");
assert.equal(copied.localShellName, "");
assert.equal(copied.localShellIcon, "");
const named = createHostTerminalSession("named-zsh", edited);
assert.equal(named.localShellName, "Zsh");
assert.equal(named.localShellIcon, "zsh");
});
+2 -2
View File
@@ -153,8 +153,8 @@ export const createHostTerminalSession = (
// Preserve field presence: an edited host must not contribute its args
// to a session that was launched with an explicit shell and no args.
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
localShellName: host.localShellName ?? "",
localShellIcon: host.localShellIcon ?? "",
localShellName: host.localShellName,
localShellIcon: host.localShellIcon,
// Effective directory from resolveHostDefaultLocalShell, including a
// Settings directory the saved host inherits. Copy Tab overlays this
// snapshot, so a later settings or vault edit cannot move the relaunch.
-2
View File
@@ -1358,7 +1358,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
handleUnmanageGroup,
managedGroupPaths,
moveHostToGroup,
moveHostsToGroup,
setDragOverDropTarget,
setGroupDragOverDropTarget,
} = useVaultGroupDragHandlers({
@@ -1603,7 +1602,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
managedSources,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
Network,
newFolderName,
newHostGroupPath,
+1 -1
View File
@@ -86,7 +86,7 @@ const SessionTabIcon: React.FC<{
// Local protocol → shell-specific icon if available, else OS-specific icon
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
// Use shell icon from discovery when available
const iconId = shellIcon ?? host?.localShellIcon;
const iconId = shellIcon || host?.localShellIcon;
if (iconId) {
return (
<img
-54
View File
@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Combobox } from "../ui/combobox";
import React, { useCallback } from "react";
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
import { VaultGroupDeletionConfirmationChangedError } from "../../application/state/useVaultGroupDeletion";
@@ -244,7 +243,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
managedSources,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
Network,
newFolderName,
newHostGroupPath,
@@ -447,8 +445,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
resizeAriaLabel: t("vault.panel.resizeWidth"),
};
const [isMoveHostsOpen, setIsMoveHostsOpen] = React.useState(false);
const [moveTargetGroup, setMoveTargetGroup] = React.useState("");
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
const keyListRef = React.useRef(keys);
keyListRef.current = keys;
@@ -1134,19 +1130,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
count: selectedHostIds.size,
})}
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedHostIds.size === 0}
onClick={() => {
setMoveTargetGroup("");
setIsMoveHostsOpen(true);
}}
>
<FolderTree size={12} className="mr-1" />
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
</Button>
<Button
variant="destructive"
size="sm"
@@ -1811,43 +1794,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</DialogContent>
</Dialog>
<Dialog open={isMoveHostsOpen} onOpenChange={setIsMoveHostsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}</DialogTitle>
<DialogDescription>{t("vault.hosts.moveSelected.description")}</DialogDescription>
</DialogHeader>
<Combobox
options={[
{ value: "", label: t("vault.hosts.allHosts") },
...allGroupPaths
.filter((path: string) => path !== "General"
|| customGroups.includes("General")
|| allGroupPaths.some((group: string) => group.startsWith("General/")))
.map((path: string) => ({ value: path, label: path })),
]}
value={moveTargetGroup}
onValueChange={setMoveTargetGroup}
ariaLabel={t("hostDetails.group.placeholder")}
placeholder={t("vault.hosts.allHosts")}
clearable={false}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsMoveHostsOpen(false)}>{t("common.cancel")}</Button>
<Button
disabled={selectedHostIds.size === 0}
onClick={() => {
moveHostsToGroup(selectedHostIds, moveTargetGroup || null);
setIsMoveHostsOpen(false);
clearHostSelection();
}}
>
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ImportVaultDialog
open={isImportOpen}
onOpenChange={setIsImportOpen}
@@ -1,85 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host, ManagedSource } from '../../types';
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
const host = (id: string, extra: Partial<Host> = {}): Host => ({
id, label: `Host ${id}`, hostname: `${id}.example`, username: 'user', port: 22,
tags: ['keep'], group: 'Old', ...extra,
});
test('bulk group move publishes all selected hosts together and preserves unselected hosts', async () => {
const original = [host('a'), host('b', { protocol: 'telnet' }), host('c')];
const writes: Host[][] = [];
await withActions(original, [], actions => {
act(() => actions.moveHostsToGroup(new Set(['a', 'b']), 'New/Nested'));
assert.equal(writes.length, 1);
assert.deepEqual(writes[0].map(h => h.group), ['New/Nested', 'New/Nested', 'Old']);
assert.equal(writes[0][2], original[2]);
assert.deepEqual(writes[0][0].tags, ['keep']);
assert.equal(writes[0][0].hostname, original[0].hostname);
}, writes);
});
test('mixed protocols entering and leaving managed groups retain single-host move rules', async () => {
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
const writes: Host[][] = [];
await withActions(original, sources, actions => {
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), 'Managed/Nested'));
assert.equal(writes.length, 1);
assert.equal(writes[0][0].managedSourceId, 'child');
assert.equal(writes[0][0].label, 'Hostssh');
assert.equal(writes[0][1].managedSourceId, undefined);
assert.equal(writes[0][1].label, 'Host telnet');
assert.equal(writes[0][2].managedSourceId, undefined);
assert.equal(writes[0][2].label, 'Host local');
}, writes);
await withActions(writes[0], sources, actions => {
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), null));
assert.deepEqual(writes[1].map(h => h.group), ['', '', '']);
assert.ok(writes[1].every(h => h.managedSourceId === undefined));
}, writes);
});
test('same-group, missing and empty selections do not publish changes', async () => {
const writes: Host[][] = [];
await withActions([host('a')], [], actions => {
act(() => actions.moveHostsToGroup(new Set(['a']), 'Old'));
act(() => actions.moveHostsToGroup(new Set(['missing']), 'New'));
act(() => actions.moveHostsToGroup(new Set(), 'New'));
assert.equal(writes.length, 0);
act(() => actions.moveHostToGroup('a', 'New'));
assert.equal(writes.length, 1);
assert.equal(writes[0][0].group, 'New');
}, writes);
});
async function withActions(
hosts: Host[], managedSources: ManagedSource[],
run: (actions: ReturnType<typeof useVaultGroupDragHandlers>) => void,
writes: Host[][],
) {
const previousWindow = globalThis.window;
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = { setTimeout: () => 1, clearTimeout: () => {} } as unknown as Window & typeof globalThis;
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useVaultGroupDragHandlers>;
function Probe() {
actions = useVaultGroupDragHandlers({ hosts, managedSources,
onUpdateHosts: next => writes.push(next), onUpdateManagedSources: () => {}, t: key => key });
return null;
}
try {
await act(async () => { renderer = create(<Probe />); });
run(actions!);
} finally {
await act(async () => renderer?.unmount());
globalThis.window = previousWindow;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
}
+40 -28
View File
@@ -66,40 +66,53 @@ export function useVaultGroupDragHandlers({
setDragOverDropTarget(path ? { kind: "group", path } : null);
}, []);
const moveHostsToGroup = useCallback((hostIds: ReadonlySet<string>, groupPath: string | null) => {
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
const targetGroup = groupPath || "";
const hostToMove = hosts.find((h) => h.id === hostId);
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
setDragOverDropTarget(null);
return;
}
// Find the most specific (deepest) managed source that matches the target group
const targetManagedSource = managedSources
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
let movedCount = 0;
const updatedHosts = hosts.map(host => {
if (!hostIds.has(host.id) || (host.group || "") === targetGroup) return host;
movedCount += 1;
const canBeManaged = !host.protocol || host.protocol === "ssh";
return {
...host,
group: targetGroup,
label: targetManagedSource && canBeManaged
? host.label.replace(/\s/g, '') : host.label,
managedSourceId: targetManagedSource && canBeManaged ? targetManagedSource.id : undefined,
};
});
const movedHost = {
...hostToMove,
group: targetGroup,
};
const updatedHost = (() => {
const h = movedHost;
// Only SSH hosts can be managed (SSH config only supports SSH)
const canBeManaged = !h.protocol || h.protocol === "ssh";
// Sanitize label if moving to a managed group (SSH config requires no spaces in Host alias)
let label = h.label;
if (targetManagedSource && canBeManaged && label) {
label = label.replace(/\s/g, '');
}
return {
...h,
label,
group: targetGroup,
managedSourceId: (targetManagedSource && canBeManaged) ? targetManagedSource.id : undefined,
};
})();
onUpdateHosts(hosts.map((host) => (host.id === hostId ? updatedHost : host)));
setDragOverDropTarget(null);
if (movedCount === 0) return;
// Publish the entire selection once; repeated single-host updates would
// each start from the same render snapshot and overwrite earlier moves.
onUpdateHosts(updatedHosts);
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
toast.success(t("vault.hosts.moveMultiple.success", {
count: movedCount,
group: groupPath || t("vault.hosts.allHosts"),
}));
toast.success(
t("vault.hosts.moveToGroup.success", {
host: hostToMove.label,
group: groupPath || t("vault.hosts.allHosts"),
}),
);
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
moveHostsToGroup(new Set([hostId]), groupPath);
}, [moveHostsToGroup]);
const getDropTargetClasses = (target: DropTarget) =>
cn(
isSameDropTarget(dragOverDropTarget, target) &&
@@ -138,7 +151,6 @@ export function useVaultGroupDragHandlers({
handleUnmanageGroup,
managedGroupPaths,
moveHostToGroup,
moveHostsToGroup,
setDragOverDropTarget,
setGroupDragOverDropTarget,
};
-18
View File
@@ -875,21 +875,3 @@ test("restore retains an explicit backend-default local shell snapshot", () => {
assert.deepEqual(restored.sessions[0].localShellArgs, []);
assert.equal(restored.sessions[0].localStartDir, "~");
});
test("restore retains absent local display snapshots while legacy fields remain absent", () => {
const payload = buildSessionRestorePayload({
sessions: [
{ ...session("empty-display"), protocol: "local", localShellName: "", localShellIcon: "" },
{ ...session("named-display"), protocol: "local", localShellName: "Bash", localShellIcon: "bash" },
{ ...session("legacy-display"), protocol: "local" },
],
workspaces: [], tabOrder: [], activeTabId: "empty-display", now: 123,
});
const restored = sanitizeSessionRestorePayload(JSON.parse(JSON.stringify(payload)));
assert.equal(restored.sessions[0].localShellName, "");
assert.equal(restored.sessions[0].localShellIcon, "");
assert.equal(restored.sessions[1].localShellName, "Bash");
assert.equal(restored.sessions[1].localShellIcon, "bash");
assert.equal(restored.sessions[2].localShellName, undefined);
assert.equal(restored.sessions[2].localShellIcon, undefined);
});
+4 -4
View File
@@ -154,8 +154,8 @@ const restoreSession = (session: TerminalSession): RestoredTerminalSession => {
...(serialConfig ? { serialConfig } : {}),
...(session.localShell !== undefined ? { localShell: session.localShell } : {}),
...(session.localShellArgs ? { localShellArgs: [...session.localShellArgs] } : {}),
...(session.localShellName !== undefined ? { localShellName: session.localShellName } : {}),
...(session.localShellIcon !== undefined ? { localShellIcon: session.localShellIcon } : {}),
...(session.localShellName ? { localShellName: session.localShellName } : {}),
...(session.localShellIcon ? { localShellIcon: session.localShellIcon } : {}),
...(session.localStartDir ? { localStartDir: session.localStartDir } : {}),
...(session.fontSize !== undefined ? { fontSize: session.fontSize } : {}),
...(session.fontSizeOverride !== undefined ? { fontSizeOverride: session.fontSizeOverride } : {}),
@@ -197,8 +197,8 @@ const restoreSessionFromUnknown = (value: unknown): RestoredTerminalSession | nu
...(serialConfig ? { serialConfig } : {}),
...(readStringAllowEmpty(value, "localShell") !== undefined ? { localShell: readStringAllowEmpty(value, "localShell") } : {}),
...(Array.isArray(value.localShellArgs) ? { localShellArgs: value.localShellArgs.filter((arg): arg is string => typeof arg === "string") } : {}),
...(readStringAllowEmpty(value, "localShellName") !== undefined ? { localShellName: readStringAllowEmpty(value, "localShellName") } : {}),
...(readStringAllowEmpty(value, "localShellIcon") !== undefined ? { localShellIcon: readStringAllowEmpty(value, "localShellIcon") } : {}),
...(readString(value, "localShellName") ? { localShellName: readString(value, "localShellName") } : {}),
...(readString(value, "localShellIcon") ? { localShellIcon: readString(value, "localShellIcon") } : {}),
...(readString(value, "localStartDir") ? { localStartDir: readString(value, "localStartDir") } : {}),
...(readNumber(value, "fontSize") !== undefined ? { fontSize: readNumber(value, "fontSize") } : {}),
...(readBoolean(value, "fontSizeOverride") !== undefined ? { fontSizeOverride: readBoolean(value, "fontSizeOverride") } : {}),
-58
View File
@@ -967,64 +967,6 @@ test("posix wrapper keeps cd contained in the subshell (documented trade-off)",
}
});
// Issue #3485: interactive bash 4.4+ expands PS0 after reading a command and
// before executing it, so a user-defined PS0 hook (commonly a timestamp from
// ~/.bashrc; root usually has none) prints extra output inside the hidden
// wrapper's echo window. The wrapper must silence it for its own reads and
// restore the original value afterwards.
test("posix wrapper saves PS0 before wrapping and restores it after execution", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo PS0_HOOK_TEST", "posix", marker);
assert.match(wrapped, /\[ -n "\$\{PS0-\}" \]&&\{ __NCMCP_PS0=\$\{PS0-\}; PS0=; \}; printf/);
assert.match(
wrapped,
/\[ -n "\$\{__NCMCP_PS0-\}" \]&&\{ PS0=\$\{__NCMCP_PS0-\}; unset __NCMCP_PS0; \}; \(exit \$__NCMCP_rc\); \}/,
);
const result = spawnSync(
"bash",
["--norc", "-c", 'PS0="[hook] "\n' + wrapped + 'printf "RESTORED_PS0=%s\\n" "$PS0"'],
{ encoding: "utf8" },
);
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /PS0_HOOK_TEST/);
// The user's PS0 value is back once the wrapper group finishes, so their
// own subsequent commands still run the hook.
assert.match(result.stdout, /RESTORED_PS0=\[hook\] /);
});
test("posix wrapper leaves PS0 alone when the user has none set", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo NO_PS0_TEST", "posix", marker);
const result = spawnSync(
"bash",
["--norc", "-c", `unset PS0\n${wrapped}printf "PS0_SET=[%s]\\n" "\${PS0-}"`],
{ encoding: "utf8" },
);
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
// Nothing was saved and nothing is restored: PS0 stays unset afterwards.
assert.match(result.stdout, /NO_PS0_TEST/);
const ps0Line = result.stdout.split("\n").find((line) => line.startsWith("PS0_SET="));
assert.equal(ps0Line, "PS0_SET=[]");
});
test("posix wrapper silencing still works under sh (dash) where PS0 does not exist", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo DASH_TEST", "posix", marker);
const result = spawnSync("sh", ["-c", `${wrapped}printf 'SH_ALIVE\\n'`], { encoding: "utf8" });
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /SH_ALIVE/);
});
// Issue #3265: with plain HISTCONTROL the wrapper line is recorded in bash
// history; arrow-up recall then redraws the marker text, the preload __NCMCP_
// filter suppresses the redraw fragments containing it, and readline's row
+2 -13
View File
@@ -274,27 +274,16 @@ function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCl
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = omitBashHistoryCleanup ? [] : [buildBashHistoryCleanup(marker)];
// Issue #3485: interactive bash 4.4+ expands PS0 after reading a command
// and before executing it. A user-set PS0 hook (commonly a pre-exec
// timestamp/hook defined in ~/.bashrc — root usually has none) prints extra
// output inside the hidden wrapper's echo window, garbling the agent's
// command echo while manual typing renders natively. Save PS0 only while it
// is currently set (so an interrupted run cannot overwrite the saved value
// with empty), silence it for every wrapper read, and restore it after the
// wrapper group finishes. zsh/dash/fish have no PS0, so both guards are
// no-ops there.
const ps0Save = '[ -n "${PS0-}" ]&&{ __NCMCP_PS0=${PS0-}; PS0=; }';
const ps0Restore = '[ -n "${__NCMCP_PS0-}" ]&&{ PS0=${__NCMCP_PS0-}; unset __NCMCP_PS0; }';
// OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep
// every physical line below that limit, even for short user commands.
// Emit a short standalone input marker before the shell reads any PS2
// continuation. A leading newline separates it from concurrent PTY echo.
return `${marker}=0; ${ps0Save}; printf '\\n%s\\n' '${marker}_I'\n` + [
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'\n` + [
` : '${marker}'; ${cmdAssign}`,
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
...historyCleanup,
`${ps0Restore}; (exit $__NCMCP_rc); }`,
`(exit $__NCMCP_rc); }`,
].join(`; \\\n: '${marker}'; `);
}
@@ -560,7 +560,6 @@ async function runOpenCodeTurn({
let opencode = null;
let sessionId = resumeSessionId || null;
let hasContent = false;
let backgroundWarningCount = 0;
let failed = false;
let abortSent = false;
let removeAbortListener = null;
@@ -620,13 +619,6 @@ async function runOpenCodeTurn({
if (abortController?.signal?.aborted) break;
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
if (eventSessionId && eventSessionId !== sessionId) continue;
if (!eventSessionId && getOpenCodeEventPayload(event)?.type === "session.error") {
const message = extractOpenCodeErrorMessage(getOpenCodeEventPayload(event).properties?.error)
|| "OpenCode reported a background error";
if (emitter.warning) emitter.warning(`opencode-background-error:${++backgroundWarningCount}`, message);
else emitter.status(message);
continue;
}
const result = translateOpenCodeEvent(event, emitter, state);
if (result.content) hasContent = true;
if (result.error) {
@@ -500,52 +500,6 @@ test("runOpenCodeTurn continues after a tool error part and reaches session.idle
assert.equal(events.some((event) => event.k === "error"), false);
});
for (const scoped of [false, true]) {
test(`runOpenCodeTurn ${scoped ? "fails on the current session error" : "warns about plugin errors without stopping the session"} (#3173)`, async () => {
const { events, emitter } = collector();
emitter.warning = (id, message) => events.push({ k: "warning", id, message });
const message = scoped ? "bad key" : "Failed to install plugin: invalid package name";
const stream = {
async *[Symbol.asyncIterator]() {
yield {
type: "session.error",
properties: { ...(scoped ? { sessionID: "sess-1" } : {}), error: { data: { message } } },
};
if (!scoped) {
yield {
type: "session.error",
properties: { error: { data: { message: "Failed to install another plugin" } } },
};
}
yield {
type: "message.part.updated",
properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "reply" }, delta: "reply" },
};
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
await runOpenCodeTurn({
prompt: "continue",
resumeSessionId: "sess-1",
emitter,
openCodeFactory: async () => ({
client: {
global: { event: async () => ({ stream }) },
session: { promptAsync: async () => ({ data: true }) },
},
server: { close() {} },
}),
});
assert.equal(events.some((event) => event.k === "error" && event.m === message), scoped);
assert.equal(events.some((event) => event.k === "warning" && event.message === message), !scoped);
assert.equal(events.some((event) => event.k === "text" && event.t === "reply"), !scoped);
assert.equal(events.some((event) => event.k === "done"), !scoped);
const warnings = events.filter((event) => event.k === "warning");
assert.equal(warnings.length, scoped ? 0 : 2);
assert.equal(new Set(warnings.map((event) => event.id)).size, warnings.length);
});
}
test("mapOpenCodeModels flattens providers", () => {
assert.deepEqual(mapOpenCodeModels({
providers: [
-43
View File
@@ -1,8 +1,5 @@
import assert from "node:assert/strict";
import test from "node:test";
import { generateText, streamText } from "ai";
import { buildCattySdkMessages, createContinuationContext } from "./harness/turnDrivers/cattyMessageBuilder";
import { ToolOutputStore } from "./harness/toolOutputStore";
import { createModelFromConfig, resolveProviderEndpoint } from "./sdk/providers";
import type { ProviderConfig } from "./types";
@@ -283,43 +280,3 @@ test("SDK requests forward custom headers and scope OpenCode sessions", async (t
}
assert.equal(requests.length, 16);
});
test("OpenAI Chat sends inline image attachments as data URLs through the bridge", async (t) => {
const browserGlobal = globalThis as typeof globalThis & { window?: unknown };
const originalWindow = browserGlobal.window;
t.after(() => { browserGlobal.window = originalWindow; });
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
Reflect.set(globalThis, "window", { netcatty: {
aiFetch: async (url: string, _method: string, _headers: Record<string, string>, body: string) => {
requests.push({ url, body: JSON.parse(body) });
return { ok: false, status: 400, data: '{"error":{"message":"captured request"}}' };
},
aiChatStream: async (_requestId: string, url: string, _headers: Record<string, string>, body: string) => {
requests.push({ url, body: JSON.parse(body) });
return { ok: false, error: "captured request" };
},
onAiStreamData: () => () => {},
onAiStreamEnd: () => () => {},
onAiStreamError: () => () => {},
} });
for (const [mediaType, base64Data] of [["image/png", "iVBORw0KGgo="], ["image/jpeg", "/9j/4A=="]]) {
const messages = buildCattySdkMessages({
allMessages: [], includeCurrentUserMessage: true, trimmed: "Read this image",
attachments: [{ mediaType, base64Data }],
continuationContext: createContinuationContext("p", "openai", "m", false),
chatSessionId: "images", toolOutputStore: new ToolOutputStore(), fieldsByMessage: new Map(),
});
const model = createModelFromConfig(makeConfig({ openaiApi: "chat", apiKey: "test-key", baseURL: "https://example.test/v1" }));
await assert.rejects(generateText({ model, messages, maxRetries: 0 }), /captured request/);
await assert.rejects(async () => await streamText({ model, messages, maxRetries: 0, onError: () => {} }).text);
for (const request of requests.slice(-2)) {
assert.equal(request.url, "https://example.test/v1/chat/completions");
assert.deepEqual(request.body.messages, [{ role: "user", content: [
{ type: "text", text: "Read this image" },
{ type: "image_url", image_url: { url: `data:${mediaType};base64,${base64Data}` } },
] }]);
}
}
assert.equal(requests.length, 4);
});
@@ -1077,132 +1077,6 @@ for (const rejects of [false, true]) {
});
}
for (const rejects of [false, true]) {
test(`final failure notification and ${rejects ? "rejection" : "reply"} cannot restart an exhausted cycle`, async (t) => {
let statusListener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
let forceFinalAttempt = true;
const reconnectRule = rule({ id: `final-double-failure-${rejects}`, autoStart: true });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
startPortForward: async () => {
if (forceFinalAttempt) getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
statusListener?.("error", "SSH connection failed");
if (rejects) throw new Error("SSH connection failed");
return { success: false, error: "SSH connection failed" };
},
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
statusListener = listener;
return () => undefined;
},
} },
});
let reconnects = 0;
setReconnectCallback(async () => { reconnects += 1; return { success: true }; });
t.after(async () => {
setReconnectCallback(null);
await stopAndCleanupRuleAndWait(reconnectRule.id);
});
t.mock.timers.enable({ apis: ["setTimeout"] });
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
t.mock.timers.tick(3000);
assert.equal(reconnects, 0);
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
// An explicit restart must still get a new retry cycle if it fails.
forceFinalAttempt = false;
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
t.mock.timers.tick(3000);
assert.equal(reconnects, 1);
});
}
for (const adoption of ["sync", "reconcile"] as const) {
test(`${adoption} starts a fresh retry cycle when adopting an active backend tunnel`, async (t) => {
const reconnectRule = rule({ id: `exhausted-adoption-${adoption}`, autoStart: true });
const listeners = new Map<string, (status: PortForwardingRule["status"]) => void>();
const backendTunnel = { ruleId: reconnectRule.id, tunnelId: "fresh-backend-tunnel", status: "active" };
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
startPortForward: async () => ({ success: true }),
listPortForwards: async () => [backendTunnel],
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
onPortForwardStatus: (id: string, listener: (status: PortForwardingRule["status"]) => void) => {
listeners.set(id, listener);
return () => undefined;
},
} },
});
let reconnects = 0;
setReconnectCallback(async () => { reconnects += 1; return { success: true }; }, () => true);
t.after(async () => {
setReconnectCallback(null);
await stopAndCleanupRuleAndWait(reconnectRule.id);
});
t.mock.timers.enable({ apis: ["setTimeout"] });
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
const oldConnection = getActiveConnection(reconnectRule.id)!;
oldConnection.reconnectAttempts = 5;
const oldListener = listeners.get(oldConnection.tunnelId)!;
oldListener("error");
oldListener("inactive");
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
else await reconcileWithBackend();
assert.equal(resetReconnectAttempts(reconnectRule.id), false);
const replacement = getActiveConnection(reconnectRule.id)!;
assert.equal(replacement.tunnelId, backendTunnel.tunnelId);
oldListener("error");
assert.equal(getActiveConnection(reconnectRule.id), replacement);
listeners.get(backendTunnel.tunnelId)!("inactive");
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
t.mock.timers.tick(3000);
assert.equal(reconnects, 1);
});
}
for (const adoption of ["sync", "reconcile"] as const) {
for (const rejects of [false, true]) {
test(`${adoption} preserves exhaustion if backend adoption ${rejects ? "rejects" : "finds a stopped tunnel"}`, async (t) => {
const reconnectRule = rule({ id: `failed-adoption-${adoption}-${rejects}`, autoStart: true });
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
startPortForward: async () => ({ success: true }),
listPortForwards: async () => [{ ruleId: reconnectRule.id, tunnelId: "stopped-before-subscribe", status: "active" }],
subscribePortForward: async () => {
if (rejects) throw new Error("backend unavailable during adoption");
return { tunnelId: "stopped-before-subscribe", status: "inactive" };
},
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
statusListener = listener;
return () => undefined;
},
} },
});
setReconnectCallback(async () => ({ success: true }), () => true);
t.after(async () => {
setReconnectCallback(null);
await stopAndCleanupRuleAndWait(reconnectRule.id);
});
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
statusListener!("error");
statusListener!("inactive");
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
else await reconcileWithBackend();
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
});
}
}
test("inactive close events preserve an already scheduled reconnect", async (t) => {
let statusListener: ((status: PortForwardingRule["status"], error?: string | null) => void) | undefined;
Object.defineProperty(globalThis, "window", {
@@ -234,10 +234,6 @@ const scheduleReconnectIfNeeded = (
return true;
}
// One failed dial can report both a status event and a rejected start.
// Once exhausted, neither notification may begin another retry cycle.
if (exhaustedReconnectRules.has(ruleId)) return false;
const currentConn = activeConnections.get(ruleId);
if (currentConn?.reconnectSuppressed) return false;
if (currentConn?.reconnectTimerCallback) {
@@ -685,10 +681,6 @@ const subscribeSyncedConnection = async (
connection.status = resolveBackendStatus(snapshot.status);
connection.error = snapshot.error;
}
if (connection.status === 'active') {
exhaustedReconnectRules.delete(ruleId);
connection.reconnectAttempts = 0;
}
return true;
};
@@ -828,10 +820,6 @@ export const reconcileWithBackend = async (): Promise<{
if (existing.status !== backendStatus || existing.error !== tunnel.error) {
existing.status = backendStatus;
existing.error = tunnel.error;
if (backendStatus === 'active') {
exhaustedReconnectRules.delete(ruleId);
existing.reconnectAttempts = 0;
}
result.appeared.push(ruleId);
}
}
@@ -905,9 +893,7 @@ export const startPortForward = async (
// Clear any existing reconnect timer
clearReconnectTimer(rule.id);
// Explicit starts (including network recovery) authorize a fresh cycle.
exhaustedReconnectRules.delete(rule.id);
if (!bridge?.startPortForward) {
// Fallback for browser/dev mode - simulate the connection
logger.warn('[PortForwardingService] Backend not available, simulating connection...');
+6 -36
View File
@@ -16,7 +16,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/google": "^4.0.0",
"@ai-sdk/openai": "4.0.8",
"@ai-sdk/openai": "^4.0.0",
"@aws-sdk/client-s3": "^3.956.0",
"@eslint-community/regexpp": "4.12.2",
"@fontsource/jetbrains-mono": "^5.2.8",
@@ -197,43 +197,13 @@
}
},
"node_modules/@ai-sdk/openai": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.8.tgz",
"integrity": "sha512-yW2UuS6jlu2LRYCd+cSpVdu/umf+QjypihYAiGvKk5HnpqQf21ffNjFbke9xo9jhT+TBt2YR30Fs5Sy0md/dfA==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.0.tgz",
"integrity": "sha512-XcI/bJEG+Ymx8ZwQMY9GE9waHdEcSzT6wn2o+YW80hOQPf8IAGQvdNWKaD0mxLi6AmOM0L01y/p626w7rImblQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.2",
"@ai-sdk/provider-utils": "5.0.5"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.2.tgz",
"integrity": "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.5.tgz",
"integrity": "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.2",
"@standard-schema/spec": "^1.1.0",
"@workflow/serde": "4.1.0",
"eventsource-parser": "^3.0.8"
"@ai-sdk/provider": "4.0.0",
"@ai-sdk/provider-utils": "5.0.0"
},
"engines": {
"node": ">=22"
+1 -1
View File
@@ -76,7 +76,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/google": "^4.0.0",
"@ai-sdk/openai": "4.0.8",
"@ai-sdk/openai": "^4.0.0",
"@aws-sdk/client-s3": "^3.956.0",
"@eslint-community/regexpp": "4.12.2",
"@fontsource/jetbrains-mono": "^5.2.8",