Compare commits

...
Author SHA1 Message Date
bincxz 247718a0be fix(terminal): preserve punctuation inside path text 2026-10-15 13:47:23 +08:00
bincxz a9939dca00 fix(terminal): exclude relative path suffix highlights 2026-10-15 13:39:41 +08:00
bincxz aec1588145 fix(terminal): keep Unicode and quoted paths together 2026-10-15 13:31:40 +08:00
bincxz 4fedd5a70b feat(terminal): highlight Unix paths with existing keyword rules 2026-10-15 13:25:51 +08:00
陈大猫 35b83d4655 fix(ai): preserve original chat when undo reaches history limit (#3670) 2026-10-15 13:09:10 +08:00
Netcatty-Bot b6b575b045 fix: avoid unused Bash cleanup input in live zsh sessions (#3576)
Keep the initial live-shell probe short and omit unused Bash history-cleanup input after a confirmed zsh reply. Preserve the existing bounded task wrapper, Bash cleanup, history and cancellation behavior.

Related to #3575. This repairs a confirmed controlled failure; the reporter-specific startup contrast remains open for confirmation and release delivery.

Validated through real configured zsh and Bash terminals, quoted/multiline and 3,000-character commands, the complete local test suite, exact-head Codex review and current automated checks.
2026-10-15 13:00:10 +08:00
陈大猫 3d5cd6b8ec fix: match host reuse to actual protocol settings (#3669)
Match host reuse to the actual Telnet login, ET hop ports and serial/local session settings. Preserve already-scoped reconnecting tabs when other sessions remain live, and reject Telnet proxy settings consistently before reusing a connection.

Follow-up to #3614. Related to #3613; no claim of a released fix or full PR3616 inherited-default coverage.

Validated with focused checks, real Electron/TCP Telnet connections, build/lint, exact-head Codex review and current automated test/package checks.
2026-10-15 12:55:56 +08:00
13 changed files with 370 additions and 51 deletions

No files matched your search

+1 -1
View File
@@ -718,7 +718,7 @@ export function useAIState() {
const branched = result.session;
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, source, ...prev.filter(session => session.id !== source.id)];
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
@@ -155,7 +155,7 @@ test('undo copies chat only, preserving original outputs and job ownership', asy
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
assert.equal(JSON.stringify(capture.sessions.find(item => item.id === source.id)), original);
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
stateStore.clear(source.id);
outputStore.prune(source.id);
@@ -163,17 +163,32 @@ test('undo copies chat only, preserving original outputs and job ownership', asy
container.remove(); dom.window.close();
});
test('undo keeps an old source beside its copy within ordinary history retention', async () => {
const sources = Array.from({ length: 50 }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`,
}));
const { dom, container, root, capture } = await setupAiState(sources);
await act(async () => { await capture.ai!.undoLastTurnInSession('history-49'); });
assert.equal(capture.sessions[1].id, 'history-49');
await new Promise(resolve => setTimeout(resolve, 350));
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.ok(saved.some((item: AISession) => item.id === 'history-49'));
assert.deepEqual(saved.find((item: AISession) => item.id === 'history-49').messages, sources[49].messages);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
}));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branchId: string | undefined;
await act(async () => {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => reloaded.root.unmount());
reloaded.container.remove(); reloaded.dom.window.close();
});
}
@@ -2,9 +2,9 @@ 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, Identity, SSHKey, TerminalSession } from '../../domain/models';
import type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
@@ -14,14 +14,14 @@ const host = (extra: Partial<Host> = {}): Host => ({
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
});
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] } = {}) {
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[]; resolveEffectiveHost?: (host: Host) => Host } = {}) {
let hosts = [initial, ...(extra.hosts ?? [])];
let keys = extra.keys ?? [];
let identities = extra.identities ?? [];
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
let find: ReturnType<typeof useHostOpenSessionReuse>;
function Harness() {
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: effective });
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: extra.resolveEffectiveHost ?? effective });
return null;
}
let renderer: ReactTestRenderer;
@@ -146,3 +146,129 @@ test('an unchanged restored plugin configuration can be reused after manual reco
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
assert.equal((await api.find())?.sessionId, 'tab');
});
for (const [field, value] of [
['telnetPort', 2324], ['telnetUsername', 'other-telnet-user'], ['telnetPassword', 'rotated-telnet-password'],
] as const) {
test(`a Telnet ${field} edit cannot reuse the old transport`, async (t) => {
const original = host({ protocol: 'telnet', telnetPort: 2323, telnetUsername: 'telnet-user', telnetPassword: 'telnet-password' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('Telnet identity credential rotation and missing identity reject reuse', async (t) => {
const identity: Identity = { id: 'telnet-login', label: 'Telnet login', username: 'telnet-user', password: 'original', authMethod: 'password', created: 0 };
const original = host({ protocol: 'telnet', telnetIdentityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
for (const identities of [[{ ...identity, username: 'other-user' }], [{ ...identity, password: 'rotated' }], []]) {
await api.update({ identities });
assert.equal(await api.find(), undefined);
}
});
test('Telnet explicit empty login does not fall back to SSH login fields', async (t) => {
const original = host({ protocol: 'telnet', telnetUsername: '', telnetPassword: '', password: 'ssh-only' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, label: 'Renamed', username: 'unrelated-ssh-user', password: 'unrelated-ssh-password', identityId: 'missing-ssh-identity', telnetPort: 23 } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, telnetUsername: undefined, telnetPassword: undefined } });
assert.equal(await api.find(), undefined, 'removing explicit empty overrides enables the actual fallback login');
});
for (const transport of [{ protocol: 'et' as const }, { protocol: 'ssh' as const, etEnabled: true }]) {
test(`changing a jump ET server port rejects the old ${JSON.stringify(transport)} transport`, async (t) => {
const original = host({ ...transport, hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', etPort: 2022 });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ hosts: [original, { ...jump, etPort: 3022 }] });
assert.equal(await api.find(), undefined);
});
}
test('serial direct auto-login password is not masked by an unrelated SSH identity', async (t) => {
const identity: Identity = { id: 'ssh-login', label: 'SSH login', username: 'ssh-user', password: 'ssh-password', authMethod: 'password', created: 0 };
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', password: 'serial-password', identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, password: 'rotated-serial-password' } });
assert.equal(await api.find(), undefined);
});
test('unchanged serial direct username matches the actual auto-login input', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: 'serial-user', password: 'serial-password' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, username: 'other-serial-user' } });
assert.equal(await api.find(), undefined);
});
for (const [field, value] of [
['localShell', '/bin/bash'], ['localShellArgs', ['--norc']], ['localStartDir', '/replacement'],
] as const) {
test(`a saved local ${field} edit cannot reuse the old process`, async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/original' });
const session = { ...createHostTerminalSession('tab', original), status: 'connected', localShell: original.localShell, localShellArgs: original.localShellArgs, localStartDir: original.localStartDir } as TerminalSession;
const api = await harness(original, { sessions: [session] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
const alreadyEdited = await harness({ ...original, [field]: value }, { sessions: [session] });
t.after(alreadyEdited.close);
assert.equal(await alreadyEdited.find(), undefined, 'opening fields come from the actual local session');
});
}
test('local default shell and cwd treat empty values as omitted but explicit empty args stay distinct', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost' });
const api = await harness(original);
t.after(api.close);
await api.update({ host: { ...original, localShell: '', localStartDir: '', label: 'Renamed' } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, localShellArgs: [] } });
assert.equal(await api.find(), undefined, 'empty args bypass backend login-shell defaults');
});
for (const [label, proxy] of [
['a missing saved profile', { proxyProfileId: 'missing-profile' }],
['a usable proxy', { proxyConfig: { type: 'socks5', host: 'proxy.invalid', port: 1080 } }],
['a missing proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'missing-identity' } }],
['an incomplete proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'incomplete-identity' } }],
] as const) {
test(`Telnet adding ${label} cannot bypass actual starter rejection through reuse`, async (t) => {
const original = host({ protocol: 'telnet' });
const api = await harness(original, { identities: [{ id: 'incomplete-identity', label: 'Incomplete', username: 'proxy-user', authMethod: 'password', created: 0 }] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, ...proxy } });
assert.equal(await api.find(), undefined);
await api.update({ host: { ...original, label: 'Renamed without a proxy' } });
assert.equal((await api.find())?.sessionId, 'tab');
});
}
test('Telnet inheriting a proxy profile cannot reuse its previous direct session', async (t) => {
const original = host({ protocol: 'telnet', group: 'prod/web' });
const groupConfigs: GroupConfig[] = [];
const proxyProfiles = [{ id: 'group-proxy', label: 'Proxy', createdAt: 0, config: { type: 'socks5' as const, host: 'proxy.invalid', port: 1080 } }];
const resolveEffectiveHost = (candidate: Host) => resolveEffectiveTerminalHost({ host: candidate, groupConfigs, proxyProfiles });
const api = await harness(original, { resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
groupConfigs.push({ path: 'prod', proxyProfileId: 'group-proxy' });
assert.equal(resolveEffectiveHost(original).proxyConfig?.host, 'proxy.invalid');
await api.update({ host: { ...original } });
assert.equal(await api.find(), undefined);
});
+50 -3
View File
@@ -1,6 +1,10 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { findReusableHostSession } from '../../domain/hostOpenSession';
import { resolveTelnetPassword, resolveTelnetPort, resolveTelnetUsername } from '../../domain/host';
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { findIncompleteProxyIdentityId, findMissingProxyIdentityId, hasUsableProxyConfig } from '../../domain/proxyProfiles';
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
import { createHostTerminalSession } from './sessionFactories';
@@ -17,9 +21,45 @@ type HostOpenSessionReuseInput = {
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
const openingKeys = useRef(new Map<string, Promise<string | undefined>>());
const connectionKey = useCallback((host: Host) => {
const protocol = resolveEffectiveTerminalProtocol(host);
if (protocol === 'local') {
const options = {
hostname: host.hostname,
username: '',
localShell: host.localShell || undefined,
// undefined lets the backend choose login arguments; [] explicitly does not.
localShellArgs: host.localShellArgs,
localStartDir: host.localStartDir || undefined,
};
return buildTransferPoolKey({ hostname: host.hostname, hostId: host.id, protocol, connectionOptions: options });
}
if (protocol === 'telnet') {
// The actual Telnet starter rejects these settings before connecting.
if ((host.proxyProfileId && !host.proxyConfig)
|| findMissingProxyIdentityId(host.proxyConfig, identities)
|| findIncompleteProxyIdentityId(host.proxyConfig, identities)
|| hasUsableProxyConfig(host.proxyConfig)) {
throw new Error('Telnet proxy configuration cannot be used');
}
// Match the actual Telnet starter, including explicit empty overrides.
const identity = host.telnetIdentityId ? identities.find((item) => item.id === host.telnetIdentityId) : undefined;
if (host.telnetIdentityId && (!identity?.username?.trim() || identity.password === undefined)) {
throw new Error('Telnet identity is missing or incomplete');
}
const rawPassword = identity ? identity.password : resolveTelnetPassword(host);
if (isEncryptedCredentialPlaceholder(rawPassword)) throw new Error('Telnet credentials are unavailable');
const options = {
hostname: host.hostname,
port: resolveTelnetPort(host),
username: identity ? identity.username?.trim() : resolveTelnetUsername(host),
password: sanitizeCredentialValue(rawPassword),
};
return buildTransferPoolKey({ ...options, hostId: host.id, protocol, connectionOptions: options });
}
// Reuse the existing credential/chain expansion and transport identity
// comparison. Presentation names and SFTP-only choices do not change a
// terminal's destination or login.
if (protocol === 'serial' && isEncryptedCredentialPlaceholder(host.password)) throw new Error('Serial credentials are unavailable');
const options = buildSftpHostCredentials({
host, hosts: hosts.map(resolveEffectiveHost), keys, identities,
});
@@ -28,13 +68,17 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
for (const hop of options.jumpHosts ?? []) delete hop.label;
const connectionOptions = {
...options,
protocol: host.protocol || 'ssh',
protocol,
moshEnabled: host.moshEnabled,
moshServerPath: host.moshServerPath,
etEnabled: host.etEnabled,
etPort: host.etPort,
etJumpPorts: protocol === 'et'
? host.hostChain?.hostIds.map((id) => hosts.find((item) => item.id === id)).map((hop) => hop && (resolveEffectiveHost(hop).etPort ?? 2022))
: undefined,
pluginConnection: host.pluginConnection,
serialConfig: host.protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
serialConfig: protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
serialLogin: protocol === 'serial' ? { username: host.username?.trim(), password: sanitizeCredentialValue(host.password) } : undefined,
agentForwarding: host.agentForwarding,
x11Forwarding: host.x11Forwarding,
};
@@ -54,13 +98,16 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
openingKeys.current.set(session.id, connectionKey({
...resolveEffectiveHost(host),
hostname: session.hostname,
username: session.username,
username: session.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username,
port: session.port ?? host.port,
protocol: session.protocol ?? host.protocol,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
serialConfig: session.serialConfig ?? host.serialConfig,
pluginConnection: session.pluginConnection ?? host.pluginConnection,
localShell: session.localShell ?? host.localShell,
localShellArgs: session.localShellArgs ?? host.localShellArgs,
localStartDir: session.localStartDir ?? host.localStartDir,
}).catch(() => undefined));
} catch {
openingKeys.current.set(session.id, Promise.resolve(undefined));
+45
View File
@@ -149,3 +149,48 @@ test("normalize keeps custom (non-built-in) rules verbatim", () => {
assert.deepEqual(rule.patterns, customRule.patterns);
assert.equal(rule.label, customRule.label);
});
test("Unix path highlighting covers terminal paths without matching relative prose or URL slashes", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const path of ["/sbin/iptables", "/var/log/syslog", "/usr/local/bin/g++", "~/projects/netcatty"]) {
const matches = [...`'${path}'`.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
for (const text of ["RELATED,ESTABLISHED", "TCP/IP", "https://example.com/log", " / ", "./foo", "../foo", "../../foo"]) {
assert.equal(matchesAny(rule.patterns, text), false, text);
}
});
test("Unix path rule upgrades old settings while preserving disabled highlights and edits", () => {
const legacy = DEFAULT_KEYWORD_HIGHLIGHT_RULES.filter((rule) => rule.id !== "unix-path");
const upgraded = normalizeTerminalSettings({ keywordHighlightEnabled: false, keywordHighlightRules: legacy });
assert.equal(upgraded.keywordHighlightEnabled, false);
assert.equal(getRule(upgraded.keywordHighlightRules, "unix-path").enabled, true);
const edited = { ...getRule(upgraded.keywordHighlightRules, "unix-path"), color: "#123456", enabled: false, customized: true, patterns: ["/chosen/path"] };
const restored = normalizeTerminalSettings({ keywordHighlightRules: [...legacy, edited] });
assert.deepEqual(getRule(restored.keywordHighlightRules, "unix-path"), edited);
});
test("Unix path highlighting keeps Unicode paths and quoted spaces together", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const [text, path] of [
["/home/用户/file", "/home/用户/file"],
['cat "/Users/me/My Projects/app"', "/Users/me/My Projects/app"],
["cat '/home/用户/My Projects/app'", "/home/用户/My Projects/app"],
]) {
const matches = [...text.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
});
test("Unix path highlighting keeps punctuation up to the text boundary", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const path of [
"/run/user/1000/gvfs/smb-share:server=nas,share=files/report.txt",
"/tmp/build=release/output",
]) {
const matches = [...path.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
});
+1
View File
@@ -288,6 +288,7 @@ export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, IPV6_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
{ id: 'unix-path', label: 'Unix paths', patterns: ["(?<=\")(?:/|~/)[^\"\\r\\n]+(?=\")|(?<=')(?:/|~/)[^'\\r\\n]+(?=')|(?<![\\w.:/\"'\\u0080-\\uFFFF])(?:/|~/)[^\\s\"']+"], color: '#A78BFA', enabled: true },
];
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
+15 -7
View File
@@ -6,7 +6,17 @@ const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecH
// short-lived sh in the interactive PTY, rather than the SSH login shell.
function buildLiveShellProbe(marker) {
const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; '
+ `printf "${marker}_P:%s\\n" "$name"`;
+ `printf "\\n${marker}_P:%s\\n" "$name"`;
// Keep the initial probe short before the active shell is known. Quoted
// multiline cleanup input can leave a configured zsh editor waiting for
// continuation input, so defer that Bash-only work until after the reply.
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`;
}
// This is sent after the short probe, except for zsh/fish replies. Keep the
// existing guarded cleanup and bounded physical lines for Bash's history and
// canonical-input behavior, including renamed Bash executables.
function buildLiveShellProbeCleanup(marker) {
// command eval bypasses an eval customization; plain eval is the fallback
// when command itself is shadowed. Never invoke a shadowed builtin after the
// command path already succeeded. Both eval bodies remain Bash-guarded.
@@ -14,10 +24,7 @@ function buildLiveShellProbe(marker) {
const { dispatcher } = bashHistoryScratchNames(marker);
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
// Start display suppression in the PTY before any continuation is read,
// independently of PS2 and echo mode. Keep every later physical line short.
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
return ` : '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true\n`;
}
// Interactive bash prints PS2 (`> ` by default) on backslash continuations.
@@ -35,11 +42,12 @@ function parseLiveShellProbe(output, marker) {
if (!normalized.startsWith(`${marker}_P:`)) continue;
const name = normalized.slice(marker.length + 3).trim().split("/").pop().replace(/^-/, "");
return {
shellName: name,
kind: name === "fish" ? "fish"
: /^(?:ba|da|z|k|a)?sh$/.test(name) ? "posix" : null,
};
}
return { kind: null };
return { kind: null, shellName: "" };
}
module.exports = { buildLiveShellProbe, parseLiveShellProbe, normalizeLiveShellProbeLine };
module.exports = { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe, normalizeLiveShellProbeLine };
+28 -11
View File
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs');
const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require('./liveShellProbe.cjs');
const { startPtyJob } = require('./ptyExec.cjs');
test('live shell response excludes echoed commands, stale markers and partial lines', () => {
@@ -9,11 +9,11 @@ test('live shell response excludes echoed commands, stale markers and partial li
assert.equal(parseLiveShellProbe(buildLiveShellProbe(marker), marker), null);
assert.equal(parseLiveShellProbe(`${marker}_P:fi`, marker), null);
assert.equal(parseLiveShellProbe('__NCMCP_old___P:fish\n', marker), null);
assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null });
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:bash\n> ${marker}_Q`, marker), { kind: 'posix' });
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:fish\n> ${marker}_Q`, marker), { kind: 'fish' });
assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish', shellName: 'fish' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix', shellName: 'zsh' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null, shellName: '' });
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:bash\n> ${marker}_Q`, marker), { kind: 'posix', shellName: 'bash' });
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:fish\n> ${marker}_Q`, marker), { kind: 'fish', shellName: 'fish' });
});
test('probe waits for complete reply before choosing the first wrapper', async () => {
@@ -260,7 +260,7 @@ for (const [customization, listHistory] of [
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_HISTORY_PROBE__';
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
+ buildLiveShellProbe(marker)
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker)
+ (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '')
+ '\nprintf \"\\n\"\n' + listHistory + '\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
@@ -337,7 +337,7 @@ for (const invocationName of ['sh', 'renamed-bash']) {
const marker = '__NCMCP_RENAMED_BASH__';
const result = spawnSync(shell, ['--noprofile', '--norc', '-i'], {
input: 'HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nbuiltin history -c\necho preserve_user_history\n'
+ buildLiveShellProbe(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
@@ -389,7 +389,7 @@ for (const historyControl of ['', 'ignorespace']) {
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_FALLBACK_GUARD__';
const input = `HISTFILE=/dev/null; HISTCONTROL=${historyControl}; PS1=; PS2=\nbuiltin() { printf UNEXPECTED_FALLBACK; }\ncommand history -c\necho user_one\n`
+ buildLiveShellProbe(marker)
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker)
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
+ '\ncommand history\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
@@ -406,7 +406,7 @@ for (const historyControl of ['', 'ignorespace']) {
}
for (const stop of ['cancel', 'timeout']) {
test(`paced probe stops writing after ${stop}`, async () => {
test(`paced Bash probe cleanup stops writing after ${stop}`, async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = data => writes.push(data);
@@ -414,8 +414,9 @@ for (const stop of ['cancel', 'timeout']) {
shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000,
enforceWallTimeout: stop === 'timeout',
});
pty.emit('data', `${job.marker}_P:bash\n${job.marker}_Q`);
await new Promise(resolve => setTimeout(resolve, 45));
assert.ok(writes.length >= 2, 'probe must have started a later chunk');
assert.ok(writes.length >= 3, 'cleanup must have started a later chunk');
if (stop === 'cancel') {
job.cancel();
pty.emit('close');
@@ -427,3 +428,19 @@ for (const stop of ['cancel', 'timeout']) {
assert.ok(writes.every(data => !data.includes('must_not_run')));
});
}
for (const shellName of ['zsh', '/bin/zsh', 'zsh-5.9', 'fish', 'bash', 'sh', 'renamed-bash']) {
test(`live probe defers Bash history input until shell reply (${shellName})`, async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const job = startPtyJob(pty, 'printf success', { shellKind: 'posix', probeLiveShell: true, timeoutMs: 1000 });
assert.doesNotMatch(writes.join(''), /history -d|printf success/);
pty.emit('data', `${job.marker}_P:${shellName}\n${job.marker}_Q`);
const deferred = writes.slice(1).join('');
const needsCleanup = shellName !== 'fish' && !/zsh/.test(shellName);
assert.equal(deferred.includes('\\command eval'), needsCleanup);
pty.emit('data', `${job.marker}_S\nsuccess\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
});
}
+12 -5
View File
@@ -28,7 +28,7 @@ const {
} = require("./ptyExecHelpers.cjs");
const { extractTrailingIdlePrompt } = require("./shellUtils.cjs");
const { buildLiveShellProbe, parseLiveShellProbe } = require("./liveShellProbe.cjs");
const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require("./liveShellProbe.cjs");
const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024;
const END_MARKER_PROMPT_WAIT_MS = 30000;
@@ -560,7 +560,9 @@ function startPtyJob(ptyStream, command, options) {
probeOutput = "";
if (probe.kind) resolvedShellKind = probe.kind;
if (finished || cancelRequested) return;
writeWrappedCommand();
const isZsh = /^zsh(?:[-.][0-9].*)?$/i.test(probe.shellName);
const skipProbeCleanup = probe.kind === "fish" || isZsh;
writeWrappedCommand(skipProbeCleanup ? "" : buildLiveShellProbeCleanup(marker), isZsh);
return;
}
@@ -822,9 +824,14 @@ function startPtyJob(ptyStream, command, options) {
writeNext();
}
function writeWrappedCommand() {
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${wrapped}`);
function writeWrappedCommand(probeCleanup = "", isZsh = false) {
// The Bash-only cleanup is a no-op in zsh, but its long input still goes
// through the configured editor. Keep the existing bounded wrapper and
// omit only that unused fragment after a live zsh reply.
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell, {
omitBashHistoryCleanup: isZsh,
});
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${probeCleanup}${wrapped}`);
}
// Prime the renderer's display suppression before the first byte is typed
+5 -5
View File
@@ -243,7 +243,7 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
].join(` \\\n: "${marker}"; `);
}
function buildPosixWrapperBody(command, marker, startFormat) {
function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCleanup = false } = {}) {
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
let cmdAssign = commandLines.length > 1
@@ -273,7 +273,7 @@ function buildPosixWrapperBody(command, marker, startFormat) {
}
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = buildBashHistoryCleanup(marker);
const historyCleanup = omitBashHistoryCleanup ? [] : [buildBashHistoryCleanup(marker)];
// 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
@@ -282,12 +282,12 @@ function buildPosixWrapperBody(command, marker, startFormat) {
` : '${marker}'; ${cmdAssign}`,
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
historyCleanup,
...historyCleanup,
`(exit $__NCMCP_rc); }`,
].join(`; \\\n: '${marker}'; `);
}
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false, options = {}) {
// A live probe leaves its completion marker unterminated to hide the next
// prompt. POSIX input also hides arbitrary PS2 prompts until a fresh start
// marker line; keep the fish behavior controlled by separateStartMarker.
@@ -366,7 +366,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
// Without that config the prefix is harmless; it just doesn't
// suppress history recording.
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n")}\n`;
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n", options)}\n`;
}
}
}
+9
View File
@@ -727,6 +727,15 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
}
return { ok: true, count: 0, chatSessionId };
}
// A reconnecting renderer tab can be absent from the backend map while
// another terminal is still live. Preserve its already scoped metadata;
// authoritative renderer updates remove it when the tab actually closes.
const liveIds = new Set(sessionList.map((entry) => entry.sessionId));
for (const [sessionId, meta] of existingMeta) {
if (!liveIds.has(sessionId) && meta.connected === false) {
sessionList.push({ sessionId, ...meta });
}
}
updateSessionMetadata(sessionList, chatSessionId);
return { ok: true, count: sessionList.length, chatSessionId };
}
@@ -142,3 +142,21 @@ test("getContext still hides disconnected sessions outside the agent scope", asy
});
assert.equal(environment.hosts.length, 0);
});
test("external context preserves reconnecting metadata beside another live transport", async (t) => {
const bridge = setup(t, { sessions: new Map([["online", { hostname: "online", stream: { write() {} } }]]) });
const scope = bridge.EXTERNAL_MCP_CHAT_SESSION_ID;
bridge.setExternalMcpHooks({ isEnabled: () => true });
bridge.updateSessionMetadata([
{ sessionId: "online", hostname: "online", connected: true },
{ sessionId: "retrying", hostname: "retrying", connected: false },
], scope);
const context = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: scope });
assert.equal(context.hostCount, 2);
assert.equal(context.hosts.find(host => host.sessionId === "retrying")?.connected, false);
assert.equal(context.hosts.find(host => host.sessionId === "online")?.connected, true);
// A renderer-authoritative removal must still remove the closed tab.
bridge.updateSessionMetadata([{ sessionId: "online", connected: true }], scope);
const afterClose = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: scope });
assert.deepEqual(afterClose.hosts.map(host => host.sessionId), ["online"]);
});
@@ -87,6 +87,32 @@ test('MCP host_open forwards the schema input through RPC and vault service to t
const editedTarget = await run({ hostId: host.id });
assert.notEqual(editedTarget.sessionId, recovered.sessionId, 'a saved endpoint edit must not reuse the old transport');
assert.equal(editedTarget.reused, undefined);
tabs.length = 0;
host.protocol = 'telnet';
host.telnetPort = 2323;
host.telnetUsername = 'telnet-user';
host.telnetPassword = 'telnet-password';
const telnet = await run({ hostId: host.id });
tabs[0].status = 'connected';
assert.equal((await run({ hostId: host.id })).sessionId, telnet.sessionId);
host.telnetPort = 2324;
const changedTelnet = await run({ hostId: host.id });
assert.notEqual(changedTelnet.sessionId, telnet.sessionId, 'the actual Telnet destination port change requires a new transport');
assert.equal(changedTelnet.reused, undefined);
// Current-main fields only: saved-local creation/default snapshots remain #3616's responsibility.
tabs.length = 0;
host.protocol = 'local';
host.hostname = 'localhost';
host.localShell = '/bin/zsh';
host.localShellArgs = ['-l'];
host.localStartDir = '/original';
const local = await run({ hostId: host.id });
tabs[0].status = 'connected';
assert.equal((await run({ hostId: host.id })).sessionId, local.sessionId);
host.localStartDir = '/replacement';
const changedLocal = await run({ hostId: host.id });
assert.notEqual(changedLocal.sessionId, local.sessionId, 'an explicit saved local directory change must not reuse the old process');
assert.equal(changedLocal.reused, undefined);
// Saved serial hosts use the same first-party host_open path and factory.
tabs.length = 0;
host.protocol = 'serial';