mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247718a0be | ||
|
|
a9939dca00 | ||
|
|
aec1588145 | ||
|
|
4fedd5a70b | ||
|
|
35b83d4655 | ||
|
|
b6b575b045 | ||
|
|
3d5cd6b8ec |
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user