[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
454
components/terminal/terminalClipboardPaste.test.ts
Normal file
454
components/terminal/terminalClipboardPaste.test.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { handleTerminalClipboardPaste } from "./terminalClipboardPaste";
|
||||
|
||||
test("terminal user paste does not inspect or upload remote clipboard images", async () => {
|
||||
const pasted: string[] = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const bridge = {
|
||||
readClipboardImage: async () => assert.fail("user paste must not read clipboard images"),
|
||||
readClipboardFiles: async () => [],
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("remote user paste should use xterm paste handling"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(readTextCalls, ["read"]);
|
||||
assert.deepEqual(pasted, ["hello"]);
|
||||
});
|
||||
|
||||
test("terminal user paste still inserts local clipboard file paths", async () => {
|
||||
const writes: Array<{ data: string; sensitive?: boolean }> = [];
|
||||
const scrolled: string[] = [];
|
||||
let focused = false;
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [
|
||||
{ path: "/Users/alice/shot.png", name: "shot.png", isDirectory: false },
|
||||
{ path: "/Users/alice/report.txt", name: "report.txt", isDirectory: false },
|
||||
],
|
||||
hasClipboardImage: async () => assert.fail("local file paste must not probe clipboard images"),
|
||||
},
|
||||
isLocalConnection: true,
|
||||
isSensitiveInput: () => true,
|
||||
readClipboardText: async () => assert.fail("local file paste should not fall through to text"),
|
||||
scrollToBottomAfterProgrammaticInput: (data) => scrolled.push(data),
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data, options) => writes.push({
|
||||
data,
|
||||
sensitive: options?.sensitive,
|
||||
}),
|
||||
},
|
||||
term: {
|
||||
focus: () => {
|
||||
focused = true;
|
||||
},
|
||||
paste: () => assert.fail("local file paste should write paths directly"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{
|
||||
data: "/Users/alice/shot.png /Users/alice/report.txt",
|
||||
sensitive: true,
|
||||
}]);
|
||||
assert.deepEqual(scrolled, ["/Users/alice/shot.png /Users/alice/report.txt"]);
|
||||
assert.equal(focused, true);
|
||||
});
|
||||
|
||||
test("terminal user paste auto-uploads a clipboard image in remote sessions", async () => {
|
||||
const writes: Array<{ data: string }> = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
closeSftp: async () => {},
|
||||
deleteTempFile: async () => ({ success: true }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push({ data }),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("image upload should insert the remote path, not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ data: "/home/alice/.netcatty-paste-images/shot.png" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste falls back to text when the clipboard holds no image", async () => {
|
||||
const pasted: string[] = [];
|
||||
let sftpOpened = false;
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => null,
|
||||
openSftpForSession: async () => {
|
||||
sftpOpened = true;
|
||||
return "sftp-1";
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => "hello",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("no image should fall back to text paste"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpOpened, false);
|
||||
assert.deepEqual(pasted, ["hello"]);
|
||||
});
|
||||
|
||||
test("terminal user paste reports failed uploads instead of pasting text", async () => {
|
||||
const results: unknown[] = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
onClipboardImageUploadResult: (result) => results.push(result),
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("failed upload should not paste anything"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("failed upload should not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste reports thrown upload failures instead of pasting text", async () => {
|
||||
const results: unknown[] = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => {
|
||||
throw new Error("SFTP unavailable");
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
onClipboardImageUploadResult: (result) => results.push(result),
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("thrown upload failure must not paste anything"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("thrown upload failure must not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste keeps local file path paste even with auto-upload enabled", async () => {
|
||||
const writes: Array<{ data: string }> = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [
|
||||
{ path: "/Users/alice/shot.png", name: "shot.png", isDirectory: false },
|
||||
],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
isLocalConnection: true,
|
||||
readClipboardText: async () => assert.fail("local file paste should not fall through to text"),
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push({ data }),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("local file paste should write paths directly"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ data: "/Users/alice/shot.png" }]);
|
||||
});
|
||||
|
||||
test("local paste forwards Ctrl+V when clipboard holds only an image", async () => {
|
||||
const writes: Array<{ data: string; sensitive?: boolean }> = [];
|
||||
const scrolled: string[] = [];
|
||||
let focused = false;
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
hasClipboardImage: async () => true,
|
||||
},
|
||||
isLocalConnection: true,
|
||||
isSensitiveInput: () => true,
|
||||
readClipboardText: async () => "",
|
||||
sessionId: "session-1",
|
||||
scrollToBottomAfterProgrammaticInput: (data) => scrolled.push(data),
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data, options) => writes.push({
|
||||
data,
|
||||
sensitive: options?.sensitive,
|
||||
}),
|
||||
},
|
||||
term: {
|
||||
focus: () => {
|
||||
focused = true;
|
||||
},
|
||||
paste: () => assert.fail("image-only local paste must not use xterm paste"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ data: "\u0016", sensitive: true }]);
|
||||
assert.deepEqual(scrolled, ["\u0016"]);
|
||||
assert.equal(focused, true);
|
||||
});
|
||||
|
||||
test("local paste prefers clipboard text over forwarding Ctrl+V for images", async () => {
|
||||
const pasted: string[] = [];
|
||||
let hasImageCalls = 0;
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
hasClipboardImage: async () => {
|
||||
hasImageCalls += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
isLocalConnection: true,
|
||||
readClipboardText: async () => "hello",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("text paste should not write Ctrl+V"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(pasted, ["hello"]);
|
||||
assert.equal(hasImageCalls, 0);
|
||||
});
|
||||
|
||||
test("local paste forwards Ctrl+V when clipboard text read fails but an image is present", async () => {
|
||||
const writes: string[] = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
hasClipboardImage: async () => true,
|
||||
},
|
||||
isLocalConnection: true,
|
||||
readClipboardText: async () => {
|
||||
throw new Error("clipboard text unavailable");
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push(data),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("failed text read must not use xterm paste"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, ["\u0016"]);
|
||||
});
|
||||
|
||||
test("local paste treats whitespace-only clipboard text as empty for image forwarding", async () => {
|
||||
const writes: string[] = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
hasClipboardImage: async () => true,
|
||||
},
|
||||
isLocalConnection: true,
|
||||
readClipboardText: async () => " \n\t",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push(data),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("whitespace-only text must not use xterm paste"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, ["\u0016"]);
|
||||
});
|
||||
|
||||
test("local paste keeps whitespace-only clipboard text when no image is present", async () => {
|
||||
const pasted: string[] = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
hasClipboardImage: async () => false,
|
||||
},
|
||||
isLocalConnection: true,
|
||||
readClipboardText: async () => " \n\t",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("whitespace paste should use xterm paste"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(pasted, [" \n\t"]);
|
||||
});
|
||||
|
||||
test("remote paste keeps whitespace-only clipboard text", async () => {
|
||||
const pasted: string[] = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
hasClipboardImage: async () => assert.fail("remote paste must not probe clipboard images"),
|
||||
},
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => "\t",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("remote whitespace paste should use xterm paste"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(pasted, ["\t"]);
|
||||
});
|
||||
|
||||
test("local paste does not write when clipboard has neither text nor image", async () => {
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
hasClipboardImage: async () => false,
|
||||
},
|
||||
isLocalConnection: true,
|
||||
readClipboardText: async () => "",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("empty local clipboard must not write"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("empty local clipboard must not use xterm paste"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("remote paste does not forward Ctrl+V for clipboard images", async () => {
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
hasClipboardImage: async () => assert.fail("remote paste must not probe clipboard images"),
|
||||
},
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => "",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("remote empty paste must not write Ctrl+V"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("empty remote paste must not call xterm paste"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user