mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40541bd0b1 |
No files matched your search
@@ -3536,6 +3536,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
/** Submit compose blocks through the shell's advertised paste support. */
|
||||
preferBracketedPaste?: boolean;
|
||||
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
|
||||
focus?: boolean;
|
||||
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
|
||||
@@ -3584,7 +3586,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
||||
// outside the paste markers — otherwise shells treat it as pasted text
|
||||
// instead of a submit action.
|
||||
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
||||
// Compose submits a command block, so keep it in the shell's input buffer
|
||||
// even when clipboard bracketing is disabled. Otherwise a running command
|
||||
// can consume the later lines as stdin (#4062). Still require shell support.
|
||||
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode
|
||||
&& (options?.preferBracketedPaste || !disableBracketedPasteRef.current)) {
|
||||
data = wrapBracketedPaste(data);
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
@@ -2499,6 +2499,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (executor) {
|
||||
pendingSends.push(Promise.resolve(executor(text, false, {
|
||||
broadcast: false,
|
||||
preferBracketedPaste: true,
|
||||
// Preserve both prompt snapshots across an asynchronous wake: the
|
||||
// peer may clear its prompt classification before the actual write.
|
||||
...(focusedSensitive || recipientSensitive ? { sensitive: true } : {}),
|
||||
@@ -2538,7 +2539,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sensitive = isTerminalSensitiveInputActive(targetId);
|
||||
// Explicitly local: an omitted broadcast option falls back to the
|
||||
// toolbar switch, which can still be on while compose is active-only.
|
||||
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
|
||||
pendingSends.push(Promise.resolve(executor(text, false, {
|
||||
broadcast: false,
|
||||
preferBracketedPaste: true,
|
||||
})).then(
|
||||
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
|
||||
));
|
||||
} else {
|
||||
|
||||
@@ -1446,6 +1446,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
let anyRecipientSensitive = false;
|
||||
let broadcastRecipientIds: readonly string[] = [];
|
||||
const sent = await executeSnippetCommand(text, false, {
|
||||
preferBracketedPaste: true,
|
||||
// Explicit target: active-only stays local while toolbar
|
||||
// broadcast (keystrokes) can remain on, and send-to-all
|
||||
// still fans out when the toolbar switch is off.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { normalizeLineEndings, wrapBracketedPaste } from '../../../lib/utils';
|
||||
import { AUTO_RUN_SNIPPET_LINE_DELAY_MS, shouldDelayAutoRunSnippetInput } from '../terminalHelpers';
|
||||
|
||||
// Exercise the shared production sender without mounting an Electron terminal.
|
||||
const source = readFileSync(new URL('../../Terminal.tsx', import.meta.url), 'utf8');
|
||||
const start = source.indexOf(' const executeSnippetCommand = useCallback(');
|
||||
const end = source.indexOf(' const executeSnippet = useCallback(', start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send = executeSnippetCommand;', {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
type SendOptions = { preferBracketedPaste?: boolean; broadcast?: boolean; multiLineRunMode?: 'paste' | 'lineDelay' };
|
||||
function setup(bracketedPasteMode = true, disableBracketedPaste = true) {
|
||||
const writes: Array<{ id: string; data: string; lineDelayMs?: number }> = [];
|
||||
const broadcasts: string[] = [];
|
||||
const context = {
|
||||
send: undefined as unknown as (command: string, noAutoRun?: boolean, options?: SendOptions) => Promise<boolean>,
|
||||
useCallback: (callback: unknown) => callback,
|
||||
termRef: { current: { modes: { bracketedPasteMode }, focus() {} } },
|
||||
sessionRef: { current: 'source' }, sessionId: 'source', hibernatedRef: { current: false },
|
||||
passwordPromptActiveRef: { current: false }, broadcastPasswordBypassRef: { current: false },
|
||||
isBroadcastEnabledRef: { current: false },
|
||||
shouldBroadcastDuringSensitivePrompt: () => true,
|
||||
isPromptSecurityAlternateScreenActive: () => false,
|
||||
onBroadcastInputRef: { current: (data: string) => { broadcasts.push(data); return ['peer']; } },
|
||||
normalizeLineEndings, wrapBracketedPaste, shouldDelayAutoRunSnippetInput, AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
disableBracketedPasteRef: { current: disableBracketedPaste },
|
||||
prepareProgrammaticSudoInput: (data: string) => data,
|
||||
scrollToBottomAfterProgrammaticInput() {}, markTerminalBroadcastUserInput() {},
|
||||
terminalBackend: { writeToSession: (id: string, data: string, options: { lineDelayMs?: number }) => {
|
||||
writes.push({ id, data, lineDelayMs: options.lineDelayMs });
|
||||
} },
|
||||
host: { protocol: 'ssh' }, serialConfig: { lineMode: false },
|
||||
};
|
||||
runInNewContext(code, context);
|
||||
return { send: context.send, writes, broadcasts };
|
||||
}
|
||||
|
||||
test('compose brackets multiline commands before the final Enter even when clipboard bracketing is disabled', async () => {
|
||||
const { send, writes, broadcasts } = setup();
|
||||
assert.equal(await send('echo one\r\necho two\necho three', false, {
|
||||
preferBracketedPaste: true, broadcast: true,
|
||||
}), true);
|
||||
assert.equal(writes[0].data, '\x1b[200~echo one\necho two\necho three\x1b[201~\r');
|
||||
assert.equal(writes[0].lineDelayMs, undefined);
|
||||
assert.deepEqual(broadcasts, [writes[0].data]);
|
||||
});
|
||||
|
||||
test('single-line compose and snippet paste/line-delay preferences stay unchanged', async () => {
|
||||
const { send, writes } = setup();
|
||||
await send('echo one', false, { preferBracketedPaste: true });
|
||||
await send('echo one\necho two', false);
|
||||
await send('echo one\necho two', true);
|
||||
await send('echo one\necho two', false, { multiLineRunMode: 'lineDelay' });
|
||||
assert.deepEqual(writes.map(({ data, lineDelayMs }) => ({ data, lineDelayMs })), [
|
||||
{ data: 'echo one\r', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two\r', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two\r', lineDelayMs: 250 },
|
||||
]);
|
||||
const unsupported = setup(false);
|
||||
await unsupported.send('echo one\necho two', false, { preferBracketedPaste: true });
|
||||
assert.equal(unsupported.writes[0].data.includes('\x1b[200~'), false);
|
||||
const enabled = setup(true, false);
|
||||
await enabled.send('echo one\necho two', false);
|
||||
assert.equal(enabled.writes[0].data, '\x1b[200~echo one\necho two\x1b[201~\r');
|
||||
});
|
||||
|
||||
// Python's standard-library PTY lets Bash/Readline exercise the actual terminal
|
||||
// bytes without depending on node-pty's Electron-specific native ABI.
|
||||
const bashPty = String.raw`
|
||||
import json, os, pty, select, signal, sys, time
|
||||
payload = json.load(sys.stdin)
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvpe('bash', ['bash', '--noprofile', '--norc', '-i'],
|
||||
dict(os.environ, PS1='__COMPOSE_PROMPT__', TERM='xterm-256color',
|
||||
INPUTRC='/dev/null', HISTFILE='/dev/null', PROMPT_COMMAND=''))
|
||||
def prompt():
|
||||
output = b''
|
||||
deadline = time.monotonic() + 5
|
||||
while b'__COMPOSE_PROMPT__' not in output:
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Bash prompt timeout: ' + repr(output))
|
||||
if select.select([fd], [], [], 0.1)[0]:
|
||||
output += os.read(fd, 65536)
|
||||
# Multiple unbracketed commands produce a prompt between each line. Drain
|
||||
# their output too, so the echo control does not stop at the first prompt.
|
||||
while time.monotonic() < deadline and select.select([fd], [], [], 0.1)[0]:
|
||||
output += os.read(fd, 65536)
|
||||
return output
|
||||
try:
|
||||
prompt()
|
||||
os.write(fd, b"stty -echo; bind 'set enable-bracketed-paste on'\r")
|
||||
prompt()
|
||||
os.write(fd, payload.encode())
|
||||
sys.stdout.write(prompt().decode(errors='replace'))
|
||||
finally:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.close(fd)
|
||||
os.waitpid(pid, 0)
|
||||
`;
|
||||
const hasBashPty = process.platform !== 'win32'
|
||||
&& spawnSync('python3', ['-c', 'import pty']).status === 0
|
||||
&& spawnSync('bash', ['-c', '(( BASH_VERSINFO[0] >= 5 ))']).status === 0;
|
||||
|
||||
for (const [name, command, expected] of [
|
||||
['all echo lines run', "echo '__ONE__'\necho '__TWO__'\necho '__THREE__'", ['__ONE__', '__TWO__', '__THREE__']],
|
||||
['long first command cannot consume later commands as stdin',
|
||||
"sleep 0.4; read -r -t 0.4 consumed; printf '__STDIN__:%s\\n' \"$consumed\"\necho '__TWO__'\necho '__THREE__'",
|
||||
['__STDIN__:', '__TWO__', '__THREE__']],
|
||||
['single-line still runs', "echo '__ONE__'", ['__ONE__']],
|
||||
] as const) {
|
||||
test(`Bash PTY compose: ${name}`, { skip: hasBashPty ? false : 'requires Python PTY support and Bash 5+' }, async () => {
|
||||
const { send, writes } = setup();
|
||||
await send(command, false, { preferBracketedPaste: true });
|
||||
const result = spawnSync('python3', ['-c', bashPty], {
|
||||
input: JSON.stringify(writes[0].data), encoding: 'utf8', timeout: 10000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr || String(result.error));
|
||||
const lines = stripVTControlCharacters(result.stdout).split(/\r?\n/);
|
||||
assert.deepEqual(lines.filter(line => line.startsWith('__') && !line.startsWith('__COMPOSE_PROMPT__')), expected);
|
||||
});
|
||||
}
|
||||
@@ -260,6 +260,7 @@ export interface TerminalProps {
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
preferBracketedPaste?: boolean;
|
||||
focus?: boolean;
|
||||
},
|
||||
) => boolean | Promise<boolean>) | null,
|
||||
|
||||
@@ -112,6 +112,7 @@ export type SnippetExecutor = (
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
preferBracketedPaste?: boolean;
|
||||
/** When false, do not steal keyboard focus (multi-tab fan-out). Default true. */
|
||||
focus?: boolean;
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send =
|
||||
}).outputText;
|
||||
|
||||
type Status = 'connected' | 'connecting' | 'disconnected';
|
||||
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean }) => boolean | Promise<boolean>;
|
||||
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean; preferBracketedPaste?: boolean }) => boolean | Promise<boolean>;
|
||||
function setup(
|
||||
statuses: Status[],
|
||||
broadcast = false,
|
||||
@@ -96,6 +96,24 @@ test('compose send-to-all fans out while toolbar broadcast stays off', async ()
|
||||
assert.deepEqual(writes, ['0', '1']);
|
||||
});
|
||||
|
||||
for (const sendToAll of [false, true]) {
|
||||
test(`workspace compose requests supported bracketed paste on each target (all=${sendToAll})`, async () => {
|
||||
const calls: string[] = [];
|
||||
const text = 'echo one\necho two\necho three';
|
||||
const executors = new Map<string, Executor>(['0', '1'].map(id => [id, async (command, noAutoRun, options) => {
|
||||
assert.equal(command, text);
|
||||
assert.equal(noAutoRun, false);
|
||||
assert.equal(options?.preferBracketedPaste, true);
|
||||
assert.equal(options?.broadcast, false);
|
||||
calls.push(id);
|
||||
return true;
|
||||
}]));
|
||||
const { send } = setup(['connected', 'connected'], false, executors, new Set(), false, new Set(), sendToAll);
|
||||
assert.equal(await send(text), true);
|
||||
assert.deepEqual(calls, sendToAll ? ['0', '1'] : ['0']);
|
||||
});
|
||||
}
|
||||
|
||||
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async () => true],
|
||||
@@ -231,7 +249,8 @@ test('solo compose send uses the compose-bar target while toolbar broadcast stay
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean; preferBracketedPaste?: boolean }) => {
|
||||
assert.equal(options?.preferBracketedPaste, true);
|
||||
calls.push(options?.broadcast);
|
||||
return true;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user