mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
2835 lines
84 KiB
TypeScript
2835 lines
84 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
createTerminalSessionStarters,
|
|
getMissingChainHostIds,
|
|
} from "./createTerminalSessionStarters";
|
|
import { createPromptLineBreakState } from "./promptLineBreak";
|
|
import { resolveStartupCommand } from "./terminalStartupCommands";
|
|
import { pasteTextIntoTerminal } from "./terminalUserPaste";
|
|
|
|
const noop = () => undefined;
|
|
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
|
|
|
const armSudoPrompt = (
|
|
autofill: { armForCommand: (command: string) => void } | null,
|
|
command = "sudo whoami",
|
|
): string => {
|
|
autofill?.armForCommand(command);
|
|
return "[sudo] password for alice: ";
|
|
};
|
|
|
|
const createTermStub = (overrides: Record<string, unknown> = {}) => ({
|
|
cols: 120,
|
|
rows: 32,
|
|
write: (_data: string, callback?: () => void) => callback?.(),
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
...overrides,
|
|
});
|
|
|
|
const createStarterContext = (overrides: Record<string, unknown> = {}) => ({
|
|
onSudoHint: () => true,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
},
|
|
keys: [],
|
|
identities: [],
|
|
knownHosts: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: true },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
promptLineBreakStateRef: { current: createPromptLineBreakState() },
|
|
sudoAutofillRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
...overrides,
|
|
});
|
|
|
|
test("getMissingChainHostIds reports unresolved jump hosts", () => {
|
|
assert.deepEqual(
|
|
getMissingChainHostIds(
|
|
{
|
|
id: "host-1",
|
|
label: "Example",
|
|
hostname: "example.test",
|
|
username: "alice",
|
|
hostChain: { hostIds: ["jump-1", "jump-2"] },
|
|
} as never,
|
|
[{ id: "jump-1" }] as never,
|
|
),
|
|
["jump-2"],
|
|
);
|
|
});
|
|
|
|
test("startSSH forwards custom ProxyCommand to the SSH bridge", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
port: 2200,
|
|
proxyConfig: {
|
|
type: "command",
|
|
host: "",
|
|
port: 0,
|
|
command: "cloudflared access ssh --hostname %h",
|
|
},
|
|
},
|
|
keys: [],
|
|
identities: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: (_data: string, callback?: () => void) => callback?.(),
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(term as never);
|
|
|
|
assert.deepEqual(capturedOptions?.proxy, {
|
|
type: "command",
|
|
host: "",
|
|
port: 0,
|
|
command: "cloudflared access ssh --hostname %h",
|
|
username: undefined,
|
|
password: undefined,
|
|
});
|
|
});
|
|
|
|
test("startSSH resolves target proxy credentials from an identity", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
proxyConfig: {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
identityId: "identity-1",
|
|
},
|
|
},
|
|
identities: [{
|
|
id: "identity-1",
|
|
label: "Proxy login",
|
|
username: "proxy-user",
|
|
authMethod: "password",
|
|
password: "proxy-secret",
|
|
created: 1,
|
|
}],
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.deepEqual(capturedOptions?.proxy, {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
username: "proxy-user",
|
|
password: "proxy-secret",
|
|
});
|
|
});
|
|
|
|
test("startSSH resolves jump host proxy credentials from an identity", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
},
|
|
resolvedChainHosts: [{
|
|
id: "jump-1",
|
|
label: "Jump",
|
|
hostname: "jump.example.test",
|
|
username: "jump",
|
|
proxyConfig: {
|
|
type: "socks5",
|
|
host: "jump-proxy.example.test",
|
|
port: 1080,
|
|
identityId: "identity-1",
|
|
},
|
|
}],
|
|
identities: [{
|
|
id: "identity-1",
|
|
label: "Proxy login",
|
|
username: "proxy-user",
|
|
authMethod: "password",
|
|
password: "proxy-secret",
|
|
created: 1,
|
|
}],
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
const jumpHosts = capturedOptions?.jumpHosts as Array<Record<string, unknown>>;
|
|
assert.deepEqual(jumpHosts[0]?.proxy, {
|
|
type: "socks5",
|
|
host: "jump-proxy.example.test",
|
|
port: 1080,
|
|
username: "proxy-user",
|
|
password: "proxy-secret",
|
|
});
|
|
});
|
|
|
|
test("startSSH rejects missing saved proxy profiles before connecting", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
proxyProfileId: "missing-proxy",
|
|
},
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /Saved proxy for host "Target" is missing/);
|
|
});
|
|
|
|
test("startSSH rejects missing saved proxy profiles on jump hosts before connecting", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
},
|
|
resolvedChainHosts: [{
|
|
id: "jump-1",
|
|
label: "Jump",
|
|
hostname: "jump.example.test",
|
|
username: "jump",
|
|
proxyProfileId: "missing-proxy",
|
|
}],
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /Saved proxy for jump host "Jump" is missing/);
|
|
});
|
|
|
|
test("startSSH rejects missing proxy identities before connecting", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
proxyConfig: {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
identityId: "missing-identity",
|
|
},
|
|
},
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /Proxy identity/);
|
|
assert.match(error, /Target/);
|
|
});
|
|
|
|
test("startSSH rejects incomplete proxy identities before connecting", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
proxyConfig: {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
identityId: "identity-1",
|
|
},
|
|
},
|
|
identities: [{
|
|
id: "identity-1",
|
|
label: "Proxy login",
|
|
username: "proxy-user",
|
|
authMethod: "password",
|
|
created: 1,
|
|
}],
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /Proxy identity/);
|
|
assert.match(error, /incomplete/);
|
|
});
|
|
|
|
test("startSSH rejects proxy identities with blank usernames even when passwords are encrypted", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
proxyConfig: {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
identityId: "identity-1",
|
|
},
|
|
},
|
|
identities: [{
|
|
id: "identity-1",
|
|
label: "Proxy login",
|
|
username: " ",
|
|
authMethod: "password",
|
|
password: ENCRYPTED_CREDENTIAL_PLACEHOLDER,
|
|
created: 1,
|
|
}],
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /Proxy identity/);
|
|
assert.match(error, /incomplete/);
|
|
});
|
|
|
|
test("startSSH rejects target proxy identity passwords that cannot be decrypted", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
proxyConfig: {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
identityId: "identity-1",
|
|
},
|
|
},
|
|
identities: [{
|
|
id: "identity-1",
|
|
label: "Proxy login",
|
|
username: "proxy-user",
|
|
authMethod: "password",
|
|
password: ENCRYPTED_CREDENTIAL_PLACEHOLDER,
|
|
created: 1,
|
|
}],
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /Proxy credentials cannot be decrypted/);
|
|
});
|
|
|
|
test("startSSH rejects missing jump host proxy identities before connecting", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
},
|
|
resolvedChainHosts: [{
|
|
id: "jump-1",
|
|
label: "Jump",
|
|
hostname: "jump.example.test",
|
|
username: "jump",
|
|
proxyConfig: {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
identityId: "missing-identity",
|
|
},
|
|
}],
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /Proxy identity/);
|
|
assert.match(error, /Jump/);
|
|
});
|
|
|
|
test("startSSH rejects incomplete jump host proxy identities before connecting", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
},
|
|
identities: [{
|
|
id: "identity-1",
|
|
label: "Proxy login",
|
|
username: "",
|
|
authMethod: "password",
|
|
password: ENCRYPTED_CREDENTIAL_PLACEHOLDER,
|
|
created: 1,
|
|
}],
|
|
resolvedChainHosts: [{
|
|
id: "jump-1",
|
|
label: "Jump",
|
|
hostname: "jump.example.test",
|
|
username: "jump",
|
|
proxyConfig: {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
identityId: "identity-1",
|
|
},
|
|
}],
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /Proxy identity/);
|
|
assert.match(error, /incomplete/);
|
|
assert.match(error, /Jump/);
|
|
});
|
|
|
|
test("startSSH rejects jump host proxy identity passwords that cannot be decrypted", async () => {
|
|
let started = false;
|
|
let error = "";
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
started = true;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalBackend,
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
},
|
|
identities: [{
|
|
id: "identity-1",
|
|
label: "Proxy login",
|
|
username: "proxy-user",
|
|
authMethod: "password",
|
|
password: ENCRYPTED_CREDENTIAL_PLACEHOLDER,
|
|
created: 1,
|
|
}],
|
|
resolvedChainHosts: [{
|
|
id: "jump-1",
|
|
label: "Jump",
|
|
hostname: "jump.example.test",
|
|
username: "jump",
|
|
proxyConfig: {
|
|
type: "http",
|
|
host: "proxy.example.test",
|
|
port: 3128,
|
|
identityId: "identity-1",
|
|
},
|
|
}],
|
|
setError: (message: string) => { error = message; },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(started, false);
|
|
assert.match(error, /cannot be decrypted/);
|
|
assert.match(error, /Jump/);
|
|
});
|
|
|
|
test("startSSH sends key and password together in one connection for publickey+password MFA hosts", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
let startCalls = 0;
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
startCalls += 1;
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "key",
|
|
identityFilePaths: ["/Users/me/.ssh/key"],
|
|
password: "login-secret",
|
|
savePassword: true,
|
|
},
|
|
keys: [],
|
|
identities: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: (_data: string, callback?: () => void) => callback?.(),
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(term as never);
|
|
|
|
assert.equal(startCalls, 1, "credentials must go in a single connection, not separate per-factor attempts");
|
|
assert.equal(capturedOptions?.password, "login-secret");
|
|
assert.deepEqual(capturedOptions?.identityFilePaths, ["/Users/me/.ssh/key"]);
|
|
});
|
|
|
|
test("startSSH resets the TCP dial timeout state before password fallback", async () => {
|
|
let chainProgressListener: (
|
|
sessionId: string,
|
|
hop: number,
|
|
total: number,
|
|
label: string,
|
|
status: string,
|
|
error?: string,
|
|
) => void = noop;
|
|
let startCalls = 0;
|
|
const tcpDialState: boolean[] = [];
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
startCalls += 1;
|
|
if (startCalls === 1) {
|
|
chainProgressListener("session-1", 1, 1, "target.example.test", "tcp-connected");
|
|
throw new Error("Authentication failed");
|
|
}
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: (listener: typeof chainProgressListener) => {
|
|
chainProgressListener = listener;
|
|
return noop;
|
|
},
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "key",
|
|
identityFileId: "key-1",
|
|
password: "login-secret",
|
|
},
|
|
keys: [{
|
|
id: "key-1",
|
|
name: "Key",
|
|
privateKey: "plain-private-key",
|
|
publicKey: "",
|
|
source: "embedded",
|
|
}],
|
|
terminalBackend,
|
|
setIsConnectionPastTcpDial: (value: boolean) => {
|
|
tcpDialState.push(value);
|
|
},
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(startCalls, 2);
|
|
assert.deepEqual(tcpDialState, [false, false, true, false]);
|
|
});
|
|
|
|
test("startSSH resets the TCP dial timeout state when a jump host starts forwarding", async () => {
|
|
let chainProgressListener: (
|
|
sessionId: string,
|
|
hop: number,
|
|
total: number,
|
|
label: string,
|
|
status: string,
|
|
error?: string,
|
|
) => void = noop;
|
|
const tcpDialState: boolean[] = [];
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => {
|
|
chainProgressListener("session-1", 1, 2, "bastion.example.test", "tcp-connected");
|
|
chainProgressListener("session-1", 1, 2, "bastion.example.test", "forwarding");
|
|
chainProgressListener("session-1", 2, 2, "target.example.test", "tcp-connected");
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: (listener: typeof chainProgressListener) => {
|
|
chainProgressListener = listener;
|
|
return noop;
|
|
},
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
password: "login-secret",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
},
|
|
resolvedChainHosts: [{
|
|
id: "jump-1",
|
|
label: "Bastion",
|
|
hostname: "bastion.example.test",
|
|
username: "alice",
|
|
password: "jump-secret",
|
|
}],
|
|
terminalBackend,
|
|
setIsConnectionPastTcpDial: (value: boolean) => {
|
|
tcpDialState.push(value);
|
|
},
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.deepEqual(tcpDialState, [false, false, true, false, true]);
|
|
});
|
|
|
|
test("startSSH forwards the saved sudo autofill password to the SSH bridge", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
password: "login-secret",
|
|
},
|
|
terminalBackend,
|
|
sudoAutofillPassword: "sudo-secret",
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
|
|
assert.equal(capturedOptions?.sudoAutofillPassword, "sudo-secret");
|
|
});
|
|
|
|
test("startSSH enables sudo autofill only with the host saved password", async () => {
|
|
let onData: ((data: string) => void) | null = null;
|
|
const sent: string[] = [];
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (_id: string, data: string) => sent.push(data),
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
password: "saved-secret",
|
|
},
|
|
terminalBackend,
|
|
sudoAutofillPassword: "saved-secret",
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
onData?.(armSudoPrompt(ctx.sudoAutofillRef.current));
|
|
ctx.sudoAutofillRef.current?.confirmFill();
|
|
|
|
assert.deepEqual(sent, ["saved-secret\n"]);
|
|
});
|
|
|
|
test("startSSH does not use unsaved retry passwords for sudo autofill", async () => {
|
|
let onData: ((data: string) => void) | null = null;
|
|
const sent: string[] = [];
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (_id: string, data: string) => sent.push(data),
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
},
|
|
pendingAuthRef: {
|
|
current: {
|
|
authMethod: "password",
|
|
username: "alice",
|
|
password: "temporary-secret",
|
|
savedToHost: false,
|
|
},
|
|
},
|
|
terminalBackend,
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
|
|
onData?.("[sudo] password for alice: ");
|
|
|
|
assert.deepEqual(sent, []);
|
|
});
|
|
|
|
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
|
|
let onData: ((data: string) => void) | null = null;
|
|
const sent: string[] = [];
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (_id: string, data: string) => sent.push(data),
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
},
|
|
pendingAuthRef: {
|
|
current: {
|
|
authMethod: "password",
|
|
username: "alice",
|
|
password: "pending-secret",
|
|
savedToHost: true,
|
|
},
|
|
},
|
|
terminalBackend,
|
|
sudoAutofillPasswordRef: { current: "stale-secret" },
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
|
|
onData?.("[sudo] password for alice: ");
|
|
ctx.sudoAutofillRef.current?.confirmFill();
|
|
|
|
assert.deepEqual(sent, ["pending-secret\n"]);
|
|
});
|
|
|
|
test("startSSH does not use merged group default passwords for sudo autofill", async () => {
|
|
let onData: ((data: string) => void) | null = null;
|
|
const sent: string[] = [];
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (_id: string, data: string) => sent.push(data),
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
password: "group-default-secret",
|
|
},
|
|
terminalBackend,
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
|
|
onData?.("[sudo] password for alice: ");
|
|
|
|
assert.deepEqual(sent, []);
|
|
});
|
|
|
|
test("startSSH uses the provided sudo autofill password", async () => {
|
|
let onData: ((data: string) => void) | null = null;
|
|
const sent: string[] = [];
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (_id: string, data: string) => sent.push(data),
|
|
resizeSession: noop,
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
},
|
|
terminalBackend,
|
|
sudoAutofillPassword: "host-secret",
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
onData?.(armSudoPrompt(ctx.sudoAutofillRef.current));
|
|
ctx.sudoAutofillRef.current?.confirmFill();
|
|
|
|
assert.deepEqual(sent, ["host-secret\n"]);
|
|
});
|
|
|
|
test("startSerial captures direct connected banner in terminal log data", async () => {
|
|
const capturedLogData: string[] = [];
|
|
const writtenData: string[] = [];
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "serial-host",
|
|
label: "Serial",
|
|
hostname: "COM3",
|
|
username: "",
|
|
protocol: "serial",
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {
|
|
verifyHostKeys: false,
|
|
},
|
|
terminalBackend,
|
|
serialConfig: {
|
|
path: "COM3",
|
|
baudRate: 9600,
|
|
dataBits: 8,
|
|
stopBits: 1,
|
|
parity: "none",
|
|
flowControl: "none",
|
|
},
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
onTerminalLogData: (data: string) => capturedLogData.push(data),
|
|
};
|
|
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: (data: string, callback?: () => void) => {
|
|
writtenData.push(data);
|
|
callback?.();
|
|
},
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSerial(term as never);
|
|
|
|
const banner = "[Connected to COM3 at 9600 baud]";
|
|
assert.deepEqual(writtenData, [`${banner}\r\n`]);
|
|
assert.deepEqual(capturedLogData, [`${banner}\r\n`]);
|
|
});
|
|
|
|
test("local session captures paste cleanup writes in terminal log data", async () => {
|
|
const capturedLogData: string[] = [];
|
|
const writes: string[] = [];
|
|
let onData: ((data: string) => void) | null = null;
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
onTerminalLogData: (data: string) => capturedLogData.push(data),
|
|
};
|
|
|
|
const term = {
|
|
cols: 20,
|
|
rows: 4,
|
|
paste: noop,
|
|
write: (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
callback?.();
|
|
},
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
|
pasteTextIntoTerminal(term, longPaste, { scrollOnPaste: false });
|
|
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
|
|
|
|
assert.notEqual(onData, null);
|
|
onData?.("\x1b[7mline 3 with enough content\x1b[27m");
|
|
|
|
assert.deepEqual(writes, ["line 3 with enough content", "\x1b[K"]);
|
|
assert.deepEqual(capturedLogData, ["line 3 with enough content", "\x1b[K"]);
|
|
});
|
|
|
|
test("local session runs startup command after attaching", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const attached: string[] = [];
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: "docker logs -f --tail 200 abc123",
|
|
promptLineBreakStateRef: undefined,
|
|
onSessionAttached: (id: string) => attached.push(id),
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.deepEqual(attached, ["local-session"]);
|
|
assert.deepEqual(sessionWrites, [{
|
|
id: "local-session",
|
|
data: "docker logs -f --tail 200 abc123\r",
|
|
automated: true,
|
|
}]);
|
|
});
|
|
|
|
test("local session sends multi-line startup snippets in one write by default", async () => {
|
|
const attached: string[] = [];
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const terminalBackend = {
|
|
localAvailable: () => true,
|
|
startLocalSession: async () => "local-session",
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: 'sudo apt install gconf2-common -y\necho "123456"',
|
|
promptLineBreakStateRef: undefined,
|
|
onSessionAttached: (id: string) => attached.push(id),
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.deepEqual(attached, ["local-session"]);
|
|
assert.deepEqual(sessionWrites, [{
|
|
id: "local-session",
|
|
data: 'sudo apt install gconf2-common -y\necho "123456"\r',
|
|
automated: true,
|
|
}]);
|
|
});
|
|
|
|
test("local session wraps multi-line startup paste when bracketed paste is active", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const terminalBackend = {
|
|
localAvailable: () => true,
|
|
startLocalSession: async () => "local-session",
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: "sudo apt install gconf2-common -y\necho done",
|
|
promptLineBreakStateRef: undefined,
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub({
|
|
modes: { bracketedPasteMode: true },
|
|
}) as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.deepEqual(sessionWrites, [{
|
|
id: "local-session",
|
|
data: "\x1b[200~sudo apt install gconf2-common -y\necho done\x1b[201~\r",
|
|
automated: true,
|
|
}]);
|
|
});
|
|
|
|
test("local session respects disabled bracketed paste for startup paste", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const terminalBackend = {
|
|
localAvailable: () => true,
|
|
startLocalSession: async () => "local-session",
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: "first\nsecond",
|
|
promptLineBreakStateRef: undefined,
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub({
|
|
modes: { bracketedPasteMode: true },
|
|
options: { ignoreBracketedPasteMode: true },
|
|
}) as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.deepEqual(sessionWrites, [{
|
|
id: "local-session",
|
|
data: "first\nsecond\r",
|
|
automated: true,
|
|
}]);
|
|
});
|
|
|
|
test("local session can send multi-line startup snippets line by line", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const terminalBackend = {
|
|
localAvailable: () => true,
|
|
startLocalSession: async () => "local-session",
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: "first cmd\nsecond cmd",
|
|
multiLineRunMode: "lineDelay",
|
|
promptLineBreakStateRef: undefined,
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.deepEqual(sessionWrites, [{ id: "local-session", data: "first cmd\r", automated: true }]);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.deepEqual(sessionWrites, [
|
|
{ id: "local-session", data: "first cmd\r", automated: true },
|
|
{ id: "local-session", data: "second cmd\r", automated: true },
|
|
]);
|
|
});
|
|
|
|
test("local session sends host startup commands in one write by default", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const terminalBackend = {
|
|
localAvailable: () => true,
|
|
startLocalSession: async () => "local-session",
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
startupCommand: "enter prompt\nrun command",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: undefined,
|
|
promptLineBreakStateRef: undefined,
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.deepEqual(sessionWrites, [{
|
|
id: "local-session",
|
|
data: "enter prompt\nrun command\r",
|
|
automated: true,
|
|
}]);
|
|
});
|
|
|
|
test("local session can send host startup commands line by line", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const terminalBackend = {
|
|
localAvailable: () => true,
|
|
startLocalSession: async () => "local-session",
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
startupCommand: "first host cmd\nsecond host cmd",
|
|
startupCommandRunMode: "lineDelay",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: undefined,
|
|
promptLineBreakStateRef: undefined,
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.deepEqual(sessionWrites, [{ id: "local-session", data: "first host cmd\r", automated: true }]);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.deepEqual(sessionWrites, [
|
|
{ id: "local-session", data: "first host cmd\r", automated: true },
|
|
{ id: "local-session", data: "second host cmd\r", automated: true },
|
|
]);
|
|
});
|
|
|
|
test("startup command suppression is consumed only when scheduling", () => {
|
|
const suppressHostStartupCommandRef = { current: true };
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
startupCommand: "echo host-startup",
|
|
},
|
|
startupCommand: undefined,
|
|
suppressHostStartupCommandRef,
|
|
});
|
|
|
|
assert.equal(resolveStartupCommand(ctx as never), undefined);
|
|
assert.equal(suppressHostStartupCommandRef.current, true);
|
|
assert.equal(
|
|
resolveStartupCommand(ctx as never, { consumeSuppressHostStartupCommand: true }),
|
|
undefined,
|
|
);
|
|
assert.equal(suppressHostStartupCommandRef.current, false);
|
|
assert.equal(resolveStartupCommand(ctx as never), "echo host-startup");
|
|
});
|
|
|
|
test("restored local reconnect does not fall back to host startup command", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
startupCommand: "echo host-startup",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: undefined,
|
|
suppressHostStartupCommandRef: { current: true },
|
|
promptLineBreakStateRef: undefined,
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.deepEqual(sessionWrites, []);
|
|
});
|
|
|
|
test("local session restores cwd before startup command after attaching", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const executedCommands: string[] = [];
|
|
const progressLogs: string[] = [];
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const restoreCwdIntentRef = {
|
|
current: { cwd: "/srv/app dir", command: "cd -- '/srv/app dir'" },
|
|
};
|
|
const ctx = createStarterContext({
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: "pwd",
|
|
promptLineBreakStateRef: undefined,
|
|
restoreCwdIntentRef,
|
|
setProgressLogs: (updater: (prev: string[]) => string[]) => {
|
|
progressLogs.splice(0, progressLogs.length, ...updater(progressLogs));
|
|
},
|
|
onCommandExecuted: (command: string) => {
|
|
executedCommands.push(command);
|
|
},
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.equal(restoreCwdIntentRef.current, null);
|
|
assert.deepEqual(sessionWrites, [
|
|
{ id: "local-session", data: "cd -- '/srv/app dir'\r", automated: true },
|
|
{ id: "local-session", data: "pwd\r", automated: true },
|
|
]);
|
|
assert.deepEqual(executedCommands, ["pwd"]);
|
|
assert.deepEqual(progressLogs, ["Restoring working directory: /srv/app dir"]);
|
|
});
|
|
|
|
test("ssh session restores cwd before startup command after attaching", async () => {
|
|
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
|
const executedCommands: string[] = [];
|
|
const progressLogs: string[] = [];
|
|
const restoredCwds: string[] = [];
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
|
sessionWrites.push({ id, data, automated: options?.automated });
|
|
},
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const restoreCwdIntentRef = {
|
|
current: { cwd: "/srv/app dir", command: "cd -- '/srv/app dir'" },
|
|
};
|
|
const ctx = createStarterContext({
|
|
terminalSettings: { startupCommandDelayMs: 0 },
|
|
terminalBackend,
|
|
startupCommand: "pwd",
|
|
promptLineBreakStateRef: undefined,
|
|
restoreCwdIntentRef,
|
|
setProgressLogs: (updater: (prev: string[]) => string[]) => {
|
|
progressLogs.splice(0, progressLogs.length, ...updater(progressLogs));
|
|
},
|
|
onRestoreCwdIntentConsumed: (cwd: string) => {
|
|
restoredCwds.push(cwd);
|
|
},
|
|
onCommandExecuted: (command: string) => {
|
|
executedCommands.push(command);
|
|
},
|
|
});
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.equal(restoreCwdIntentRef.current, null);
|
|
assert.deepEqual(restoredCwds, ["/srv/app dir"]);
|
|
assert.deepEqual(sessionWrites, [
|
|
{ id: "ssh-session", data: "cd -- '/srv/app dir'\r", automated: true },
|
|
{ id: "ssh-session", data: "pwd\r", automated: true },
|
|
]);
|
|
assert.deepEqual(executedCommands, ["pwd"]);
|
|
assert.deepEqual(progressLogs, ["Restoring working directory: /srv/app dir"]);
|
|
});
|
|
|
|
test("local session resets terminal timestamp state when reusing a terminal", async () => {
|
|
const writes: string[] = [];
|
|
const markerLines: number[] = [];
|
|
const disposedMarkerLines: number[] = [];
|
|
let onData: ((data: string) => void) | null = null;
|
|
let cursorLine = 0;
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
showLineTimestamps: true,
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {
|
|
showLineTimestamps: true,
|
|
scrollOnOutput: false,
|
|
forcePromptNewLine: false,
|
|
},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: true },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
|
|
const term = {
|
|
cols: 20,
|
|
rows: 4,
|
|
buffer: { active: { type: "normal" } },
|
|
write: (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
for (const char of data) {
|
|
if (char === "\n") {
|
|
cursorLine += 1;
|
|
}
|
|
}
|
|
callback?.();
|
|
},
|
|
registerMarker: (offset: number) => {
|
|
const line = cursorLine + offset;
|
|
markerLines.push(line);
|
|
const marker = {
|
|
line,
|
|
isDisposed: false,
|
|
dispose() {
|
|
marker.isDisposed = true;
|
|
disposedMarkerLines.push(line);
|
|
},
|
|
};
|
|
return marker;
|
|
},
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
const starters = createTerminalSessionStarters(ctx as never);
|
|
await starters.startLocal(term as never);
|
|
onData?.("unfinished");
|
|
await starters.startLocal(term as never);
|
|
onData?.("fresh");
|
|
|
|
assert.equal(writes.length, 2);
|
|
assert.equal(writes[0], "unfinished");
|
|
assert.equal(writes[1], "fresh");
|
|
assert.deepEqual(markerLines, [0, 0]);
|
|
assert.deepEqual(disposedMarkerLines, [0]);
|
|
});
|
|
|
|
test("session data waits for prior terminal writes before evaluating prompt line breaks", async () => {
|
|
const writes: string[] = [];
|
|
const writeCallbacks: Array<() => void> = [];
|
|
let onData: ((data: string) => void) | null = null;
|
|
let cursorX = 0;
|
|
let lineText = "";
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const promptState = createPromptLineBreakState();
|
|
promptState.lastPromptText = "$ ";
|
|
promptState.pendingCommand = true;
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: { forcePromptNewLine: true },
|
|
terminalBackend,
|
|
promptLineBreakStateRef: { current: promptState },
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
|
|
const term = {
|
|
get buffer() {
|
|
return {
|
|
active: {
|
|
get cursorX() {
|
|
return cursorX;
|
|
},
|
|
cursorY: 0,
|
|
baseY: 0,
|
|
getLine(line: number) {
|
|
if (line !== 0) return undefined;
|
|
return {
|
|
isWrapped: false,
|
|
translateToString() {
|
|
return lineText;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
},
|
|
write: (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
if (callback) writeCallbacks.push(callback);
|
|
},
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
|
|
|
|
assert.notEqual(onData, null);
|
|
onData?.("hello");
|
|
onData?.("$ ");
|
|
|
|
assert.deepEqual(writes, ["hello"]);
|
|
|
|
cursorX = 5;
|
|
lineText = "hello";
|
|
writeCallbacks.shift()?.();
|
|
|
|
assert.deepEqual(writes, ["hello", "\r\n$ "]);
|
|
});
|
|
|
|
test("prompt line break display insertion does not mutate captured session log data", async () => {
|
|
const writes: string[] = [];
|
|
const capturedLogData: string[] = [];
|
|
const writeCallbacks: Array<() => void> = [];
|
|
let onData: ((data: string) => void) | null = null;
|
|
let cursorX = 0;
|
|
let lineText = "";
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const promptState = createPromptLineBreakState();
|
|
promptState.lastPromptText = "$ ";
|
|
promptState.pendingCommand = true;
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: { forcePromptNewLine: true },
|
|
terminalBackend,
|
|
promptLineBreakStateRef: { current: promptState },
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
onTerminalLogData: (data: string) => capturedLogData.push(data),
|
|
};
|
|
|
|
const term = {
|
|
get buffer() {
|
|
return {
|
|
active: {
|
|
get cursorX() {
|
|
return cursorX;
|
|
},
|
|
cursorY: 0,
|
|
baseY: 0,
|
|
getLine(line: number) {
|
|
if (line !== 0) return undefined;
|
|
return {
|
|
isWrapped: false,
|
|
translateToString() {
|
|
return lineText;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
},
|
|
write: (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
if (callback) writeCallbacks.push(callback);
|
|
},
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
|
|
|
|
assert.notEqual(onData, null);
|
|
onData?.("hello");
|
|
onData?.("$ ");
|
|
|
|
cursorX = 5;
|
|
lineText = "hello";
|
|
writeCallbacks.shift()?.();
|
|
|
|
assert.deepEqual(writes, ["hello", "\r\n$ "]);
|
|
assert.deepEqual(capturedLogData, ["hello", "$ "]);
|
|
});
|
|
|
|
test("local session exit text waits for pending terminal output writes", async () => {
|
|
const writes: string[] = [];
|
|
const writeCallbacks: Array<() => void> = [];
|
|
let onData: ((data: string) => void) | null = null;
|
|
let onExit: ((evt: { reason?: "closed" }) => void) | null = null;
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async () => "ssh-session",
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: (_id: string, cb: (data: string) => void) => {
|
|
onData = cb;
|
|
return noop;
|
|
},
|
|
onSessionExit: (_id: string, cb: (evt: { reason?: "closed" }) => void) => {
|
|
onExit = cb;
|
|
return noop;
|
|
},
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "local-host",
|
|
label: "Local",
|
|
hostname: "local",
|
|
username: "",
|
|
protocol: "local",
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
|
|
const term = {
|
|
cols: 20,
|
|
rows: 4,
|
|
write: (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
if (callback) writeCallbacks.push(callback);
|
|
},
|
|
writeln: (data: string) => {
|
|
writes.push(`${data}\r\n`);
|
|
},
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
|
|
|
|
assert.notEqual(onData, null);
|
|
assert.notEqual(onExit, null);
|
|
onData?.("partial output");
|
|
onExit?.({ reason: "closed" });
|
|
|
|
assert.deepEqual(writes, ["partial output"]);
|
|
|
|
writeCallbacks.shift()?.();
|
|
|
|
assert.deepEqual(writes, ["partial output", "\r\n[session closed]\r\n"]);
|
|
});
|
|
|
|
test("startSSH allows jump hosts that use reference key files with unavailable saved passphrases", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
let error = "";
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
port: 2200,
|
|
},
|
|
keys: [{
|
|
id: "jump-key",
|
|
label: "Jump key",
|
|
source: "reference",
|
|
privateKey: "",
|
|
filePath: "/Users/alice/.ssh/id_ed25519",
|
|
passphrase: ENCRYPTED_CREDENTIAL_PLACEHOLDER,
|
|
}],
|
|
resolvedChainHosts: [{
|
|
id: "jump-1",
|
|
label: "Jump",
|
|
hostname: "jump.example.test",
|
|
username: "jumper",
|
|
authMethod: "key",
|
|
identityFileId: "jump-key",
|
|
}],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: (message: string) => { error = message; },
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: noop,
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(term as never);
|
|
|
|
assert.equal(error, "");
|
|
assert.ok(capturedOptions);
|
|
const jumpHosts = capturedOptions.jumpHosts as Array<Record<string, unknown>>;
|
|
assert.deepEqual(jumpHosts[0]?.identityFilePaths, ["/Users/alice/.ssh/id_ed25519"]);
|
|
assert.equal(jumpHosts[0]?.privateKey, undefined);
|
|
assert.equal(jumpHosts[0]?.passphrase, undefined);
|
|
});
|
|
|
|
test("startSSH forwards the SSH debug logging setting to the native bridge", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
port: 22,
|
|
password: "pw",
|
|
},
|
|
keys: [],
|
|
knownHosts: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalBackend,
|
|
sshDebugLogEnabled: true,
|
|
terminalSettings: { keepaliveInterval: 30, keepaliveCountMax: 10 },
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
onSessionAttached: noop,
|
|
};
|
|
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: (_data: string, cb?: () => void) => cb?.(),
|
|
loadAddon: noop,
|
|
};
|
|
await createTerminalSessionStarters(ctx as unknown as TerminalSessionStartersContext).startSSH(term);
|
|
|
|
assert.equal(capturedOptions?.sshDebugLogEnabled, true);
|
|
});
|
|
|
|
test("startSSH omits identity file paths when password auth is selected", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
authMethod: "password",
|
|
password: "secret",
|
|
identityFilePaths: ["/Users/alice/.ssh/id_ed25519"],
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: noop,
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(term as never);
|
|
|
|
assert.ok(capturedOptions);
|
|
assert.equal(capturedOptions.password, "secret");
|
|
assert.equal(capturedOptions.identityFilePaths, undefined);
|
|
});
|
|
|
|
test("startSSH passes known host records to the SSH bridge", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
const knownHosts = [{
|
|
id: "kh-1",
|
|
hostname: "target.example.test",
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: "SHA256:trusted-key",
|
|
discoveredAt: 1,
|
|
}];
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
authMethod: "password",
|
|
password: "secret",
|
|
},
|
|
keys: [],
|
|
knownHosts,
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {
|
|
verifyHostKeys: false,
|
|
},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: noop,
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(term as never);
|
|
|
|
assert.ok(capturedOptions);
|
|
assert.equal(capturedOptions.knownHosts, knownHosts);
|
|
assert.equal(capturedOptions.verifyHostKeys, false);
|
|
});
|
|
|
|
test("startSSH omits jump host identity file paths when password auth is selected", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
hostChain: { hostIds: ["jump-1"] },
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [{
|
|
id: "jump-1",
|
|
label: "Jump",
|
|
hostname: "jump.example.test",
|
|
username: "jumper",
|
|
authMethod: "password",
|
|
password: "secret",
|
|
identityFilePaths: ["/Users/alice/.ssh/jump_ed25519"],
|
|
}],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: noop,
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(term as never);
|
|
|
|
assert.ok(capturedOptions);
|
|
const jumpHosts = capturedOptions.jumpHosts as Array<Record<string, unknown>>;
|
|
assert.equal(jumpHosts[0]?.password, "secret");
|
|
assert.equal(jumpHosts[0]?.identityFilePaths, undefined);
|
|
});
|
|
|
|
test("startSSH sends local identity file paths with saved passwords for key auth", async () => {
|
|
let capturedOptions: Record<string, unknown> | null = null;
|
|
|
|
const terminalBackend = {
|
|
backendAvailable: () => true,
|
|
telnetAvailable: () => true,
|
|
moshAvailable: () => true,
|
|
localAvailable: () => true,
|
|
serialAvailable: () => true,
|
|
execAvailable: () => true,
|
|
startSSHSession: async (options: Record<string, unknown>) => {
|
|
capturedOptions = options;
|
|
return "ssh-session";
|
|
},
|
|
startTelnetSession: async () => "telnet-session",
|
|
startMoshSession: async () => "mosh-session",
|
|
startLocalSession: async () => "local-session",
|
|
startSerialSession: async () => "serial-session",
|
|
execCommand: async () => ({}),
|
|
onSessionData: () => noop,
|
|
onSessionExit: () => noop,
|
|
onChainProgress: () => noop,
|
|
writeToSession: noop,
|
|
resizeSession: noop,
|
|
};
|
|
|
|
const ctx = {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Target",
|
|
hostname: "target.example.test",
|
|
username: "alice",
|
|
authMethod: "key",
|
|
password: "saved-password",
|
|
identityFilePaths: ["/Users/alice/.ssh/id_ed25519"],
|
|
},
|
|
keys: [],
|
|
resolvedChainHosts: [],
|
|
sessionId: "session-1",
|
|
terminalSettings: {},
|
|
terminalBackend,
|
|
sessionRef: { current: null },
|
|
hasConnectedRef: { current: false },
|
|
hasRunStartupCommandRef: { current: false },
|
|
disposeDataRef: { current: null },
|
|
disposeExitRef: { current: null },
|
|
fitAddonRef: { current: null },
|
|
serializeAddonRef: { current: null },
|
|
pendingAuthRef: { current: null },
|
|
updateStatus: noop,
|
|
setStatus: noop,
|
|
setError: noop,
|
|
setNeedsAuth: noop,
|
|
setAuthRetryMessage: noop,
|
|
setAuthPassword: noop,
|
|
setProgressLogs: noop,
|
|
setProgressValue: noop,
|
|
setChainProgress: noop,
|
|
};
|
|
|
|
const term = {
|
|
cols: 120,
|
|
rows: 32,
|
|
write: noop,
|
|
writeln: noop,
|
|
scrollToBottom: noop,
|
|
};
|
|
|
|
await createTerminalSessionStarters(ctx as never).startSSH(term as never);
|
|
|
|
assert.ok(capturedOptions);
|
|
assert.equal(capturedOptions.password, "saved-password");
|
|
assert.deepEqual(capturedOptions.identityFilePaths, ["/Users/alice/.ssh/id_ed25519"]);
|
|
});
|