Compare commits

...
Author SHA1 Message Date
陈奇 8e7c28fe1b fix(plugins): use effective pane ownership for terminal sharing 2026-10-26 19:35:41 +00:00
陈奇 3e5d450a1e fix(plugins): publish selection targets after runtime mount and wake 2026-10-26 19:00:02 +00:00
陈奇 3cfc7db371 feat(plugins): complete selected terminal sharing and inert input drafts 2026-10-26 18:42:37 +00:00
陈奇 6cc7ab9501 merge: retain selected-terminal sharing WIP history 2026-10-26 18:42:37 +00:00
陈奇 4cbb34517a merge: reconcile current main with plugin integration 2026-10-26 18:42:37 +00:00
陈大猫 f0897c3c17 test(transfers): control TERM-SSHD alias arrival order (#3995) 2026-10-27 02:08:36 +08:00
陈大猫 8d826594d3 fix(ssh): support ChaCha20 in Electron crypto runtimes (#3992)
* fix(ssh): support ChaCha20 in Electron crypto runtimes

* test(ssh): verify packaged cipher transport across platforms

* test(ssh): isolate unused Electron UI APIs in packaged transport harness

* test(ssh): isolate packaged dependency resolution from the checkout
2026-10-27 01:59:11 +08:00
陈奇 37df67256e feat(examples): prepare fixed public Rfam acceptance candidate 2026-10-26 17:39:17 +00:00
陈奇 110448afb8 Merge late transport lifecycle and example checks into integration 2026-10-26 17:29:55 +00:00
陈大猫 f1410f8bb1 feat(plugins): validate installed stream example and integrate final source isolation 2026-10-27 01:19:32 +08:00
a4999fdf1d fix: clear keychain ref when remembering KI password change (#3983)
* 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>

* 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>

* 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>

* 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>

* test(ssh): exercise KI remember input and reconnect

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 01:11:50 +08:00
陈大猫 e907336ef5 fix(terminal): preserve active SSH input mode in attached popups (#3991) 2026-10-27 01:07:10 +08:00
陈奇 6a4fb178cd docs(plugins): provide reproducible samples and native integration handoff 2026-10-26 17:04:42 +00:00
陈奇 e6f44460ac fix(plugins): normalize local peers and settle cancelled stream credit 2026-10-26 17:03:15 +00:00
陈奇 fffe5a9fca Merge connected plugin AI integration without reviewing its internals 2026-10-26 16:52:55 +00:00
陈奇 7189864c7c Merge source identity history into byte stream integration 2026-10-26 16:52:55 +00:00
陈奇 173ef5abbb docs(plugin-ai): retain red totals and record shared blocker repair 2026-10-26 16:25:16 +00:00
陈奇 0ba3ea6069 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:23:53 +00:00
陈奇 06dd46f94f docs(plugin-ai): record connected host QA and native handoff 2026-10-26 16:20:50 +00:00
陈奇 42a9826603 fix(plugin-ai): yield native modal views to host context selection 2026-10-26 16:15:14 +00:00
陈奇 da0c79ff0d fix(example-assistant): import URL in the package build tool 2026-10-26 16:03:01 +00:00
陈奇 a6e0cb8995 feat(plugin-ai): connect canonical trusted host IPC and app UI 2026-10-26 15:51:05 +00:00
陈奇 bb2fc729b8 Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into feat/plugin-ai-context 2026-10-26 15:35:13 +00:00
陈奇 c29fc95531 docs(plugin-ai): define read-only context authority boundary 2026-10-26 15:30:52 +00:00
陈奇 b0386a5769 test(plugin-ai): isolate the timeout fixture from normal reply tests 2026-10-26 15:29:04 +00:00
陈奇 dc45d03c7b test(plugin-ai): verify installed assistant and pending selection teardown 2026-10-26 15:21:35 +00:00
陈大猫 58c6a216ac feat: save SSH input compatibility per host (#3977)
* feat: save SSH input compatibility per host

* test: update extracted input harnesses for host mode

* test: provide required sync vault fields

* test: cover legacy cloud host migration after import

* fix: preserve non-SSH input compatibility behavior

* fix: keep Mosh and Eternal Terminal input policy unchanged

* fix: default new SSH link and importer drafts to standard input

* fix: retain connection input mode across renderer hibernation

* docs: distinguish input timing and native SSH evidence

* docs: record final native input compatibility acceptance
2026-10-26 23:16:06 +08:00
陈奇 9290debe91 fix(plugin-ai): cancel active resources when source or host guards fail 2026-10-26 15:09:15 +00:00
陈奇 6cf931ac4d feat(plugin-ai): implement trusted renderer selection and model lifecycle 2026-10-26 15:07:38 +00:00
Codex 4213d8e672 fix(plugins): distinguish third-party catalog status copy 2026-10-26 14:48:46 +00:00
Codex 59721f9717 fix(plugins): pin sync owners across source lifecycle awaits 2026-10-26 14:37:25 +00:00
陈奇 3cdc97782e wip(plugins): save selected-terminal sharing authority and contract 2026-10-26 13:04:31 +00:00
陈大猫 eb2c4b1611 fix(terminal): bound printable ZMODEM prefix echo delay (#3966) 2026-10-26 19:16:34 +08:00
陈大猫 392a094297 fix: collapse timestamp gutter on alternate screen (#3975)
Alternate-screen programs still skip line timestamps; collapse the empty left gutter while alt buffer is active and restore on normal screen.

Fixes #3972
2026-10-26 18:13:38 +08:00
陈大猫 f300d583b2 fix(ssh): treat russh banners as single-channel (skip env) (#3974)
Fixes #3973

russh CHANNEL_FAILURE on wantReply=false env desyncs ssh2 FIFO;
add russh to SINGLE_CHANNEL_SOFTWARE_TOKENS so startSession skips env.
OpenSSH path unchanged.
2026-10-26 18:08:34 +08:00
陈大猫 70d58a0cad Merge pull request #3968 from binaricat/codex/fix-sudo-transfer-concurrency
fix(sftp): preserve ordinary sudo transfer concurrency
2026-10-26 17:45:30 +08:00
bincxz b92177ec42 fix(sftp): preserve ordinary sudo transfer concurrency 2026-10-26 17:28:34 +08:00
陈大猫 54481b6306 Merge pull request #3944 from lazzman/fix/single-channel-sftp-folder-upload
fix(sftp): 串行化单通道目录上传事务
2026-10-26 17:04:45 +08:00
bincxz 7e81bece9e test(sftp): wait for the second upload to reach the shared channel 2026-10-26 16:39:44 +08:00
陈大猫 04ee9afa7f fix(mcp): match two-line idle prompts against live PTY CR bytes (#3963)
Related #3958 / follow-up to #3961.

hasExpectedPromptSuffix must match live PTY \r\r\n against a cached two-line idle prompt (CR→LF form) so cancel/completion matching works after readiness.

No release / no tag.
2026-10-26 16:39:38 +08:00
陈大猫 efe5d4530d fix(win): kill leftover Netcatty.exe before NSIS install (#3946) (#3962)
NSIS customInit taskkill /F /IM Netcatty.exe (no /T) so orphaned external-MCP Netcatty.exe cannot lock $INSTDIR during in-app update.

Fixes #3946
2026-10-26 16:30:56 +08:00
陈大猫 32e75b6ace fix(mcp): recognize two-line user@host idle prompts (#3961)
Fixes #3958. extractTrailingIdlePrompt now accepts two-line user@host[:path] + bare $/ # idle prompts (including CR-normalized live PTY form) so MCP terminal_execute getReadyPrompt no longer times out.

No release/tag.
2026-10-26 16:26:29 +08:00
陈大猫 4a831f41c2 Merge pull request #3950 from binaricat/automation/nix-release-v1.1.90
Update Nix release metadata for v1.1.90
2026-10-26 15:09:55 +08:00
github-actions[bot] 141148ae0d Update Nix release metadata for v1.1.90 2026-10-26 05:54:33 +00:00
陈大猫 8d15a29bc6 fix(mcp): recognize Python env-prefixed idle POSIX prompts (#3948)
Idle SSH Bash prompts that start with a Python virtualenv, conda, or BT-panel name such as `(3.12.12) ` were not recognized. Recognition now strips one optional leading `(name) ` prefix, then tests the existing POSIX and Alpine prompt patterns.

Fixes #3947
2026-10-26 13:33:40 +08:00
lazzman fcb6723c3d fix(sftp): 串行化单通道目录上传事务 2026-10-26 12:02:48 +08:00
陈大猫 519c24cc4c fix(terminal): skip keyword highlight on ANSI-colored cells (#3941)
Keyword highlighting no longer overwrites cells whose PTY foreground is already 16/256/truecolor (Starship directory segments). Default-foreground matches still get the unix-path purple.

Fixes #3940
2026-10-26 11:45:28 +08:00
189 changed files with 9102 additions and 298 deletions

No files matched your search

+9
View File
@@ -330,6 +330,9 @@ jobs:
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Test tray panel layout
run: npm run test:tray-panel-layout
@@ -631,6 +634,9 @@ jobs:
done
npm run pack:linux-x64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -765,6 +771,9 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
+11
View File
@@ -53,11 +53,17 @@ jobs:
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
npm run test:plugin-examples
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
run: npm test
- name: Test SSH cipher negotiation in Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
@@ -144,5 +150,10 @@ jobs:
- name: Build
run: npm run build
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Exercise two SSH password prompts in the desktop app
run: node scripts/terminal-broadcast-ssh.live.cjs
+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]);
+2
View File
@@ -26,6 +26,7 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -856,6 +857,7 @@ function AppViewInner({ domains }: AppViewProps) {
sessions={sessions}
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
+11
View File
@@ -1262,6 +1262,14 @@ Highlight the focused split pane:
"plugins.interaction.share": "Share with plugin",
"plugins.interaction.copy": "Copy to clipboard",
"plugins.interaction.failed": "This request expired or could not be completed.",
"plugins.command.failed": "The plugin command could not be completed. Check its required selection and permissions.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Matches the current activation",
"settings.plugins.permissions.inactive": "Inactive for the current activation",
"settings.plugins.permissions.title": "Saved grants",
@@ -1298,6 +1306,9 @@ Highlight the focused split pane:
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
+3
View File
@@ -975,6 +975,9 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
+11
View File
@@ -1262,6 +1262,14 @@ Resalta el panel dividido enfocado:
"plugins.interaction.share": "Compartir con el complemento",
"plugins.interaction.copy": "Copiar al portapapeles",
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
"plugins.command.failed": "No se pudo completar el comando del complemento. Comprueba la selección requerida y los permisos.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
"settings.plugins.permissions.title": "Autorizaciones guardadas",
@@ -1298,6 +1306,9 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
+3
View File
@@ -965,6 +965,9 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
+11
View File
@@ -1157,6 +1157,14 @@ export const ruCoreMessages: Messages = {
"plugins.interaction.share": "Передать плагину",
"plugins.interaction.copy": "Копировать в буфер обмена",
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
"plugins.command.failed": "Не удалось выполнить команду плагина. Проверьте необходимые выбранные объекты и разрешения.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Соответствует текущему запуску",
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
"settings.plugins.permissions.title": "Сохранённые разрешения",
@@ -1193,6 +1201,9 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
+3
View File
@@ -1014,6 +1014,9 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
+11
View File
@@ -1177,6 +1177,14 @@ Vaults 标签保持扁平,不使用这块背景。
"plugins.interaction.share": "分享给插件",
"plugins.interaction.copy": "复制到剪贴板",
"plugins.interaction.failed": "此请求已过期或无法完成。",
"plugins.command.failed": "无法完成插件命令,请检查所需选区或所选对象及权限。",
"plugins.interaction.terminalDisclosure": "仅分享此终端的当前选区,最多 4096 UTF-8 字节。终端文本可能包含密码、令牌或其他秘密。分享前请检查完整预览;不保证自动脱敏。",
"plugins.interaction.terminalTarget": "所选终端",
"plugins.interaction.terminalPreview": "预览终端选区",
"plugins.interaction.draftNote": "此可编辑草稿仅供审阅,不向终端发送输入或回车。如决定发送,请自行复制并使用常规终端界面。编辑内容仅保留在此对话框。",
"plugins.interaction.draft": "终端输入草稿",
"plugins.interaction.exactText": "转义控制字符后的完整文本",
"plugins.interaction.closeDraft": "关闭草稿",
"settings.plugins.permissions.current": "匹配当前运行实例",
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
"settings.plugins.permissions.title": "已保存的授权",
@@ -1213,6 +1221,9 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
+3
View File
@@ -384,6 +384,9 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
+11
View File
@@ -1180,6 +1180,14 @@ Vaults 分頁維持扁平,不使用這塊背景。
"plugins.interaction.share": "分享給外掛",
"plugins.interaction.copy": "複製到剪貼簿",
"plugins.interaction.failed": "此請求已過期或無法完成。",
"plugins.command.failed": "無法完成外掛指令,請檢查所需選取範圍或所選項目及權限。",
"plugins.interaction.terminalDisclosure": "僅分享此終端的目前選取範圍,最多 4096 UTF-8 位元組。終端文字可能包含密碼、權杖或其他秘密。分享前請檢查完整預覽;不保證自動遮蔽。",
"plugins.interaction.terminalTarget": "所選終端",
"plugins.interaction.terminalPreview": "預覽終端選取範圍",
"plugins.interaction.draftNote": "此可編輯草稿僅供審閱,不向終端傳送輸入或 Enter。如決定傳送,請自行複製並使用一般終端介面。編輯內容僅保留在此對話框。",
"plugins.interaction.draft": "終端輸入草稿",
"plugins.interaction.exactText": "逸出控制字元後的完整文字",
"plugins.interaction.closeDraft": "關閉草稿",
"settings.plugins.permissions.current": "符合目前執行個體",
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
"settings.plugins.permissions.title": "已儲存的授權",
@@ -1241,6 +1249,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
+3
View File
@@ -384,6 +384,9 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理',
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -0,0 +1,21 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPluginTerminalSelectionSource } from './usePluginTerminalSelection';
import { readPluginTerminalSelection, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
import type { Terminal } from '@xterm/xterm';
function fixture() {
let reads = 0;
const term = { cols: 80, element: { isConnected: true },
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 3, y: 0 } }),
getSelection() { reads++; return 'raw'; },
buffer: { active: { getLine: () => ({ length: 3, translateToString: () => 'normalized' }) } },
} as unknown as Terminal;
const state = { term, sessionId: 'a', bootEpoch: 1, visible: true, isFocusedPane: true, connected: true, sensitive: false, normalize: false, label: 'Synthetic A' };
const source = createPluginTerminalSelectionSource(() => state, 'renderer');
return { term, state, source, get reads() { return reads; } };
}
test('registered reader honors explicit false normalization and reads only on Preview', () => {
const f = fixture();
const cleanup = registerTerminalSelectionSource('a', f.source);
try {
assert.equal(f.reads, 0);
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'raw');
assert.equal(f.reads, 1);
f.state.normalize = true;
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'normalized');
} finally { cleanup(); }
});
test('separate terminal references, hidden/attached renderer, reconnect and empty selection have no fallback', () => {
const a = fixture(), b = fixture(); b.state.sessionId = 'b';
const cleanupA = registerTerminalSelectionSource('a', a.source), cleanupB = registerTerminalSelectionSource('b', b.source);
try {
assert.throws(() => readPluginTerminalSelection('wrong', 'renderer:1'), /no longer/);
a.term.getSelection = () => '';
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /between 1/);
assert.equal(b.reads, 0);
a.state.bootEpoch++;
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /no longer/);
for (const field of ['visible', 'isFocusedPane', 'connected'] as const) {
a.state[field] = false;
assert.equal(a.source.target(), null);
a.state[field] = true;
}
a.state.sensitive = true;
assert.equal(a.source.target(), null);
a.state.sensitive = false;
Object.defineProperty(a.term.element, 'isConnected', { value: false });
assert.equal(a.source.target(), null);
} finally { cleanupA(); cleanupB(); }
assert.throws(() => readPluginTerminalSelection('b', 'renderer:1'), /no longer/);
});
test('oversized ranges refuse to materialize selection and UTF-8 bytes are bounded', () => {
const f = fixture(), cleanup = registerTerminalSelectionSource('a', f.source);
try {
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 100 } });
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /bounded/);
assert.equal(f.reads, 0);
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 0 } });
f.term.getSelection = () => '\u20ac'.repeat(1366);
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /4096 bytes/);
} finally { cleanup(); }
});
test('real pane snapshots give sharing ownership to solo, Focus mode, and only the selected split pane', async () => {
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('../../components/terminalPaneVisibility');
const workspaceById = new Map<string, import('../../types').Workspace>([
['split', { id: 'split', title: 'Split', viewMode: 'split', focusedSessionId: 'split-a', root: { id: 'split-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'split-a' }, { id: 'b', type: 'pane', sessionId: 'split-b' }] } }],
['focus', { id: 'focus', title: 'Focus', viewMode: 'focus', focusedSessionId: 'focus-a', root: { id: 'focus-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'focus-a' }, { id: 'b', type: 'pane', sessionId: 'focus-b' }] } }],
]);
const cases = [
{ name: 'solo', activeTabId: 'solo', sessionId: 'solo', eligible: true },
{ name: 'inactive solo', activeTabId: 'solo', sessionId: 'other', eligible: false },
{ name: 'Focus mode', activeTabId: 'focus', sessionId: 'focus-a', sessionWorkspaceId: 'focus', eligible: true },
{ name: 'other Focus pane', activeTabId: 'focus', sessionId: 'focus-b', sessionWorkspaceId: 'focus', eligible: false },
{ name: 'selected split', activeTabId: 'split', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: true },
{ name: 'other visible split', activeTabId: 'split', sessionId: 'split-b', sessionWorkspaceId: 'split', eligible: false },
{ name: 'inactive workspace', activeTabId: 'solo', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: false },
{ name: 'hidden layer', activeTabId: 'solo', sessionId: 'solo', isTerminalLayerVisible: false, eligible: false },
];
for (const item of cases) {
const snapshot = getTerminalPaneRenderSnapshot({ ...item, workspaceById, isTerminalLayerVisible: item.isTerminalLayerVisible ?? true });
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(snapshot));
const f = fixture();
const state = { ...f.state, sessionId: item.sessionId, visible: inputProps.isVisible,
isFocusedPane: inputProps.isFocusedPane };
const source = createPluginTerminalSelectionSource(() => state, 'derived-pane');
assert.equal(source.target() !== null, item.eligible, `${item.name}: ${snapshot}`);
if (item.name === 'solo' || item.name === 'Focus mode') assert.equal(inputProps.isFocused, false, 'do not redefine the legacy focus bit');
}
});
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import { getNotesSnapshot } from './notesStore';
import { useActiveTabId } from './activeTabStore';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const activeTabId = useActiveTabId();
const latest = useRef({ activeTabId, sessions, workspaces });
latest.current = { activeTabId, sessions, workspaces };
const [choice, setChoice] = useState<{ confirm: (id: string) => void; cancel: () => void } | null>(null);
const [noteId, setNoteId] = useState('');
const [noteChoices, setNoteChoices] = useState<Array<{ id: string; title: string }>>([]);
useEffect(() => {
const api = netcattyBridge.get();
if (!api?.onPluginAIHostRequest || !api.setPluginAIHostReady || !api.respondPluginAIHost) return;
const bridge = {
onPluginAIHostRequest: api.onPluginAIHostRequest.bind(api),
setPluginAIHostReady: api.setPluginAIHostReady.bind(api),
respondPluginAIHost: api.respondPluginAIHost.bind(api),
};
let selectedNoteId: string | null = null;
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAIHostRuntime({
getModel: getConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
if (request.kind === 'request.cancel') {
requests.get(String(request.payload.requestId))?.controller.abort();
return;
}
if (request.kind === 'runtime.release') {
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId);
return;
}
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController();
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
window.addEventListener('pagehide', pageHide);
void bridge.setPluginAIHostReady(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
for (const pending of requests.values()) pending.controller.abort();
requests.clear(); runtime.shutdown();
void bridge.setPluginAIHostReady(false).catch(() => {});
};
}, []);
return { choice, noteId, setNoteId, noteChoices };
}
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
@@ -18,7 +19,7 @@ export function usePluginHostInteractions() {
current.current = null; setPrompt(null);
} else {
current.current = event; setPrompt(event);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
}
pending.current = false; setBusy(false); setError(false);
});
@@ -36,8 +37,11 @@ export function usePluginHostInteractions() {
try {
const bridge = netcattyBridge.require();
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
const selection = action === 'preview' && active.request.kind === 'terminal.selection.read'
? readPluginTerminalSelection(active.terminalSessionId ?? '', active.terminalRendererId ?? '') : undefined;
const accepted = await bridge.respondPluginInteraction({
requestId: active.requestId, actionToken: active.actionToken, action,
...(selection === undefined ? {} : { value: selection }),
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
});
if (!accepted && current.current?.actionToken === active.actionToken) {
@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react';
import type { Terminal } from '@xterm/xterm';
import { getTerminalSelectionForClipboard } from '../../components/terminal/normalizeTerminalSelection';
import { publishTerminalSelectionTarget, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
type SelectionState = {
sessionId: string | null;
bootEpoch: number;
term: Terminal | null;
visible: boolean;
isFocusedPane: boolean | undefined;
connected: boolean;
sensitive: boolean;
normalize: boolean;
label: string;
};
// No retained snapshots, PTY adapter, recorder, or AI context reader enters here.
export function createPluginTerminalSelectionSource(readState: () => SelectionState, instanceId: string) {
const target = () => {
const state = readState();
// Undefined means solo/Focus mode; only a visible selected split pane may use true.
if (state.visible !== true || state.isFocusedPane === false || state.connected !== true || state.sensitive !== false
|| !state.sessionId || !state.term || state.term.element?.isConnected !== true) return null;
return { sessionId: state.sessionId, rendererId: `${instanceId}:${state.bootEpoch}`, label: state.label.slice(0, 160) || 'Terminal' };
};
return {
target,
read() {
if (!target()) throw new Error('The selected terminal is unavailable');
const { term, normalize } = readState();
const range = term!.getSelectionPosition();
// Refuse a large range before asking xterm to materialize its text.
if (!range || Math.abs(range.end.y - range.start.y) * term!.cols + Math.abs(range.end.x - range.start.x) > 4096) {
throw new Error('Select a bounded terminal range first');
}
return getTerminalSelectionForClipboard(term!, normalize);
},
};
}
export function usePluginTerminalSelection(key: string, readState: () => SelectionState, lifecycle: string) {
const latest = useRef(readState);
latest.current = readState;
const instance = useRef<string | null>(null);
if (instance.current === null) instance.current = crypto.randomUUID();
useEffect(() => registerTerminalSelectionSource(key, createPluginTerminalSelectionSource(() => latest.current(), instance.current!)), [key]);
// The target getter reads current refs. Publication revokes old renderer/boot identities.
useEffect(() => { publishTerminalSelectionTarget(); }, [lifecycle]);
}
@@ -26,6 +26,7 @@ import {
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -307,6 +308,17 @@ export function usePluginViewLifecycle({
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
+1
View File
@@ -335,6 +335,7 @@ export const useTerminalBackend = () => {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
},
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import type { Host } from "../../domain/models";
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
// Exercise actual vault save/load/JSON import and sync boundaries, including
// the asynchronous encrypted-field adapter (without a native safeStorage key).
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
const mount = async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
await act(async () => { await state!.readPersistedHosts(); });
};
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
try {
await mount();
assert.deepEqual(choices(), [["legacy", true]]);
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
if (created.ok === false) throw Error(created.error);
assert.equal(created.ok, true);
const standard = { ...created.host, id: "standard" };
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
// A later deliberate false must survive migration, including another boot.
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
const expected = [["legacy", false], ["standard", false], ["copy", true]];
const exported = JSON.stringify(state!.exportData());
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
assert.deepEqual(choices(), expected);
// Use the real sync payload builder and apply importer, not JSON alone.
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), expected);
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
// Older cloud snapshots enter through the same per-record migration.
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), [["cloud-legacy", true]]);
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false]]);
// These drafts enter HostDetailsPanel as initialData, then the real vault
// save/normalization path. They must not be mistaken for legacy records.
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
type: "draft" as const, draft: { kind: "host" as const, value: {
hostname: `import-${choice}.test`, protocol: "ssh",
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
} },
})));
assert.deepEqual(imported.errors, []);
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+1
View File
@@ -1570,6 +1570,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
+11
View File
@@ -1,3 +1,14 @@
!macro customInit
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
; and /T would terminate the installer itself. Ignore a missing process.
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
Pop $0
!macroend
!macro customInstall
; Open a selected folder in a new Netcatty local terminal.
; Args go after `--` so Electron/Chromium does not swallow them. Use
@@ -503,6 +503,19 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<>
<ToggleRow
label={t("hostDetails.sshInputCompatibility")}
hint={t("hostDetails.sshInputCompatibility.desc")}
enabled={form.sshInputCompatibility ?? true}
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sshInputCompatibility.reconnect")}
</p>
</>
)}
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
+1
View File
@@ -182,6 +182,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
os: "linux",
authMethod: undefined,
authPolicyVersion: 1,
sshInputCompatibility: false,
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
@@ -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();
}
});
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
test('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
// evidence. All state is synthetic, with no real provider, key, Note or host.
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-ui-'));
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = fixtureRoot;
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('localStorage', dom.window.localStorage);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL } = await import('../infrastructure/config/storageKeys');
const { createAssistantProviderFixture } = await import('../scripts/plugin-assistant-provider-fixture.mjs');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const aiBridge = require('../electron/bridges/aiBridge.cjs');
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
let service: ReturnType<typeof createPluginHostService> | undefined;
let panel: JSDOM | undefined;
const root = createRoot(document.getElementById('root')!);
let locked = false; let contentAllowed = false; let reviewAllowed = false;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const reviews: Array<Record<string, unknown>> = [];
const trustedPayloads: unknown[] = [];
try {
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string');
const endpoint = `http://127.0.0.1:${address.port}/v1`;
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (channel: string, payload: unknown) => {
if (channel === 'netcatty:plugins:ai-host-request') trustedPayloads.push(structuredClone(payload));
ipcRenderer.emit(channel, {}, structuredClone(payload));
} });
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
ipcRenderer.invoke = async (channel, payload) => {
if (channel === 'netcatty:ai:chat:stream') {
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
return { ok: status.statusCode === 200, ...status };
}
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
const handler = ipcHandlers.get(channel);
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
return handler({ sender }, structuredClone(payload));
};
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
Object.defineProperty(dom.window, 'netcatty', { value: api });
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' }]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
service = createPluginHostService({
app: { getAppPath: () => rootPath, getPath: () => fixtureRoot, getVersion: () => '0.0.0' }, env: {},
getInteractionWindow: () => mainWindow, isAppLocked: () => locked,
subscribeAppLock: (listener: (state: { locked: boolean }) => void) => { lockListeners.add(listener); return () => lockListeners.delete(listener); },
electron: { ipcMain: { handle: (name: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(name, handler), removeHandler: (name: string) => ipcHandlers.delete(name) },
dialog: { showMessageBox: async (_window: unknown, options: Record<string, unknown>) => { reviews.push(options); return { response: reviewAllowed ? 0 : 1 }; } } },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => request.permission === 'context.selected.read' && !contentAllowed
? { requestId: request.requestId, decision: 'deny' }
: { requestId: request.requestId, decision: 'allow', scope: request.permission === 'context.selected.read' ? 'once' : 'application' },
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, 'examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
const waitFor = async (condition: () => boolean) => {
const deadline = Date.now() + 5000;
while (!condition()) {
if (Date.now() >= deadline) throw Error('Connected host UI state timed out');
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: {
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
onDidReceiveMessage: () => ({ dispose() {} }),
} });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
assert.equal(noteChoice.options.length, 2);
await act(async () => { noteChoice.value = syntheticNote.id; noteChoice.dispatchEvent(new Event('change', { bubbles: true })); });
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Share selected summaries')!.click(); });
const element = (id: string) => panel!.window.document.getElementById(id)!;
await waitFor(() => element('status').textContent === 'Ready');
assert.match(element('model').textContent!, /QA fixture/);
const reference = element('context') as HTMLSelectElement;
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
contentAllowed = true;
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 1);
assert.equal(reviews[0].defaultId, reviews[0].cancelId); assert.match(String(reviews[0].detail), /Synthetic retry plan: add a retry button/);
reviewAllowed = true;
send(); await waitFor(() => element('status').textContent === 'Complete');
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
element('stop').dispatchEvent(new panel.window.Event('click'));
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
for (const listener of lockListeners) listener({ locked: true });
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|apiKey|privateOwner|permissionMode|mcp/);
} finally {
panel?.window.close();
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve()));
dom.window.close();
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
+64 -2
View File
@@ -22,25 +22,34 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostInteractionHost } = await import('./plugins/PluginHostInteractionHost');
const { PluginHostInteractionService, assertInteractionRequest } = await import('../electron/plugins/hostInteractionService.cjs');
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('./terminalPaneVisibility');
const { createPluginTerminalSelectionSource } = await import('../application/state/usePluginTerminalSelection');
const { registerTerminalSelectionSource } = await import('../infrastructure/plugins/terminalSelectionRegistry');
const { privateOwner, LOCAL_SOURCE } = await import('../electron/plugins/sourceIdentity.cjs');
let selectionReads = 0;
let selectionText = 'synthetic terminal selection\nsecond line';
let listener: ((event: unknown) => void) | null = null;
let reads = 0;
const writes: string[] = [];
const audits: unknown[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(_channel: string, payload: unknown) { listener?.(payload); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const terminalManager = { getPluginSessionBinding: (id: string, owner: number) => id === 'terminal-a' && owner === 7 ? { epoch: 'worker-a', generation: 1 } : null };
const service = new PluginHostInteractionService({
getWindow: () => owner, isLocked: () => false,
permissionEngine: { onDidRevoke() { return { dispose() {} }; } },
database: { recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
getTerminalWorkerManager: () => terminalManager,
database: { privateOwner: (id: string) => privateOwner(LOCAL_SOURCE, id), publicPluginId: () => 'com.example.synthetic', recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
quotaManager: { chargeBytes() {} },
clipboard: { readText() { reads++; return 'synthetic private text'; }, writeText(value: string) { writes.push(value); } },
});
Object.assign(dom.window, { netcatty: {
async setPluginTerminalTarget(target: unknown) { return service.setTerminalTarget(sender, target); },
onPluginInteraction(callback: (event: unknown) => void) { listener = callback; return () => { listener = null; }; },
async setPluginInteractionReady(ready: boolean) { return service.setReady(sender, ready); },
async respondPluginInteraction(response: unknown) { return service.respond(sender, response); },
} });
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', signal: new AbortController().signal, async assertActive() {} };
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', manifest: { features: { required: ['terminal.selection-sharing', 'terminal.input-drafts'] } }, signal: new AbortController().signal, async assertActive() {} };
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
@@ -79,6 +88,59 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
await click(button('Share with plugin'));
assert.deepEqual(await share.promise, { cancelled: false, value: 'synthetic private text' });
assert.equal(JSON.stringify(audits).includes('synthetic private text'), false);
const terminalElement = document.createElement('div'); document.body.append(terminalElement);
const terminal = { cols: 80, element: terminalElement,
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 10, y: 0 } }),
getSelection() { selectionReads++; return selectionText; },
} as unknown as import('@xterm/xterm').Terminal;
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(getTerminalPaneRenderSnapshot({
activeTabId: 'terminal-a', sessionId: 'terminal-a', workspaceById: new Map(), isTerminalLayerVisible: true,
})));
assert.equal(inputProps.isFocused, false);
const selectionState = { term: terminal, sessionId: 'terminal-a', bootEpoch: 1, visible: inputProps.isVisible, isFocusedPane: inputProps.isFocusedPane, connected: true, sensitive: false, normalize: false, label: 'Synthetic selected terminal' };
const cleanupSelection = registerTerminalSelectionSource('terminal-a', createPluginTerminalSelectionSource(() => selectionState, 'renderer-a'));
try {
const noShare = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /passwords, tokens/);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Synthetic selected terminal/);
await click(button('Cancel'));
assert.deepEqual(await noShare.promise, { cancelled: true });
assert.equal(selectionReads, 0);
const selected = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
assert.equal(selectionReads, 0);
await click(button('Preview terminal selection'));
assert.equal(selectionReads, 1);
assert.equal(document.querySelector('textarea')?.value, selectionText);
selectionText = 'changed after preview';
await click(button('Share with plugin'));
const result = await selected.promise as { cancelled: false; value: { text: string; handle: string } };
assert.equal(result.value.text, 'synthetic terminal selection\nsecond line');
assert.equal(selectionReads, 1);
const draft = await begin({ kind: 'terminal.input.propose', handle: result.value.handle, title: 'Synthetic editable review', text: 'first\nsecond\r\u0003\u001b[31m\u0000' });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /sends no terminal input or Enter/);
const textarea = document.querySelector('textarea')!;
assert.equal(textarea.readOnly, false);
await act(async () => {
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, 'edited synthetic draft\nnext line');
textarea.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
textarea.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await settle();
});
assert.equal(textarea.value, 'edited synthetic draft\nnext line');
assert.equal(service.pending.size, 1, 'Enter in the editable review does not submit or dispatch');
assert.match(document.querySelector('pre')?.textContent ?? '', /edited synthetic draft/);
assert.deepEqual(writes, ['synthetic output']);
await click(button('Close draft'));
assert.deepEqual(await draft.promise, { cancelled: false, value: true });
assert.deepEqual(writes, ['synthetic output']);
selectionState.visible = false;
const hidden = await begin({ kind: 'terminal.selection.read', title: 'Hidden terminal' });
await click(button('Preview terminal selection'));
assert.ok(document.querySelector('[role="alert"]'));
assert.equal(selectionReads, 1);
await click(button('Cancel'));
assert.deepEqual(await hidden.promise, { cancelled: true });
} finally { cleanupSelection(); terminalElement.remove(); }
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
await click(button('Cancel'));
assert.deepEqual(await cancelled.promise, { cancelled: true });
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from './test-support/renderReactDom';
test('a rejected palette plugin command shows bounded existing toast feedback without raw diagnostics or retry', async () => {
const dom = installDomEnvironment();
const globals = { localStorage: dom.window.localStorage, Element: dom.window.Element, ResizeObserver: class { observe() {} disconnect() {} } };
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 { QuickSwitcher } = await import('./QuickSwitcher');
const { toast } = await import('./ui/toast');
const renderer = await createDomRenderer(dom.document);
const notices: string[] = [], calls: unknown[][] = [];
const error = toast.error;
toast.error = (message) => { notices.push(message); };
Object.defineProperty(dom.window, 'netcatty', { value: {
getPluginRuntimeStatus: async () => ({ available: true }),
getPluginContributions: async () => ({ locale: 'en', plugins: [{
id: 'com.example.selection', version: '1.0.0', displayName: 'Synthetic selection', description: '',
commands: [{ id: 'com.example.selection.share', title: 'Share selected terminal', enabled: true }],
menus: [{ id: 'menu', command: 'com.example.selection.share', title: 'Share selected terminal', location: 'commandPalette', visible: true, enabled: true }],
keybindings: [], settings: [], views: [],
}] }),
executePluginCommand: async (...args: unknown[]) => { calls.push(args); throw new Error('synthetic-private-diagnostic'); },
} });
let closes = 0;
try {
await renderer.render(<I18nProvider locale="en"><QuickSwitcher isOpen query="Share selected terminal" results={[]} sessions={[]} workspaces={[]}
onQueryChange={() => {}} onSelect={() => {}} onSelectTab={() => {}} onClose={() => { closes++; }} showSftpTab={false} /></I18nProvider>);
await flushEffects(); await flushEffects();
const button = [...dom.document.querySelectorAll('button')].find((node) => node.textContent?.includes('Share selected terminal'));
assert.ok(button);
await dispatchDomEvent(button, new dom.window.MouseEvent('click', { bubbles: true }));
await flushEffects();
assert.equal(calls.length, 1);
assert.equal(closes, 1);
assert.deepEqual(notices, ['The plugin command could not be completed. Check its required selection and permissions.']);
assert.equal(notices.join('').includes('synthetic-private-diagnostic'), false);
} finally {
toast.error = error;
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();
}
});
+2 -1
View File
@@ -21,6 +21,7 @@ import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMono
import { usePluginContributions } from "../application/state/usePluginContributions";
import { requestOpenPluginView } from "./plugins/PluginContributionHost";
import { PluginContributionIcon } from "./plugins/PluginContributionIcon";
import { toast } from "./ui/toast";
type QuickSwitcherItemBase = {
id: string;
@@ -507,7 +508,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
useAlternate && item.altCommand ? item.altCommand : item.commandId,
undefined,
{ 'netcatty.surface': 'commandPalette' },
).catch(() => {});
).catch(() => toast.error(t('plugins.command.failed')));
onClose();
}
break;
+30 -2
View File
@@ -1,3 +1,5 @@
import { publishTerminalSelectionTarget } from "../infrastructure/plugins/terminalSelectionRegistry";
import { usePluginTerminalSelection } from "../application/state/usePluginTerminalSelection";
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { shouldBroadcastDuringSensitivePrompt } from "../domain/terminalBroadcast";
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from "../infrastructure/config/storageKeys";
@@ -158,6 +160,8 @@ import {
} from "./terminal/runtime/createTerminalSessionStarters";
import {
createXTermRuntime,
getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput,
resetKittyKeyboardModeStateForSession,
type XTermRuntime,
} from "./terminal/runtime/createXTermRuntime";
@@ -924,6 +928,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
statusRef.current = status;
const getSessionConnectedRef = useRef(() => statusRef.current === "connected" && Boolean(sessionRef.current));
getSessionConnectedRef.current = () => statusRef.current === "connected" && Boolean(sessionRef.current);
// Split input ownership is omitted for solo/Focus mode; the legacy focus bit is false there.
usePluginTerminalSelection(sessionId, () => ({
sessionId: sessionRef.current,
bootEpoch: bootEpochRef.current,
term: termRef.current,
visible: isVisibleRef.current === true,
isFocusedPane,
connected: getSessionConnectedRef.current(),
sensitive: passwordPromptActiveRef.current,
normalize: terminalSettings?.normalizeTextOnCopy ?? true,
label: sessionDisplayName ?? host.label,
}), JSON.stringify([sessionId, status, isVisible, isFocusedPane, sessionDisplayName, host.label]));
const sudoAutofillRef = useRef<SudoPasswordAutofill | null>(null);
// Prefer parent-supplied candidates (TerminalLayer); otherwise derive from
// host/keys/identities so standalone popups (TerminalPopupPage) still work.
@@ -1683,6 +1699,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
passwordPromptActiveRef.current,
knownCwdRef.current ?? null,
terminalTitleRef.current ?? null,
xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
);
}));
}
@@ -1704,6 +1722,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
throw new Error("Terminal output did not settle before applying the snapshot");
}
}
if (typeof payload.perCharacterInput === "boolean") {
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
}
if (typeof payload.kittyKeyboardProtocolEnabled === "boolean") {
xtermRuntimeRef.current?.setKittyKeyboardProtocolEnabled(
payload.kittyKeyboardProtocolEnabled,
@@ -1873,6 +1895,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snap,
{
...finalContext,
perCharacterInput: xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
kittyKeyboardModeState: xtermRuntimeRef.current?.getKittyKeyboardModeState(),
kittyKeyboardProtocolEnabled:
xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled(),
@@ -1959,6 +1983,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serializeAddonRef.current = null;
searchAddonRef.current = null;
hasRuntimeRef.current = false;
publishTerminalSelectionTarget();
};
const clearHibernateRuntimeState = useCallback(() => {
@@ -4063,7 +4088,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
finishReconnectPreparation();
xtermRuntimeRef.current?.resetKittyConnectionInputState();
xtermRuntimeRef.current?.resetConnectionInputState(host);
resetKittyKeyboardModeStateForSession(terminalSettingsRef);
if (effectiveTerminalProtocol.startsWith("plugin:")) {
sessionStarters.startPluginConnection(term);
@@ -4700,7 +4725,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
updateStatus: (next) => updateStatusRef.current(next),
replayChunkBytes: resolveTerminalHibernateReplayChunkBytes(terminalSettings),
additionalKeywordHighlightRules: pluginDecorationRulesRef.current,
}).then((ok) => ok).catch((err) => {
}).then((ok) => {
publishTerminalSelectionTarget();
return ok;
}).catch((err) => {
logger.error("[Terminal] Failed to resume from hibernate", err);
return false;
}).finally(() => {
@@ -0,0 +1,22 @@
import React from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import { usePluginAIContextHost } from '../../application/state/usePluginAIContextHost';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const { choice, noteId, setNoteId, noteChoices } = usePluginAIContextHost({ sessions, workspaces });
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
<DialogContent data-host-security-surface="">
<DialogHeader><DialogTitle>Choose a reference for the plugin</DialogTitle>
<DialogDescription>Share a Note summary and the current selected workspace, tab, session, and host summaries. Reading content requires a separate permission and text review.</DialogDescription></DialogHeader>
<label className="text-sm" htmlFor="plugin-ai-note">Note</label>
<select id="plugin-ai-note" value={noteId} onChange={(event) => setNoteId(event.target.value)} className="rounded border border-border bg-background p-2 text-foreground">
<option value="">No Note</option>
{noteChoices.map((note) => <option key={note.id} value={note.id}>{note.title}</option>)}
</select>
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
<Button onClick={() => choice?.confirm(noteId)}>Share selected summaries</Button></DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -19,17 +19,23 @@ function HostInteractionDialog({ state, prompt }: {
const { t } = useI18n();
const [search, setSearch] = useState('');
const request = prompt.request;
const terminalShare = request.kind === 'terminal.selection.read';
const inputDraft = request.kind === 'terminal.input.propose';
const previewClipboard = request.kind === 'clipboard.read' && prompt.phase === 'request';
const needsPreview = previewClipboard || (terminalShare && prompt.phase === 'request');
const textBytes = new TextEncoder().encode(state.value).length;
const canSubmit = request.kind === 'quickPick' ? request.items.some((item) => item.id === state.value) : textBytes <= PLUGIN_HOST_INTERACTION_LIMITS.textBytes;
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
<DialogContent className="sm:max-w-[520px]" hideCloseButton>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[520px]" hideCloseButton>
<DialogHeader>
<DialogTitle>{t('plugins.interaction.title')}</DialogTitle>
<DialogDescription className="break-all">{prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
</DialogHeader>
<p className="break-words text-sm font-medium">{request.title}</p>
<p className="text-xs text-muted-foreground">{t('plugins.interaction.sharing')}</p>
<p className="text-xs text-muted-foreground">{t(inputDraft ? 'plugins.interaction.draftNote' : terminalShare ? 'plugins.interaction.terminalDisclosure' : 'plugins.interaction.sharing')}</p>
{(terminalShare || inputDraft) && <p className="break-all text-sm">{t('plugins.interaction.terminalTarget')}: {prompt.terminalLabel} ({prompt.terminalSessionId})</p>}
{inputDraft && <textarea aria-label={t('plugins.interaction.draft')} className="min-h-28 max-h-64 w-full rounded-md border bg-muted/30 p-3 font-mono text-xs" value={state.value} maxLength={PLUGIN_HOST_INTERACTION_LIMITS.textBytes} disabled={state.busy} onChange={(event) => state.setValue(event.target.value)} />}
{(inputDraft || (terminalShare && prompt.phase === 'preview')) && <pre aria-label={t('plugins.interaction.exactText')} className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-md border p-2 font-mono text-xs">{JSON.stringify(inputDraft ? state.value : prompt.previewText)}</pre>}
{request.kind === 'input' && <Input aria-label={t('plugins.interaction.input')} value={state.value} maxLength={PLUGIN_HOST_INTERACTION_LIMITS.textBytes} autoComplete="off" autoFocus disabled={state.busy} onChange={(event) => state.setValue(event.target.value)} />}
{request.kind === 'quickPick' && <>
<Input aria-label={t('plugins.interaction.filter')} value={search} onChange={(event) => setSearch(event.target.value)} autoFocus disabled={state.busy} />
@@ -45,8 +51,8 @@ function HostInteractionDialog({ state, prompt }: {
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.interaction.failed')}</p>}
<DialogFooter>
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
<Button disabled={state.busy || !canSubmit} onClick={() => void state.respond(previewClipboard ? 'preview' : 'submit')}>
{t(previewClipboard ? 'plugins.interaction.previewAction' : request.kind === 'clipboard.read' ? 'plugins.interaction.share' : request.kind === 'clipboard.write' ? 'plugins.interaction.copy' : 'common.continue')}
<Button disabled={state.busy || !canSubmit} onClick={() => void state.respond(needsPreview ? 'preview' : 'submit')}>
{t(needsPreview ? terminalShare ? 'plugins.interaction.terminalPreview' : 'plugins.interaction.previewAction' : inputDraft ? 'plugins.interaction.closeDraft' : request.kind === 'clipboard.read' || terminalShare ? 'plugins.interaction.share' : request.kind === 'clipboard.write' ? 'plugins.interaction.copy' : 'common.continue')}
</Button>
</DialogFooter>
</DialogContent>
@@ -104,8 +104,28 @@ test('information previews on hover/focus, pins on click and Escape returns focu
await act(async () => { healthy.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
const thirdParty: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Independent source', origin: 'https://example.com' };
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={{ ...catalog, entries: [], listError: 'ECONNREFUSED', source: 'cached' }} installed={[]} selected={null} onSelect={() => {}} /></I18nProvider>); await settle(); });
const thirdStatus = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 1"]')!;
await act(async () => { thirdStatus.focus(); await settle(); });
const thirdPreview = document.querySelector('[role="tooltip"]')?.textContent ?? '';
assert.match(thirdPreview, /provided by the selected third party/);
assert.match(thirdPreview, /If a previously validated catalog exists for this source/);
assert.doesNotMatch(thirdPreview, /bundled examples|Curated open-source|First-party/);
assert.equal(document.querySelector('[role="tooltip"] button'), null);
await act(async () => { thirdStatus.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /ECONNREFUSED/);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Retry/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /bundled examples|Curated open-source/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={catalog} installed={[]} selected={entry.id} onSelect={() => {}} /></I18nProvider>); await settle(); });
const sourceInfo = document.querySelector<HTMLButtonElement>('button[aria-label="Source example"]')!;
await act(async () => { sourceInfo.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Source-only entry from this provider/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /First-party/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
} finally {
await act(async () => root.unmount());
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
@@ -67,7 +67,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
};
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
{toolbarContainer ? createPortal(<><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{toolbarContainer ? createPortal(<><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className="plugin-marketplace-browser">
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
@@ -98,7 +98,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
{conflictingInstall && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.sources.conflict')} {conflictingInstall.source?.origin ?? t('settings.plugins.sources.local')}</p>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t('settings.plugins.market.sourceOnly')}</PluginInfo></div>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t(marketSource?.kind === 'third-party' ? 'settings.plugins.sources.sourceOnly' : 'settings.plugins.market.sourceOnly')}</PluginInfo></div>}
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
+3 -3
View File
@@ -3,12 +3,12 @@ import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { PluginInfo } from './PluginInfo';
export function PluginStatus({ noVault, offlineError, retry }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
const { t } = useI18n();
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.offline' : 'settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t('settings.plugins.market.note')}</p></>;
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : 'settings.plugins.market.note')}</p></>;
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
}
+123
View File
@@ -10,9 +10,11 @@ import {
formatTerminalTitleConnectionAddress,
focusTerminalFromDisconnectedNotice,
getLineTimestampToggleHostUpdate,
resolveLineTimestampGutterLayout,
resolveNetworkDeviceTipRightInset,
resolveTerminalRightInset,
resolveTerminalTopOffsets,
subscribeTerminalAlternateScreenChanges,
shouldBlockTerminalReconnectForTarget,
shouldEnableStatusBarDisconnect,
shouldEnableStatusBarReconnect,
@@ -182,6 +184,127 @@ test("line timestamp toolbar toggle is hidden when timestamps are unavailable",
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
});
test("line timestamp gutter reserves no space while the alternate screen is active", () => {
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: true,
alternateScreenActive: true,
gutterWidth: 72,
}), { enabled: false, width: 0 });
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: true,
alternateScreenActive: false,
gutterWidth: 72,
}), { enabled: true, width: 72 });
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: false,
alternateScreenActive: false,
gutterWidth: 72,
}), { enabled: false, width: 0 });
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
assert.match(source, /const activeLineTimestampGutterWidth = lineTimestampGutterLayout\.width/);
assert.match(source, /enabled=\{lineTimestampGutterLayout\.enabled\}/);
assert.doesNotMatch(source, /enabled=\{showLineTimestampGutter\}/);
assert.match(source, /aria-pressed=\{showLineTimestampGutter\}/);
assert.doesNotMatch(source, /aria-pressed=\{[^}]*alternateScreenActive/);
});
type AlternateScreenWatchTerm = {
buffer: { active: { type: string } };
onRender: (callback: () => void) => { dispose: () => void };
};
function createAlternateScreenWatchTerm(type: "normal" | "alternate") {
const renders: Array<() => void> = [];
let disposed = 0;
const term: AlternateScreenWatchTerm = {
buffer: { active: { type } },
onRender(callback) {
renders.push(callback);
return { dispose: () => { disposed += 1; } };
},
};
return {
term,
renders,
disposeCount: () => disposed,
};
}
test("alternate screen enter and leave drive the timestamp gutter", (t) => {
const watched = createAlternateScreenWatchTerm("normal");
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => watched.term,
(active) => { seen.push(active); },
);
t.after(stop);
assert.deepEqual(seen, [false]);
watched.term.buffer.active.type = "alternate";
watched.renders[0]();
watched.renders[0]();
watched.term.buffer.active.type = "normal";
watched.renders[0]();
assert.deepEqual(seen, [false, true, false]);
stop();
assert.equal(watched.disposeCount(), 1);
watched.term.buffer.active.type = "alternate";
watched.renders[0]();
assert.deepEqual(seen, [false, true, false]);
});
test("alternate screen watch attaches after the terminal runtime exists", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const watched = createAlternateScreenWatchTerm("alternate");
let current: AlternateScreenWatchTerm | null = null;
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => current,
(active) => { seen.push(active); },
);
t.after(() => {
stop();
t.mock.timers.reset();
});
assert.deepEqual(seen, [false]);
current = watched.term;
t.mock.timers.tick(50);
assert.deepEqual(seen, [false, true]);
assert.equal(watched.renders.length, 1);
t.mock.timers.tick(500);
assert.equal(watched.renders.length, 1);
assert.deepEqual(seen, [false, true]);
});
test("alternate screen watch follows a replacement terminal runtime", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const first = createAlternateScreenWatchTerm("alternate");
const second = createAlternateScreenWatchTerm("normal");
let current: AlternateScreenWatchTerm = first.term;
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => current,
(active) => { seen.push(active); },
);
t.after(() => {
stop();
t.mock.timers.reset();
});
assert.deepEqual(seen, [true]);
current = second.term;
t.mock.timers.tick(250);
assert.deepEqual(seen, [true, false]);
assert.equal(first.disposeCount(), 1);
assert.equal(second.renders.length, 1);
second.term.buffer.active.type = "alternate";
second.renders[0]();
assert.deepEqual(seen, [true, false, true]);
});
test("selection AI overlay honors the visibility preference", () => {
const overlayPosition = { left: 120, top: 80 };
const addSelection = () => {};
+116 -2
View File
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { Terminal as XTerm } from '@xterm/xterm';
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
import { isTerminalSensitiveInputActive } from './runtime/terminalSensitiveInputRegistry';
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
@@ -18,6 +19,7 @@ import {
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from './TerminalTimestampGutter';
import { isTerminalAlternateScreenActive } from './terminalHibernateRuntime';
import {
Dialog,
DialogContent,
@@ -122,6 +124,108 @@ export function shouldShowLineTimestampToolbarToggle(
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
}
export function resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth,
}: {
showLineTimestampGutter: boolean;
alternateScreenActive: boolean;
gutterWidth: number;
}): { enabled: boolean; width: number } {
const enabled = showLineTimestampGutter && !alternateScreenActive;
return { enabled, width: enabled ? gutterWidth : 0 };
}
const ALTERNATE_SCREEN_ATTACH_RETRY_MS = 50;
const ALTERNATE_SCREEN_RUNTIME_WATCH_MS = 250;
type TerminalAlternateScreenDisposable = { dispose: () => void };
type TerminalAlternateScreenWatchTarget = {
buffer?: { active?: { type?: string } };
onRender?: (callback: () => void) => TerminalAlternateScreenDisposable;
};
const readAlternateScreenActive = (
term: TerminalAlternateScreenWatchTarget | null | undefined,
): boolean => {
try {
if (!term?.buffer?.active) return false;
return isTerminalAlternateScreenActive(term as XTerm);
} catch {
return false;
}
};
/**
* Alternate-screen programs intentionally skip timestamp recording. onRender
* collapses and restores the gutter immediately. The timer attaches once xterm
* exists and follows a replacement runtime after hibernate or reconnect.
*/
export function subscribeTerminalAlternateScreenChanges(
getTerm: () => TerminalAlternateScreenWatchTarget | null | undefined,
onChange: (alternateScreenActive: boolean) => void,
): () => void {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let renderDisposable: TerminalAlternateScreenDisposable | null = null;
let attachedTerm: TerminalAlternateScreenWatchTarget | null = null;
let lastActive: boolean | null = null;
const publish = (active: boolean) => {
if (disposed || lastActive === active) return;
lastActive = active;
onChange(active);
};
const detachRender = () => {
renderDisposable?.dispose();
renderDisposable = null;
};
const watch = () => {
if (disposed) return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const term = getTerm() ?? null;
if (term !== attachedTerm) {
detachRender();
attachedTerm = term;
if (term?.onRender) {
renderDisposable = term.onRender(() => {
if (disposed) return;
const current = getTerm() ?? null;
if (current !== term) {
watch();
return;
}
publish(readAlternateScreenActive(current));
});
}
}
publish(readAlternateScreenActive(term));
if (disposed) return;
const delay = term?.onRender
? ALTERNATE_SCREEN_RUNTIME_WATCH_MS
: ALTERNATE_SCREEN_ATTACH_RETRY_MS;
timer = setTimeout(watch, delay);
};
watch();
return () => {
disposed = true;
if (timer !== null) clearTimeout(timer);
detachRender();
attachedTerm = null;
};
}
/** Keep the tab/pane; only tear down the live transport. */
export function shouldEnableStatusBarDisconnect(
status: 'connecting' | 'connected' | 'disconnected' | undefined,
@@ -486,6 +590,11 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
}, [hostTimestampsEnabled, timestampOverride]);
const showLineTimestampGutter = lineTimestampsAvailable !== false
&& (timestampOverride ?? hostTimestampsEnabled);
const [alternateScreenActive, setAlternateScreenActive] = useState(false);
useEffect(() => subscribeTerminalAlternateScreenChanges(
() => termRef.current,
setAlternateScreenActive,
), [sessionId, status, termRef]);
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
const [lineTimestampGutterWidth, setLineTimestampGutterWidth] = useState(() => (
resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize })
@@ -497,7 +606,12 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
setLineTimestampGutterWidth((current) => (current === width ? current : width));
}, []);
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
const lineTimestampGutterLayout = resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth: lineTimestampGutterWidth,
});
const activeLineTimestampGutterWidth = lineTimestampGutterLayout.width;
const lineTimestampToggleLabel = showLineTimestampGutter
? t("terminal.toolbar.timestampsDisable")
: t("terminal.toolbar.timestampsEnable");
@@ -1086,7 +1200,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
<TerminalTimestampGutter
termRef={termRef}
containerRef={containerRef}
enabled={showLineTimestampGutter}
enabled={lineTimestampGutterLayout.enabled}
top={terminalContentTop}
left={terminalBodyInset}
bottom={terminalBottomInset}
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
import type { Terminal as XTermType } from "@xterm/xterm";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
import {
noteTerminalOutputPressureData,
@@ -81,6 +82,76 @@ test("new output is colored on the written cells without decorations", async ()
term.dispose();
});
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
// Starship directory segments set a truecolor foreground (#3940). Those
// cells must keep the PTY color; only default-foreground matches are recolored.
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
assert.ok(unixPath);
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
const cream = 0xfbf1c7;
const yellow = 0xfabd2f;
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([unixPath], true);
await write(
term,
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
+ " \x1b[38;5;220m/mnt\x1b[0m"
+ " \x1b[31m/bin\x1b[0m"
+ " /var",
);
const tokenCells = (text: string) => {
const line = term.buffer.active.getLine(0);
const index = (line?.translateToString(true) ?? "").indexOf(text);
assert.ok(index >= 0, text);
return Array.from({ length: text.length }, (_, offset) => {
const cell = line?.getCell(index + offset);
assert.ok(cell, `${text}[${offset}]`);
return cell;
});
};
for (const cell of tokenCells("/opt")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColorMode(), 0x3000000);
assert.equal(cell.getFgColor(), cream);
assert.equal(cell.isBgRGB(), true);
assert.equal(cell.getBgColor(), yellow);
}
for (const cell of tokenCells("/mnt")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x2000000);
assert.equal(cell.getFgColor(), 220);
}
for (const cell of tokenCells("/bin")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x1000000);
assert.equal(cell.getFgColor(), 1);
}
for (const cell of tokenCells("/var")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColor(), keywordRgb);
}
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter output does not revisit existing history", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
+8 -1
View File
@@ -67,6 +67,7 @@ const CELL_INDICES = 3;
const CELL_CONTENT = 0;
const CELL_FG = 1;
const STYLE_MASK = 0xfc000000;
const CM_MASK = 0x3000000;
const CM_RGB = 0x3000000;
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
@@ -1061,7 +1062,13 @@ export class KeywordHighlighter implements IDisposable {
const dataIndex = x * CELL_INDICES;
const content = internal._data[dataIndex + CELL_CONTENT];
const currentFg = internal._data[dataIndex + CELL_FG];
if (!originals.mask[x] || originals.content[x] !== content) {
// A cell already carrying a PTY palette/truecolor foreground (starship
// directory segments, ANSI logs) must keep that color. Keyword RGB is
// only for the default foreground.
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
if ((ptyFg & CM_MASK) !== 0) continue;
if (!maskedForContent) {
originals.fg[x] = currentFg;
originals.content[x] = content;
originals.mask[x] = 1;
@@ -34,7 +34,7 @@ test("local terminals stay out of hibernate and hidden-renderer paths", () => {
test("reconnect clears keyboard pairing before resetting protocol state", () => {
assert.match(
terminalSource,
/startNewSession = \(\) => \{[\s\S]*resetKittyConnectionInputState\(\);[\s\S]*resetKittyKeyboardModeStateForSession/,
/startNewSession = \(\) => \{[\s\S]*resetConnectionInputState\(host\);[\s\S]*resetKittyKeyboardModeStateForSession/,
);
assert.match(
runtimeSource,
@@ -42,7 +42,7 @@ test("reconnect clears keyboard pairing before resetting protocol state", () =>
);
assert.match(
runtimeSource,
/resetKittyConnectionInputState: clearKittyConnectionInputState/,
/resetConnectionInputState: \(host\) => \{[\s\S]*clearKittyConnectionInputState\(\)/,
);
assert.match(
runtimeSource,
@@ -357,6 +357,34 @@ test("startPluginConnection displays status diagnostics when a Provider exits wi
]);
});
test("startSSH forwards a saved ChaCha20-only host setting to the transport", async () => {
const host = JSON.parse(JSON.stringify({
id: "host-chacha",
label: "Synthetic ChaCha20 server",
hostname: "127.0.0.1",
username: "synthetic",
algorithms: { cipher: ["chacha20-poly1305@openssh.com"] },
}));
let captured: Record<string, unknown> | undefined;
const ctx = createStarterContext({
host,
terminalBackend: {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured = options;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
},
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.deepEqual(captured?.algorithmOverrides, host.algorithms);
});
test("startSSH forwards imported system agent authentication settings", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
@@ -985,13 +985,12 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// Strict bastion prompts (QAX) treat one channel write as one keystroke and
// drop multi-character chunks, so IME commits and short raw pastes must be
// sent one character per write instead of one write per commit.
// The legacy workaround selected IME commits and short raw pastes (#3077).
// Host settings now gate that selection at the shared send boundary.
assert.match(source, /perCharacterWrites\?: boolean/);
assert.match(
source,
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
/getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)/,
);
// Every chunk carries the same write options as the original single write.
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
@@ -252,6 +252,17 @@ type TerminalBackendApi = {
// renderer hibernation. Weak ownership keeps negotiated keyboard state alive
// for that session without introducing a process-wide session registry.
const kittyKeyboardStates = createKittyKeyboardSessionStateStore();
// The same session-owned ref also keeps the connection's input choice alive
// when hibernation destroys xterm. Only a real reconnect refreshes this choice.
const inputCompatibilityStates = new WeakMap<object, boolean>();
// The stable session owner survives xterm disposal while its home renderer sleeps.
export const getTerminalPerCharacterInput = (owner: object): boolean | undefined =>
inputCompatibilityStates.get(owner);
export const restoreTerminalPerCharacterInput = (owner: object, enabled: boolean): void => {
inputCompatibilityStates.set(owner, enabled);
};
const resolveKittyKeyboardModeState = (
ctx: Pick<
@@ -307,8 +318,10 @@ export type XTermRuntime = {
* terminal reset or FIFO eviction.
*/
hasInlineImages: () => boolean;
/** Clear local/per-target keyboard state before reusing this runtime. */
resetKittyConnectionInputState: () => void;
/** Reset keyboard state and snapshot endpoint settings for a fresh connection. */
resetConnectionInputState: (host: Host) => void;
getPerCharacterInput: () => boolean;
restorePerCharacterInput: (enabled: boolean) => void;
/** Emit any owed releases before detaching or closing this renderer. */
flushKittyKeyboardReleases: () => void;
/** Transfer negotiated keyboard state across renderer attach handoffs. */
@@ -515,6 +528,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const rendererType = settings?.rendererType ?? "auto";
const bridge = netcattyBridge.get();
const isLocalTerminalHost = ctx.host.protocol === "local";
// Capture the terminal endpoint's choice for this connection, never a chain hop.
const usesPerCharacterInput = (host: Host) =>
(host.protocol !== undefined && host.protocol !== "ssh") || host.moshEnabled === true
|| host.etEnabled === true || host.sshInputCompatibility === true;
let usePerCharacterInput = inputCompatibilityStates.get(ctx.terminalSettingsRef)
?? usesPerCharacterInput(ctx.host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
const windowsPty =
platform === "win32" && isLocalTerminalHost
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
@@ -1325,10 +1345,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
lineDelayMs?: number;
pasteRequestId?: string;
/**
* Send plain text as one write per character. Strict bastion prompts
* (QAX) treat one channel write as a keystroke and drop multi-character
* chunks, so IME commits and short raw pastes must go out per
* character (#3077). Bookkeeping still sees the whole payload once.
* Request the legacy IME/short-paste workaround reported in #3077.
* The SSH host's compatibility choice gates the final writes below.
* Bookkeeping still sees the whole payload once.
*/
perCharacterWrites?: boolean;
},
@@ -1550,7 +1569,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
ctx.onOutputTriggerUserInputRef?.current?.(outData);
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
for (const chunk of getTextInputWireChunks(outData, usePerCharacterInput && options?.perCharacterWrites === true, (ctx.host.protocol === undefined || ctx.host.protocol === "ssh") && !ctx.host.moshEnabled && !ctx.host.etEnabled)) {
ctx.terminalBackend.writeToSession(id, chunk, {
sensitive,
serialEraseChar,
@@ -3140,7 +3159,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
broadcastLegacyDataPending = null;
}
// Raw multi-character onData is an unbracketed paste (or a committed
// composition): short plain text must reach strict bastions per character.
// composition): selected short plain text can use compatibility writes.
// Decide on the sanitized payload so removed zero-width characters cannot
// keep a qualifying paste above the split cap (#3138).
const sanitizedRawData = sanitizeTerminalInput(data);
@@ -3479,8 +3498,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ligatures.apply(enabled);
},
hasInlineImages,
resetKittyConnectionInputState: clearKittyConnectionInputState,
resetConnectionInputState: (host) => {
usePerCharacterInput = usesPerCharacterInput(host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
clearKittyConnectionInputState();
},
flushKittyKeyboardReleases: clearKittyTransientInputState,
getPerCharacterInput: () => usePerCharacterInput,
restorePerCharacterInput: (enabled) => {
usePerCharacterInput = enabled;
restoreTerminalPerCharacterInput(ctx.terminalSettingsRef, enabled);
},
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
kittyKeyboardMode,
@@ -27,7 +27,7 @@ for (const prefix of ['fresh','empty-arrow','submit','paste-submit','interrupt',
const session={encoding, serialPort:{write(data: string | Buffer){for(const byte of Buffer.from(data)) { if(byte===127||byte===8) wire=wire.subarray(0,Math.max(0,wire.length-1)); else wire=Buffer.concat([wire,Buffer.from([byte])]);}}}};
bridge.init({sessions:new Map([['s',session]]),electronModule:{}});
const ctx={host:{protocol:'serial',id:'h',label:'h'},sessionId:'s',sessionRef:{current:'s'},statusRef:{current:'connected'},commandBufferRef:{current:''},onBroadcastInputRef:{current:null},isBroadcastEnabledRef:{current:false},terminalBackend:{writeToSession(id:string,data:string,opts: {serialEraseChar?: string; sensitive?: boolean}){bridge.writeToSession({}, {sessionId:id,data,...opts})}},serialByteOrientedBackspace:true,serialLocalEcho:false};
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
const env={usePerCharacterInput:true,...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
vm.runInNewContext(code,env); const {handleTerminalInputData:input,recordSerialSnippetInput:snippet}=env.api;
if(prefix==='empty-arrow') input('\x1b[D');
if(prefix==='submit') {input('旧');input('\x1b[D');input('\r');}
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
// Real xterm + runtime input callbacks; canvas measurement is stubbed because
// these assertions concern writes, not rendering or a native OS input method.
test("SSH host mode gates every interactive text entry and stays fixed until reconnect", async () => {
const root = fileURLToPath(new URL("../../..", import.meta.url));
const bundle = buildSync({
stdin: {
contents: 'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal"; export {dispatchTerminalLinePaste} from "./components/terminal/runtime/terminalUserPaste"; export {wakeTerminalFromHibernate} from "./components/terminal/terminalRuntimeMount";',
loader: "ts", resolveDir: root,
},
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
alias: {
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
},
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
Object.assign(dom.window, { structuredClone });
try {
dom.window.eval(`
window.matchMedia = () => ({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
window.ResizeObserver = window.IntersectionObserver = class {observe(){} unobserve(){} disconnect(){}};
HTMLCanvasElement.prototype.getContext = function(type) {
if (type !== '2d') return null;
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
{get:(target,key)=>key in target ? target[key] : ()=>{}});
};
`);
dom.window.eval(bundle);
const result = await dom.window.eval(`(async () => {
const {createXTermRuntime, DEFAULT_TERMINAL_SETTINGS, dispatchTerminalLinePaste, wakeTerminalFromHibernate} = runtimeTest;
const ref = current => ({current});
const panes = [];
const make = host => {
const el = document.createElement('div'); document.body.appendChild(el);
const writes = [], logical = [], broadcasts = [];
const context = {
container:el, host, getHost:()=>host,
fontFamilyId:'jetbrains-mono', resolvedFontFamily:'monospace', fontSize:14,
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,rendererType:'dom',fontLigatures:false,cursorBlink:false}),
terminalBackend:{writeToSession:(_,data)=>writes.push(data),resizeSession(){},openExternalAvailable:false},
sessionRef:ref(host.id),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(true),
onBroadcastInputRef:ref(data=>{broadcasts.push(data);return [];}),
onOutputTriggerUserInputRef:ref(data=>logical.push(data)),
sessionId:host.id,statusRef:ref('connected'),commandBufferRef:ref(''),requestSearchFocus(){},
kittyKeyboardProtocolEnabled:true,
};
const runtime = createXTermRuntime(context);
const pane={...runtime,writes,logical,broadcasts,context,el}; panes.push(pane); return pane;
};
const equal = (actual, expected, label) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label+': '+JSON.stringify({actual,expected}));
};
const wait = () => new Promise(resolve=>setTimeout(resolve,20));
const check = (pane, text, split, label) => {
equal(pane.writes.splice(0),split ? Array.from(text) : [text],label);
equal(pane.logical.splice(0),[text],label+' logical payload once');
pane.broadcasts.length=0;
};
const hosts=[false,true].map((mode,index)=>({id:'mode-'+index,label:'Mode',hostname:'localhost',protocol:'ssh',sshInputCompatibility:mode}));
const metrics=[];
for (const host of hosts) {
const pane=make(host), term=pane.term, ta=term.textarea, mode=host.sshInputCompatibility;
for (const text of ['中文','👩🏽‍💻é','x'.repeat(32),'x'.repeat(33)]) {
term.paste(text); check(pane,text,mode && Array.from(text).length<=32,'raw paste '+mode);
}
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中国'}));
check(pane,'中国',mode,'insertText');
const longIme='中'.repeat(40);
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:longIme}));
check(pane,longIme,mode,'IME keeps its uncapped scope');
ta.dispatchEvent(new KeyboardEvent('keydown',{bubbles:true,keyCode:229,key:'Process'}));
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
check(pane,'中文',mode,'IME 229 insertText');
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
ta.value='中文';
ta.dispatchEvent(new CompositionEvent('compositionupdate',{bubbles:true,data:'中文'}));
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:'中文'}));
await wait(); check(pane,'中文',mode,'compositionend');
// xterm's composition fallback reaches onData independently of insertText.
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
term.input('中国',true); check(pane,'中国',mode,'composition onData fallback');
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:''})); await wait();
pane.writes.length=0; pane.logical.length=0;
for (const data of ['\\x1b[A','\\x03','\\x03\\x04','\\x1b[0;;200u']) {
term.input(data,true); check(pane,data,false,'terminal control');
}
await new Promise(resolve=>term.write('\\x1b[?2004h',resolve));
term.paste('中文'); check(pane,'\\x1b[200~中文\\x1b[201~',false,'bracketed paste');
await new Promise(resolve=>term.write('\\x1b[?2004l',resolve));
dispatchTerminalLinePaste(term,'中文\\r',{lineDelayMs:1,sensitive:false});
equal(pane.writes.splice(0),['中文\\r'],'existing line-paste path stays intact');
pane.logical.length=0;
await new Promise(resolve=>term.write('\\x1b[>25u',resolve));
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
const kittyWrites=pane.writes.splice(0);
equal(kittyWrites,['\\x1b[0;;20013:25991u'],'Kitty associated text stays in one transport write');
pane.logical.length=0;
await new Promise(resolve=>term.write('\\x1b[<u',resolve));
host.sshInputCompatibility=!mode;
term.paste('中文'); check(pane,'中文',mode,'save does not change active runtime');
// The app reconnects with the existing runtime, so exercise that reset too.
pane.resetConnectionInputState(host);
term.paste('中文'); check(pane,'中文',!mode,'existing runtime reconnect');
pane.dispose();
const reconnected=make(host);
reconnected.term.paste('中文'); check(reconnected,'中文',!mode,'reconnect reads saved mode');
metrics.push({mode,entryPoints:5,reconnectedMode:!mode});
}
// Exercise the actual wake entry that recreates xterm but only reattaches
// the existing backend. Its context contains the newly saved host record.
for (const mode of [false,true]) {
const connectedHost={id:'hibernate-'+mode,label:'Hibernate',hostname:'127.0.0.1',protocol:'ssh',sshInputCompatibility:mode};
const savedHost={...connectedHost,sshInputCompatibility:!mode};
let pane=make(connectedHost), reattached=0;
const wake = async old => {
old.dispose();
const refs={xtermRuntimeRef:ref(null),termRef:ref(null),fitAddonRef:ref(null),
serializeAddonRef:ref(null),searchAddonRef:ref(null),hasRuntimeRef:ref(false)};
const context={...old.context,host:savedHost};
const success=await wakeTerminalFromHibernate({
refs,runtimeContext:context,container:old.el,
getPayload:()=>({snapshot:'',alternateScreen:false,pendingBuffer:''}),
prepareWakeFlow:async()=>true,takePendingBuffer:()=>'',
stopHibernateDataListener(){},stopHibernateListeners(){},
reattachSession(){reattached++;},safeFit(){},resizeSession(){},forceSyncRenderAfterResize(){},
lastFittedSizeRef:ref(null),isBootActiveRef:ref(false),sessionId:connectedHost.id,updateStatus(){},
});
equal(success,true,'hibernate wake succeeds');
equal(context.sessionRef.current,connectedHost.id,'hibernate retains backend session');
const next={...refs.xtermRuntimeRef.current,context,el:old.el,
writes:old.writes,logical:old.logical,broadcasts:old.broadcasts};
panes.push(next); next.writes.length=0; next.logical.length=0; return next;
};
pane=await wake(pane);
pane.term.paste('中文');check(pane,'中文',mode,'wake retains connection-time mode');
pane.resetConnectionInputState(savedHost);
pane.term.paste('中文');check(pane,'中文',!mode,'real reconnect refreshes mode after wake');
pane=await wake(pane);
pane.term.paste('中文');check(pane,'中文',!mode,'next wake retains refreshed connection mode');
equal(reattached,2,'both wakes reattach the existing backend');
}
// The SSH-only field cannot change other transports' existing policy.
for (const protocol of ['local','telnet','serial','mosh','et','ssh-mosh','ssh-et']) {
const pane=make({id:'other-'+protocol,label:protocol,hostname:'127.0.0.1',
protocol:protocol.startsWith('ssh-')?'ssh':protocol,
moshEnabled:protocol==='ssh-mosh',etEnabled:protocol==='ssh-et',sshInputCompatibility:false});
pane.term.paste('中文'); check(pane,'中文',true,protocol+' retains legacy short paste');
pane.term.input('a\\rb',true); check(pane,'a\\rb',true,protocol+' retains legacy control-containing text');
}
for (const pane of panes) { try {pane.dispose();} catch {} }
return JSON.stringify(metrics);
})()`);
assert.deepEqual(JSON.parse(result), [
{ mode: false, entryPoints: 5, reconnectedMode: true },
{ mode: true, entryPoints: 5, reconnectedMode: false },
]);
} finally {
dom.window.close();
}
});
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
import React, { useEffect } from "react";
import { act, create } from "react-test-renderer";
import ts from "typescript";
import type { Host } from "../../../domain/models";
import { resolveTerminalPopupHost } from "../../TerminalPopupPage";
import { useTerminalEffects } from "../useTerminalEffects";
import { isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture } from "../terminalHibernateRuntime";
const root = fileURLToPath(new URL("../../..", import.meta.url));
const terminalSource = readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
const homeStart = terminalSource.indexOf(" useEffect(() => {", terminalSource.indexOf("// Home renderer for AI observe popups"));
const homeEnd = terminalSource.indexOf(" const cleanupSession =", homeStart);
const homeEffect = ts.transpileModule(terminalSource.slice(homeStart, homeEnd), {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const cleanupEnd = terminalSource.indexOf(" const cleanupSessionRef =", homeEnd);
const popupCleanup = ts.transpileModule(`${terminalSource.slice(homeEnd, cleanupEnd)}
globalThis.cleanup = cleanupSession;`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const noop = () => {};
const ref = <T,>(current: T) => ({ current });
// Actual popup host resolution, home snapshot callbacks, attach boot effect,
// and real xterm writes. Unrelated visual effects are omitted by the hook seam.
test("attached popups retain the active mode across snapshots and return, including hibernated homes", async (t) => {
const bundle = buildSync({
stdin: { contents: 'export * from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal";', loader: "ts", resolveDir: root },
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
alias: {
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
},
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
const keys = ["window", "document", "navigator", "IS_REACT_ACT_ENVIRONMENT"];
const previous = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator, IS_REACT_ACT_ENVIRONMENT: true })) {
Object.defineProperty(globalThis, key, { configurable: true, value });
}
Object.assign(dom.window, { structuredClone });
dom.window.eval(`
window.matchMedia=()=>({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
window.ResizeObserver=window.IntersectionObserver=class {observe(){} unobserve(){} disconnect(){}};
HTMLCanvasElement.prototype.getContext=function(type){
if(type!=='2d')return null;
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
{get:(target,key)=>key in target?target[key]:()=>{}});
};
`);
dom.window.eval(bundle);
const runtimeModule = dom.window.eval("runtimeTest");
try {
for (const mode of [false, true]) for (const hibernated of [false, true]) {
await t.test(`connected=${mode}, saved=${!mode}, home hibernated=${hibernated}`, async () => {
const sessionId = `popup-${mode}-${hibernated}`;
const connectedHost: Host = { id: "host", label: "Host", hostname: "127.0.0.1", port: 22379, username: "agent", protocol: "ssh" as const, tags: [], os: "linux" as const, sshInputCompatibility: mode };
const savedHost = { ...connectedHost, sshInputCompatibility: !mode };
const session = { id: sessionId, hostId: "host", hostLabel: "Host", hostname: "127.0.0.1", username: "agent", protocol: "ssh" as const, status: "connected" as const, createdAt: 1 };
const popupHost = resolveTerminalPopupHost({ title: "Host", startupCommand: "", parentSessionId: sessionId, sourceSession: session, attachSessionId: sessionId }, [savedHost]);
assert.equal(popupHost.sshInputCompatibility, !mode, "the popup sees the latest vault preference");
const settings = { ...runtimeModule.DEFAULT_TERMINAL_SETTINGS, rendererType: "dom", fontLigatures: false, cursorBlink: false };
const homeSettings = ref(settings);
const writes: string[] = [];
const makeContext = (host: Host, settingsRef = ref(settings)) => ({
container: document.body.appendChild(document.createElement("div")), host,
fontFamilyId: "jetbrains-mono", resolvedFontFamily: "monospace", fontSize: 14,
terminalTheme: { colors: { background: "#111111", foreground: "#eeeeee", cursor: "#ffffff", selection: "#444444" } },
terminalSettingsRef: settingsRef,
terminalBackend: { writeToSession: (_id: string, data: string) => writes.push(data), resizeSession: noop, openExternalAvailable: false },
sessionRef: ref<string | null>(sessionId), sessionId, statusRef: ref("connected"),
hotkeySchemeRef: ref("disabled"), disableTerminalFontZoomRef: ref(false), keyBindingsRef: ref([]),
onHotkeyActionRef: ref(undefined), isBroadcastEnabledRef: ref(false), onBroadcastInputRef: ref(undefined),
commandBufferRef: ref(""), requestSearchFocus: noop, kittyKeyboardProtocolEnabled: false,
});
const homeContext = makeContext(connectedHost, homeSettings);
const homeRuntime = runtimeModule.createXTermRuntime(homeContext);
const homeRuntimeRef = ref(hibernated ? null : homeRuntime);
const homeTermRef = ref(hibernated ? null : homeRuntime.term);
if (hibernated) homeRuntime.dispose();
let snapshotRequest: (payload: object) => Promise<void>;
let applySnapshot: (payload: object) => Promise<boolean>;
let snapshot: Record<string, unknown> = {};
const bridge = {
onTerminalSessionSnapshotRequest(callback: typeof snapshotRequest) { snapshotRequest = callback; return noop; },
onTerminalSessionApplySnapshot(callback: typeof applySnapshot) { applySnapshot = callback; return noop; },
respondTerminalSessionSnapshot(_request: string, text: string, keyboard: unknown, enabled: boolean, sensitive: boolean, cwd: string, title: string, perCharacterInput: boolean) {
snapshot = { success: true, snapshot: text, kittyKeyboardModeState: keyboard, kittyKeyboardProtocolEnabled: enabled, passwordPromptActive: sensitive, cwd, title, perCharacterInput };
},
};
const homeEnv = {
useEffect: (effect: () => void) => effect(), netcattyBridge: { get: () => bridge },
attachExistingSession: false, sessionId, terminalSettingsRef: homeSettings,
xtermRuntimeRef: homeRuntimeRef, termRef: homeTermRef,
serializeAddonRef: ref(hibernated ? null : homeRuntime.serializeAddon),
hibernatedRef: ref(hibernated), softHiddenRef: ref(false),
hibernateSnapshotRef: ref(""), hibernatePendingBufferRef: ref(""), hibernateViewportSnapshotRef: ref(""), hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""), hibernateContextViewportSnapshotRef: ref(""), hibernateContextScrollbackSnapshotRef: ref(""), hibernateAlternateScreenRef: ref(false),
kittyKeyboardProtocolEnabledForSession: false, passwordPromptActiveRef: ref(false), knownCwdRef: ref(null), terminalTitleRef: ref(null), pluginTerminalLifecycleRef: ref(null),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, terminalCwdTracker: { setRendererCwd: noop },
flushPendingTerminalWritesBeforeHibernate: async () => true, resetTerminalLineTimestamps: noop,
applyAuthoritativeHibernateSnapshot: noop, logger: { warn: noop },
getTerminalPerCharacterInput: runtimeModule.getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput: runtimeModule.restoreTerminalPerCharacterInput,
};
vm.runInNewContext(homeEffect, homeEnv);
const popupContext = makeContext(popupHost);
popupContext.sessionRef.current = null;
popupContext.statusRef.current = "connecting";
const values = {
...popupContext, host: popupHost, terminalSettings: settings,
containerRef: ref(popupContext.container), xtermRuntimeRef: ref(null), termRef: ref(null), fitAddonRef: ref(null), serializeAddonRef: ref(null), searchAddonRef: ref(null), hasRuntimeRef: ref(false),
attachExistingSession: true, attachAuthorization: "owned-popup", vaultInitialized: true,
CONNECTION_TIMEOUT: 30000, Error, XTERM_PERFORMANCE_CONFIG: {}, effectiveTheme: popupContext.terminalTheme,
effectiveFontSize: 14, effectiveFontWeight: 400, fontWeightFixupDoneRef: ref(false),
isVisible: true, isVisibleRef: ref(true), isFocused: true, inWorkspace: true, isLocalConnection: false, isNetworkDevice: false, isSerialConnection: false,
pluginDecorationRules: [], pluginTerminalLifecycle: new Proxy({}, { get: () => noop }),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, keys: [], identities: [], serialConfig: {}, zmodem: {},
connectionLogBufferRef: ref({ reset: noop }), terminalOutputHistory: { clear: noop }, terminalDataCapturedRef: ref(false), hasConnectedRef: ref(false), pendingOutputScrollRef: ref(false), promptLineBreakStateRef: ref(null), terminalLogSanitizerRef: ref(null),
kittyKeyboardProtocolEnabledForSession: false, terminalCwdTracker: { getRendererCwd: () => null, setRendererCwd: noop }, passwordPromptActiveRef: ref(false),
createPromptLineBreakState: () => ({}), createReplaySafeTerminalLogSanitizer: () => ({}),
prepareInitialCwdIntent: noop, prepareRestoredReconnect: noop,
applyUserCursorPreference: noop, applyUserCursorWidthPreference: noop, shouldApplyUserCursorPreference: () => false,
terminalAltKeyOptions: () => ({}), shouldStartTerminalBackend: () => true,
createXTermRuntime: runtimeModule.createXTermRuntime,
terminalBackend: {
...popupContext.terminalBackend,
acquireSessionFlowPauseLease: async () => ({ waitForPause: async () => ({ success: true }), release: noop }),
requestSessionSnapshot: async () => { await snapshotRequest!({ sessionId, requestId: "request" }); return snapshot; },
rebindSessionOutput: async () => ({ success: true, previousWebContentsId: 10 }),
restoreSessionOutput: async () => ({ success: true }),
},
sessionStarters: { reattachSession: (term: { paste: (data: string) => void }) => { term.paste("中文"); return true; } },
setStatus: noop, setProgressLogs: noop, setShowLogs: noop, setIsCancelling: noop, setIsDisconnectedDialogDismissed: noop,
setError: (error: unknown) => { if (error) errors.push(error); },
updateStatus: (status: string) => { popupContext.statusRef.current = status; },
safeFit: noop, logger: { warn: noop, error: (...args: unknown[]) => errors.push(args) },
onPluginRuntimeCwdChange: noop, onOpenExternalError: noop, requestSearchFocus: noop,
requestPluginTerminalProviders: undefined, isPluginTerminalProviderAvailable: () => false,
useEffect: (effect: React.EffectCallback, dependencies: React.DependencyList) => {
useEffect(() => {
if (effect.toString().includes("bootAbort")) return effect();
// The injected hook owns this dependency list; unrelated effects stay inert.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
},
useLayoutEffect: noop,
};
const errors: unknown[] = [];
const defaultRefs = new Map<PropertyKey, ReturnType<typeof ref>>();
const context = new Proxy(values, { get: (target, key) => {
if (key in target) return target[key as keyof typeof target];
if (!defaultRefs.has(key)) defaultRefs.set(key, ref(null));
return defaultRefs.get(key);
} });
const Harness = () => { useTerminalEffects(context); return null; };
let renderer: ReturnType<typeof create>;
await act(async () => { renderer = create(React.createElement(Harness)); });
for (let attempt = 0; popupContext.statusRef.current !== "connected" && attempt < 30 && !errors.length; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
try {
assert.deepEqual(errors, [], "actual attach boot succeeded");
assert.equal(popupContext.sessionRef.current, sessionId, "attach retains the original SSH backend");
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "first attached input must retain the connection mode");
assert.equal(snapshot.perCharacterInput, mode, "home snapshot exports captured mode, including false and no live xterm");
const popupRuntime = values.xtermRuntimeRef.current as typeof homeRuntime;
assert.equal(popupRuntime.getPerCharacterInput(), mode);
const cleanupEnv = {
...homeEnv, attachExistingSession: true, attachAuthorization: "owned-popup",
sessionRef: popupContext.sessionRef, xtermRuntimeRef: values.xtermRuntimeRef,
terminalSettingsRef: popupContext.terminalSettingsRef, termRef: values.termRef,
serializeAddonRef: values.serializeAddonRef, attachHomeWebContentsIdRef: ref(10),
disposeDataRef: ref(null), disposeExitRef: ref(null), disposeTelnetEchoModeRef: ref(null),
telnetLocalEchoRef: ref(false), sessionCleanupPromiseRef: ref(null),
isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture,
releaseTerminalFlowBeforeHibernate: noop,
terminalBackend: {
...values.terminalBackend,
applySessionSnapshot: async (_id: string, text: string, context: { perCharacterInput?: boolean }) => {
assert.equal(context.perCharacterInput, mode, "actual close exports the popup's active choice");
return { success: await applySnapshot!({ sessionId, snapshot: text, ...context }) };
},
},
};
vm.runInNewContext(popupCleanup, cleanupEnv);
await (cleanupEnv as typeof cleanupEnv & { cleanup: () => Promise<void> }).cleanup();
assert.equal(runtimeModule.getTerminalPerCharacterInput(homeSettings), mode, "actual popup close retains the home captured state");
const restoredHome = hibernated ? runtimeModule.createXTermRuntime({ ...homeContext, host: savedHost }) : homeRuntime;
restoredHome.term.paste("中文");
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "home input after return and wake retains the active choice");
if (hibernated) restoredHome.dispose();
} finally {
values.hasConnectedRef.current = false;
await act(async () => { renderer!.unmount(); });
homeRuntime.dispose();
}
});
}
} finally {
dom.window.close();
for (const key of keys) { const descriptor = previous.get(key); if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -42,7 +42,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
/for \(const chunk of getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
);
assert.match(
runtimeSource,
@@ -127,6 +127,7 @@ for (const [protocol, lineMode, sensitive] of [
? { isWrapped: false, translateToString: () => liveLine } : undefined } },
};
const env = {
usePerCharacterInput: true,
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
@@ -341,6 +342,7 @@ test("bypassed sensitive line paste keeps the paced fan-out with sourceSensitive
buffer: { active: { cursorX: 0, cursorY: 0, baseY: 0, getLine: () => undefined } },
};
const env = {
usePerCharacterInput: true,
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
@@ -66,4 +66,20 @@ test("wire chunking honors the per-character request only for plain text", () =>
assert.deepEqual(getTextInputWireChunks("\x1b[200~ab\x1b[201~", true), [
"\x1b[200~ab\x1b[201~",
]);
});
});
test("compatibility preserves exact Unicode byte order and keeps terminal controls intact", () => {
for (const text of ["中文", "👩🏽‍💻", "e\u0301", "क्‍ष"]) {
assert.deepEqual(Buffer.from(getTextInputWireChunks(text, true).join("")), Buffer.from(text));
}
for (const data of ["\x03", "\x1b[A", "\x1b[200~中文\x1b[201~", "\x1b[0;;200u", "\x03\x04", "a\r\nb"]) {
assert.deepEqual(getTextInputWireChunks(data, true, true), [data]);
}
});
test("non-SSH callers retain legacy control-containing text chunking", () => {
assert.equal(shouldSplitRawPasteInputForWire("a\rb"), true);
assert.deepEqual(getTextInputWireChunks("a\rb", true), ["a", "\r", "b"]);
assert.deepEqual(getTextInputWireChunks("a\rb", true, true), ["a\rb"]);
});
@@ -1,13 +1,16 @@
/**
* Strict bastion prompts (e.g. QAX/奇安信) treat one SSH channel write as a
* single keystroke and silently drop every multi-character chunk (#3077). IME
* commits and short raw pastes therefore have to leave the renderer as
* per-character writes, which is what typing produces and what Xshell does.
* Legacy workaround introduced by #3168 after #3077 reported that individual
* hanzi worked where multi-character input did not. The remote parser and
* other terminals' default send behavior were not verified. SSH sessions now
* select this workaround in their host settings.
*/
/** Raw pastes longer than this keep the single-write behavior. */
export const MAX_RAW_PASTE_PER_CHARACTER_LENGTH = 32;
// Terminal control bytes are intentional: never fragment shortcuts or reports.
// eslint-disable-next-line no-control-regex
const TERMINAL_CONTROL = /[\x00-\x1f\x7f-\x9f]/;
const ESC = "\x1b";
/** True while the payload only carries plain text and no escape sequence. */
@@ -16,7 +19,7 @@ export const isPlainTerminalInputText = (data: string): boolean => !data.include
/** One chunk per Unicode code point so surrogate pairs stay intact. */
export const splitTextIntoCodePointWrites = (data: string): string[] => Array.from(data);
/** IME commits: any plain text with more than one character splits per glyph. */
/** IME commits: any plain text with more than one character splits per code point. */
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
Array.from(text).length > 1 && isPlainTerminalInputText(text);
@@ -35,11 +38,13 @@ export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
return true;
};
/** Chunks to write for one input payload; escape sequences are never split. */
/** Escape sequences never split. SSH callers also preserve terminal controls. */
export const getTextInputWireChunks = (
data: string,
perCharacterWrites: boolean,
preserveTerminalControls = false,
): string[] =>
perCharacterWrites && isPlainTerminalInputText(data)
&& !(preserveTerminalControls && TERMINAL_CONTROL.test(data))
? splitTextIntoCodePointWrites(data)
: [data];
@@ -212,3 +212,60 @@ test("first width-only update after actual runtime creation preserves an applica
await act(async () => renderer!.unmount());
});
test('real pane props publish late-mounted selection targets only for the effective input owner', async () => {
const { usePluginTerminalSelection } = await import('../../application/state/usePluginTerminalSelection');
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('../terminalPaneVisibility');
const workspace = (id: string, viewMode: 'focus' | 'split'): import('../../types').Workspace => ({
id, title: id, viewMode, focusedSessionId: `${id}-a`, root: { id: `${id}-root`, type: 'split', direction: 'vertical',
children: ['a', 'b'].map((suffix) => ({ id: `${id}-${suffix}`, type: 'pane', sessionId: `${id}-${suffix}` })) },
});
const workspaceById = new Map([['focus', workspace('focus', 'focus')], ['split', workspace('split', 'split')]]);
const cases = [
{ activeTabId: 'solo', sessionId: 'solo', eligible: true },
{ activeTabId: 'solo', sessionId: 'other', eligible: false },
{ activeTabId: 'focus', sessionId: 'focus-a', sessionWorkspaceId: 'focus', eligible: true },
{ activeTabId: 'focus', sessionId: 'focus-b', sessionWorkspaceId: 'focus', eligible: false },
{ activeTabId: 'split', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: true },
{ activeTabId: 'split', sessionId: 'split-b', sessionWorkspaceId: 'split', eligible: false },
{ activeTabId: 'solo', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: false },
{ activeTabId: 'solo', sessionId: 'solo', isTerminalLayerVisible: false, eligible: false },
];
const previous = Object.getOwnPropertyDescriptor(window, 'netcatty');
const targets: Array<{ sessionId: string | null }> = [];
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
async setPluginTerminalTarget(target: { sessionId: string | null }) { targets.push(target); return true; },
} });
try {
for (const item of cases) {
targets.length = 0;
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(getTerminalPaneRenderSnapshot({
...item, workspaceById, isTerminalLayerVisible: item.isTerminalLayerVisible ?? true,
})));
const element = document.createElement('div'); document.body.append(element);
const term = Object.assign(createTerm(), { element });
const { context, termRef } = createContext(term, { cursorShape: 'block' });
function SelectionHarness() {
usePluginTerminalSelection(item.sessionId, () => ({ sessionId: item.sessionId, term: termRef.current as never,
bootEpoch: 1, visible: inputProps.isVisible, isFocusedPane: inputProps.isFocusedPane,
connected: true, sensitive: false, normalize: false, label: 'Synthetic mount' }), JSON.stringify(inputProps));
context.useEffect = useEffect; context.useLayoutEffect = useLayoutEffect;
useTerminalEffects(context);
return null;
}
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(<SelectionHarness />); await Promise.resolve(); await Promise.resolve(); });
assert.equal(termRef.current, term);
assert.equal(targets.some((target) => target.sessionId === item.sessionId), item.eligible,
`${item.activeTabId}/${item.sessionId}: publication must follow real pane ownership after late assignment`);
} finally {
await act(async () => renderer?.unmount());
assert.equal(targets.at(-1)?.sessionId, null);
element.remove();
}
}
} finally {
if (previous) Object.defineProperty(window, 'netcatty', previous); else Reflect.deleteProperty(window, 'netcatty');
}
});
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { publishTerminalSelectionTarget } from '../../infrastructure/plugins/terminalSelectionRegistry';
import type { TerminalSettings } from '../../types';
import { publishPluginTerminalRuntimeLifecycleEvent } from '../../application/state/pluginTerminalRuntimeLifecycle';
import { markTerminalCommandCompletionPending } from './runtime/promptLineBreak';
@@ -517,6 +518,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
searchAddonRef.current = null;
hasRuntimeRef.current = false;
previousCursorPreferenceRef.current = null;
publishTerminalSelectionTarget();
}
try {
runtime.dispose();
@@ -640,6 +642,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
serializeAddonRef.current = runtime.serializeAddon;
searchAddonRef.current = runtime.searchAddon;
hasRuntimeRef.current = true;
publishTerminalSelectionTarget();
// Runtime construction already applied the current cursor shape and blink
// settings. Publishing records that snapshot so a first width-only update
// cannot clear a DEC/vi cursor-style override emitted after boot.
@@ -716,6 +719,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!snap?.success) {
throw new Error(snap?.error || "Failed to capture terminal snapshot");
}
// Restore the active connection policy before the attached session
// accepts input. Saved host changes still require a reconnect.
if (typeof snap.perCharacterInput === "boolean") {
runtime.restorePerCharacterInput(snap.perCharacterInput);
}
if (typeof snap.kittyKeyboardProtocolEnabled === "boolean") {
runtime.setKittyKeyboardProtocolEnabled(snap.kittyKeyboardProtocolEnabled);
}
@@ -43,6 +43,7 @@ import type { TerminalContextReader } from '../../domain/terminalContextRead';
import {
getTerminalPaneRenderSnapshot,
parseTerminalPaneRenderSnapshot,
resolveTerminalPaneInputProps,
resolveInactiveTerminalPaneStyle,
shouldUseTerminalPaneSplitLayout,
type TerminalPaneHiddenSize,
@@ -1296,7 +1297,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
[isTerminalLayerVisible, session.id, session.workspaceId, workspaceById],
);
const renderSnapshot = useSyncExternalStore(activeTabStore.subscribe, getRenderSnapshot, getRenderSnapshot);
const { paneState, isFocusedPane } = parseTerminalPaneRenderSnapshot(renderSnapshot);
const parsedPane = parseTerminalPaneRenderSnapshot(renderSnapshot);
const { paneState, isFocusedPane } = parsedPane;
const terminalInputProps = resolveTerminalPaneInputProps(parsedPane);
// Live titles/icons are store-driven; per-session snapshot so other panes do
// not re-render when only a sibling title changes.
const presentedSession = usePresentedSession(session);
@@ -1590,14 +1593,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
chainHosts={chainHosts}
appearanceTheme={sessionAppearanceTheme}
knownHosts={knownHosts}
isVisible={isVisible}
{...terminalInputProps}
paneLayoutKey={paneLayoutKey}
inWorkspace={keepsWorkspacePresentation}
isResizing={isResizing}
isFocusMode={layoutWorkspace?.viewMode === 'focus'}
isPaneMagnified={isMagnified}
isFocused={isFocusedPane}
isFocusedPane={isSplitViewVisible ? isFocusedPane : undefined}
fontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
+9
View File
@@ -238,3 +238,12 @@ export function parseTerminalPaneRenderSnapshot(snapshot: string): {
isFocusedPane: focusToken === "focused",
};
}
/** Preserve the existing Terminal input props: solo/Focus mode have no split focus contention. */
export function resolveTerminalPaneInputProps({ paneState, isFocusedPane }: ReturnType<typeof parseTerminalPaneRenderSnapshot>) {
return {
isVisible: paneState.isVisible,
isFocused: isFocusedPane,
isFocusedPane: paneState.mode === "split" ? isFocusedPane : undefined,
};
}
+130 -43
View File
@@ -2,23 +2,25 @@
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
Branch: `feat/plugin-ai-context`.
Status: isolated host internals and fixture-tested transport reuse. Public contract,
production adapters, installable example, independent review, and native acceptance
are pending. This source is not ready to merge or release.
Status: source-bound services, default trusted main/renderer adapters, app preload,
the AppView host chooser, canonical private IPC types, and an installable minimal
example are connected. Complete independent review and native acceptance remain
pending. This source is not ready to merge or release.
## Proposed public boundaries
## Public boundaries
The proposed optional features are `ai.model-invocation` and
`context.selected-read`. The parent has not confirmed these names. No canonical
schema, generated contract, SDK, runtime peer, hostService, or installer changes
are included in this checkpoint.
The parent approved `ai.model-invocation` and `context.selected-read`. The
transport task owns the public schema, SDK, and runtime peer. Its
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
routes and is merged into this branch. The parent assigned this task the additive
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
The model API uses a host-selected configured model. Plugins receive a chat
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
`read` pulls one chunk with one pending read per turn. The parent can map this
iterator onto the existing stream router after the canonical contract is agreed.
`read` pulls one chunk with one pending read per turn through the registered SDK
and runtime routes.
Context summary enumeration returns only references explicitly shared with the
current runtime and source owner. Content reads require an opaque handle and an
@@ -32,7 +34,10 @@ or arbitrary entity-ID lookup.
| --- | --- |
| This task | `electron/plugins/ai/`, `electron/plugins/context/`, `infrastructure/ai/plugin/`, `examples/plugins/assistant/`, this document |
| This task | Opt-in `requireBridge` and `maxStreamBytes` request context in `infrastructure/ai/sdk/providers.ts` |
| Parent coordination | Canonical schema, generated files, SDK, runtime forwarding, hostService registration, trusted renderer adapters |
| This task | Additive trusted-host IPC types and generation, app preload methods/types, AppView host mount, AI-only hostService adapter construction/registration |
| This task | Host security-dialog visibility hook in the modal/tab plugin view lifecycle |
| This task, reassigned by parent | Four permission descriptions and the bounded `network.stream` contract test expectation |
| Transport task | Public canonical schema, generated SDK, runtime forwarding, generic stream registration |
| Market task | Source identity, private data-owner namespace, installer database, marketplace UI |
| Transport task | Generic TCP and TLS streams |
| Logging task | Optional local plugin logs |
@@ -44,6 +49,12 @@ This branch does not claim that workflow is complete.
## Implemented authority
`PluginAISourceBinding` authenticates the current browser runtime before reading
`getActivePlugin().source`, `getRetainedPluginSource`, `privateOwner`, and
`publicPluginId`. Source binding is rechecked after asynchronous work. The source
database and sync implementation are unchanged. Source origin does not imply
that a package is officially maintained.
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
history and makes no database changes. Data owners are opaque host identities;
@@ -67,7 +78,10 @@ trusted sharing UI must preview the selected text before sharing it.
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
`streamText`, through the existing host provider transport. Credential injection
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
SSE payloads have an aggregate 128 KiB cap before SDK buffering. SDK retries are
SSE payloads have an aggregate 128 KiB cap before SDK buffering. The main
transport also enforces a plugin-specific 128 KiB response cap and a 60-second
total deadline. Host-owned request tags allow sender/runtime cancellation
without cancelling the internal Agent's ordinary streams. SDK retries are
disabled. Only text and allowlisted token totals reach the broker. Provider
errors, raw chunks, reasoning, headers, and tool payloads do not cross it.
@@ -85,25 +99,35 @@ a tokenizer or a monetary billing cap. Provider usage is reported separately.
Reservations are not refunded after cancellation. Rate limits survive runtime
restart within the broker's lifetime. Host shutdown clears this in-memory state.
## Required host adapters
## Connected host path
Production integration is blocked on these parent-owned contracts:
`createPluginHostService` constructs `PluginAITrustedHostAdapter` by default from
the existing interaction window, app-lock subscription, and native dialog. It
registers the trusted ready/response IPC handlers. App preload exposes the request
subscription and validated responses only to the trusted application renderer;
the sandbox view preload does not expose these methods.
1. `resolveDataOwner(context)` must return the current private source owner from
host provenance. A plugin-supplied source value or plugin ID is insufficient.
2. `selectModel(context)` must return a trusted invocation adapter for the model
explicitly selected in the main UI. No provider configuration crosses RPC.
3. The renderer adapter must validate the current window, selected resource,
lifecycle generation, version, and owner before and after asynchronous reads.
Reconnect, tab or session close, navigation, lock, disable, and permission
revocation must invalidate handles and abort turns.
4. Summary enumeration, content reads, and model invocation need separate
permissions. Only the canonical schema can define their wire format.
Model invocation permission does not grant context or action permissions.
5. Host review must show selected content before it becomes model input.
A command-palette or menu ID is never resource authority.
6. The shared hostService adapter must register teardown and permission-revoke
hooks, and discard results if its renderer owner changes.
`AppView` mounts `PluginAIContextHost`. Its hook chooses the currently configured
host model and opens an app-owned Note chooser. `trustedHostRuntime` keeps the
provider adapter and selected readers in renderer-local maps; only canonical,
validated request/results cross the main/renderer IPC boundary. The default
adapter authenticates the current main-window sender, uses one-use nonces,
rechecks the current owner after awaits, and invalidates on lock, navigation,
hide, destruction, or renderer replacement. Plugin permission revocation,
disable, and runtime release abort that runtime's turns and transport requests.
Summary enumeration, content reads, and model invocation require separate
permissions. Before selected text becomes model input, a native host dialog
shows that exact text and version with Cancel as the default. Model invocation
permission grants neither context nor action access. Menu IDs are never data
authority. While the app-owned chooser is present, the active native modal/tab
plugin view yields so it cannot cover the chooser. Existing embedded views use
their host-overlay occlusion observer. Native stacking still requires desktop QA.
The selected-context authority is read-only text. Its constructor and lifecycle
API are documented in [Selected context authority](selected-context-authority.md).
Host metadata handles cannot be promoted into edit/connect authority; those
actions require separate typed host targets, grants, and confirmations.
## QA evidence
@@ -134,19 +158,82 @@ node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
```
The final focused domain run passes 16 tests. The transport suite passes three
tests, including the actual assistant panel's Send, streaming, usage, and Stop
handlers in JSDOM. The test bridge uses structured clones to model the existing
view command boundary. It does not replace installed-plugin or native coverage.
The wider plugin runtime run passes 556 tests with one skipped. The existing
provider regression suites pass 34 tests.
The installed assistant command test passes on this branch's ordinary merge with
transport head `c4433d496`. It installs and
activates the six-file sample through the actual package manager and runtime peer,
then checks canonical SDK/RPC, independent model/content denial, text-review
cancellation, text/usage streaming, spoofed fields, revocation, and stale versions.
The in-process browser harness is a Node test transport, not native sandbox proof.
The full repository typecheck fails in existing code, including provider tool-call
inference and plugin CLI test manifest literals. No new adapter type errors were
reported. The focused lint passes.
`components/PluginAIContextHost.test.tsx` connects the default main adapter, actual
app preload factory, mounted host chooser, installed sample command/runtime peer,
existing provider factory, and existing main HTTP transport to the loopback
fixture. It checks content denial, review cancellation, streaming usage, Stop,
lock revocation, and absence of keys/configuration/policy on the trusted wire.
The view-visibility tests cover chooser appearance, dismissal, hidden surfaces,
and teardown without restoring an old view. DOM tests do not establish native
WebContentsView stacking or Chromium sandbox behavior.
Native Electron launch aborts because the bundled `chrome-sandbox` is not owned
by root with mode 4755. No sandbox bypass was attempted. Native screenshots,
video, installed-plugin UI tests, packaged builds, and independent review are
pending on the parent's working desktop after shared integration. The panel
source alone is not evidence of native acceptance.
The trusted main/renderer suites cover nonce replay, sender substitution, owner
replacement, navigation, hide, runtime release, lock, cancellation, focus recovery,
safe field projection, and late selected-content results. The main transport test
also verifies targeted plugin cancellation leaves an ordinary synthetic Agent
stream running and aborts an oversized plugin stream.
Failed aggregate runs remain evidence, not passing acceptance records:
| Log in `/tmp` | Result |
| --- | --- |
| `netcatty-ai-qa-tests-full.log` | 14,912 tests: 14,783 passed, 85 failed, 1 cancelled, 43 skipped; a stuck FIDO child was terminated |
| `netcatty-ai-final-tests.log` | After standard dependency patches: 15,246 tests, 15,192 passed, 11 failed, 43 skipped |
| `netcatty-ai-final-runtime.log` | 610 tests: 607 passed, 1 failed, 2 skipped |
| `netcatty-ai-connected-final-tests.log` | Connected code `42a982660`: 15,248 tests, 15,204 passed, 1 failed, 0 cancelled, 43 skipped |
| `netcatty-ai-connected-final-runtime.log` | Connected code: 610 tests, 607 passed, 1 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-fixed-runtime.log` | After `0ba3ea606`: 610 tests, 608 passed, 0 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-fixed-contract-suite.log` | After `0ba3ea606`: all 88 contract/SDK/CLI tests passed |
The ten Cursor failures in the second full run came from its missing SQLite
native dependency. Rebuilding the existing dependency with the standard
`node-gyp --devdir` option repaired them; the focused rerun passed 42 with 3
skipped. The sole connected full-run failure was a test expectation missing
`network.stream` after `network`. The runtime failure was permission-copy coverage
missing descriptions for `ai.invoke`, `context.selected.list`,
`context.selected.read`, and `network.stream`. The parent reassigned these two
hunks here. Commit `0ba3ea606988e95df256d449395adb7190b46fa0` adds accurate English
and Chinese descriptions and updates the resource-scope test. The test checks
both bounded optional stream declarations and rejection of the unbounded stream
shorthand. Transport can consume this isolated two-file commit.
The permission-copy test, complete 88-test plugin contract/SDK/CLI suite, and
610-test runtime aggregate now pass (with 2 existing runtime skips). The earlier
whole-repository red totals remain historical records; no passing whole-repository
rerun is claimed from these focused repairs. Exact-head CI is recorded on the PR.
The final connected UI/visibility run passes all 3 tests. The 47 focused
service/preload/runtime checks and 7 connected provider/renderer checks also pass.
`npm run check:plugin-contract` passes. The six-file package validates and retains
the SHA-256 recorded in the native recipe.
The connected AppView production build passes. Full lint after the permission
repair reports no errors and
seven existing warnings. The repository typecheck remains red with 773 diagnostics;
the new adapter, component, hook, and visibility modules have no diagnostics. Native packaging's
standard Electron rebuild stops at its hardcoded `/home/agent/.electron-gyp`
cache outside this executor's writable roots. No dependency patch or home-directory
override was used to bypass it.
The package build validates six files. The final artifact and native procedure
are recorded in [the native QA recipe](assistant-native-qa.md). The source staging
excludes build tooling and has no Node entry, network, filesystem, secret, storage,
or terminal permission.
Independent review confirmed the malformed-reference revocation defect on
`afe831bfc`. The two regression tests failed before the repair and pass afterward.
The repair is retained in #3985 head `4882e0501`. Further independent review was
blocked by the review tool/model restriction. That review is incomplete and has
not been retried through another route. CI is not a substitute for that review.
Native Electron launch on this executor aborts because `chrome-sandbox` is not
root-owned mode 4755. No sandbox bypass or native retry was attempted. Native
screenshots, video, and installed UI acceptance belong to the parent's working
cloud GUI machine on the integrated exact head. No native acceptance is claimed here.
+120
View File
@@ -0,0 +1,120 @@
# Test the selected Note assistant on an isolated desktop
This recipe tests a sandbox plugin against a deterministic loopback provider.
The provider does not run a model or contact a paid service. The profile contains
only synthetic data. The parent owns native screenshots and video on the working
cloud GUI machine. The local executor's sandbox-helper failure remains a separate
failed launch record.
## Prepare the integrated head
Use a head containing the market source API, #3985's malformed-context repair,
#3986, and the transport task's canonical AI/context registration. Record the exact
commit and artifact hashes. Keep all PRs Draft. Do not merge or release them.
Run the build and tests with the repository's supported Node version:
```sh
git rev-parse HEAD
npm run build
npm run build:plugin-runtime-deps
node examples/plugins/assistant/build.mjs
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant-example.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/assistant-example.ncpkg
sha256sum /tmp/assistant-example.ncpkg
```
The package contains six files: its manifest, README, three panel files, and one
browser runtime entry. It has no Node entry, networking, filesystem, secrets,
storage, terminal, or command-execution permission. Its `commands` permission
registers its own view callbacks. It cannot execute terminal commands.
The six-file artifact at this checkpoint has SHA-256
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
## Start the deterministic fixture
Start `node scripts/plugin-assistant-provider-fixture.mjs` from the repository.
The server prints its loopback URL. It listens only on `127.0.0.1`; use a fresh
unused port. Its response is fixed synthetic text, split into delayed SSE chunks.
The response includes fixed usage metadata. It accepts no request tools or system
messages. It keeps only request counts and prints no request content or headers.
Launch the exact integrated app on the working desktop with a fresh disposable
user-data directory. Preserve Chromium sandboxing. Do not use a real app profile,
cloud account, credential, host, Note, or chat history.
On that working desktop, use a separate shell for each long-running process:
```sh
node scripts/plugin-assistant-provider-fixture.mjs --port 9217
```
```sh
netcattyAssistantQaProfile=$(mktemp -d /tmp/netcatty-assistant-qa.XXXXXX)
env -u ELECTRON_RUN_AS_NODE node node_modules/electron/cli.js . --user-data-dir="$netcattyAssistantQaProfile"
```
The fixture's counts are available from `http://127.0.0.1:9217/fixture/status`.
Use a different unused loopback port if 9217 is occupied and update the provider
URL accordingly. These launch commands are for the working GUI machine; they do
not override this executor's failed sandbox-helper check.
In AI Settings, create a custom OpenAI-compatible provider with the fixture's
`/v1` URL, model `deterministic-fixture`, and the literal synthetic credential marker
`fixture-only`. This marker exercises the existing credential injection path
without a real API credential. Select that
provider and model through the host UI. Do not enable an external Agent or tools.
Create one Note named `Synthetic retry plan` containing:
```text
Synthetic retry plan: add a retry button and show a connection status label.
```
## Record the installed-plugin path
1. Import the package. Record its ID `com.netcatty.assistant`, version `0.1.0`,
source, SHA-256, browser runtime, required permissions, and optional permissions.
Installation must not grant optional access. Source labels do not imply that
an author or package is officially maintained.
2. Enable the plugin and open its `Selected Note Assistant` modal from the plugin
view catalog. Allow only its required view and command registration permissions.
3. Grant `ai.invoke` and `context.selected.list` when the host prompts. Record the
host-selected model label. The host's Note chooser must show app-owned chrome.
Verify the native plugin modal yields while this chooser is open and restores
after dismissal; the plugin must not cover the host's selection controls.
Select only `Synthetic retry plan` and click **Share selected summaries**.
4. Verify that the plugin reference dropdown starts at **No reference**. Select
`Synthetic retry plan` explicitly, then click **Send**.
5. Deny `context.selected.read`. No fixture request may be sent. The optional
model permission must not authorize Note access.
6. Retry. Allow the single selected-context read. The separate host text dialog
must show the exact synthetic text, scope, and version, with **Cancel** selected
by default. Cancel once and confirm that no fixture request is sent.
7. Retry and click **Share this text**. Record streamed text, completion, and token
usage. Capture the full host window, not only the plugin iframe.
8. Click **Send** again, share the same synthetic text, and click **Stop** while
the delayed fixture is streaming. The text must stop changing; the fixture's
aborted-connection counter must increase. Record this sequence as video.
9. Change the Note during a pending review and attempt to share the old dialog.
The expected-version check must reject the result. Do not send stale content.
10. Revoke the plugin's AI/context permission while a turn is active. Disable,
re-enable, and close its view. Old handles and late results must not resume.
Lock/unlock the app and navigate or reload the main window in the isolated
profile. Record a fresh successful run after reauthorization.
11. Keep the fixture request counter and aborted-connection counter in the
evidence. Do not include prompt bodies, headers, credentials, or Note content
other than the synthetic text above in logs.
The automated suites cover source substitution, runtime identity, two-handle
revocation after malformed input, late replies, byte/token/time bounds, and
no-key serialization. Native execution must still verify the real prompts,
installed runtime, configured provider transport, and UI lifecycle.
## Record remaining limits
Independent review found the malformed-context revocation bug. The regression
reproduced and the repair passed focused tests. Further independent review was
blocked by the review tool/model restriction. That review remains incomplete and
has not been retried through another route. CI and native evidence do not replace
that missing review.
@@ -0,0 +1,94 @@
# Byte stream acceptance evidence
This is the historical report published by the transport owner at #3988
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its local failures, package hash,
and missing AI wiring describe that branch and environment. The current owner
is #3993 on `integration/plugin-byte-stream-handoff`. Follow
[the integration handoff](byte-stream-native-qa.md) for current checks, the
connected AI checkpoint, and native prerequisites. The old GUI-local Docker
recipe is not applicable to the occupied GUI machine without Docker.
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
The transport delta does not edit the source database or sync implementation.
Draft PR #3988 records the published review head. No merge or release is authorized.
## Completed local checks
The delegated environment uses Node 24.19.0 on Linux.
- `npm run test:plugin-contract`: 90 passed, zero skipped.
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
Database-specific testing is now entirely in the example directory.
- Final focused transport, source-isolation, installed entrypoint and disposable
database run before the test-file move: 32 passed, zero skipped. After the
move, the same installed database test passed again at its example path.
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
real MariaDB result and server-side INSERT denial passed through both the
core and the ordinary installed example entrypoint. No host ports, fixture
override, real hosts, or real credentials were used by this database test.
Its container, network, package and profile were removed.
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
clearing before dispatch and safe text rendering passed.
- Production `npm run build`, generated contract drift, changed-source lint,
and whitespace checks passed.
Real broker TLS tests validate trust and the original requested hostname using
a disposable fixture CA. Database traffic is plain TCP inside an internal
Docker network. Neither check proves native BrowserWindow sandbox behavior,
database TLS, device testing, or real model inference. The registered AI/context
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
wiring owned by the AI task.
The EOF and last-await cancellation regressions each failed before their fix
and passed after it with real loopback sockets. Source changes before grant
publication, source changes during a pending read, and a stream returned after
runtime release have separate passing regressions.
## Reproducible native sample
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
Two separate staging directories produced the same five-file package:
- Archive bytes: 11,621.
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
The artifact contains no credentials or server setup. Changing its approved
fixture endpoint requires a newly prepared package and a recorded new hash.
## Checks that did not pass locally
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
Electron build caches under `/workspace` passed that step, then failed while
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
directory packaging remains failed locally and needs the normal CI or native
QA build environment.
The broad `npm test` run reported failures in existing transfer-resume,
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
tests, then stalled without a final aggregate. Its owned process tree was
terminated. It is not reported as a passing full test run.
An independent source-baseline worktree at `4213d8e` ran five affected files in
the same environment with shared dependencies: 145 tests, 118 passed,
24 failed, three skipped. It reproduced failures in transfer-resume,
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
This diagnoses that tested subset only. It does not classify every broad-suite
failure, replace exact-head CI, or make any failed result successful.
## Pending acceptance
Exact published-head CI and independent review remain pending. The parent owns
sequential native GUI acceptance for streams and the database example, then AI,
then logging integration. The native recipe covers ordinary install/source and
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
password, audit inspection, screenshots, and a short recording. No native GUI
run or screenshot/video result is claimed by this delegated executor.
+33 -15
View File
@@ -1,9 +1,16 @@
# Byte stream integration checkpoint
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
It integrates source APIs from #3981 `ab8764a31752fd2d226c9d029c4d65498ba7b063`
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
No source database or sync implementation is changed by the transport delta.
The original transport checkpoint is #3988
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The separate integration branch
`integration/plugin-byte-stream-handoff` preserves that history and ordinarily
merges source identity #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
and connected AI #3986 `173ef5abbb1f8cea4e2c409c5791186926b778e1`.
The original transport branch remains untouched. Follow the
[current native handoff](byte-stream-native-qa.md) for the integrated package.
The late transport checkpoint `f1410f8` is subsequently preserved through an
ordinary merge. [The comparison record](late-transport-integration.md) lists its
remaining changes and the conflict resolutions that preserve the two P2 fixes
and newer connected AI files.
## Registered transport
@@ -51,7 +58,7 @@ Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
implemented. Generic audit records contain endpoint, runtime, handle, and byte
counts. The host does not inspect SQL or record payloads or credentials.
## Additive AI interface for the parallel owner
## Integrated AI interface
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
@@ -59,13 +66,12 @@ and `context.selectedContext.list/read`. Host registration advertises
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
plugin disable, revocation through the service, and shutdown.
The trusted host adapter constructor option is `pluginAIHostAdapter`. It has
the service owner's `assertActive`, `selectModel`, `listSelected`, `reviewText`,
and optional lifecycle callbacks. These callbacks are never exposed to plugin
views. The default adapter rejects model invocation, returns no selected
resources, and refuses text review. The AI owner still owns real renderer
wiring and model/context UX. A registered synthetic adapter test is not proof
of that remaining production wiring.
The connected host renderer and shared permission descriptions from #3986 are
preserved. Its owner documents the adapter and existing configured model path in
[the AI checkpoint](ai-context-wip.md), with a
[synthetic native assistant recipe](assistant-native-qa.md). No further
independent review of AI internals was performed. The prior review denial remains
unresolved and has not been retried through another route.
## Database example
@@ -106,6 +112,18 @@ connection-management subsystem. See the example README for native setup.
removes the container and network. Database transport is plain TCP inside
that disposable network, not database TLS.
Normal native BrowserWindow, full app/package checks, exact-head independent
review, and screenshot/video acceptance remain separate required evidence.
Do not disable the Chromium sandbox to obtain that evidence.
The integration fixes own-machine comparison across IPv4 and mapped IPv6.
Its regression mocks interface lookup and uses a fake dialer. It does not retry
the reviewer's failed EPERM interface lookup. Other private peers remain allowed.
Cancellation now discards buffered browser bytes, settles producer-cancelled
credit once, and retains terminal producer state only until outstanding credit
arrives. Existing sequence, schema, and negotiated-window checks still reject
unknown, duplicate, out-of-order, and excess credit. Incoming cleanup runs even
if a cancellation refund cannot be sent. This uses the existing stream lifecycle
and runtime shutdown, without another scheduler.
Normal native BrowserWindow and screenshot/video acceptance remain pending.
The native GUI machine lacks Docker and is occupied with SSH acceptance. See
the handoff for fixture prerequisites. Do not disable the Chromium sandbox or
relax endpoint restrictions to obtain that evidence.
@@ -0,0 +1,176 @@
# Test integrated byte streams on the native desktop
Use `integration/plugin-byte-stream-handoff` from its integration PR. Record the
exact commit with `git rev-parse HEAD`. The branch preserves transport #3988
(`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`), AI integration #3986
(`173ef5abbb1f8cea4e2c409c5791186926b778e1`), and source identity #3981
(`4213d8e6726c44e7a479418ece3adf16ea494782`) through ordinary merges.
## Prepare the desktop and fixture
Wait until the approved native GUI machine is free from SSH acceptance work.
Use a fresh disposable app profile, a working display, and the platform's normal
Electron sandbox. Keep the existing endpoint policy and system security settings.
The database pass requires a disposable MariaDB fixture at a reachable private
IPv4 address. The address must not belong to the GUI machine. The current native
GUI machine lacks Docker, so fixture provisioning is a prerequisite. Use an
already approved separate Docker host or disposable database machine with an
existing permitted network path. Do not install new software or create local
forwarding exceptions as part of this recipe. If no fixture is available, record
the database pass as pending.
There is also a separately labeled
[public Rfam candidate](../../examples/plugins/database-rfam-readonly/README.md).
It fixes one public hostname, public account, database, and `SELECT 1 AS value`.
It has only been prepared and checked offline. The parent must obtain exact
package and endpoint authorization before any install, permission activation,
or connection. Its authentication and TLS requirements remain unverified. This
candidate does not replace the owned fixture regression or prove that the GUI
can reach the coding container's private database.
The registered automated fixture uses
`mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873`.
It runs on an internal Docker network, publishes no host ports, generates a
disposable password, and removes its container and network. On a prepared Linux
executor with that recognized image, run:
```sh
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
```
That test exercises the protocol, broker, and installed package's SDK/RPC path
in a Node harness. It does not prove native GUI behavior. For the GUI fixture,
have its operator create only
synthetic data and a dedicated SELECT-only account. Run this SQL using the
fixture's administration channel, replacing the password placeholder with a
fresh disposable password that is never included in screenshots or logs:
```sql
CREATE DATABASE plugin_demo;
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<fresh-disposable-password>';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
```
Use protocol 4.1 with `mysql_native_password`. The example uses plain TCP only
within the approved isolated fixture network. Verify INSERT denial independently
through the fixture's client. The plugin intentionally offers only its fixed read.
## Build and identify the packages
Use Node 22 or later and the lockfile. Run these commands in sequence:
```sh
npm ci
npm run test:plugin-runtime
npm run test:plugin-contract
npm run test:plugin-examples
npm run generate:plugin-contract
npm run check:plugin-contract
npm run generate:capability-tools
npm run check:codex-app-server-schema
npm run lint
npm run build
git diff --exit-code
```
For the reproducible packaging-only example, use the fixed synthetic endpoint:
```sh
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example-repeat.ncpkg
cmp /tmp/database-example.ncpkg /tmp/database-example-repeat.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
sha256sum /tmp/database-example.ncpkg
```
The two five-file packages built on Node 24.19.0 with the locked dependencies
match byte for byte. The current owned-fixture package SHA-256 is
`588cb6766616c2f3057c7c8df1c20dbac947e0d5e9d01b2a928c7581b00a69c5`.
The earlier `6a4fb178` package used hash `88c14283…dbc1af4`. The example-only
protocol now has separate fixed owned-fixture and Rfam entrypoints; the owned
fixture database, query, and opt-in live regression are retained.
The six-file assistant package retains SHA-256
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
The preparation tool removes temporary build paths from its minified browser
bundle. Rebuild with the actual fixture endpoint before native testing. Validate
and record that package's new hash. Preparation does not dial the endpoint.
Build the assistant and follow its existing
[native QA recipe](assistant-native-qa.md). Use its deterministic local provider,
synthetic credential marker, and synthetic Note. No paid model call is required.
The AI internals were integrated without another independent review. The earlier
review denial remains in effect and is not replaced by these tests.
## Record the installed browser path
1. Launch the integrated app on the approved desktop with a new disposable
`--user-data-dir`. Capture the full app window and record the exact commit.
2. In Settings > Plugins, import the database package. Capture its ID, version,
package hash, source identity, browser entry, and exact endpoint permission.
The import must leave the plugin disabled and optional access ungranted.
3. Enable it and open **Disposable database example**. Deny the first endpoint
request. Capture the failure and verify the runtime remains usable.
4. Retry with only the disposable fixture credentials and allow the exact
endpoint. Capture the one-row result and the source-aware permission prompt.
The password field must clear when the request starts.
5. Record a video of a delayed fixture operation followed by **Cancel**. Use a
delay offered by the fixture operator. Do not add a host protocol scheduler
or change the query. Confirm the request stops and a fresh read still works.
6. Revoke `network.stream` while a connection is active. Confirm the panel reports
failure and a new request prompts again. Disable, re-enable, close the view,
and restart the app. No old connection or result may resume.
7. Record the assistant's denial, exact-text review, streamed result, usage,
Stop, and post-revocation recovery through its linked recipe. Use the same
integrated head and preserve its separate permission descriptions.
8. Remove only the synthetic plugins and profile. Have the fixture operator
remove the disposable account, data, container, and network. Stop the synthetic
model provider.
## Record the remaining evidence
Save the exact head, app platform/version, package hashes, ordered commands and
exit codes, screenshots, and video paths. Distinguish a skipped Docker test,
synthetic Node runtime integration, and actual native sandbox acceptance.
The address regression uses a mocked `os.networkInterfaces()` result and a fake
dialer. It never retries the reviewer's EPERM interface lookup. The cancellation
regression uses the registered package, host service, SDK, RPC, and real loopback
echo fixture under the existing test-only override. This override is unavailable
through the production service constructor. Neither test certifies the native
BrowserWindow sandbox or a live database pass on the GUI machine.
The integration executor completed these checks in order on 2026-10-26:
| Check | Result |
| --- | --- |
| `npm run test:plugin-runtime` | 627 tests: 626 passed, 1 native skip, 0 failed |
| `npm run test:plugin-contract` | 90 passed, 0 skipped, 0 failed |
| `npm run test:plugin-examples` | 4 passed, 1 opt-in Docker skip, 0 failed |
| Plugin contract generate and drift check | Passed, no generated changes |
| Capability tool generation and drift check | Passed, no generated changes |
| Codex App Server schema check | Passed |
| `npm run lint` | Passed, 0 errors and 7 warnings outside the transport changes |
| `npm run build` | Passed, Vite production build completed in 40.23 seconds |
| Database repeat packaging and both package validations | Passed |
The late transport merge moves the opt-in database test into the example directory.
Run its panel and protocol tests explicitly with
`node --test examples/plugins/database-minimal/*.test.cjs`. The database test
still requires explicit opt-in and the native Electron suite remains separate.
This executor has Docker, but the pinned MariaDB image
is not cached. No image was installed and no new live database run is claimed.
Native GUI acceptance remains pending on the occupied desktop. Full repository
tests and platform packages run in the integration PR's exact-head CI. Record
their final workflow conclusions separately from these local results.
After the late transport merge and Rfam candidate preparation, the registered
runtime suite has 626 passed and one native skip, contract/SDK/CLI has 90 passed,
and `npm run test:plugin-examples` has four passed and one Docker skip. The
example suite is now registered in CI. Public protocol checks use only synthetic
packet bytes and package preparation; no Rfam connection or permission was
activated. Final CI belongs to the current #3993 head, not the older `6a4fb178`
head whose two workflows already succeeded.
@@ -0,0 +1,70 @@
# Reconcile the late transport checkpoint
The integration owner is #3993, branch
`integration/plugin-byte-stream-handoff`. Its independently rechecked head
`6a4fb178cde73b57f78b1711a4e1f9a0f394b4fb` passed both
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40365457418) and
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40365457024).
The original transport owner later published #3988 at
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its common baseline with #3993 is
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The late commit has that baseline
and source checkpoint `4213d8e6726c44e7a479418ece3adf16ea494782` as parents.
The source checkpoint is already an ancestor of #3993.
## Keep the remaining transport changes
The late delta is not entirely superseded. An ordinary merge retains its
published history and these useful changes:
- Attach cleanup cancels a producer returned after runtime release exactly
once. The new regression fails against the old #3993 service and passes
with the late service change. The test uses a loopback fixture and a fake
stream producer. It does not enumerate real interfaces.
- Service tests cover source replacement before permission publication and
during a pending read. A router test checks one close notification for idle
peer cancellation. The installed entrypoint also checks consumer cancellation.
- CLI and SDK tests cover the public canonical byte-stream requests. Shared
manifest checks require the corresponding AI/context feature declarations.
No AI runtime, broker, adapter, or renderer implementation is reviewed here.
- Database-specific testing moves out of `electron/plugins` into the minimal
example. The opt-in test also installs its real package and checks the
SDK/RPC path and audit omission. Its live pass at `f1410f8` remains historical
evidence, not a new live pass on the current integration head.
- The example panel test checks password clearing and text-only result output.
## Resolve overlap in favor of the integrated behavior
| Overlap | Resolution |
| --- | --- |
| Marketplace source files and lockfile | Already identical to the late checkpoint |
| Own-machine address comparison | Retain #3993's IPv4 and mapped IPv6 normalization and regression |
| Cancelled buffered and in-flight credit | Retain #3993's stream lifecycle fix and regressions |
| Service test file | Combine the normalization test with all three late lifecycle/source tests |
| Installed entrypoint test | Keep retained-byte revocation coverage and add the late consumer-cancel case |
| Shared permission descriptions | Retain the newer connected AI checkpoint's descriptions |
| Assistant preparation | Retain the newer six-file staging build |
| Contract test | Retain the repaired optional-stream expectation and positive exact-resource assertion |
| Database preparation | Both sides minify the bundle. Keep the existing deterministic preparation |
| Native instructions | Keep the GUI's actual prerequisites. Its missing Docker and unsupported path to the coding container are not resolved by the late branch's local Docker recipe |
The original reports are retained in
[the historical acceptance record](byte-stream-acceptance.md). Neither their old
AI wiring status nor their environment-specific broad-test failures replace the
connected implementation and exact-head CI evidence on #3993. No main merge,
force-push, endpoint-policy relaxation, AI internal review retry, or release is
part of this reconciliation.
## Check the reconciled source
The merged transport source passed 627 registered runtime tests: 626 passed and
one native Electron test skipped. Contract/SDK/CLI tests passed 90 of 90. The
example panel passed; its opt-in database test remained skipped. The late attach
regression failed against the old #3993 service with two cancel calls instead
of one, then passed after the merge.
A subsequent example-only Rfam candidate reuses the limited browser protocol
through two fixed entrypoints. Its offline tests check the original owned
fixture database/query and the fixed public database/query, plus rejection of
an unsupported auth plugin. No general query API is exported and no host
database code is introduced. The owned MariaDB live regression remains opt-in.
+18 -1
View File
@@ -60,7 +60,7 @@ recovery and uninstall. Cross-source install or update is rejected, even when
version and package bytes match. Local imports are a separate source.
Private settings, view state, persistent and in-memory grants, secrets, and cloud
sidecars are namespaced by host-derived source identity. KV keys are qualified
sidecars are namespaced by host-derived source identity. Every source, including local imports, uses an opaque encoded owner; public plugin IDs are never storage-owner aliases in sync payloads. Before each sync entry and after every awaited contribution update/reset, the host rechecks that owner against the live source and contributions. Hydration captures its owner before yielding and never remaps a public ID for a later write. KV keys are qualified
inside the existing per-plugin FK. Code, permissions and source configuration do
not participate in cloud sync. Old-source cloud settings may remain orphaned;
they cannot hydrate the same plugin ID from a different source. Generic KV still
@@ -82,3 +82,20 @@ Tests use synthetic source responses and first-party packages. Passing tests is
not a sandbox certification. Native UI, real third-party hosting, network
availability and multi-device encrypted-sync acceptance must be reported
separately. No real credentials or untrusted community code are needed for tests.
## Source-owner integration
Use `PluginDatabase.getActivePlugin(context.pluginId)` to require a current host
installation, then `database.privateOwner(plugin.id)` for an opaque data partition.
`plugin.source` is host-owned `{id, kind, name, origin}`.
`getRetainedPluginSource(pluginId)` returns a retained uninstall binding or null.
`publicPluginId(owner)` resolves only an encoded owner matching the current source;
it rejects raw public IDs and stale owners. Never accept owner/source from a plugin
request or invent a `pluginId` fallback. Capture owner/source at async operation
start and revalidate before committing; follow runtime lifecycle cancellation.
Plugin database schema 7 atomically converts existing raw local private rows and
KV keys to the same encoded-owner model without deleting them. This is a one-time
conversion, not a parallel runtime compatibility path. A conflicting conversion
fails without publishing schema 7. Old raw-ID cloud entries remain orphaned and
cannot hydrate an installed plugin. Host/Vault/input schemas are unchanged.
@@ -0,0 +1,44 @@
# Selected-context authority reference
`electron/plugins/context/selectedContextAuthority.cjs` implements text disclosure.
It does not issue typed Host targets or authorize edit, connect, or command actions.
The constructor is `new SelectedContextAuthority({ resolveDataOwner, clock?,
limits? })`. `resolveDataOwner(context)` returns the authenticated host-owned source
binding. Runtime identity comes from the host RPC context, never plugin arguments.
| Method | Meaning |
| --- | --- |
| `share({scope,title,version,readText,isCurrent}, context)` | Trusted host issues an opaque, expiring text handle. Returns a summary. |
| `summaries(context)` | Enumerates only this source/runtime's shared summaries. Does not call `readText`. |
| `read({handle,version}, context)` | Checks identity, expected version, current owner, lifetime, and resource currency before and after the bounded read. |
| `assertCurrent({handle,version}, context)` | Checks currency without reading content. |
| `onDidInvalidate(listener)` | Observes handle revocation. One failed listener cannot interrupt cleanup. |
| `release(handle)` | Revokes one text handle. |
| `releaseRuntime(runtimeId)` | Revokes that runtime's text handles. |
| `releaseOwner(owner)` | Revokes that exact owner binding's text handles. |
| `shutdown()` | Revokes all handles. |
The summary contains `handle`, `scope`, `title`, `version`, and `expiresAt`.
`scope: "host"` means curated host-related text. It is not a Host entity reference.
A summary, menu ID, title, or host-related text cannot become an action target.
`PluginAISourceBinding` in `electron/plugins/ai/sourceBinding.cjs` consumes the
market owner's source API. `resolve(context)` first authenticates the current
browser runtime and then returns a stable frozen object containing `runtimeId`,
`pluginId`, `pluginVersion`, `sourceKey`, and `privateOwner`. It revalidates source
binding after asynchronous authentication. The object never crosses plugin RPC.
`sourceKey` binds the source ID, kind, and origin. It is not a trust endorsement.
This helper supplies neither resource selection nor action authorization.
Resource currency remains a trusted `isCurrent()` callback. The callback binds
the selected resource, expected revision, current host window, and its lifecycle.
The authority stores the issued version separately and never accepts a later
plugin-supplied revision as authoritative.
The Host/Vault action task requires a separate typed selection authority. Such a
target binds the host-owned source identity, runtime, plugin version, window owner
and generation, exact Host ID, expected Host revision, expiry, and allowed action.
Each action needs its own grant and host confirmation. Text-read handles must not
implicitly gain edit or connect authority. Host draft review and connection
confirmation remain separate workflows owned by that task.
@@ -0,0 +1,67 @@
# Check selected terminal sharing on the native desktop
The parent owns scheduling for this acceptance pass. The native GUI slot remains
reserved. Use the Draft branch `feat/plugin-terminal-sharing-complete` only after
that slot becomes available. Record `git rev-parse HEAD` and the package hash.
Keep the platform's normal sandbox and helper permissions.
## Prepare synthetic terminals
Use a disposable app profile and two local disposable terminal sessions, A and B.
Do not use SSH credentials, remote services, paid providers, or user resources.
Manually display only `synthetic terminal A` in A and `synthetic terminal B` in B.
Run the flow once in a solo tab, once in workspace Focus mode, and once in the
selected split pane. Other visible split panes and inactive tabs must remain
ineligible. Select a small, distinct range in each terminal. Build and import the exact
[two-file sample](../../examples/plugins/terminal-selection-draft/README.md).
Its expected archive SHA-256 is
`f3b8ac268edbf616effe76110f372d2249e414675d877020a074c89f03c65950`.
Check required `commands` and `menus`, and optional `terminal.selection.read` and
`terminal.input.propose`. Enable the package only in this disposable profile.
## Check disclosure and cancellation
1. Focus A and invoke **Share selected terminal text**.
2. Cancel the permission prompt. Confirm that no preview or draft opens. If an
unavailable command cannot start, confirm that the palette shows its bounded
failure toast rather than silently closing.
3. Invoke it again and allow the one-time selection permission. Before Preview,
confirm that terminal text is absent from the dialog.
4. Click **Preview terminal selection**. Confirm the A label and session ID,
complete selected text, escaped controls, byte bound, and secrets disclosure.
5. Cancel. Confirm that the plugin does not proceed to a draft.
6. Repeat and click **Share with plugin**. Choose **Review selected terminal draft**
and confirm its separate permission.
7. Cancel the draft permission, then repeat and allow it. Edit the draft, including
a synthetic newline and visible control escapes. Confirm no terminal output,
command, cursor-input change, or Enter occurs while the draft opens or closes.
8. Click **Close draft**. Confirm no terminal bytes were sent and the plugin sees
only an acknowledgement. Do not send the draft during this acceptance check.
## Check lifecycle and input compatibility
Repeat from A and verify these outcomes:
- Switch to B before Share. A's pending request closes. A new request previews
only B's selection, never A's text or combined history.
- Clear B's selection. Preview fails visibly. It never substitutes the viewport,
retained history, another terminal, or an empty-but-successful disclosure.
- Hide A, close it, reconnect it, or attach it to a popup while a share or draft
is pending. The pending interaction closes or refuses completion. Start a fresh
selection after restoring an eligible main-window terminal.
- Disable/revoke the plugin, lock/hide the window, or replace the owner window
during review. Old controls cannot complete the cancelled operation.
- Wait 120 seconds after issuance. A retained reference cannot open a new draft.
- With copy normalization disabled, verify exact raw selected text in the preview.
Restore normal settings after the check.
- With the normal compose bar already holding synthetic text and broadcast enabled,
open and close a plugin draft. Confirm the existing compose draft and broadcast
target are unchanged, and neither A nor B receives input.
- Run the established local input-mode and attach checks from #3991. Plugin preview
and draft review must preserve the existing terminal controls and input mode.
Capture the disclosure, selected terminal, editable draft, cancellation, and
post-review terminal state. Record native acceptance as pending until these checks
actually run. The automated Node/JSDOM tests do not establish Electron sandbox or
native GUI acceptance.
@@ -0,0 +1,87 @@
# Selected terminal validation record
The continuation uses ordinary merges from integration #3993 at
`37df67256e7eedc1050f186bcf401dc376cb5e71`, current main at
`f0897c3c1`, and WIP `3cdc97782ef3262969eee9d540e03ebe6fdee210`.
All three remain ancestors. Main includes input-mode snapshot correction #3991.
The original checkout remains clean at `58c6a216a`. No frozen feature branch,
main branch, or release was rewritten or published.
## Automated checks
| Check | Result |
| --- | --- |
| `npm run test:plugin-runtime` | 644 tests: 643 passed, one native skip, zero failures |
| `npm run test:plugin-contract` | 91 passed |
| `npm run test:plugin-examples` | Five passed, one existing opt-in database skip |
| Focused renderer, worker-manager, and #3991 popup input-mode tests | 119 passed |
| Final selection reader, host-dialog, and manifest tests | Five passed |
| Late runtime-mount regression, selection/dialog, and popup input-mode checks | 11 passed |
| Pane ownership correction, real runtime mount, consent dialog, palette feedback, and compatibility checks | 40 passed |
| Generated plugin contract, capability tools, Codex schema checks | Passed; tool catalogs unchanged |
| `npm run lint` | Zero errors, seven existing warnings |
| `npm run build` | Passed; existing chunk-size warning |
| Two independent sample builds | Same archive and content hashes |
The installed browser entrypoint tests use the real package store, manager, SDK,
RPC registry, permission middleware, and host interaction service. A linked worker
protocol harness supplies owned synthetic sessions through the real worker manager
and worker runtime. It creates no real SSH or provider connection. JSDOM exercises
the registered terminal selection reader and actual host dialog with signed tokens.
This is not an Electron sandbox or native GUI acceptance claim.
Assertions cover denial and consent cancellation, immutable snapshots, independent
terminal references, explicit false copy normalization, empty and oversized
selections, hidden and detached surfaces, reconnect, attached owner changes,
renderer replacement, expiry, source/runtime/version revocation, missing encoded
owners, late replies, and zero PTY writes for multiline/control-containing drafts.
The existing compose draft, broadcast behavior, and input-mode compatibility keep
their original paths.
Standalone `tsc --noEmit` remains outside the clean repository checks. A comparison
with the merged baseline reports the same 447 normalized diagnostics, with no new
or removed diagnostic. Package builds and the prescribed production build pass.
A real runtime-mount regression failed against the first published head
`3cfc7db3716b42f346e2819c822f546e949e1a88`: the selection hook saw a null ref before
asynchronous xterm creation, without a later focus/status render. Publication now
also runs after runtime assignment, teardown, and hibernate wake completion.
The regression and the associated popup input-mode checks pass. This uses the
existing mount and wake paths and adds no polling or terminal reader fallback.
That earlier head passed [test CI](https://github.com/binaricat/Netcatty/actions/runs/40380234637)
with 15,316 tests, 15,266 passed, 50 skipped, and zero failures. All four
[platform package jobs](https://github.com/binaricat/Netcatty/actions/runs/40380234655)
also passed. The Draft PR records fresh exact-head results for the lifecycle fix.
The subsequent pane-ownership regression failed on `3e5d450a1`: the actual solo
snapshot `solo|solo|na` yields the legacy `isFocused: false`. Workspace Focus mode
has the same `na` token. The new selection reader had incorrectly treated that
legacy bit as effective input ownership. It now consumes the existing optional
`isFocusedPane` semantics: undefined outside split contention, true for the
selected split pane, false for other split panes. Visibility remains mandatory.
The parser and the legacy focus bit are unchanged.
The production pane-to-Terminal props are factored into
`resolveTerminalPaneInputProps` without changing their values. Reader and real
runtime-mount tests consume that same mapping for solo, Focus mode, selected and
other split panes, inactive tabs/workspaces, and a hidden terminal layer. The host
consent-dialog test also starts from the real solo props rather than a supplied
`focused: true`. All existing source, connection, preview, and lifecycle guards
remain in place. A rejected palette plugin command now uses the existing bounded
localized toast; it exposes no raw diagnostic and performs no retry.
## Packaging and native acceptance
Local `npm run pack:dir` completed its production build, then stopped while
rebuilding `@serialport/bindings-cpp` for Electron 42.11.12. The proxy returned
HTTP 403 for the configured Electron header download under
`https://www.electronjs.org/headers`. It produced no accepted desktop package.
The download was not retried through a mirror. Exact-head platform CI remains the
packaging verification surface; its result is recorded on the Draft PR.
The sample archive is
`f3b8ac268edbf616effe76110f372d2249e414675d877020a074c89f03c65950`.
The parent-coordinated [native recipe](terminal-sharing-native-qa.md) remains
pending on the reserved GUI machine. No GUI launch, sandbox bypass, helper
permission change, paid provider call, or real-user resource was used.
@@ -0,0 +1,56 @@
# Selected terminal sharing — implementation WIP
This historical checklist describes commit `3cdc97782`. The continuation on
`feat/plugin-terminal-sharing-complete` is documented in
[the implemented contract](terminal-sharing.md). Native acceptance remains
[parent-coordinated and pending](terminal-sharing-native-qa.md).
Base: ThemePack `f868221b5f11810802f929369ac8eee46166079a`.
Branch: `feat/plugin-terminal-sharing`. This is saved unfinished source, not an
accepted API or an installable/native-tested workflow. Do not merge or release.
Implemented source so far:
- Canonical optional action-only `terminal.selection.read`, feature
`terminal.selection-sharing`, bounded result and host-target schemas; SDK
`context.terminals.shareSelection()` and runtime forwarding through the
existing `ui.interact` broker. No independent parallel RPC stack.
- Main-only worker/session generation and window-owner binding accessor; no
endpoint or Host DTO returned to plugins.
- Host target/handle authority, opaque signed short-lived receipts, capacity,
expiry and lifecycle hooks; no selected text stored in the authority.
- Broker admission binding to the selected resource before a once-only grant,
signed Preview/Share phases, fixed audit metadata and teardown integration.
- Trusted app-only selected-target setter, not a plugin API. No database,
installation/source/cache identity or private-storage schema changes.
Still missing, and required before this counts as working:
1. Trusted renderer target publication for the real active terminal/workspace;
actual bounded selection reader registered by the terminal surface. Read
only on Preview, from a visible connected terminal, never through Agent/MCP
history/context readers; refuse sensitive input, empty or oversized selection.
2. Host dialog Preview response and explicit Share rendering, failure states,
exact resource/selection consistency and global lifecycle verification.
3. Canonical/SDK/broker/authority/real-worker/renderer normal and boundary tests,
generated-contract and compatibility review (including optional-only
declaration semantics), quotas, owner/reconnect/close/revoke/late-result cases.
4. Useful ordinary-browser synthetic example, fixture ID/hash/precise permissions,
build/CI, independent review/fix/review and parent-owned native acceptance.
5. Separate input-draft layer: reuse the actual compose control without inheriting
broadcast targets, overwriting an existing draft silently, writing PTY bytes
or sending Enter. Require a separately approved target-bound draft action and
final active identity check. No input-proposal method/permission is delivered
by this WIP.
Known integration constraints:
- The market source-binding task owns installation, cache, authorization/private
storage identity and associated database schema. Coordinate before touching
those locations; runtime/assertActive identity must remain host-owned.
- Keep the ThemePack security-color delta in its own branch/head. Incorporate
the eventually accepted head with a normal integration merge, not by rewriting
frozen evidence.
- The user allows simplification of unreleased plugin historical data/protocols;
do not add legacy migration layers. Do not remove unrelated host/input data
migrations or weaken the sandbox.
+63
View File
@@ -0,0 +1,63 @@
# Selected terminal sharing and input drafts
The `terminal.selection-sharing` feature adds
`context.terminals.shareSelection(title)`. It resolves to `undefined` on cancel,
or a frozen `{text, handle, expiresAt}` snapshot after host Preview and Share.
Text is limited to 4096 UTF-8 bytes. Only the visible, connected input owner's current xterm selection is eligible:
a solo tab, the selected Focus-mode pane, or the selected split pane. Other
visible split panes remain ineligible. Empty, oversized, sensitive-input, detached,
and unavailable selections fail closed. The user's copy-normalization setting,
including explicit `false`, remains in effect.
The trusted renderer publishes its backend session ID and renderer identity.
`TerminalSelectionAuthority` checks the worker's current connection generation and
window ownership. It also binds the host database's encoded source owner, plugin
version, runtime, and expiry. Neither raw terminal objects nor endpoint/store/PTY
objects cross plugin RPC. The host dialog identifies the terminal and previews
exact text, including escaped control characters, before the user shares it.
Raw terminal output can contain secrets. There is no redaction safety guarantee.
A handle expires after 120 seconds. A runtime can hold four handles and the host
can hold 64. Session close, reconnect, attached-owner change, renderer replacement,
selection of another terminal, loss of the selected visible target, window owner
change, plugin disable/revoke, and source replacement invalidate references.
Currency is checked again before disclosure or draft review. Late replies cannot
reuse an old phase's one-use action token. Text already explicitly disclosed to a
plugin cannot be recalled from that plugin's memory.
## Input proposal contract
The additive `terminal.input-drafts` feature adds
`context.terminals.proposeInput(handle, text, title)`. The handle identifies only
the previously selected terminal. A distinct `terminal.input.propose` permission
and host review are required. Both new permissions are optional-only manifest
declarations with one-time operation grants. The existing `ui.interact` broker
owns correlation, limits, cancellation, and renderer consent.
`proposeInput` opens an editable textarea in the existing host interaction dialog.
It resolves to `true` after **Close draft**, or `false` on cancel. Edits stay in
that dialog. The API neither returns the edits nor dispatches terminal input.
Multiline and control-containing proposals remain inert text, bounded to 4096
UTF-8 bytes. The proposal's creation, editing, and closure produce no PTY writes,
Enter, clipboard replacement, broadcast, or automatic retry.
The normal compose bar sends on Enter and can broadcast, so this slice does not
insert into that control. Users can copy text themselves and later act through
the normal host UI. No new terminal editor or recorder is introduced.
## Authority boundaries
The terminal reader uses the existing selection-normalization primitive. It has
no access to the Agent/MCP history readers or retained terminal snapshots. Source
ownership comes only from `database.privateOwner` and `database.publicPluginId`.
A missing encoded owner fails closed. No database schema or source-storage API
changes are needed.
The separate [AI selected-context interface](selected-context-authority.md) remains
text-read-only. Its handles cannot become terminal targets or authorize proposals.
This implementation consumes documented boundaries and does not review or extend
AI business logic, SSH authentication, SFTP, or the tool catalogs.
The [sample](../../examples/plugins/terminal-selection-draft/README.md) and
[native recipe](terminal-sharing-native-qa.md) identify the exact package and
remaining desktop acceptance work.
+83
View File
@@ -0,0 +1,83 @@
# SSH input compatibility
The host advanced settings include **输入兼容模式:逐字发送** (input compatibility: send character by character). The help text is **中文输入或短粘贴出现丢字时尝试开启。** Saved changes take effect after reconnecting.
New SSH hosts use standard mode. Compatibility mode retains the interactive text workaround introduced by [PR #3168](https://github.com/binaricat/Netcatty/pull/3168):
- Literal IME commits with multiple Unicode code points use separate writes.
- Plain, unbracketed pastes of at most 32 Unicode code points use separate writes.
- Longer raw pastes, bracketed pastes, Kitty associated text, and the existing confirmed line-paste path retain their current transport behavior.
- Terminal controls stay together. Unicode code points retain their byte order, including combining marks and emoji sequences.
The setting belongs to the terminal endpoint host. A host used only as an SSH network jump does not supply the target's input setting. Other transports retain their existing input behavior. Automated commands, connect scripts, and SFTP do not use this setting.
## Stored records
`Host.sshInputCompatibility` stores the choice as a boolean. `sanitizeHost` migrates an SSH record with a missing field to `true`, including records whose omitted protocol means SSH. It preserves explicit `false` and `true` values on every subsequent load, import, and save. The existing vault normalization and persistence paths save the migrated value. No global migration marker or new storage key is required.
New host creation paths save `false`. Duplicates copy the source value. JSON vault backups and cloud sync retain the field. CSV exports include `SshInputCompatibility`; imports accept `true` and `false`. External imports without that column create standard-mode hosts.
The connection captures the host's setting under its session-owned terminal settings ref. This snapshot survives xterm disposal and the actual hibernate wake/reattach path. Reconnect refreshes the setting through `resetConnectionInputState(host)`, including reconnects that reuse the existing runtime.
Attaching an observe popup transfers this captured choice through the existing terminal snapshot IPC. The popup restores it before attaching input to the live backend. Closing the popup returns it with the display snapshot. A hibernated home reads and restores the same session-owned state without consulting the current Vault preference. These handoffs do not reconnect or apply a newly saved choice.
## Historical evidence
[Issue #3077](https://github.com/binaricat/Netcatty/issues/3077#issuecomment-5433744875) reports that individual hanzi worked in a Qianxin search prompt where multi-character input failed. The report also says Windows with Xshell worked. Those observations do not establish the remote parser's SSH-write semantics or Xshell's default implementation.
The [Xshell 7 manual](https://cdn.netsarang.net/docs/Xshell7_manual.pdf), section 5.2.21, printed page 27, describes session paste character delay, line delay, and prompt waiting. [SecureCRT's echo flow control tip](https://www.vandyke.com/support/tips/echoflowctrl.html) describes a session line delay and an optional script that waits for each line's echo. These references support configurable send compatibility. They do not establish default per-character IME sends or equivalence to a Qianxin-specific fix. This feature adds no delays or echo waiting.
## Verification boundaries
`sshInputCompatibility.integration.test.ts` runs real xterm and `createXTermRuntime` callbacks under JSDOM. It covers raw paste, literal `insertText`, key code 229, composition events, composition fallback, long IME text, terminal controls, bracketed paste, Kitty text, reconnect with a reused runtime, and the actual `wakeTerminalFromHibernate` entry after a saved setting changes. Local, Telnet, serial, Mosh, and Eternal Terminal cases verify that the SSH field leaves their legacy text and control-containing payload behavior unchanged. Canvas measurement is stubbed. These tests do not exercise native input methods or application windows.
`sshInputCompatibility.popup.test.ts` runs the actual popup host resolver, home snapshot callbacks, attach boot effect, popup close callback, and real xterm paste handlers. Both saved-setting directions are covered with a live or hibernated home, including input after returning and waking. `terminalBridge.rebindOutput.test.cjs` exercises the preload and authorized main-process snapshot round trip, preserving explicit `false` and rejecting invalid metadata. These automated popup checks do not constitute native multi-window or OS IME validation.
`useVaultState.inputCompatibility.test.ts` exercises the actual vault hook's asynchronous save, reload, duplicate, JSON backup, local backup payload, and cloud payload apply paths. It uses test localStorage and no native safeStorage key. CSV and endpoint selection have separate round-trip and jump-host tests.
## Isolated OpenSSH timing fixture
The implementation-side fixture connected to an owned OpenSSH server on `127.0.0.1:22379` using public-key authentication. It executed `cat` without a PTY because the unprivileged daemon could not allocate one. Production IME/raw-paste selectors and the code-point chunker supplied an `ssh2` client's `channel.write` calls. This fixture did not include native GUI events, renderer IPC, clipboard handling, or an OS input method.
Each sample used 15 consecutive repetitions per mode on the same ready SSH channel. Standard mode ran before compatibility mode for each sample; the modes were not randomized or interleaved. Authentication, command startup, selector evaluation, and chunk-array construction finished before timing. Each repetition started immediately before its first `channel.write` call and ended after the awaited receive-length condition resolved: the client's echo buffer contained at least the expected UTF-8 byte length. The complete received buffer was then compared byte-for-byte with the input, outside the timer. No artificial send delay was added.
The table reports milliseconds. Median is the eighth sorted observation. P95 uses nearest rank, which is the largest of 15 observations here. Every repetition in all six tested sample pairs preserved the exact input bytes.
| Sample | UTF-8 bytes | Client writes, standard / compatibility | Echo median, standard / compatibility | Echo P95, standard / compatibility |
|---|---:|---:|---:|---:|
| `中文输入` (IME selector) | 12 | 1 / 4 | 47.439 / 44.572 | 57.971 / 48.418 |
| `中文👩🏽‍💻é-hostname-123` (raw paste) | 37 | 1 / 21 | 46.628 / 47.957 | 51.753 / 55.012 |
| `中` repeated 32 times (raw paste) | 96 | 1 / 32 | 44.122 / 47.536 | 49.040 / 54.734 |
| `中文abc👩🏽‍💻é` repeated 4,096 times (raw paste) | 110,592 | 1 / 1 | 5.433 / 6.240 | 26.105 / 54.073 |
| Arrow sequence `ESC [ A` | 3 | 1 / 1 | 44.320 / 47.657 | 55.726 / 52.414 |
| Bracketed paste containing `中文` | 18 | 1 / 1 | 44.151 / 44.995 | 48.959 / 55.769 |
These are local end-to-end SSH echo completion times for this fixture. They are not native app paste latency, remote processing time, a throughput benchmark, or evidence that the previous behavior caused lag. Client write-call counts do not establish SSH packet boundaries, server read boundaries, or SSH channel-data block counts.
## Native desktop results
The final native report tested production code commit `bd5f27ff42d29b18c0e9cb3513415da3243dd636` against two owned loopback SSH endpoints in an isolated profile. The following checks passed:
- New hosts defaulted to standard mode. Duplicate, save, and application restart preserved each host's choice.
- Saving during a connection did not change its input mode. A real reconnect adopted the saved mode in both directions.
- Full hibernation used a 5-second hidden-tab delay and zero retained renderers, with xterm absence confirmed. Wake/reattach retained the old connection choice in both directions; a subsequent reconnect adopted the saved choice.
- A missing-field legacy record was seeded into the isolated host store. Real application startup migrated it to `true`. After the user disabled compatibility, saved, and restarted, the value remained `false`.
- The Chinese setting label, help text, and reconnect notice matched the required text and were captured in screenshots. Arrow and Ctrl+C bytes remained intact.
| Native sample | Received-content result | Observed SSH channel-data blocks, standard / compatibility |
|---|---|---|
| `中文测试🙂é输入兼容模式` — 37 UTF-8 bytes, 13 code points | Hashes matched between modes | 1 / 13 |
| Long sample — 131,072 bytes | Hashes matched between modes | Four 32,768-byte frames / four 32,768-byte frames |
| Negotiated bracketed paste — 49 bytes | Preserved | 1 / 1 |
The native short sample differs from the implementation fixture's 37-byte sample above. Its 1 / 13 SSH channel-data block observation is not the implementation fixture's 1 / 21 client write-call count. The native 131,072-byte long sample is also distinct from the 110,592-byte timing sample.
The native timing observation was one PTY first-to-last receive span per mode: standard **3.408724 ms**, compatibility **1.600044 ms**. It starts at the first received PTY payload and ends at the last received payload. It excludes the input-to-first-receive interval and is not total GUI paste latency. One observation per mode does not provide a median, P95, or causal performance comparison. These spans cannot be substituted for the 15-repetition client echo timings above. Neither run establishes throughput superiority or that the previous workaround caused lag.
Actual OS IME input and Qianxin hardware were not tested. Native networked cloud sync, import, and jump-chain behavior were also not tested; those data flows have automated coverage rather than claimed native validation. Synthetic DOM composition events, native GUI/paste coverage, client write calls, SSH data blocks, and receive timing remain separate evidence categories.
Independent review confirmed the three reported P2 findings resolved, with no new blocking findings. The native application, SSH and clipboard fixture processes, isolated profile, and test keys were cleaned up; the source tree was clean after that run.
## CI result
The `test` workflow completed successfully for documentation commit `38c5f4744cb5fc3ecdd4e624b323587a471e4333`: [run 40319492139](https://github.com/binaricat/Netcatty/actions/runs/40319492139). That workflow includes `npm run lint`, `npm test`, generated-spec checks, and `npm run build`. The production code is unchanged from the native-tested `bd5f27ff` commit. Subsequent acceptance-text edits change documentation only. The original feature was merged in [PR #3977](https://github.com/binaricat/Netcatty/pull/3977); those results predate the popup snapshot follow-up.
+12
View File
@@ -793,3 +793,15 @@ test("sanitizeHost preserves named-pipe spaces and restores the authoritative se
assert.equal(sanitizeHost(host).serialConfig?.path, path);
assert.equal(sanitizeHost(makeHost({ hostname: 'example.com extra' })).hostname, 'example.com');
});
test("SSH input compatibility migrates legacy hosts once and preserves later choices", () => {
const legacy = { id: "legacy", label: "Legacy", hostname: "localhost", username: "user", tags: [], os: "linux" } as Host;
for (const protocol of [undefined, "ssh"] as const) {
const migrated = sanitizeHost({ ...legacy, protocol });
assert.equal(migrated.sshInputCompatibility, true);
assert.equal(sanitizeHost(migrated).sshInputCompatibility, true);
assert.equal(sanitizeHost({ ...migrated, sshInputCompatibility: false }).sshInputCompatibility, false);
}
assert.equal(sanitizeHost({ ...legacy, protocol: "telnet" }).sshInputCompatibility, undefined);
});
+5
View File
@@ -466,6 +466,11 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
? undefined
: migrated.authMethod ?? inferredLegacyAuthMethod,
authPolicyVersion: 1,
// Legacy SSH records used per-character IME/short-paste writes globally.
// Persist the choice on normalization; explicit false survives every reload.
sshInputCompatibility: host.protocol === undefined || host.protocol === 'ssh'
? host.sshInputCompatibility ?? true
: host.sshInputCompatibility,
hostname: cleanHostname,
osOverride: osSelection,
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
+3
View File
@@ -189,6 +189,9 @@ export interface Host {
identityFileId?: string; // Reference to SSHKey
protocol?: HostProtocol; // Default/primary protocol, including namespaced plugin protocols
pluginConnection?: PluginConnectionConfig;
// SSH interactive IME/short-paste compatibility; applied on reconnect.
// Missing on legacy records; sanitizeHost preserves their previous behavior.
sshInputCompatibility?: boolean;
// Runtime marker for in-memory-only hosts (e.g. password deep links).
// Ephemeral hosts are never persisted to the vault or session restore.
ephemeral?: boolean;
+26
View File
@@ -820,3 +820,29 @@ test('plugin importer preview is bounded and never exposes secret or command pay
assert.equal(JSON.stringify(preview).includes('private-key-material'), false);
assert.equal(JSON.stringify(preview).includes('contains-sensitive-command'), false);
});
test('new imported SSH drafts default to standard input and preserve explicit boolean choices', () => {
for (const protocol of [undefined, 'ssh'] as const) {
for (const choice of [undefined, false, true]) {
const result = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host', value: {
hostname: 'new.test', ...(protocol ? { protocol } : {}),
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
},
} }]);
assert.deepEqual(result.errors, []);
assert.equal(result.hosts[0].sshInputCompatibility, choice ?? false);
}
}
});
test('imported host drafts reject non-boolean input compatibility choices', () => {
for (const choice of ['false', 'true', 0, null]) {
const result = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host', value: { hostname: 'new.test', protocol: 'ssh', sshInputCompatibility: choice },
} }]);
assert.deepEqual(result.hosts, []);
assert.deepEqual(result.errors, ['Importer returned an invalid host draft.']);
}
});
+2
View File
@@ -204,6 +204,7 @@ const normalizeHost = (value: JsonValue): Host | null => {
};
if (Object.values(optionalStrings).some((item) => item === INVALID_FIELD)) return null;
const optionalBooleans = {
sshInputCompatibility: optionalBooleanValue(object, 'sshInputCompatibility'),
moshEnabled: optionalBooleanValue(object, 'moshEnabled'),
etEnabled: optionalBooleanValue(object, 'etEnabled'),
telnetEnabled: optionalBooleanValue(object, 'telnetEnabled'),
@@ -242,6 +243,7 @@ const normalizeHost = (value: JsonValue): Host | null => {
createdAt: Date.now(),
ephemeral: false,
managedSourceId: undefined,
sshInputCompatibility: false,
...(protocol ? { protocol } : {}),
...(pluginConnection ? { pluginConnection: structuredClone(pluginConnection) } : {}),
...(deviceType ? { deviceType } : {}),
+6
View File
@@ -11,8 +11,13 @@ const PERMISSION_DESCRIPTIONS = {
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
"terminal.selection.read": [true, "Share terminal selection", "Read only text explicitly previewed and shared from the selected live terminal. It may contain secrets. Each action requires approval.", "分享终端选区", "仅读取用户从当前活跃终端明确预览并分享的文本,可能包含秘密;每次操作均需确认。"],
"terminal.input.propose": [true, "Review terminal input draft", "Open an editable host draft for one selected terminal. Does not send input or Enter.", "审阅终端输入草稿", "打开所选终端的可编辑宿主草稿,不发送输入或回车。"],
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
"terminal.input": [true, "Send terminal input", "Send input to a terminal; input may execute commands or change a remote host.", "发送终端输入", "向终端发送输入,可能执行命令或更改远程主机。"],
@@ -26,6 +31,7 @@ const PERMISSION_DESCRIPTIONS = {
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
"network.stream": [true, "TCP and TLS connections", "Send and receive data over TCP or TLS at approved endpoints. Data may leave this device.", "TCP 和 TLS 连接", "通过 TCP 或 TLS 与已批准的端点收发数据。数据可能离开此设备。"],
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
+1
View File
@@ -68,6 +68,7 @@ export const buildQuickConnectHost = ({
os: "linux",
protocol: protocol === "mosh" || protocol === "et" ? "ssh" : protocol,
authMethod,
sshInputCompatibility: false,
identityId: applicableIdentityId,
password: !applicableIdentityId && authMethod === "password" ? password : undefined,
identityFileId:
+4
View File
@@ -16,6 +16,9 @@
// CloudLinker — issue #3592: the vendor identifies a single-channel gateway
// and proposes this distinctive banner. Older "Go" banners cannot identify it
// safely; this compatibility entry requires the vendor banner change.
// russh — issue #3973: russh returns CHANNEL_FAILURE for wantReply=false env
// requests, desynchronizing ssh2's callback FIFO so pty-req/shell appear to
// fail. Skip env via the single-channel path. No chunked-write evidence.
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
@@ -23,6 +26,7 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"TERM-SSHD",
"CLOUDBILITY",
"CLOUDLINKER",
"russh",
]);
// #3146 also drops multi-character writes. That evidence is only for these
+4
View File
@@ -16,6 +16,9 @@
// CloudLinker — issue #3592: the vendor identifies a single-channel gateway
// and proposes this distinctive banner. Older "Go" banners cannot identify it
// safely; this compatibility entry requires the vendor banner change.
// russh — issue #3973: russh returns CHANNEL_FAILURE for wantReply=false env
// requests, desynchronizing ssh2's callback FIFO so pty-req/shell appear to
// fail. Skip env via the single-channel path. No chunked-write evidence.
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
@@ -23,6 +26,7 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"TERM-SSHD",
"CLOUDBILITY",
"CLOUDLINKER",
"russh",
]);
// #3146 also drops multi-character writes. That evidence is only for these
+13 -1
View File
@@ -20,6 +20,10 @@ test("single-channel banners match only the supported software tokens", () => {
"CloudLinker",
"SSH-2.0-CloudLinker",
"cloudlinker-1.0",
"russh",
"SSH-2.0-russh",
"SSH-2.0-russh_0.45.0",
"RuSSH",
]) {
assert.equal(remoteSoftwareRequiresSingleChannel(version), true, version);
}
@@ -49,6 +53,12 @@ test("single-channel banner match ignores OpenSSH, JumpServer, and lookalike tok
"SSH-2.0-Go",
"notcloudlinker",
"CloudLinkerProxy",
"russhd",
"myrussh",
"prerussh",
"russhserver",
"SSH-2.0-russhd",
"notRussh",
]) {
assert.equal(remoteSoftwareRequiresSingleChannel(version), false, String(version));
}
@@ -59,6 +69,8 @@ test("chunked-write limit stays on BHostSSH and TERM-SSHD", () => {
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-TERM-SSHD"), true);
assert.equal(remoteDisallowsChunkedChannelWrite("CLOUDBILITY-4.14"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-CloudLinker"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-russh"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-russh_0.45.0"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("OpenSSH_9.6"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("superterm-sshd"), false);
});
@@ -82,7 +94,7 @@ test("CommonJS and renderer modules agree on SSH banner behavior", async () => {
const browserModule = await import("./singleChannelSshBanner.shared.mjs");
const nodeModule = require("./singleChannelSshBanner.shared.cjs");
for (const version of [
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "",
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-russh_0.45.0", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "",
]) {
assert.equal(browserModule.remoteSoftwareRequiresSingleChannel(version), nodeModule.remoteSoftwareRequiresSingleChannel(version));
assert.equal(browserModule.remoteDisallowsChunkedChannelWrite(version), nodeModule.remoteDisallowsChunkedChannelWrite(version));
+13
View File
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models";
import { resolveHostAutofillPassword } from "./sshAuth";
import { sanitizeHost } from "./host";
import {
buildSshDeepLinkConnectionHost,
buildSshDeepLinkEphemeralHost,
@@ -242,8 +243,10 @@ test("buildSshDeepLinkHostDraft prepares an editable new ssh host", () => {
tags: [],
os: "linux",
protocol: "ssh",
sshInputCompatibility: false,
createdAt: 123,
});
assert.equal(sanitizeHost(draft).sshInputCompatibility, false);
});
test("buildSshDeepLinkOpenHost falls back to a draft host when no saved host matches", () => {
@@ -331,3 +334,13 @@ test("buildSshNoteLinkOpenHost ignores unrelated external links", () => {
assert.equal(openHost, null);
});
test("deep links derived from saved hosts preserve explicit input choices", () => {
const target = parseSshDeepLink("ssh://alice:secret@example.com")!;
for (const sshInputCompatibility of [false, true]) {
const saved = host({ sshInputCompatibility });
assert.equal(buildSshDeepLinkConnectionHost(saved).sshInputCompatibility, sshInputCompatibility);
assert.equal(buildSshDeepLinkEphemeralHostFromSaved(saved, target, { id: "copy", now: 1 }).sshInputCompatibility, sshInputCompatibility);
}
});
+1
View File
@@ -165,6 +165,7 @@ export const buildSshDeepLinkHostDraft = (
tags: [],
os: "linux",
protocol: "ssh",
sshInputCompatibility: false,
createdAt: options.now,
});
+13
View File
@@ -675,3 +675,16 @@ test("resolveTerminalChainHosts skips non-SSH jump candidates", () => {
assert.deepEqual(resolved.map((host) => host.id), ["jump-ssh", "jump-mosh"]);
});
test("input compatibility belongs to the terminal endpoint, independently of SSH jump hops", () => {
const target: Host = { id: "target", label: "Target", hostname: "target.example.test", username: "alice",
protocol: "ssh", tags: [], os: "linux", sshInputCompatibility: false, hostChain: { hostIds: ["bastion"] } };
const bastion: Host = { ...target, id: "bastion", hostChain: undefined, sshInputCompatibility: true };
const resolve = (hostId: string) => resolveTerminalSessionHost({
session: { ...baseSession, hostId }, hosts: [target, bastion], groupConfigs: [], proxyProfiles: [], localOs: "linux",
});
assert.equal(resolve("target").sshInputCompatibility, false);
assert.equal(resolve("bastion").sshInputCompatibility, true);
assert.equal(resolveTerminalChainHosts({ host: target, hosts: [target, bastion], groupConfigs: [], proxyProfiles: [] })[0].sshInputCompatibility, true);
});
+7
View File
@@ -1956,3 +1956,10 @@ test('applyVaultHostDelete keeps jump hosts referenced by empty group defaults',
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /still used as a group jump host/i);
});
test('new SSH hosts explicitly use standard interactive input', () => {
const created = buildVaultHostFromDraft({ hostname: 'localhost', username: 'user' });
assert.equal(created.ok, true);
if (created.ok) assert.equal(created.host.sshInputCompatibility, false);
});
+1
View File
@@ -329,6 +329,7 @@ export function buildVaultHostFromDraft(
os,
osOverride: parsedOs ?? 'auto',
protocol,
sshInputCompatibility: false,
createdAt: now,
...(keyPath
? {
+5
View File
@@ -665,6 +665,7 @@ const importFromCsv = (
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
const inputCompatibilityIdx = findExactHeaderIndex(header, ["sshinputcompatibility"]);
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
const fuzzyNamedPasswordIdx = findHeaderIndex(header, ["password", "passwd"]);
@@ -795,6 +796,10 @@ const importFromCsv = (
tags,
notes,
});
if (inputCompatibilityIdx >= 0 && (protocol === undefined || protocol === "ssh")) {
const choice = row[inputCompatibilityIdx]?.trim().toLowerCase();
if (choice === "true" || choice === "false") host.sshInputCompatibility = choice === "true";
}
if (proxyConfig) host.proxyConfig = proxyConfig;
if (directProxy) host.directProxy = true;
if (localShellSpec && protocol === "local") {
+13
View File
@@ -842,3 +842,16 @@ test("CSV import warns and ignores a malformed LocalShell value", () => {
assert.equal(imported.hosts[0]?.localShell, undefined);
assert.ok(imported.issues.some((issue) => issue.message.includes("row 2") && issue.message.includes("LocalShell")));
});
test("CSV export/import preserves independent SSH input compatibility choices", () => {
const hosts: Host[] = [false, true].map((choice, index) => ({
...hostDefaults, id: `mode-${index}`, label: `Mode ${index}`,
hostname: `mode-${index}.example.test`, protocol: "ssh", sshInputCompatibility: choice,
}));
const { csv } = exportHostsToCsvWithStats(hosts);
const imported = importVaultHostsFromText("csv", csv);
assert.deepEqual(imported.hosts.map(host => host.sshInputCompatibility), [false, true]);
const second = importVaultHostsFromText("csv", exportHostsToCsvWithStats(imported.hosts).csv);
assert.deepEqual(second.hosts.map(host => host.sshInputCompatibility), [false, true]);
});
+3 -2
View File
@@ -37,7 +37,7 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
@@ -115,7 +115,7 @@ const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
};
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
const rows: string[][] = [header];
let unreadableProxyCredentialCount = 0;
@@ -217,6 +217,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
encodeCsvProxy(proxyValue),
encodeCsvProxyJump(proxyJumpValue),
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
host.protocol === undefined || host.protocol === "ssh" ? String(host.sshInputCompatibility ?? true) : "",
]);
}
+13 -2
View File
@@ -68,7 +68,8 @@ const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
const ansiFreePrompt = stripAnsi(String(expectedPrompt || ""));
const normalizedPrompt = ansiFreePrompt.replace(/\r/g, "");
if (!normalizedPrompt) return false;
const fullText = String(text || "");
const windowChars = Math.max(
@@ -76,7 +77,17 @@ function hasExpectedPromptSuffix(text, expectedPrompt) {
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
);
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
const strippedTail = stripAnsi(tail);
// Drop CR so a redraw inside one line (`root@host:~\r# `) still matches
// the cached prompt. That collapses a two-line PS1's `\r\r\n` to a single
// LF, which no longer ends with the cache (`user@host: /root\n\n\n$ `).
if (strippedTail.replace(/\r/g, "").endsWith(normalizedPrompt)) return true;
// The idle-prompt cache turns those CRs into LFs and keeps the blank
// lines. Compare that form too, only when the cache itself spans lines,
// so a bare `$`/`#` is unchanged.
if (!ansiFreePrompt.includes("\n")) return false;
const crAsLfPrompt = ansiFreePrompt.replace(/\r/g, "\n");
return strippedTail.replace(/\r/g, "\n").endsWith(crAsLfPrompt);
}
function escapePosixSingleQuoted(text) {
@@ -3,7 +3,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
const { stripAnsi } = require("./shellUtils.cjs");
const { extractTrailingIdlePrompt, stripAnsi } = require("./shellUtils.cjs");
// The behaviour before the tail window was introduced: normalise the whole
// text, then compare its end.
@@ -41,6 +41,38 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
});
test("live PTY CR bytes match a cached two-line idle prompt", () => {
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
// matching has to accept that form; stripping CR down to one LF does not.
const live = "user@host: /workspace\r\r\n$ ";
const cached = extractTrailingIdlePrompt(`welcome\r\n${live}`);
assert.equal(cached, "user@host: /workspace\n\n\n$ ");
const matchLive = hasExpectedPromptSuffix(live, cached);
assert.equal(matchLive, true);
assert.equal(hasExpectedPromptSuffix(`hostname\r\nbox\r\n${live}`, cached), true);
assert.equal(
hasExpectedPromptSuffix("\x1b[?2004huser@host: /workspace\r\r\n$ ", cached),
true,
);
const rootLive = "user@host: /root\r\r\n# ";
const rootCached = extractTrailingIdlePrompt(rootLive);
assert.equal(rootCached, "user@host: /root\n\n\n# ");
assert.equal(hasExpectedPromptSuffix(rootLive, rootCached), true);
// Readiness for a real LF two-line prompt still matches, and a bare
// `$`/`#` is neither this prompt nor a POSIX prompt on its own.
const lfCached = extractTrailingIdlePrompt("welcome\r\nuser@host: /workspace\n$ ");
assert.equal(lfCached, "user@host: /workspace\n$ ");
assert.equal(hasExpectedPromptSuffix("user@host: /workspace\n$ ", lfCached), true);
assert.equal(hasExpectedPromptSuffix("output\r\n$ ", cached), false);
assert.equal(hasExpectedPromptSuffix("$ ", cached), false);
assert.equal(hasExpectedPromptSuffix("# ", rootCached), false);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
});
test("prompt suffix check does not scan the whole buffer on every call", () => {
// 2M characters of CRLF output used to cost seconds per call because every
// call stripped ANSI and carriage returns over the entire text.
+35 -3
View File
@@ -69,6 +69,11 @@ const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
// the final `]` before `$`/`#` is the delimiter.
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
// Require a hostname and a home/absolute path, excluding prompt delimiters
// within the path so partially entered commands do not count as idle prompts.
@@ -84,7 +89,14 @@ function isDefaultCmdPromptLine(line) {
function isDefaultPosixPromptLine(line) {
const normalized = String(line || "").replace(/\s+$/, "");
return POSIX_PROMPT_PATTERN.test(normalized) || ALPINE_PROMPT_PATTERN.test(normalized);
// Optional virtualenv / conda / BT-panel style env name prefix: "(name) "
// before the shell prompt. Recognition only; callers still return the full
// prompt line so suffix matching sees the visible prefix (#3947).
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
return (
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
);
}
function extractTrailingIdlePrompt(output) {
@@ -95,7 +107,8 @@ function extractTrailingIdlePrompt(output) {
const normalized = stripAnsi(output).replace(/\r/g, "\n");
if (!normalized || normalized.endsWith("\n")) return "";
const lastLine = normalized.split("\n").pop() || "";
const lines = normalized.split("\n");
const lastLine = lines.pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
@@ -111,6 +124,24 @@ function extractTrailingIdlePrompt(output) {
return lastLine;
}
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
// CR-to-LF normalize above turns that into blank lines between the host
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
// uses the same normalize, and hasExpectedPromptSuffix compares this
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
if (rightTrimmed === "$" || rightTrimmed === "#") {
let hostIndex = lines.length - 1;
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
hostIndex -= 1;
}
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
}
}
return "";
}
@@ -171,7 +202,8 @@ function trackSessionIdlePrompt(session, chunk) {
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// `user@host[:path][#$]`, RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// a remote shell switch into another shell, an oh-my-posh / starship / custom
// PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
+91
View File
@@ -219,6 +219,97 @@ test("extracts a trailing RHEL-family bracketed idle prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
});
test("extractTrailingIdlePrompt recognizes a two-line user@host then bare $/# PS1", () => {
// export PS1="\u@\h: \w\n$ " paints the host line, then a lone "$ ".
// The cached prompt must keep both lines so a lone "$" cannot satisfy endsWith.
assert.equal(
extractTrailingIdlePrompt("welcome\r\nuser@host: /home/foo\n$ "),
"user@host: /home/foo\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nroot@host: /root\n# "),
"root@host: /root\n# ",
);
// Live bash PTY: prompt CR plus ONLCR is `\r\r\n`, which this normalizer
// stores as blank lines. The cache must keep them so endsWith still matches.
assert.equal(
extractTrailingIdlePrompt("\x1b[?2004huser@host: /home/foo\r\r\n$ "),
"user@host: /home/foo\n\n\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("\x1b[?2004hroot@host: /root\r\r\n# "),
"root@host: /root\n\n\n# ",
);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
assert.equal(isDefaultPosixPromptLine("$"), false);
assert.equal(isDefaultPosixPromptLine("#"), false);
assert.equal(isDefaultPosixPromptLine("$ "), false);
assert.equal(isDefaultPosixPromptLine("# "), false);
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host PS1", () => {
const session = {};
const prompt = "user@host: /home/foo\n$ ";
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
assert.equal(getFreshIdlePrompt(session), prompt);
const ptySession = {};
trackSessionIdlePrompt(ptySession, "\x1b[?2004huser@host: /home/foo\r\r\n$ ");
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
});
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
});
test("isDefaultPosixPromptLine accepts virtualenv, conda, and BT-panel env prefixes", () => {
// Python venv / conda / BT-panel prepend "(name) " before the real prompt.
// Recognition strips that prefix; a missing space after ")" is already
// accepted because the username class absorbs the parens (#3947).
for (const prompt of [
"(3.12.12) root@example:~# ",
"(3.12.12) root@example:~#",
"(venv) alice@host:~$ ",
"(base) [opc@host ~]$ ",
"(my-env) localhost:~# ",
"(3.12.12)root@example:~# ",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("extractTrailingIdlePrompt keeps the full env-prefixed POSIX prompt", () => {
// Suffix matching compares the visible PTY line, so the captured prompt
// must still include the "(name) " prefix rather than the stripped form.
for (const prompt of [
"(3.12.12) root@example:~# ",
"(3.12.12) root@example:~#",
"(venv) alice@host:~$ ",
"(base) [opc@host ~]$ ",
"(my-env) localhost:~# ",
]) {
assert.equal(extractTrailingIdlePrompt(`Last login\r\n${prompt}`), prompt, prompt);
}
});
test("env-prefixed POSIX recognition rejects incomplete prefixes and non-prompts", () => {
for (const line of [
"(3.12.12 root@example:~# ",
"(3.12.12) root@example:~# printf busy",
"(venv) not a prompt",
"root@host ~#",
"(base) [opc@host ~]$ printf busy",
"(my-env) localhost:~# printf busy",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
+31 -1
View File
@@ -241,6 +241,7 @@ let registeredVaultAgentBridge = null;
// Active streaming requests (for cancellation)
const activeStreams = new Map();
const pluginModelStreamOwners = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
const ENC_PREFIX = "enc:v1:";
@@ -613,7 +614,11 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
const parsedUrl = new URL(url);
// Register cancellation before any await so Stop during PAC/proxy lookup works.
const controller = new AbortController();
const { idleTimeoutMs, totalTimeoutMs } = resolveAIStreamTimeouts(options);
const pluginRuntimeId = /^plugin-model_([A-Za-z0-9-]{1,128})_/.exec(requestId)?.[1];
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId
? { idleTimeoutMs: Math.min(15_000, Math.max(1, Number(options.idleTimeoutMs) || 15_000)),
totalTimeoutMs: Math.min(60_000, Math.max(1, Number(options.totalTimeoutMs) || 60_000)) }
: resolveAIStreamTimeouts(options);
const maxErrorBodyBytes = Math.max(1, Number(options.maxErrorBodyBytes) || 64 * 1024);
let lifecycleFinished = false;
let idleTimer;
@@ -623,6 +628,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
clearTimeout(totalTimer);
clearTimeout(idleTimer);
activeStreams.delete(requestId);
pluginModelStreamOwners.delete(requestId);
};
const noteStreamActivity = () => {
if (lifecycleFinished) return;
@@ -636,6 +642,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
}, totalTimeoutMs);
noteStreamActivity();
activeStreams.set(requestId, controller);
if (pluginRuntimeId) pluginModelStreamOwners.set(requestId, { sender: event.sender, runtimeId: pluginRuntimeId });
if (controller.signal.aborted) {
finishLifecycle();
throw controller.signal.reason instanceof Error ? controller.signal.reason : createAbortError();
@@ -782,10 +789,19 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
settleResolve({ statusCode, statusText });
let buffer = "";
let pluginResponseBytes = 0;
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB safety limit
res.on("data", (chunk) => {
if (streamFinished) return;
noteStreamActivity();
if (pluginRuntimeId) {
pluginResponseBytes += Buffer.byteLength(chunk);
if (pluginResponseBytes > 131_072) {
failStream(new Error("Plugin model stream byte budget exceeded"));
return;
}
}
const previousBufferLength = buffer.length;
buffer += chunk.toString();
// Guard against unbounded buffer growth
@@ -1005,12 +1021,25 @@ function registerHandlers(ipcMain) {
}
}
// This entry point is main-process only. Plugin views cannot choose a sender or
// access it through IPC. Internal Agent streams have no plugin request tag.
function cancelPluginAIStreamsForSender(sender, runtimeId) {
let cancelled = 0;
for (const [requestId, owner] of [...pluginModelStreamOwners]) {
if (owner.sender !== sender || (runtimeId !== undefined && owner.runtimeId !== runtimeId)) continue;
activeStreams.get(requestId)?.abort(createAbortError());
cancelled++;
}
return cancelled;
}
// Abort active streams and child processes on shutdown
function cleanup() {
for (const [id, controller] of activeStreams) {
try { controller.abort(); } catch {}
}
activeStreams.clear();
pluginModelStreamOwners.clear();
// Abort active SDK agent streams (set by registerSdkStreamHandlers on ctx).
if (registeredContext && registeredContext.sdkActiveStreams) {
@@ -1056,6 +1085,7 @@ module.exports = {
buildExternalAgentSystemContext,
buildExternalAgentContextualPrompt,
_streamRequestForTests: streamRequest,
cancelPluginAIStreamsForSender,
_resolveAIStreamTimeoutsForTests: resolveAIStreamTimeouts,
_getActiveStreamCountForTests: () => activeStreams.size,
DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS,
@@ -836,6 +836,105 @@ test("sudo SFTP-page transfer reuses the elevated channel instead of dialing aga
assert.equal(sftpClients.has("browse-sudo"), true);
});
for (const [ident, sequential] of [
["OpenSSH_9.6", false],
["CLOUDBILITY-4.14", true],
["TERM-SSHD", true],
]) {
test(`sudo transfer aliases preserve ${ident} upload concurrency`, { timeout: 15000 }, async (t) => {
const server = await startOtpOnlyServer({ ident });
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const sftpClients = new Map();
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
transferBridge.init({ sftpClients });
t.after(async () => {
for (const sftpId of [...sftpClients.keys()].reverse()) {
await sftpBridge.closeSftp(null, { sftpId }).catch(() => {});
}
pool.discardAllTransports("test-end");
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
await server.close();
});
const options = { ...BASE_OPTIONS(server.port), reuseTransport: false };
const sender = createSender();
const opening = sftpBridge.openSftp({ sender }, { ...options, sessionId: "sudo-owner" });
opening.catch(() => {});
await waitFor(() => answerOtpPrompts(sender));
const opened = await opening;
const owner = sftpClients.get(opened.sftpId);
// Model an already-elevated channel. The real SSH/SFTP peer remains the
// ordinary server or bastion above; no second sudo/authentication is needed.
const sudoOptions = { ...BASE_OPTIONS(server.port), sudo: true };
const endpoint = pool.buildConnectionReuseEndpoint(sudoOptions, { sftpSudo: true });
owner.__netcattySudoMode = true;
owner.__netcattyEndpointKey = pool.buildEndpointKey(endpoint);
owner.__netcattyChannelAdoptionKey = pool.buildChannelAdoptionKey(endpoint);
const firstAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
const secondAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
for (const alias of [firstAlias, secondAlias]) {
const client = sftpClients.get(alias.sftpId);
assert.equal(client.__netcattySharedChannelAlias, true);
assert.equal(client.__netcattySudoMode, true);
assert.equal(client.sftp, owner.sftp, "both uploads must retain the elevated channel");
}
const root = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sudo-concurrency")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, "source.bin");
const payload = Buffer.alloc(3 * 32768 + 17, 0x5a);
fs.writeFileSync(source, payload);
const firstPath = "/home/otpuser/first.bin";
const secondPath = "/home/otpuser/second.bin";
server.files.set(firstPath, Buffer.from("original file"));
const channel = owner.sftp;
const originalLstat = channel.lstat.bind(channel);
let firstTargetChecks = 0;
let promotionStarted;
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
let releasePromotion;
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
channel.lstat = (remotePath, ...args) => {
if (String(remotePath) === firstPath && ++firstTargetChecks === 2) {
promotionStarted();
void promotionGate.then(() => originalLstat(remotePath, ...args));
return;
}
originalLstat(remotePath, ...args);
};
const upload = (alias, targetPath) => transferBridge.startTransfer({ sender: createSender() }, {
transferId: `sudo-concurrency-${ident}-${path.basename(targetPath)}`,
sourcePath: source, targetPath,
sourceType: "local", targetType: "sftp", targetSftpId: alias.sftpId,
totalBytes: payload.length, resumable: true,
});
let firstFinished = false;
let secondFinished = false;
const first = upload(firstAlias, firstPath).then((result) => { firstFinished = true; return result; });
let second;
let results;
try {
await promotionStartedPromise;
second = upload(secondAlias, secondPath).then((result) => { secondFinished = true; return result; });
await waitFor(() => secondFinished
|| transferBridge._getSessionFastDownloadWaiterCountForTests(channel) > 0);
assert.equal(firstFinished, false, "the first upload must still be held before publication");
assert.equal(secondFinished, !sequential,
sequential ? "a bastion must wait for the first promotion" : "ordinary sudo uploads must remain concurrent");
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(channel), sequential ? 1 : 0);
} finally {
releasePromotion();
results = await Promise.all(second ? [first, second] : [first]);
channel.lstat = originalLstat;
}
for (const result of results) assert.equal(result.error, undefined, result.error);
assert.deepEqual(server.files.get(firstPath), payload);
assert.deepEqual(server.files.get(secondPath), payload);
assert.equal(server.getConnectionCount(), 1, "sudo uploads must not open an unelevated connection");
assert.equal(server.getKeyboardInteractiveRounds(), 1, "uploads must not ask for another OTP");
});
}
test("reconnecting the soft-closed owner id does not replace it with a self-alias (#3310)", async (t) => {
const { sftpTransferSessionLeaseStore, channelAliasLeaseId } = require("./sftpTransferSessionLease.cjs");
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
+2 -1
View File
@@ -9,7 +9,8 @@ const FIXED_DH_GROUP_BY_KEX = Object.freeze({
});
const CIPHER_SSL_NAME_BY_ALGORITHM = Object.freeze({
"chacha20-poly1305@openssh.com": "chacha20",
// Patched ssh2 supplies original ChaCha20 when Electron/BoringSSL lacks it.
// Do not filter this SSH cipher against the OpenSSL-only inventory.
"aes128-gcm@openssh.com": "aes-128-gcm",
"aes256-gcm@openssh.com": "aes-256-gcm",
"aes128-gcm": "aes-128-gcm",
+7 -4
View File
@@ -144,17 +144,17 @@ for (const [label, buildAlgorithms] of [
});
});
test(`${label} drops chacha20-poly1305 when the runtime lacks chacha20`, () => {
test(`${label} retains chacha20-poly1305 through ssh2's fallback when the runtime lacks chacha20`, () => {
const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20");
withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => {
const algorithms = buildAlgorithms(false);
assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), false);
assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), true);
});
});
test(`${label} cipher override is filtered against runtime support`, () => {
test(`${label} preserves explicit ChaCha20 and mixed cipher overrides without native crypto`, () => {
const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20");
withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => {
@@ -164,7 +164,10 @@ for (const [label, buildAlgorithms] of [
},
});
assert.deepEqual(algorithms.cipher, ["aes256-ctr"]);
assert.deepEqual(algorithms.cipher, ["chacha20-poly1305@openssh.com", "aes256-ctr"]);
assert.deepEqual(buildAlgorithms(false, {
algorithmOverrides: { cipher: ["chacha20-poly1305@openssh.com"] },
}).cipher, ["chacha20-poly1305@openssh.com"]);
});
});
@@ -0,0 +1,140 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { Server } = require("ssh2");
const CHACHA = "chacha20-poly1305@openssh.com";
const AES = "aes128-gcm@openssh.com";
const root = path.resolve(__dirname, "../..");
const { privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const hostKey = privateKey.export({ type: "pkcs1", format: "pem" });
async function connect(t, {
serverCiphers = [CHACHA],
cipher,
entrypoint = "netcatty:start",
withoutNativeChacha = true,
} = {}) {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-chacha-"));
t.after(() => fs.rmSync(homeDir, { recursive: true, force: true }));
const offered = {};
let negotiated;
const connections = new Set();
// The server uses Node/OpenSSL's ChaCha20; only the child uses the fallback.
const server = new Server({
hostKeys: [hostKey],
algorithms: { cipher: serverCiphers },
debug(line) {
const match = /Handshake: \(remote\) (C->S|S->C) cipher: (.*)/.exec(line);
if (match) offered[match[1]] = match[2].split(",");
},
}, (client) => {
connections.add(client);
client.on("error", () => {});
client.on("close", () => connections.delete(client));
client.on("handshake", (info) => { negotiated = info; });
client.on("authentication", (ctx) => ctx.accept());
client.on("ready", () => {
client.on("session", (accept) => {
const session = accept();
session.on("pty", (acceptPty) => acceptPty?.());
session.on("env", (acceptEnv) => acceptEnv?.());
session.on("shell", (acceptShell) => {
const stream = acceptShell();
stream.on("data", (data) => stream.write(data));
});
});
});
});
t.after(() => {
for (const client of connections) client.end();
server.close();
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const runtime = process.env.NETCATTY_SSH_TEST_RUNTIME === "electron"
? require("electron")
: path.resolve(process.env.NETCATTY_SSH_TEST_RUNTIME || process.execPath);
const child = spawn(runtime, ["scripts/fixtures/ssh-chacha-client.cjs"], {
cwd: root,
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
stdio: ["pipe", "pipe", "pipe"],
});
t.after(() => child.kill());
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => { stdout += data; });
child.stderr.on("data", (data) => { stderr += data; });
child.stdin.end(JSON.stringify({
port: server.address().port,
appRoot: process.env.NETCATTY_SSH_TEST_APP_ROOT,
homeDir,
// Cross several SSH packets, exercising both nonce/counter directions.
input: "synthetic SSH echo\r\n".repeat(4000),
algorithmOverrides: cipher ? { cipher } : undefined,
entrypoint,
withoutNativeChacha,
}));
const code = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
assert.equal(code, 0, stderr || stdout);
const resultLine = stdout.split("\n").find((line) => line.startsWith("NETCATTY_TRANSPORT_RESULT="));
assert.ok(resultLine, stderr || stdout);
const result = JSON.parse(resultLine.slice("NETCATTY_TRANSPORT_RESULT=".length));
if (process.env.NETCATTY_SSH_TEST_APP_ROOT && result.connected) {
assert.equal(result.packagedDependency, "node_modules/@noble/ciphers/chacha.js");
}
return { ...result, offered, negotiated };
}
for (const entrypoint of ["netcatty:start", "netcatty:test-connection"]) {
test(`${entrypoint} advertises and negotiates an explicit ChaCha20-only override without native crypto`, { timeout: 15000 }, async (t) => {
const result = await connect(t, { cipher: [CHACHA], entrypoint });
assert.equal(result.connected, true, result.error);
assert.equal(result.nativeChacha, false);
assert.deepEqual(result.offered["C->S"], [CHACHA]);
assert.deepEqual(result.offered["S->C"], [CHACHA]);
assert.equal(result.negotiated.cs.cipher, CHACHA);
assert.equal(result.negotiated.sc.cipher, CHACHA);
if (entrypoint === "netcatty:start") assert.equal(result.echoed, true);
});
}
test("default ciphers can connect to a ChaCha20-only server", async (t) => {
const result = await connect(t);
assert.equal(result.connected, true, result.error);
assert.equal(result.echoed, true);
assert.ok(result.offered["C->S"].includes(CHACHA));
assert.equal(result.negotiated.cs.cipher, CHACHA);
});
test("default and mixed offers retain AES preference and explicit cipher order", async (t) => {
for (const cipher of [undefined, [AES, CHACHA], [CHACHA, AES]]) {
const result = await connect(t, { serverCiphers: [AES, CHACHA], cipher });
assert.equal(result.connected, true, result.error);
assert.equal(result.echoed, true);
assert.equal(result.negotiated.cs.cipher, cipher?.[0] || AES);
if (cipher) assert.deepEqual(result.offered["C->S"], cipher);
}
});
test("an explicit AES-only choice never falls back to ChaCha20", async (t) => {
const result = await connect(t, { cipher: [AES] });
assert.equal(result.connected, false);
assert.match(result.error, /no matching C->S cipher/);
assert.deepEqual(result.offered["C->S"], [AES]);
});
test("the runtime's unmodified crypto path still supports ChaCha20", async (t) => {
const result = await connect(t, { cipher: [CHACHA], withoutNativeChacha: false });
assert.equal(result.connected, true, result.error);
assert.equal(result.echoed, true);
});
+7
View File
@@ -257,6 +257,9 @@ function handleTerminalSessionSnapshotResponse(event, payload) {
pending.resolve({
success: true,
snapshot: typeof payload?.snapshot === "string" ? payload.snapshot : "",
perCharacterInput: typeof payload?.perCharacterInput === "boolean"
? payload.perCharacterInput
: undefined,
kittyKeyboardModeState: normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState),
kittyKeyboardProtocolEnabled: typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
? payload.kittyKeyboardProtocolEnabled
@@ -316,6 +319,9 @@ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = nu
const contextScrollbackSnapshot = hasContextScrollbackSnapshot ? payload.contextScrollbackSnapshot : "";
const hasAlternateScreen = typeof payload?.alternateScreen === "boolean";
const alternateScreen = payload?.alternateScreen === true;
const perCharacterInput = typeof payload?.perCharacterInput === "boolean"
? payload.perCharacterInput
: undefined;
const kittyKeyboardModeState = normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState);
const kittyKeyboardProtocolEnabled = typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
? payload.kittyKeyboardProtocolEnabled
@@ -368,6 +374,7 @@ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = nu
contextViewportSnapshot,
contextScrollbackSnapshot,
alternateScreen,
perCharacterInput,
kittyKeyboardModeState,
kittyKeyboardProtocolEnabled,
passwordPromptActive,
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more