[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:
@@ -0,0 +1,474 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
const armSudoPrompt = (
|
||||
autofill: { armForCommand: (command: string) => void } | null,
|
||||
): string => {
|
||||
autofill?.armForCommand("sudo whoami");
|
||||
return "[sudo] password for alice: ";
|
||||
};
|
||||
|
||||
const makeBackend = (
|
||||
onStartEt: (options: Record<string, unknown>) => void = noop,
|
||||
) => ({
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
etAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => "mosh-session",
|
||||
startEtSession: async (options: Record<string, unknown>) => {
|
||||
onStartEt(options);
|
||||
return "et-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
});
|
||||
|
||||
const makeCtx = (
|
||||
host: Record<string, unknown>,
|
||||
resolvedChainHosts: Array<Record<string, unknown>>,
|
||||
terminalBackend: ReturnType<typeof makeBackend>,
|
||||
sinks: { setError?: (m: string) => void } = {},
|
||||
) => ({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
etEnabled: true,
|
||||
...host,
|
||||
},
|
||||
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: sinks.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,
|
||||
};
|
||||
|
||||
test("startEt enables sudo autofill with the host saved password", async () => {
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
const sent: string[] = [];
|
||||
const backend = {
|
||||
...makeBackend(),
|
||||
onSessionData: (_id: string, cb: (data: string) => void) => {
|
||||
onData = cb;
|
||||
return noop;
|
||||
},
|
||||
writeToSession: (_id: string, data: string) => sent.push(data),
|
||||
};
|
||||
const sudoAutofillRef = { current: null };
|
||||
const ctx = {
|
||||
...makeCtx({
|
||||
password: "saved-secret",
|
||||
}, [], backend),
|
||||
sudoAutofillRef,
|
||||
sudoAutofillPassword: "saved-secret",
|
||||
onSudoHint: () => true,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
onData?.(armSudoPrompt(sudoAutofillRef.current));
|
||||
sudoAutofillRef.current?.confirmFill();
|
||||
|
||||
assert.deepEqual(sent, ["saved-secret\n"]);
|
||||
});
|
||||
|
||||
test("startEt fails loudly when a configured jump host cannot be resolved", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
const backend = makeBackend(() => { started = true; });
|
||||
// hostChain references jump-1, but resolvedChainHosts is empty (missing).
|
||||
const ctx = makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1"] } },
|
||||
[],
|
||||
backend,
|
||||
{ setError: (m) => { error = m; } },
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
// Must NOT silently fall back to a direct connection.
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /jump host is missing/i);
|
||||
assert.match(error, /jump-1/);
|
||||
});
|
||||
|
||||
test("startEt rejects a configured chain with more than one jump host even if under-resolved", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
const backend = makeBackend(() => { started = true; });
|
||||
// Two configured hops but only one resolved — a resolved-length check alone
|
||||
// would wrongly let this through.
|
||||
const ctx = makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1", "jump-2"] } },
|
||||
[{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jumper",
|
||||
}],
|
||||
backend,
|
||||
{ setError: (m) => { error = m; } },
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /at most one jump host/i);
|
||||
});
|
||||
|
||||
test("startEt rejects missing proxy identities on the target host before unsupported proxy", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
const backend = makeBackend(() => { started = true; });
|
||||
const ctx = makeCtx(
|
||||
{
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.test",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
},
|
||||
[],
|
||||
backend,
|
||||
{ setError: (m) => { error = m; } },
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Proxy identity for "Target" is missing/);
|
||||
});
|
||||
|
||||
test("startEt rejects incomplete proxy identities on the target host before unsupported proxy", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
const backend = makeBackend(() => { started = true; });
|
||||
const ctx = {
|
||||
...makeCtx(
|
||||
{
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.test",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
},
|
||||
[],
|
||||
backend,
|
||||
{ setError: (m) => { error = m; } },
|
||||
),
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
}],
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Proxy identity for "Target" is incomplete/);
|
||||
});
|
||||
|
||||
test("startEt rejects missing saved proxy profiles on jump hosts", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
const backend = makeBackend(() => { started = true; });
|
||||
const ctx = makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1"] } },
|
||||
[{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jumper",
|
||||
proxyProfileId: "missing-proxy",
|
||||
}],
|
||||
backend,
|
||||
{ setError: (m) => { error = m; } },
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Saved proxy for jump host "Jump" is missing/);
|
||||
});
|
||||
|
||||
test("startEt rejects missing proxy identities on jump hosts", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
const backend = makeBackend(() => { started = true; });
|
||||
const ctx = makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1"] } },
|
||||
[{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jumper",
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.test",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
}],
|
||||
backend,
|
||||
{ setError: (m) => { error = m; } },
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Proxy identity for "Jump" is missing/);
|
||||
});
|
||||
|
||||
test("startEt rejects incomplete proxy identities on jump hosts", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
const backend = makeBackend(() => { started = true; });
|
||||
const ctx = {
|
||||
...makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1"] } },
|
||||
[{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jumper",
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.test",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
}],
|
||||
backend,
|
||||
{ setError: (m) => { error = m; } },
|
||||
),
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
}],
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Proxy identity for "Jump" is incomplete/);
|
||||
});
|
||||
|
||||
test("startEt connects with a single resolved jump host", async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
let error = "";
|
||||
const backend = makeBackend((options) => { captured = options; });
|
||||
const ctx = makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1"] } },
|
||||
[{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jumper",
|
||||
// key auth with no saved key reference → local identity file fallback
|
||||
authMethod: "key",
|
||||
identityFilePaths: ["/Users/alice/.ssh/jump_ed25519"],
|
||||
}],
|
||||
backend,
|
||||
{ setError: (m) => { error = m; } },
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.equal(error, "");
|
||||
assert.ok(captured);
|
||||
const jumpHosts = captured.jumpHosts as Array<Record<string, unknown>>;
|
||||
assert.equal(jumpHosts.length, 1);
|
||||
assert.equal(jumpHosts[0]?.hostname, "jump.example.test");
|
||||
// Local identity file fallback is forwarded for the hop.
|
||||
assert.deepEqual(jumpHosts[0]?.identityFilePaths, ["/Users/alice/.ssh/jump_ed25519"]);
|
||||
});
|
||||
|
||||
test("startEt forwards a jump host's custom ET port", async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
const backend = makeBackend((options) => { captured = options; });
|
||||
const ctx = makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1"] } },
|
||||
[{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jumper",
|
||||
etPort: 9022,
|
||||
}],
|
||||
backend,
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
const jumpHosts = (captured as Record<string, unknown>).jumpHosts as Array<Record<string, unknown>>;
|
||||
assert.equal(jumpHosts[0]?.etPort, 9022);
|
||||
});
|
||||
|
||||
test("startEt forwards a jump host reference key path as an identity file", async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
const backend = makeBackend((options) => { captured = options; });
|
||||
const ctx = {
|
||||
...makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1"] } },
|
||||
[{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jumper",
|
||||
authMethod: "key",
|
||||
identityFileId: "ref-key",
|
||||
}],
|
||||
backend,
|
||||
),
|
||||
keys: [{
|
||||
id: "ref-key",
|
||||
label: "Reference key",
|
||||
source: "reference",
|
||||
filePath: "/Users/alice/.ssh/jump_reference_ed25519",
|
||||
// reference keys carry no inline privateKey material
|
||||
}],
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
const jumpHosts = (captured as Record<string, unknown>).jumpHosts as Array<Record<string, unknown>>;
|
||||
// privateKey must be omitted for a reference key, and the on-disk path
|
||||
// forwarded as an IdentityFile instead of being dropped.
|
||||
assert.equal(jumpHosts[0]?.privateKey, undefined);
|
||||
assert.deepEqual(jumpHosts[0]?.identityFilePaths, ["/Users/alice/.ssh/jump_reference_ed25519"]);
|
||||
});
|
||||
|
||||
test("startEt keeps a jump private key when agent filtering is unavailable", async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
const backend = makeBackend((options) => { captured = options; });
|
||||
const ctx = {
|
||||
...makeCtx(
|
||||
{ hostChain: { hostIds: ["jump-1"] } },
|
||||
[{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jumper",
|
||||
authMethod: "key",
|
||||
identityFileId: "jump-key",
|
||||
useSshAgent: true,
|
||||
}],
|
||||
backend,
|
||||
),
|
||||
keys: [{
|
||||
id: "jump-key",
|
||||
label: "Jump key",
|
||||
type: "ED25519",
|
||||
category: "key",
|
||||
source: "imported",
|
||||
created: 1,
|
||||
privateKey: "JUMP PRIVATE KEY",
|
||||
passphrase: "jump-passphrase",
|
||||
}],
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
const jumpHosts = (captured as Record<string, unknown>).jumpHosts as Array<Record<string, unknown>>;
|
||||
assert.equal(jumpHosts[0]?.useSshAgent, false);
|
||||
assert.equal(jumpHosts[0]?.privateKey, "JUMP PRIVATE KEY");
|
||||
assert.equal(jumpHosts[0]?.passphrase, "jump-passphrase");
|
||||
});
|
||||
|
||||
test("startEt connects directly when no jump host is configured", async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
let error = "";
|
||||
const backend = makeBackend((options) => { captured = options; });
|
||||
const ctx = makeCtx({}, [], backend, { setError: (m) => { error = m; } });
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.equal(error, "");
|
||||
assert.ok(captured);
|
||||
assert.equal(captured.jumpHosts, undefined);
|
||||
});
|
||||
|
||||
test("startEt forwards known hosts and algorithm options for stats companion parity", async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
const knownHosts = [{
|
||||
id: "kh-1",
|
||||
hostname: "target.example.test",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "SHA256:trusted",
|
||||
publicKey: "",
|
||||
discoveredAt: 1,
|
||||
}];
|
||||
const algorithms = { cipher: ["aes128-cbc"] };
|
||||
const backend = makeBackend((options) => { captured = options; });
|
||||
const ctx = {
|
||||
...makeCtx(
|
||||
{
|
||||
legacyAlgorithms: true,
|
||||
skipEcdsaHostKey: true,
|
||||
algorithms,
|
||||
},
|
||||
[],
|
||||
backend,
|
||||
),
|
||||
knownHosts,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.ok(captured);
|
||||
assert.equal(captured.knownHosts, knownHosts);
|
||||
assert.equal(captured.legacyAlgorithms, true);
|
||||
assert.equal(captured.skipEcdsaHostKey, true);
|
||||
assert.equal(captured.algorithmOverrides, algorithms);
|
||||
});
|
||||
Reference in New Issue
Block a user