Compare commits

...
Author SHA1 Message Date
binaricat 13d349bd4d docs(test): isolate the KI smoke app profile 2026-10-26 16:27:39 +00:00
binaricat 1a745a2a22 test(ssh): add disposable password expiry QA fixture 2026-10-26 16:15:39 +00:00
binaricat 2096e93420 test(ssh): exercise KI remember input and reconnect 2026-10-26 16:00:49 +00:00
Cursor Agentand陈大猫 56f0b55b0e fix: snapshot keychain username when remembering KI password
Clearing identityId left a different host username next to the new
password. Copy the identity username onto the host whenever it is
non-empty, matching the vault password detach.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-26 13:48:25 +00:00
Cursor Agentand陈大猫 2c84f9cb0f fix: fill blank username only when detaching KI keychain identity
detachEffectiveHostIdentity copies the effective username only when the
host username is blank. Always replacing a set username went past that
helper.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-26 13:43:35 +00:00
Cursor Agentand陈大猫 ea62a0c31e fix: snapshot keychain username when remembering KI password
resolveHostAuth prefers identity.username. Detaching the identity
without copying that username left the host's stale account next to
the new password.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-26 13:41:16 +00:00
Cursor Agentand陈大猫 d27dbbcd1d fix: clear keychain ref when remembering KI password change
Keyboard-interactive saves wrote the new host password but left
identityId attached. resolveHostAuth prefers the shared identity
password, so the next login kept the stale secret.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-26 13:34:47 +00:00
11 changed files with 578 additions and 7 deletions

No files matched your search

+215 -1
View File
@@ -7,8 +7,9 @@ import {
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import { resolveHostAuth } from '../domain/sshAuth.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host } from '../types';
import type { Host, Identity } from '../types';
const baseHost: Host = {
id: 'host-1',
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
assert.equal(bridgeResponses.length, 1);
});
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'root',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
// resolveHostAuth prefers identity.password, so the stale keychain secret
// shadows whatever is already stored on the host.
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'stale-keychain-password',
);
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate',
['rotated-password'],
'rotated-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'rotated-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, baseHost.username);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.password, 'stale-keychain-password');
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'rotated-password',
);
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'deploy',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: 'root',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, 'deploy');
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'deploy');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'deploy');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'alice',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: '',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-blank-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-blank-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].username, 'alice');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'alice');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'alice');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
let hosts: Host[] = [{
...baseHost,
+21 -5
View File
@@ -393,6 +393,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
const {
hosts,
hostsRef,
identities,
keyboardInteractiveQueue,
sessions,
setKeyboardInteractiveQueue,
@@ -424,12 +425,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
// Save password to host if requested - never for second-factor / EDR prompts
// (allowSavePassword === false) so a secondary secret cannot overwrite the
// host login password (#2150 / Codex review on #2151).
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
// identity.password and identity.username, so snapshot the identity username
// onto the host before clearing identityId (#3982). Same as
// applyVaultHostUpdate's password detach. Do not mutate the identity.
if (savePassword && host && request?.allowSavePassword !== false) {
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
...h,
password: savePassword,
savePassword: true,
} : h));
updateHosts(latestHosts.map((h: Host) => {
if (h.id !== host.id) return h;
const identity = h.identityId
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
: undefined;
const detachedUsername = identity?.username?.trim();
return {
...h,
password: savePassword,
savePassword: true,
...(h.identityId ? {
identityId: '',
...(detachedUsername ? { username: detachedUsername } : {}),
} : {}),
};
}));
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
+1 -1
View File
@@ -716,7 +716,7 @@ export function AppSideEffects() {
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { Host, Identity } from "../types";
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
const noop = () => {};
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
const dom = installDomEnvironment();
const globals = {
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
localStorage: dom.window.localStorage,
};
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const renderer = await createDomRenderer(dom.document);
try {
for (const scenario of [
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
]) {
await t.test(scenario.name, async () => {
const identity: Identity = {
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
authMethod: "password", password: "fixture-old", created: 0,
};
const identities = scenario.identityUsername === undefined ? [] : [identity];
const originalIdentity = { ...identity };
const host: Host = {
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
password: "fixture-host-old", savePassword: false,
...(identities.length ? { identityId: identity.id } : {}),
};
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
let hosts = [host, otherHost];
let updates = 0;
const request = {
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
allowSavePassword: scenario.allowSavePassword ?? true,
};
let queue = [request];
const responses: unknown[][] = [];
await renderer.render(
<I18nProvider locale="en">
<KeyboardInteractiveModal request={request} onCancel={() => false}
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
hosts, identities, keyboardInteractiveQueue: queue,
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
responses.push(args);
return { success: true };
} }) },
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
}), requestId, values, savePassword)} />
</I18nProvider>,
);
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
assert.ok(input);
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
else {
assert.ok(checkbox);
assert.equal(checkbox.checked, false);
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
}
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
assert.ok(submit);
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
assert.deepEqual(queue, []);
const saved = scenario.remember && scenario.allowSavePassword !== false;
assert.equal(updates, saved ? 1 : 0);
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
assert.equal(hosts[0].savePassword, saved);
assert.equal(hosts[1], otherHost);
assert.deepEqual(identity, originalIdentity);
// Exercise the reconnect launcher that builds the actual SSH bridge request.
for (const [index, candidate] of hosts.entries()) {
let started: { username: string; password?: string } | undefined;
const ctx = {
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
sessionId: `reconnect-${index}`, terminalSettings: {},
sessionRef: { current: null }, hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: true }, 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,
terminalBackend: {
backendAvailable: () => true,
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
writeToSession: noop, resizeSession: noop,
},
};
await createTerminalSessionStarters(ctx as never).startSSH({
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
} as never);
assert.ok(started);
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
}
// A fresh request resets the checkbox and submitting state for the next case.
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
});
}
} finally {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
@@ -0,0 +1,3 @@
*
!Dockerfile
!ca-certificates.crt
@@ -0,0 +1,4 @@
/ca-certificates.crt
/docker-config/
/private-fixture.json
/__pycache__/
@@ -0,0 +1,6 @@
FROM debian:bookworm-slim
COPY --chmod=644 ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN chmod 755 /etc/ssl /etc/ssl/certs && printf '%s\n' 'Acquire::https::CaInfo "/etc/ssl/certs/ca-certificates.crt";' > /etc/apt/apt.conf.d/99fixture-ca && sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && apt-get -o Acquire::Retries=0 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 update && apt-get install -y --no-install-recommends openssh-server passwd && rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash fixtureki && mkdir -p /run/sshd && ssh-keygen -A
RUN printf '%s\n' 'Port 22' 'ListenAddress 0.0.0.0' 'UsePAM yes' 'PasswordAuthentication yes' 'KbdInteractiveAuthentication yes' 'PubkeyAuthentication no' 'PermitRootLogin no' 'PrintMotd no' 'LogLevel VERBOSE' > /etc/ssh/sshd_config
CMD ["/usr/sbin/sshd", "-D", "-e"]
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Build the disposable fixture with this environment's existing CA trust."""
import os, socket, subprocess
from pathlib import Path
from urllib.parse import urlsplit
root=Path(__file__).resolve().parent
certs=[]
for value in ['/etc/ssl/certs/ca-certificates.crt',os.environ.get('NODE_EXTRA_CA_CERTS','')]:
path=Path(value)
if value and path.is_file(): certs.append(path.read_bytes())
(root/'ca-certificates.crt').write_bytes(b'\n'.join(certs))
(root/'ca-certificates.crt').chmod(0o644)
args=['docker','build']
seen=set()
for key in ['HTTP_PROXY','HTTPS_PROXY','http_proxy','https_proxy']:
if os.environ.get(key):
args += ['--build-arg',key]
host=urlsplit(os.environ[key]).hostname
if host and host not in seen:
seen.add(host)
args += ['--add-host',f'{host}:{socket.gethostbyname(host)}']
args += ['-t','netcatty-3982-pam-fixture',str(root)]
subprocess.run(args,env={**os.environ,'DOCKER_CONFIG':str(root/'docker-config')},check=True)
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Manage only this kit's disposable, loopback-bound SSH containers."""
import json, os, secrets, subprocess, sys
from pathlib import Path
ROOT=Path(__file__).resolve().parent
CONFIG=ROOT/'private-fixture.json'
def run(*args, input=None):
return subprocess.check_output(['docker',*args],input=input,text=True).strip()
def reset(config):
for name, host in config['hosts'].items():
run('exec','-i',host['container'],'chpasswd',input=f"{config['username']}:{config['currentPassword']}\n")
run('exec',host['container'],'chage','-d','0' if name=='target' else '1',config['username'])
if sys.argv[1]=='start':
if CONFIG.exists(): raise SystemExit('Existing fixture config: use reset or stop first.')
config={'username':'fixtureki','currentPassword':secrets.token_urlsafe(18)+'aA1!','newPassword':secrets.token_urlsafe(18)+'bB2!','hosts':{}}
try:
for name in ['target','control']:
container='netcatty-3982-'+name+'-'+secrets.token_hex(4)
run('run','--rm','-d','--name',container,'-p','127.0.0.1::22','netcatty-3982-pam-fixture')
port=int(run('port',container,'22/tcp').rsplit(':',1)[1])
config['hosts'][name]={'container':container,'hostname':'127.0.0.1','port':port}
reset(config)
except BaseException:
for host in config['hosts'].values(): subprocess.run(['docker','rm','-f',host['container']],stdout=subprocess.DEVNULL)
raise
fd=os.open(CONFIG,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
with os.fdopen(fd,'w') as f: json.dump(config,f,indent=2)
print('Disposable fixture ready. Private connection details:',CONFIG)
elif sys.argv[1]=='reset':
reset(json.loads(CONFIG.read_text()))
print('Reset target expiry and control account; retained the synthetic fixture secrets.')
elif sys.argv[1]=='stop':
config=json.loads(CONFIG.read_text())
for host in config['hosts'].values(): run('rm','-f',host['container'])
CONFIG.unlink()
print('Removed this fixture containers and private connection details.')
else: raise SystemExit('Use start, reset, or stop')
@@ -0,0 +1,68 @@
# Verify remembered KI passwords and reconnects
Run this recipe in the parent's native GUI session on PR #3983 head `2096e93420f547be6491b7fb5df90f20f75a9545`.
Use a fresh Netcatty profile and the two disposable local servers from this kit.
The OpenSSH/PAM fixture requires Docker.
Keep private-fixture.json out of screenshots, recordings, uploads, and reports.
1. Run `python3 build.py` in this directory.
2. Run `python3 manage.py start`.
3. Read private-fixture.json locally for the synthetic account and connection details. Do not copy those values into the report.
4. Build the app at the exact PR head with `npm ci` and `npm run build`.
5. From the app checkout, launch Electron directly with a new disposable profile:
```bash
node <<'NODE'
const { spawn } = require('node:child_process');
const { mkdtempSync } = require('node:fs');
const { applyElectronLaunchEnv } = require('./electron/launchEnv.cjs');
const { getTempFilePath } = require('./electron/bridges/tempDirBridge.cjs');
const profile = mkdtempSync(getTempFilePath('ki-expiry-profile-'));
console.log('Disposable app profile:', profile);
const child = spawn(require('electron'), ['.', `--user-data-dir=${profile}`], {
stdio: 'inherit',
env: applyElectronLaunchEnv(process.env),
});
child.on('error', (error) => { console.error(error.message); process.exit(1); });
child.on('exit', (code) => process.exit(code ?? 1));
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => { if (!child.killed) child.kill(signal); });
}
NODE
```
`electron/launch.cjs` does not forward command-line arguments. Do not pass the profile flag to that script. Keep the sandbox enabled. Coordinate the GUI session through the parent.
6. In **Keychain**, create one password identity from the kit's current account.
7. In **Hosts**, add the kit's target and control servers. Link both hosts to that identity.
8. Enable **Prioritize keyboard-interactive authentication** on the target host.
9. Set the target host's stored username to a different synthetic account while retaining the identity. The identity must supply the actual login account.
10. Connect to the control host. Verify that the shell opens. Close its session.
11. Connect to the target host. Accept only the disposable server's host key.
12. Enter the current password when the expiry dialog requests it.
13. Enter the new password from private-fixture.json. Select **Remember** and submit.
14. Confirm the new password when requested. Verify that the shell opens.
15. Close the target session. Reconnect by opening the saved target host. Verify that login succeeds with the saved new password.
16. Open the target host's editor. Verify that the identity reference is cleared, the actual identity username is retained, and the password remains masked.
17. Open **Keychain**. Verify that the shared identity remains unchanged without revealing its password.
18. Reconnect to the control host. Verify that it still opens with the shared identity.
19. Record the exact app head and the expected results. Capture screenshots at the expiry dialog, Remember selection, first shell, reconnected shell, detached target identity, and connected control host.
20. Repeat after `python3 manage.py reset` with **Remember** unchecked. Verify that the target keeps its identity reference. Do not treat a subsequent prompt or failed reconnect with the old password as a fix regression.
21. Repeat with a blank stored host username. Verify that the identity supplies and preserves the actual account.
22. Repeat with no identity reference. Verify that Remember saves the new host password and reconnect succeeds.
## Verify the separate wrong-current path
1. Run `python3 manage.py reset`.
2. Restore the target host's shared identity and its old fixture credential in the app.
3. Connect to the target host.
4. Enter an intentionally incorrect current password in the KI dialog.
5. Capture the transition to the terminal password-change prompt in the same connection.
6. Record whether the server rejects KI, accepts password authentication, and then opens the shell. Do not infer protocol events from the dialog alone.
7. Run `node probe.cjs /absolute/path/to/Netcatty wrong` after another reset to capture the redacted protocol event sequence.
8. Keep this outcome separate from remembered KI credential persistence. The terminal password prompt does not offer KI Remember.
## Remove the fixture
1. Close the native app sessions.
2. Run `python3 manage.py stop` to remove both containers and the private connection details.
3. Remove the disposable app profile.
@@ -0,0 +1,62 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const appRequire = createRequire(path.resolve(process.argv[2], 'package.json'));
const { Client } = appRequire('ssh2');
const { buildAuthHandler, createKeyboardInteractiveHandler } = appRequire('./electron/bridges/sshAuthHelper.cjs');
const keyboard = appRequire('./electron/bridges/keyboardInteractiveHandler.cjs');
const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'private-fixture.json')));
const mode = process.argv[3] || 'correct';
const host = config.hosts.target;
const events = [];
const originalLog = console.log;
console.log = () => {}; // Main-process debug output includes server instructions; report only bounded event labels.
(async () => {
const result = await new Promise((resolve, reject) => {
const client = new Client();
let settled = false;
let sawNew = false;
let sawCurrent = false;
const timer = setTimeout(() => done(new Error('fixture probe timed out')), 20000);
function done(error) {
if (settled) return;
settled=true;
clearTimeout(timer);
keyboard.cancelRequestsForSession('fixture-probe');
client.end();
error ? reject(error) : resolve({events,sawNew,sawCurrent});
}
const sender = { id: 3982, isDestroyed: () => false, send(channel, data) {
if(channel !== 'netcatty:keyboard-interactive') return;
const prompts = data.prompts.map(p=>p.prompt.toLowerCase());
const answers = prompts.map(prompt=>{
if(prompt.includes('current')) { sawCurrent=true; events.push('ki-current'); return mode==='wrong'?'intentionally-wrong-fixture-response':config.currentPassword; }
if(prompt.includes('new') || prompt.includes('retype')) { sawNew=true; events.push(prompt.includes('retype')?'ki-confirm':'ki-new'); return config.newPassword; }
events.push('ki-password'); return config.currentPassword;
});
queueMicrotask(()=>keyboard.handleResponse({sender:{id:3982}},{requestId:data.requestId,responses:answers,cancelled:false}));
}};
const auth = buildAuthHandler({authMethod:'password',username:config.username,password:config.currentPassword,requiresMfa:true,onAuthAttempt:m=>events.push('auth-'+m)});
client.on('keyboard-interactive', createKeyboardInteractiveHandler({sender,sessionId:'fixture-probe',hostname:'127.0.0.1',password:config.currentPassword,scope:'terminal'}));
client.on('error',done);
client.on('ready',()=>{
events.push('ssh-ready');
client.shell({term:'xterm',cols:80,rows:24},(error,stream)=>{
if(error) return done(error);
events.push('shell-open');
let text='';
stream.on('data',chunk=>{
text+=chunk.toString();
if(mode==='wrong' && /Current password:/i.test(text)) {events.push('shell-current-password');done();}
if(mode==='correct' && /fixtureki@/.test(text)) {events.push('shell-prompt');done();}
});
});
});
client.connect({host:'127.0.0.1',port:host.port,username:config.username,password:config.currentPassword,authHandler:auth.authHandler,tryKeyboard:true,readyTimeout:15000,hostVerifier:()=>true});
});
assert.equal(result.sawCurrent,true);
if(mode==='correct') assert.equal(result.sawNew,true);
else { assert.equal(result.sawNew,false); assert.ok(events.includes('shell-current-password')); }
originalLog(JSON.stringify({mode,...result},null,2));
})().catch(error=>{originalLog(error.message);process.exitCode=1;});