Compare commits

...
Author SHA1 Message Date
陈奇 0c05a96fa1 fix(plugins): align avatar delivery with registry v2 contract 2026-10-27 11:48:35 +00:00
陈奇 da16b90e9e feat(plugins): add author avatars and host-controlled maintainer badges 2026-10-27 11:25:54 +00:00
陈奇 d40b0d3c25 docs(plugins): prepare Settings visibility and current-main acceptance handoff 2026-10-27 07:08:50 +00:00
陈奇 714e2397a4 Merge reviewed Settings native-view visibility correction 2026-10-27 06:59:39 +00:00
陈奇 c4aedfa9ed Merge current main owner handoff and prompt fixes into isolated plugin acceptance 2026-10-27 06:59:39 +00:00
陈奇 1551fa8355 fix(plugins): suspend native views only for active log dialogs 2026-10-27 06:55:01 +00:00
a0cf2b487c fix(mcp): recognize Oh My Zsh ys two-line idle prompts (#4008)
The two-line idle prompt check only accepted a classic user@host host
line, so Oh My Zsh ys (`# user @ host ...` then a bare $/#) never became
ready and terminal_execute sent nothing. Match that host line narrowly
and still reject a lone marker.

Fixes #4007

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 14:31:21 +08:00
陈大猫 e3e2dcfe02 fix(ai): keep dissolved workspace chat on its surviving owner (#4003)
Fixes #3998

Keep terminal-scoped AI chat on its surviving owner pane when dissolving a workspace; remint onto preferred only when the owner is gone.
2026-10-27 13:58:44 +08:00
陈奇 2433cf0130 docs(plugins): prepare isolated Ask routing QA and preserve 020 results 2026-10-27 05:46:36 +00:00
陈奇 1b21c41fa2 Merge reviewed managed-window Ask routing into isolated acceptance candidate 2026-10-27 05:31:36 +00:00
陈奇 89beeb9374 fix(plugins): route Ask to focused managed app windows 2026-10-27 05:06:38 +00:00
陈奇 02049102b6 docs(plugins): refresh final policy candidate provenance and native limits 2026-10-27 04:07:45 +00:00
陈奇 cfa13680e6 Merge authoritative policy disclosure correction preserving all assertions 2026-10-27 04:01:44 +00:00
陈奇 79a9bd6df1 fix(plugins): describe policy modes without mandatory consent copy 2026-10-27 03:51:51 +00:00
陈奇 7856c664c3 docs(plugins): record isolated policy and AI candidate with exact sample provenance 2026-10-27 03:43:01 +00:00
陈奇 7d8a354404 test(plugins): align package disclosure assertions with declared capability policy 2026-10-27 03:34:50 +00:00
陈奇 da4e80946d test(plugins): align package details with default Allow policy 2026-10-27 03:34:32 +00:00
陈奇 5d2ff1cb86 Merge reviewed AI startup and themed sample while preserving permission policy assertions 2026-10-27 03:25:05 +00:00
陈奇 9aa899059f Merge declared capability policy corrections into isolated acceptance candidate 2026-10-27 03:24:18 +00:00
陈奇 2eee561847 fix(plugins): bind scoped Ask receipts and revoke advanced activations 2026-10-27 03:17:53 +00:00
陈奇 d33c0193cc test(plugin-ai): drain Node module warnings before observing SDK errors 2026-10-27 02:33:17 +00:00
陈奇 643e71b17b feat(plugins): default declared capabilities to allow with scoped policy controls 2026-10-27 02:28:08 +00:00
陈奇 e325742abf fix(example-assistant): follow host theme and accent environment 2026-10-27 02:22:20 +00:00
陈奇 9ca70f3290 docs(plugin-ai): record cold-start evidence and native verification limits 2026-10-27 02:03:21 +00:00
陈奇 092438803b fix(plugin-ai): prepare host provider transport independently of the AI sidebar 2026-10-27 02:00:37 +00:00
陈奇 677aac33b1 docs(plugins): hand off verified correction candidate and rebuilt sample hashes 2026-10-26 20:15:22 +00:00
陈奇 24254ed737 Merge verified assistant selected-Note recovery into acceptance 2026-10-26 20:12:05 +00:00
陈奇 bbcf9c01c3 Merge verified terminal selection and inert draft correction into acceptance 2026-10-26 20:08:08 +00:00
陈奇 ba09461676 test(plugin-ai): verify repeated selected Note expiry recovery and update QA 2026-10-26 19:55:58 +00:00
陈奇 54b6cd401f docs(plugins): record completed AI review and active native holds 2026-10-26 19:55:55 +00:00
陈奇 9e7c9df58e fix(example-assistant): explicitly recover expired selected Note references 2026-10-26 19:53:18 +00:00
陈奇 824c3838e9 Merge published AI source-budget and model-session fixes provisionally 2026-10-26 19:50:06 +00:00
陈奇 35b65ef1d7 Merge verified settings gear focus correction into acceptance 2026-10-26 19:46:41 +00:00
陈奇 8e7c28fe1b fix(plugins): use effective pane ownership for terminal sharing 2026-10-26 19:35:41 +00:00
陈奇 07eb68c5e7 Merge current source and stream base into plugin AI corrections 2026-10-26 19:34:20 +00:00
陈奇 d81896b0d3 docs(plugin-ai): record bounded review repairs and replacement sample hash 2026-10-26 19:25:57 +00:00
陈奇 e1cb95ee87 fix(example-assistant): recover expired model sessions without inference retries 2026-10-26 19:23:22 +00:00
陈奇 ee5a4a4067 fix(plugin-ai): preserve source budgets across runtime replacement 2026-10-26 19:23:22 +00:00
陈奇 3e5d450a1e fix(plugins): publish selection targets after runtime mount and wake 2026-10-26 19:00:02 +00:00
陈奇 823db94cf3 fix(plugins): focus settings after direct gear navigation 2026-10-26 18:56:13 +00:00
陈奇 174c525cd4 test(plugins): validate unified acceptance entrypoints and record gates 2026-10-26 18:51:33 +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
陈奇 2278681787 Merge verified settings and logging UI into acceptance candidate 2026-10-26 18:34:59 +00:00
陈奇 31d61e3040 Merge verified host and Vault actions into acceptance candidate 2026-10-26 18:34:25 +00:00
陈奇 52d4af2a86 Merge verified plugin byte stream integration onto acceptance main 2026-10-26 18:34:02 +00:00
陈奇 dc6e645fab fix(plugins): prebundle permission panel helper in Vite development 2026-10-26 18:18:52 +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
陈奇 41313293e1 fix(i18n): preserve Spanish parity for plugin settings 2026-10-26 17:11:19 +00:00
陈大猫 e907336ef5 fix(terminal): preserve active SSH input mode in attached popups (#3991) 2026-10-27 01:07:10 +08:00
陈奇 d16e21c848 docs(plugins): describe compact settings and manual update checks 2026-10-26 17:06:45 +00: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
陈奇 e6dd68b209 feat(plugins): check published updates from bound source catalogs 2026-10-26 17:00:56 +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
陈奇 eb6772355e fix(plugins): preserve settings navigation and accessible dialog controls 2026-10-26 16:42:16 +00:00
陈奇 8eefd9de96 feat(plugins): use compact settings rows and installed quick controls 2026-10-26 16:29:18 +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
陈奇 2a9c310498 Merge frozen plugin sources and logging for native QA
Source parent: 4213d8e672
Logging parent: 9b853130ca

Ordinary merge with no manual conflicts or additional source edits.
2026-10-26 15:14:08 +00: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
陈奇 9b853130ca fix(plugins): bind log provenance and enforce segment retention 2026-10-26 15:00:54 +00:00
陈奇 e3cc23bca8 feat(plugins): add opt-in local diagnostics and host audit logs 2026-10-26 14:26:41 +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
284 changed files with 16601 additions and 751 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
+2
View File
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
@@ -88,6 +89,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<PluginPermissionHost />
<PluginHostInteractionHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
+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
+47 -7
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
"settings.plugins.landing.updateAvailable": "Version {version} available",
"settings.plugins.landing.loading": "Loading installed plugins…",
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
"settings.plugins.landing.options": "Plugin options",
"settings.plugins.landing.manager": "Plugin manager",
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
"settings.plugins.landing.browse": "Browse",
"settings.plugins.landing.refresh": "Refresh installed plugins",
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
"settings.plugins.landing.search": "Search installed plugins",
"settings.plugins.landing.settingsFor": "Settings for {name}",
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
"settings.plugins.landing.sourceUnknown": "Source unknown",
"settings.plugins.landing.status.running": "Running",
"settings.plugins.landing.status.starting": "Starting",
"settings.plugins.landing.status.stopping": "Stopping",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "Quarantined",
"settings.plugins.landing.status.unknown": "Status unknown",
"settings.plugins.landing.status.idle": "Enabled, idle",
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -1236,6 +1262,8 @@ Highlight the focused split pane:
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.author.official": "Official maintainer",
"settings.plugins.author.officialMeaning": "Maintained by the Netcatty project. This is not a safety certification.",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
@@ -1262,21 +1290,33 @@ 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",
"settings.plugins.permissions.note": "Declared capabilities default to Allow. Ask requests a scoped decision; Deny blocks access immediately. Target selection and draft editing still use the host UI.",
"settings.plugins.permissions.allow": "Allow",
"settings.plugins.permissions.ask": "Ask",
"settings.plugins.permissions.deny": "Deny",
"settings.plugins.permissions.title": "Permissions",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"settings.plugins.permissions.loading": "Loading permissions...",
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Your Allow / Ask / Deny settings are retained.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.permissionNote": "Declared supported capabilities default to Allow. Change each capability to Ask or Deny in the permission controls. Host validation and resource scope always apply.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
@@ -1319,7 +1359,7 @@ Highlight the focused split pane:
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
"settings.plugins.manager.sensitive": "Sensitive access: this capability may expose private data. Its mode can be changed in permission settings.",
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
"settings.plugins.market.retry": "Retry",
@@ -1328,7 +1368,7 @@ Highlight the focused split pane:
"settings.plugins.market.search": "Search name, author or description",
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Host validation and the selected permission mode still apply.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
@@ -1381,7 +1421,7 @@ Highlight the focused split pane:
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
'plugins.hostActions.selectionNote': 'Only the selected hosts\' saved labels, addresses, usernames, protocols, groups and tags are shared. Group selection includes only the hosts selected now. Later edit or connection requests follow this plugin\'s permission settings.',
'plugins.hostActions.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
+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',
+45 -5
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
"settings.plugins.landing.loading": "Cargando plugins instalados…",
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
"settings.plugins.landing.options": "Opciones de plugins",
"settings.plugins.landing.manager": "Gestor de plugins",
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
"settings.plugins.landing.browse": "Explorar",
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
"settings.plugins.landing.search": "Buscar plugins instalados",
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
"settings.plugins.landing.status.running": "En ejecución",
"settings.plugins.landing.status.starting": "Iniciando",
"settings.plugins.landing.status.stopping": "Deteniendo",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "En cuarentena",
"settings.plugins.landing.status.unknown": "Estado desconocido",
"settings.plugins.landing.status.idle": "Activado, en espera",
// Common
'common.save': 'Guardar',
'common.cancel': 'Cancelar',
@@ -1235,6 +1261,8 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.author.official": "Mantenimiento oficial",
"settings.plugins.author.officialMeaning": "Mantenido por el proyecto Netcatty. No es una certificación de seguridad.",
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
@@ -1262,9 +1290,21 @@ 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",
"settings.plugins.permissions.note": "Las capacidades declaradas se permiten de forma predeterminada. Preguntar solicita una decisión con un alcance concreto; Denegar bloquea el acceso de inmediato. La selección de destinos y la edición de borradores siguen usando la interfaz de la aplicación.",
"settings.plugins.permissions.allow": "Permitir",
"settings.plugins.permissions.ask": "Preguntar",
"settings.plugins.permissions.deny": "Denegar",
"settings.plugins.permissions.title": "Permisos",
"settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
@@ -1276,7 +1316,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
"settings.plugins.manager.permissionNote": "Las capacidades declaradas y compatibles se permiten de forma predeterminada. Puede cambiar cada capacidad a Preguntar o Denegar en los controles de permisos. Siempre se verifican los destinos y el alcance de los recursos.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
@@ -1319,7 +1359,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
"settings.plugins.manager.sensitive": "Acceso sensible: esta capacidad puede exponer datos privados. Su modo se puede cambiar en los ajustes de permisos.",
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
"settings.plugins.market.retry": "Reintentar",
@@ -1328,7 +1368,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Se siguen aplicando la validación del anfitrión y el modo de permiso seleccionado.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
@@ -1381,7 +1421,7 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
'plugins.hostActions.selectionNote': 'Solo se comparten los nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados de los equipos seleccionados. Los grupos solo incluyen los equipos seleccionados ahora. Las solicitudes posteriores de edición o conexión siguen los permisos de este complemento.',
'plugins.hostActions.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexió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',
+14 -4
View File
@@ -1130,6 +1130,8 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
"settings.plugins.author.official": "Официальная поддержка",
"settings.plugins.author.officialMeaning": "Поддерживается проектом Netcatty. Это не сертификат безопасности.",
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
@@ -1157,6 +1159,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": "Сохранённые разрешения",
@@ -1171,7 +1181,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.permissionNote": "Заявленные поддерживаемые возможности по умолчанию разрешены. Для каждой можно выбрать запрос или запрет в настройках разрешений. Проверки приложения и область ресурсов действуют всегда.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
@@ -1214,7 +1224,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: эта возможность может раскрыть личные данные. Её режим можно изменить в настройках разрешений.",
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
"settings.plugins.market.retry": "Повторить",
@@ -1223,7 +1233,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверки приложения и выбранный режим разрешений продолжают действовать.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
@@ -1276,7 +1286,7 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
'plugins.hostActions.selectionNote': 'Передаются только метаданные выбранных хостов: сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Дальнейшие запросы изменения или подключения зависят от настроек разрешений плагина.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
+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': 'Прокси через хосты',
@@ -29,6 +29,16 @@ const ALL_SETTINGS_LOCALES = [
...LOCALIZED_SETTINGS_LOCALES,
];
test("Host selection disclosure describes later permission-controlled actions without promising another confirmation", () => {
const permissionWords: Record<string, RegExp> = { en: /permission settings/i, es: /permisos/i, ru: /разрешени/u, 'zh-CN': /权限设置/u, 'zh-TW': /權限設定/u };
const oldPromise = /requires a separate confirmation|requiere otra confirmación|требует отдельного подтверждения|需要单独确认|需要另外確認/iu;
for (const locale of ALL_SETTINGS_LOCALES) {
const note = locale.messages['plugins.hostActions.selectionNote'];
assert.match(note, permissionWords[locale.name], locale.name);
assert.doesNotMatch(note, oldPromise, locale.name);
}
});
test("localized settings include names for every default shortcut", () => {
for (const locale of ALL_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
+47 -7
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在载入已安装插件…",
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
"settings.plugins.landing.options": "插件选项",
"settings.plugins.landing.manager": "插件管理器",
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
"settings.plugins.landing.browse": "浏览",
"settings.plugins.landing.refresh": "刷新已安装插件",
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
"settings.plugins.landing.search": "搜索已安装插件",
"settings.plugins.landing.settingsFor": "{name} 的设置",
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
"settings.plugins.landing.sourceUnknown": "来源未知",
"settings.plugins.landing.status.running": "运行中",
"settings.plugins.landing.status.starting": "启动中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "错误",
"settings.plugins.landing.status.quarantined": "已隔离",
"settings.plugins.landing.status.unknown": "状态未知",
"settings.plugins.landing.status.idle": "已启用,空闲",
// Common
'common.save': '保存',
'common.cancel': '取消',
@@ -1151,6 +1177,8 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.default': '默认',
'settings.tab.plugins': '插件',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.author.official": "官方维护",
"settings.plugins.author.officialMeaning": "由 Netcatty 项目维护。这不是安全认证。",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
@@ -1177,21 +1205,33 @@ 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": "已保存的授权",
"settings.plugins.permissions.note": "已声明且受支持的权限默认允许。询问时显示一次范围明确的确认;拒绝会立即阻止访问。选择目标和编辑草稿仍使用宿主界面。",
"settings.plugins.permissions.allow": "允许",
"settings.plugins.permissions.ask": "询问",
"settings.plugins.permissions.deny": "拒绝",
"settings.plugins.permissions.title": "权限",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...",
"settings.plugins.permissions.loading": "正在加载权限...",
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
@@ -1234,7 +1274,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
"settings.plugins.manager.sensitive": "敏感访问:此能力可能涉及私密数据,可在权限设置中调整其模式。",
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
"settings.plugins.market.retry": "重试",
@@ -1243,7 +1283,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.market.search": "搜索名称、作者或描述",
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.manager.unbounded": "未声明更细的资源范围,仍受应用校验和所选权限模式约束。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
@@ -1296,7 +1336,7 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
'plugins.hostActions.selectionNote': '仅向此插件共享所选主机的元数据:名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。后续编辑或连接请求遵循此插件的权限设置。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
+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': '通过主机代理',
+47 -7
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
"settings.plugins.landing.options": "外掛選項",
"settings.plugins.landing.manager": "外掛管理器",
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
"settings.plugins.landing.browse": "瀏覽",
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
"settings.plugins.landing.search": "搜尋已安裝外掛",
"settings.plugins.landing.settingsFor": "{name} 的設定",
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
"settings.plugins.landing.sourceUnknown": "來源不明",
"settings.plugins.landing.status.running": "執行中",
"settings.plugins.landing.status.starting": "啟動中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "錯誤",
"settings.plugins.landing.status.quarantined": "已隔離",
"settings.plugins.landing.status.unknown": "狀態不明",
"settings.plugins.landing.status.idle": "已啟用,閒置",
// Common
'common.save': '儲存',
'common.cancel': '取消',
@@ -1153,6 +1179,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
"settings.plugins.author.official": "官方維護",
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
@@ -1180,21 +1208,33 @@ 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": "已儲存的授權",
"settings.plugins.permissions.note": "已宣告且受支援的權限預設允許。詢問時顯示一次範圍明確的確認;拒絕會立即阻止存取。選擇目標和編輯草稿仍使用宿主介面。",
"settings.plugins.permissions.allow": "允許",
"settings.plugins.permissions.ask": "詢問",
"settings.plugins.permissions.deny": "拒絕",
"settings.plugins.permissions.title": "權限",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...",
"settings.plugins.permissions.loading": "正在載入權限...",
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
@@ -1214,7 +1254,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
@@ -1262,7 +1302,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
"settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試",
@@ -1299,7 +1339,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
'plugins.hostActions.selectionNote': '僅向此外掛分享所選主機的中繼資料:名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。後續編輯或連線請求遵循此外掛的權限設定。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
+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,85 @@
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, prepareConfiguredPluginModel } 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,
prepareModel: prepareConfiguredPluginModel,
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 };
}
@@ -0,0 +1,41 @@
import { useEffect, useMemo, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginAuthorIdentity({ entry, plugin, sourceId = 'official' }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
}) {
const [element, setElement] = useState<HTMLElement | null>(null);
const [loaded, setLoaded] = useState<{ key: string; value: NetcattyPluginAuthorPresentation } | null>(null);
const pluginId = plugin?.id;
const entryId = entry?.id;
const selectionToken = entry?.selectionToken;
const request = useMemo<NetcattyPluginAuthorRequest | null>(() => pluginId
? { kind: 'installed', pluginId }
: entryId && selectionToken ? { kind: 'catalog', pluginId: entryId, selectionToken, sourceId } : null,
[pluginId, entryId, selectionToken, sourceId]);
const key = JSON.stringify([request, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source?.id, plugin?.runtime?.quarantinedAt]);
const installed = Boolean(plugin);
const version = plugin?.activeVersion;
const archiveSha256 = plugin?.archiveSha256 ?? null;
const installedSourceId = plugin?.source?.id ?? 'local';
useEffect(() => {
if (!element || !request) return;
let active = true;
let started = false;
const load = () => {
if (started) return;
started = true;
void netcattyBridge.get()?.getPluginAuthorPresentation?.(request).then((value) => {
if (!active || !value) return;
if (installed && (value.version !== version || value.archiveSha256 !== archiveSha256 || value.sourceId !== installedSourceId)) return;
setLoaded({ key, value });
}).catch(() => { /* The visible author placeholder remains usable offline. */ });
};
const observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) { load(); observer?.disconnect(); }
}, { rootMargin: '64px' });
if (observer) observer.observe(element); else load();
return () => { active = false; observer?.disconnect(); };
}, [element, request, key, installed, version, archiveSha256, installedSourceId]);
return { ref: setElement, identity: loaded?.key === key ? loaded.value : null };
}
+8 -3
View File
@@ -112,7 +112,7 @@ export function usePluginHostActions() {
const entry = current.current;
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
entry.prompt = prompt;
setReview({ ...entry });
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
void execute(entry);
return;
}
@@ -126,7 +126,12 @@ export function usePluginHostActions() {
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
claimed: false,
};
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
current.current = entry; setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
if (prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow') {
setReview(null);
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken,
action: 'submit', scriptsRun: entry.scriptsRun }).catch(() => void fail(entry));
} else setReview(entry);
} catch {
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
}
@@ -137,7 +142,7 @@ export function usePluginHostActions() {
unsubscribe();
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
};
}, [execute, getVault]);
}, [execute, fail, getVault]);
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
const entry = current.current;
+15 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
@@ -19,8 +20,17 @@ export function usePluginHostInteractions() {
current.current = null; setPrompt(null);
} else {
if (event.request.kind.startsWith('vault.')) return;
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
current.current = event as UiPrompt;
if (event.request.kind === 'terminal.selection.read' && event.phase === 'request') {
setPrompt(null);
try {
const selection = readPluginTerminalSelection(event.terminalSessionId ?? '', event.terminalRendererId ?? '');
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'preview', value: selection }).catch(() => {
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {});
});
} catch { void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {}); }
} else setPrompt(event as UiPrompt);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
}
pending.current = false; setBusy(false); setError(false);
});
@@ -38,8 +48,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) {
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginLogs() {
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(true);
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
: action === 'open' ? bridge.openPluginLogFolder?.()
: action === 'export' ? bridge.exportPluginLogs?.()
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
if (!result) throw new Error('Local plugin logging is unavailable');
if (mounted.current) setStatus(result);
} catch (cause) {
let message = cause instanceof Error ? cause.message : String(cause);
if (action === 'configure') {
// Configuration fails closed before disk I/O. Show actual host state,
// even when persisting a setting failed, rather than keeping stale ON.
try {
const actual = await netcattyBridge.require().getPluginLogStatus?.();
if (!actual) throw new Error('Local plugin log status is unavailable');
if (mounted.current) setStatus(actual);
} catch (refreshError) {
if (mounted.current) setStatus(null);
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
}
}
if (mounted.current) setError(message);
} finally {
pending.current = false;
if (mounted.current) setBusy(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void run('status');
return () => { mounted.current = false; };
}, [run]);
return { status, error, busy, run };
}
+2 -1
View File
@@ -10,7 +10,8 @@ export function usePluginManager() {
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
if (options?.clearError) setError(null);
const current = ++generation.current;
try {
const bridge = netcattyBridge.get();
@@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginPermissionPrompt() {
const [prompts, setPrompts] = useState<NetcattyPluginPermissionPrompt[]>([]);
const [busy, setBusy] = useState(false);
const active = useRef<NetcattyPluginPermissionPrompt | null>(null);
const prompt = prompts[0];
active.current = prompt ?? null;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginPermission || !bridge.setPluginPermissionReady) return;
const unsubscribe = bridge.onPluginPermission((event) => {
setPrompts((previous) => 'closed' in event ? previous.filter((item) => item.requestId !== event.requestId)
: [...previous.filter((item) => item.requestId !== event.requestId), event]);
setBusy(false);
});
void bridge.setPluginPermissionReady(true).catch(() => {});
return () => { unsubscribe(); void bridge.setPluginPermissionReady?.(false).catch(() => {}); };
}, []);
const respond = async (decision: 'allow' | 'deny' | 'cancel') => {
if (!prompt || busy || active.current !== prompt) return;
setBusy(true);
try {
const accepted = await netcattyBridge.require().respondPluginPermission?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
if (!accepted) setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId));
} catch { setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId)); }
finally { setBusy(false); }
};
return { prompt, busy, respond };
}
@@ -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]);
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { checkPluginUpdates, type PluginUpdateCheckResult } from '../../infrastructure/services/pluginUpdateCheck';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
/** Manual catalog checks only; no scheduler, install or permission mutation. */
export function usePluginUpdateCheck(plugins: readonly NetcattyInstalledPlugin[]) {
const [result, setResult] = useState<PluginUpdateCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const generation = useRef(0);
const identityKey = useMemo(() => JSON.stringify(plugins.map((plugin) => [
plugin.id, plugin.activeVersion, plugin.archiveSha256, plugin.source?.id,
plugin.source?.kind, plugin.source?.origin,
])), [plugins]);
useEffect(() => {
generation.current++;
setResult(null);
setError(null);
const requests = generation;
return () => { requests.current++; };
}, [identityKey]);
useEffect(() => {
const invalidate = () => {
generation.current++;
setResult(null);
setError(null);
};
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(invalidate);
window.addEventListener('netcatty:plugin-packages-changed', invalidate);
return () => { unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', invalidate); };
}, []);
const check = useCallback(async () => {
if (pending.current) return;
pending.current = true;
const current = generation.current;
setBusy(true); setError(null); setResult(null);
try {
const value = await checkPluginUpdates(netcattyBridge.require());
if (mounted.current && current === generation.current) setResult(value);
} catch (cause) {
if (mounted.current && current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
} finally {
pending.current = false;
// This component may still be mounted with a refreshed package list.
if (mounted.current) setBusy(false);
}
}, []);
return { result, error, busy, check };
}
@@ -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
@@ -1582,6 +1582,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();
}
});
+220
View File
@@ -0,0 +1,220 @@
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('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
// 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 { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.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;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const reviews: Array<Record<string, unknown>> = [];
const trustedPayloads: unknown[] = [];
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
const sdkErrorNames: string[] = [];
let diagnosticPhase = 'setup';
const originalConsoleError = console.error;
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
// asynchronously through console.error. Let module warnings reach normal
// stderr before observing SDK/renderer errors; do not suppress that warning.
await new Promise<void>((resolve) => setImmediate(resolve));
// The expected pre-fix SDK error contains request details. Keep diagnostics
// categorical even on the failing-before path; never print payloads/headers.
console.error = (value: unknown) => {
let category = 'unknown';
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
else if (typeof value === 'string') category = 'other-string';
else if (value && typeof value === 'object') {
const name = (value as { name?: unknown }).name;
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
}
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
};
t.after(() => { console.error = originalConsoleError; });
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 });
const providerContext = {
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
validateSender: (event: { sender: unknown }) => event.sender === sender,
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
// Only credential injection is synthetic. Admission, sync and the HTTP
// transport are the production implementations, not a direct-fetch seam.
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
},
shouldSkipTLSVerify: () => false,
streamRequest: aiBridge._streamRequestForTests,
};
registerProviderHandlers(providerContext);
assert.equal(providerContext.providerConfigs.length, 0);
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/);
}
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
const handler = ipcHandlers.get(channel);
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
const result = await handler({ sender }, structuredClone(payload));
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
if (channel === 'netcatty:ai:chat:stream') {
const status = result as { ok: boolean; statusCode?: number; error?: string };
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
}
return result;
};
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' },
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
]));
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: 1 }; } } },
requestPermissionDecision: async (request: { requestId: string; permission: string; relatedPermissions?: Array<{ permission: string }> }) => (request.permission === 'context.selected.read' || request.relatedPermissions?.some((item) => item.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);
service.database.setPermissionMode('com.netcatty.assistant', 'context.selected.read', 'ask');
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() {} }),
} });
diagnosticPhase = 'initial-selection';
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 }));
diagnosticPhase = 'deny-content';
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
contentAllowed = true;
diagnosticPhase = 'complete-stream';
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
await waitFor(() => element('status').textContent === 'Complete');
assert.equal(reviews.length, 0, 'content policy must not open a second native sharing review');
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
diagnosticPhase = 'stop-stream';
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);
diagnosticPhase = 'lock-stream';
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|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
assert.deepEqual(sdkErrorNames, []);
} finally {
console.error = originalConsoleError;
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 });
}
});
@@ -43,11 +43,11 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
let locked = false;
let onLock: ((state: { locked: boolean }) => void) | undefined;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
getInteractionWindow: () => owner, isAppLocked: () => locked,
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
subscribeAppLock: (callback: (state: { locked: boolean }) => void) => { lockListeners.add(callback); return () => lockListeners.delete(callback); },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
});
let requestClock = Date.now();
@@ -109,6 +109,7 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
const archive = path.join(directory, 'fixture.ncpkg');
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
service.database.setPermissionMode('com.netcatty.host-actions', 'ui.interact', 'ask');
service.interactionService.setReady(sender, true);
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
@@ -180,7 +181,7 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
await t.test('Vault lock cancels a pending connection', async () => {
const run = await begin('connect'); await chooseGroup();
await waitFor(() => button('Connect') !== undefined);
await act(async () => { locked = true; publish(); onLock?.({ locked: true }); await settle(); });
await act(async () => { locked = true; publish(); for (const listener of lockListeners) listener({ locked: true }); await settle(); });
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
locked = false; publish();
});
@@ -192,8 +193,8 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
await waitFor(() => preparing);
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
await act(async () => { service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'deny'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined; service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'allow');
});
await t.test('source replacement during review cannot use the old action authority', async () => {
@@ -272,13 +273,44 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
}
});
await t.test('Allow connects through the selected target without a confirmation dialog; Deny blocks every Host action', async () => {
const id = 'com.netcatty.host-actions';
const plugin = service.database.getActivePlugin(id);
const set = (permission: string, mode: string) => service.permissionEngine.setMode(plugin, permission, mode);
set('ui.interact', 'allow');
const allowed = await begin('connect'); await chooseGroup();
await act(async () => { await allowed.promise; await settle(); });
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(connections.length, 2);
for (const [permission, command] of [['vault.metadata', 'select'], ['vault.write', 'create']] as const) {
set(permission, 'deny'); requestClock += 60_001;
await assert.rejects(service.contributionService.executeCommand(`${id}.${command}`), /disabled/);
assert.equal(document.querySelector('[role="dialog"]'), null);
set(permission, 'allow');
}
set('ui.interact', 'deny');
const denied = await begin('connect'); await chooseGroup();
await assert.rejects(denied.promise, /disabled/);
assert.equal(connections.length, 2); assert.equal(saves, 1);
set('ui.interact', 'ask'); set('vault.write', 'ask');
const draft = await begin('create');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.ok(button('Save Host')); await click(button('Cancel'));
assert.deepEqual(await draft.promise, { cancelled: true });
set('vault.write', 'allow'); set('vault.metadata', 'ask');
const selection = await begin('select'); await chooseGroup();
assert.ok('hosts' in await selection.promise);
assert.equal(document.querySelector('[role="dialog"]'), null);
set('vault.metadata', 'allow');
});
await t.test('runtime removal rejects old signed replies', async () => {
const run = await begin('create');
const old = prompts.at(-1)!;
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
await assert.rejects(run.promise);
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
assert.equal(saves, 1); assert.equal(connections.length, 1);
assert.equal(saves, 1); assert.equal(connections.length, 2);
});
} finally {
await act(async () => { root.unmount(); await settle(); });
+61 -6
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import { EventEmitter } from 'node:events';
import { JSDOM } from 'jsdom';
test('host dialog drives choice/input/copy and the two-phase clipboard share using real service action tokens', async () => {
test('host dialog drives choice/input/copy and a single scoped clipboard share using real service action tokens', async () => {
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) => {
@@ -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)!;
@@ -71,14 +80,60 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
assert.deepEqual(writes, ['synthetic output']);
const share = await begin({ kind: 'clipboard.read', title: 'Share clipboard' });
assert.equal(reads, 0);
assert.equal(document.querySelector('textarea'), null);
await click(button('Preview clipboard'));
assert.equal(reads, 1);
assert.equal(document.querySelector('textarea')?.value, 'synthetic private text');
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, 1);
const selected = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
assert.equal(selectionReads, 2);
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, 2);
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' });
assert.deepEqual(await hidden.promise, { cancelled: true });
assert.equal(selectionReads, 2);
assert.equal(document.querySelector('[role="dialog"]'), null);
} 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 });
+107
View File
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', async () => {
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', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
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);
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
const { observePluginHostSecurityVisibility } = await import('../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
const listeners = new Set<(event: unknown) => void>();
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: unknown) => { for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isFocused: () => true, isVisible: () => true });
const host = new PluginPermissionDecisionHost({ getWindow: () => owner, isLocked: () => false });
let mode = 'allow', effects = 0;
const plugin = { id: 'com.example.permission-ui', activeVersion: '1.0.0', manifest: { id: 'com.example.permission-ui', publisher: 'example', permissions: { optional: [{ permission: 'network', resources: ['https://example.com'] }] } } };
const engine = new PluginPermissionEngine({ database: { getPermissionMode: () => mode, privateOwner: () => 'synthetic-source-owner',
setPermissionMode: (_id: string, _permission: string, value: string) => { mode = value; }, recordSecurityAudit() {}, deleteAllPermissionGrants() {}, listPermissionGrants: () => [] },
requestDecision: (...args: unknown[]) => host.request(...args) });
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware());
registry.registerRequest('network.fixture', async () => { effects++; return 'synthetic result'; }, { authorization: { permission: 'network', resources: ['https://example.com'] } });
const route = registry.createRoutes({ pluginId: plugin.id, pluginVersion: '1.0.0', runtimeId: 'runtime-ui', runtimeKind: 'browser', manifest: plugin.manifest }).requestHandlers['network.fixture'];
Object.assign(dom.window, { netcatty: {
onPluginPermission(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
getPluginPermissions: async () => ({ grants: [], audit: [], policies: engine.describePolicies(plugin), policyRevision: engine.getPolicyRevision(plugin) }),
setPluginPermissionMode: async (_id: string, permission: string, value: string, revision: string) => {
assert.equal(revision, engine.getPolicyRevision(plugin)); engine.setMode(plugin, permission, value); return true;
},
} });
const root = createRoot(document.getElementById('root')!);
const nativeMount = document.createElement('div'); document.body.append(nativeMount);
const nativeVisibility: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(nativeMount, (visible) => nativeVisibility.push(visible));
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === text)!;
const choose = async (text: string) => {
await act(async () => { document.querySelector('[role="combobox"]')!.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await settle(); });
const option = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find((element) => element.textContent === text)!;
assert.ok(option);
await act(async () => { option.click(); await settle(); });
};
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId={plugin.id} onChanged={async () => {}} /><PluginPermissionHost /></I18nProvider>));
await route({}, {}); assert.equal(effects, 1); assert.equal(document.querySelector('[role="dialog"]'), null);
await choose('Ask'); assert.equal(mode, 'ask');
let operation!: Promise<string>;
await act(async () => { operation = route({}, {}); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(nativeVisibility.at(-1), false, 'the existing native-view lifecycle must yield to the trusted Ask');
assert.match(document.querySelector('[role="dialog"]')!.textContent!, /https:\/\/example.com/);
assert.equal(effects, 1);
await act(async () => { button('Allow this operation').click(); await operation; await settle(); });
assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), false, 'visible permission settings remain a security surface after Ask closes');
await choose('Deny'); assert.equal(mode, 'deny');
await assert.rejects(route({}, {}), /disabled/); assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
await choose('Allow'); assert.equal(mode, 'allow');
await route({}, {}); assert.equal(effects, 3); assert.equal(document.querySelector('[role="dialog"]'), null);
// Model the settings editor closing before interacting with a native view.
document.querySelector('section[data-host-security-surface]')!.setAttribute('hidden', '');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), true);
let displayed!: Promise<unknown>;
const opaque = 'selected:0cfc16d6-3448-4333-82ef-dd4ffbddfdab:v1';
await act(async () => {
displayed = host.request({ requestId: 'host-display-fixture', pluginId: plugin.id, permission: 'ai.invoke', resources: ['*'], reason: 'Host model invocation',
relatedPermissions: [{ permission: 'context.selected.read', resources: [opaque] }],
displayContext: { modelLabel: 'QA fixture · deterministic-fixture', selections: [{ title: 'Synthetic retry plan', scope: 'note', version: 'bf304994-039a-4884-a707-3a1a40a5d3a9' }] },
}); await settle();
});
const text = document.querySelector('[role="dialog"]')!.textContent!;
assert.equal(nativeVisibility.at(-1), false, 'Ask alone must hide the attached plugin view');
assert.match(text, /QA fixture · deterministic-fixture/); assert.match(text, /Synthetic retry plan/);
assert.doesNotMatch(text, /selected:|0cfc16d6|bf304994/);
await act(async () => { button('Deny').click(); await displayed; await settle(); });
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('registered selected-summary Ask is one host chooser; Allow still selects and Deny releases nothing', { timeout: 45_000 }, async () => {
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']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
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 { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { registerPluginBridge } = require('../electron/plugins/pluginBridge.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-selected-policy-'));
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcMain = Object.assign(new EventEmitter(), { handle: (channel: string, handler: (...args: unknown[]) => unknown) => handlers.set(channel, handler), removeHandler: (channel: string) => handlers.delete(channel) });
const ipcRenderer = Object.assign(new EventEmitter(), { invoke: async (channel: string, payload: unknown) => {
const handler = handlers.get(channel); assert.ok(handler, channel); return handler({ sender }, structuredClone(payload));
} });
const prompts: Array<{ channel: string; kind?: string; closed?: boolean }> = [];
const sender = Object.assign(new EventEmitter(), { id: 42, isDestroyed: () => false, send: (channel: string, payload: { kind?: string; closed?: boolean }) => {
prompts.push({ channel, kind: payload.kind, closed: payload.closed }); ipcRenderer.emit(channel, {}, structuredClone(payload));
} });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const service = createPluginHostService({ app: { getAppPath: () => process.cwd(), getPath: () => fixtureRoot, getVersion: () => '0.0.0' },
electron: { ipcMain }, env: {}, getInteractionWindow: () => owner, isAppLocked: () => false });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
registerPluginBridge(ipcMain, { ...service, isTrustedSender: (event: { sender: unknown }) => event.sender === sender });
Object.assign(dom.window, { netcatty: createPreloadApi({ ipcRenderer, webUtils: {} }) });
const note = { id: 'synthetic-selected-note', title: 'Synthetic selection', content: 'BODY MUST NOT BE SHARED', createdAt: 1, updatedAt: 1 };
publishNotesSnapshot({ notes: [note], noteGroups: [] }); registerAppAppLockRuntime({ locked: false } as never);
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const waitForDialog = async () => {
const deadline = Date.now() + 5_000;
while (!document.querySelector('[role="dialog"]')) {
assert.ok(Date.now() < deadline, 'registered route did not present a host interaction');
await act(async () => { await settle(); });
}
};
const button = (label: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === label)!;
const begin = () => {
const result = service.contributionService.executeCommand('com.netcatty.assistant.contexts') as Promise<Array<{ handle: string; version: string; title: string }>>;
void result.catch(() => {});
return result;
};
try {
await service.manager.initialize();
const stage = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true });
const sample = path.resolve('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(stage, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(stage, 'dist/index.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(stage, archive); await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionHost /><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
const plugin = service.database.getActivePlugin('com.netcatty.assistant');
for (const mode of ['ask', 'allow']) {
service.permissionEngine.setMode(plugin, 'context.selected.list', mode);
let result!: ReturnType<typeof begin>;
await act(async () => { result = begin(); await settle(); });
await waitForDialog();
assert.ok(document.getElementById('plugin-ai-note'), 'the first interaction must be the real host chooser');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(prompts.filter((prompt) => prompt.channel === 'netcatty:plugins:permission-prompt' && !prompt.closed).length, 0, 'no capability dialog before the chooser');
await act(async () => {
const select = document.getElementById('plugin-ai-note') as HTMLSelectElement; select.value = note.id; select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); await settle();
});
await act(async () => { button('Share selected summaries').click(); await settle(); });
const summaries = await result;
assert.ok(summaries.some((item) => item.title === note.title));
assert.doesNotMatch(JSON.stringify(summaries), /BODY MUST NOT BE SHARED/);
assert.equal(document.querySelector('[role="dialog"]'), null);
}
service.permissionEngine.setMode(plugin, 'context.selected.list', 'deny');
const count = prompts.filter((prompt) => prompt.kind === 'context.select').length;
await assert.rejects(service.contributionService.executeCommand('com.netcatty.assistant.contexts'), /disabled/);
assert.equal(prompts.filter((prompt) => prompt.kind === 'context.select').length, count); assert.equal(document.querySelector('[role="dialog"]'), null);
service.permissionEngine.setMode(plugin, 'context.selected.list', 'ask');
let cancelled!: ReturnType<typeof begin>;
await act(async () => { cancelled = begin(); await settle(); });
await waitForDialog();
await act(async () => { button('Cancel').click(); await settle(); });
await assert.rejects(cancelled); assert.equal(service.aiContextService.selected.handles.size, 0);
let revoked!: ReturnType<typeof begin>;
await act(async () => { revoked = begin(); await settle(); });
await waitForDialog();
await act(async () => { service.permissionEngine.setMode(plugin, 'context.selected.list', 'deny'); await settle(); });
await assert.rejects(revoked); assert.equal(document.querySelector('[role="dialog"]'), null); assert.equal(service.aiContextService.selected.handles.size, 0);
} finally {
await act(async () => root.unmount()); await service.manager.shutdown();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock); dom.window.close(); fs.rmSync(fixtureRoot, { recursive: true, force: true });
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -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;
+2
View File
@@ -27,6 +27,7 @@ import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister"
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
import { usePluginContributions } from "../application/state/usePluginContributions";
import { PluginContributionHost } from "./plugins/PluginContributionHost";
import { PluginPermissionHost } from "./plugins/PluginPermissionHost";
import { matchesKeyBinding } from "../domain/models";
import { isPrimaryModifierWBinding } from "../application/state/windowCommandClose";
@@ -720,6 +721,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</Tabs>
</div>
<ExternalMcpApprovalsHost />
<PluginPermissionHost />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
</>
);
+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. Content access follows this plugin's Allow / Ask / Deny settings; this chooser shares summaries only.</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>;
}
+26
View File
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { UserRound } from 'lucide-react';
import { usePluginAuthorIdentity } from '../../application/state/usePluginAuthorIdentity';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
export function PluginAuthorRow({ entry, plugin, sourceId }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
}) {
const { t } = useI18n();
const { ref, identity } = usePluginAuthorIdentity({ entry, plugin, sourceId });
const [failed, setFailed] = useState<string | null>(null);
const author = identity?.author ?? entry?.author ?? plugin?.manifest?.publisher ?? '';
const imageKey = JSON.stringify([entry?.id, entry?.selectionToken, plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, sourceId, identity?.avatar]);
const avatar = identity?.avatar;
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
return <div ref={ref} className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground" data-plugin-author-row>
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted/60" aria-hidden="true">
{showImage ? <img src={avatar} alt="" width={20} height={20} className="h-full w-full object-cover" onError={() => setFailed(imageKey)} />
: <UserRound size={13} data-plugin-author-placeholder />}
</span>
<span className="min-w-0 truncate" title={author}>{author}</span>
{identity?.officialMaintained === true && <Badge className="shrink-0 whitespace-nowrap border-0 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-500/15 dark:bg-amber-400/15 dark:text-amber-300 dark:hover:bg-amber-400/15"
title={t('settings.plugins.author.officialMeaning')} data-plugin-official-maintainer>{t('settings.plugins.author.official')}</Badge>}
</div>;
}
@@ -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>
@@ -0,0 +1,38 @@
import { usePluginPermissionPrompt } from '../../application/state/usePluginPermissionPrompt';
import { useI18n } from '../../application/i18n/I18nProvider';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
/** Rendered only by the trusted app, never inside a plugin view. */
export function PluginPermissionHost() {
const { resolvedLocale } = useI18n();
const zh = resolvedLocale.startsWith('zh');
const { prompt, busy, respond } = usePluginPermissionPrompt();
if (!prompt) return null;
const description = describePluginPermission(prompt.permission, resolvedLocale);
const resources = prompt.resources.filter((resource) => resource !== '*' && !resource.startsWith('selected:'));
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{description.label}</DialogTitle>
<DialogDescription className="break-words">{prompt.pluginName ?? prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
</DialogHeader>
<p className="text-sm">{description.description}</p>
{prompt.displayContext?.modelLabel && <p className="text-sm break-words">{zh ? '模型' : 'Model'}: {prompt.displayContext.modelLabel}</p>}
{(prompt.displayContext?.selections ?? []).map((item, index) => <p key={index} className="text-sm break-words">
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
</p>)}
{(prompt.relatedPermissions ?? []).filter((item, index, items) => items.findIndex((other) => other.permission === item.permission) === index).map((item, index) => <div key={index} className="space-y-1 text-sm">
<p>{describePluginPermission(item.permission, resolvedLocale).description}</p>
<p className="text-xs text-muted-foreground break-all">{item.resources.filter((resource) => !resource.startsWith('selected:')).join(', ')}</p>
</div>)}
<p className="text-xs text-muted-foreground break-all">{prompt.pluginId}</p>
{resources.length > 0 && <ul className="max-h-44 overflow-auto space-y-1 text-xs text-muted-foreground">{resources.map((resource) => <li key={resource} className="break-all">{resource}</li>)}</ul>}
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => void respond('deny')}>{zh ? '拒绝' : 'Deny'}</Button>
<Button disabled={busy} onClick={() => void respond('allow')}>{prompt.sessionId && prompt.allowedScopes && !prompt.allowedScopes.includes('once') ? zh ? '允许此会话' : 'Allow for this session' : zh ? '允许此次操作' : 'Allow this operation'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}
+5 -2
View File
@@ -201,8 +201,9 @@ export const SettingCard: React.FC<SettingCardProps> = ({
);
interface SettingRowProps {
label?: string;
description?: string;
leading?: React.ReactNode;
label?: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
align?: "center" | "start";
/** Stable catalog id for settings search jump targets. */
@@ -210,6 +211,7 @@ interface SettingRowProps {
}
export const SettingRow: React.FC<SettingRowProps> = ({
leading,
label,
description,
children,
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
align === "start" ? "items-start" : "items-center",
)}
>
{leading && <div className="shrink-0">{leading}</div>}
<div className="flex-1 min-w-0">
{label && <div className="text-sm font-medium">{label}</div>}
{description && (
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
const entry = { id: 'com.netcatty.starter', name: 'Starter', author: 'Netcatty', version: '0.1.2',
repository: 'binaricat/Netcatty', commit: 'a'.repeat(40), selectionToken: 'b'.repeat(64),
manifest: { publisher: 'netcatty' } } as NetcattyPluginCatalogEntry;
const identity = { author: 'Netcatty', avatar: 'data:image/png;base64,eA==', officialMaintained: true,
sourceId: 'official', version: entry.version, archiveSha256: null };
test('untrusted entry strings and absent imagery produce a compact placeholder without an official badge', () => {
const claimed = { ...entry, author: '<img src="https://avatars.githubusercontent.com/u/1">',
officialMaintained: true, avatar: 'https://avatars.githubusercontent.com/u/1' };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow entry={claimed} /></I18nProvider>);
assert.match(html, /data-plugin-author-placeholder/);
assert.doesNotMatch(html, /<img|data-plugin-official-maintainer/);
assert.match(html, /&lt;img/);
assert.match(html, /items-center/);
});
test('mounted author rows load only when visible, retain fallbacks, cancel stale results and show a borderless themed maintainer pill', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true });
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 });
};
const observers: Array<{ target: Element | null; callback: IntersectionObserverCallback }> = [];
class VisibilityObserver {
record: typeof observers[number];
constructor(callback: IntersectionObserverCallback) { this.record = { callback, target: null }; observers.push(this.record); }
observe(target: Element) { this.record.target = target; }
disconnect() { this.record.target = null; }
}
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IntersectionObserver', VisibilityObserver);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const root = createRoot(document.getElementById('root')!);
const calls: NetcattyPluginAuthorRequest[] = [];
const pending: Array<(value: NetcattyPluginAuthorPresentation | null) => void> = [];
Object.assign(window, { netcatty: { getPluginAuthorPresentation: (request: NetcattyPluginAuthorRequest) => {
calls.push(request); return new Promise((resolve) => pending.push(resolve));
} } });
const render = async (value = entry, sourceId = 'official') => {
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow entry={value} sourceId={sourceId} /></I18nProvider>));
};
const visible = async () => {
await act(async () => {
for (const observer of [...observers]) if (observer.target) observer.callback([{ target: observer.target, isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
};
try {
await render(); assert.equal(calls.length, 0); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
await visible(); assert.deepEqual(calls[0], { kind: 'catalog', pluginId: entry.id, selectionToken: entry.selectionToken, sourceId: 'official' });
assert.ok(document.querySelector('[data-plugin-author-placeholder]'), 'loading uses the default placeholder');
await act(async () => pending.shift()!(identity));
const image = document.querySelector('img')!;
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 20); assert.equal(image.height, 20);
const badge = document.querySelector<HTMLElement>('[data-plugin-official-maintainer]')!;
assert.equal(badge.textContent, 'Official maintainer'); assert.match(badge.title, /not a safety certification/);
assert.ok(badge.classList.contains('border-0'));
assert.ok(badge.classList.contains('bg-amber-500/15')); assert.ok(badge.classList.contains('dark:bg-amber-400/15'));
assert.ok(badge.classList.contains('text-amber-800')); assert.ok(badge.classList.contains('dark:text-amber-300'));
document.documentElement.classList.add('dark');
assert.ok(document.querySelector('[data-plugin-author-row]')?.classList.contains('items-center'));
await act(async () => image.dispatchEvent(new Event('error')));
assert.equal(document.querySelector('img'), null); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
assert.ok(document.querySelector('[data-plugin-official-maintainer]'), 'image failure does not alter maintenance identity');
const next = { ...entry, selectionToken: 'c'.repeat(64) };
await render(next); assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
await visible(); const stale = pending.shift()!;
const copied = { ...entry, author: 'Community author', selectionToken: 'd'.repeat(64) };
await render(copied, 'third-party:copy'); await visible();
await act(async () => stale(identity));
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
assert.match(document.querySelector('[data-plugin-author-row]')!.textContent!, /Community author/);
await act(async () => pending.shift()!({ ...identity, author: copied.author, sourceId: 'third-party:copy', officialMaintained: false, avatar: 'https://avatars.githubusercontent.com/u/1' }));
assert.equal(document.querySelector('img'), null, 'renderer never uses a remote avatar URL');
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
await render({ ...entry, selectionToken: 'f'.repeat(64) }); await visible();
await act(async () => pending.shift()!({ ...identity, avatar: null, officialMaintained: false }));
assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
const installed = { id: entry.id, activeVersion: entry.version, archiveSha256: 'a'.repeat(64),
source: { id: 'official', kind: 'official', origin: 'https://plugins.netcatty.app', name: 'Official' },
manifest: entry.manifest, runtime: {} } as NetcattyInstalledPlugin;
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow plugin={installed} /></I18nProvider>));
await visible(); assert.deepEqual(calls.at(-1), { kind: 'installed', pluginId: installed.id });
await act(async () => pending.shift()!({ ...identity, version: '9.0.0', archiveSha256: installed.archiveSha256! }));
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null, 'a stale installed version cannot gain a badge');
const count = calls.length;
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow plugin={{ ...installed }} /></I18nProvider>));
await visible(); assert.equal(calls.length, count, 'unchanged installed identity does not reload on a new object reference');
await render({ ...entry, selectionToken: 'e'.repeat(64) }); await visible();
const late = pending.shift()!;
await act(async () => root.unmount());
await act(async () => late(identity));
assert.equal(document.querySelector('[data-plugin-author-row]'), null);
assert.ok(observers.every((observer) => observer.target === null));
} finally {
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -32,7 +32,9 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.equal(cards().length, 2);
const alphaCard = renderer.root.findAllByType('article')[0];
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
assert.ok(alphaCard.findAllByType('p').some((node) => node.children.includes('Example')), 'author has its own line');
const authorRow = alphaCard.findByProps({ 'data-plugin-author-row': true });
assert.ok(authorRow.findAllByType('span').some((node) => node.children.includes('Example')), 'author has its own compact row');
assert.ok(authorRow.findByProps({ 'data-plugin-author-placeholder': true }), 'an unverified installed publisher has a default avatar');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('local logging controls default off, disclose verbose risk, and call host-only management actions', async () => {
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', 'Element', 'Node', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'NodeFilter', 'DocumentFragment', 'CustomEvent', 'getComputedStyle']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginLogSettings } = await import('./PluginLogSettings');
const root = createRoot(document.getElementById('root')!);
let status = { saveToDisk: false, verbose: false, droppedRecords: 2, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
const settings: Array<{ saveToDisk: boolean; verbose: boolean }> = [];
const actions: string[] = [];
let failConfigure = false;
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return status; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { settings.push(value); status = { ...status, ...value }; if (failConfigure) throw new Error('synthetic preferences write failed'); return status; },
async openPluginLogFolder() { actions.push('open'); return status; },
async exportPluginLogs() { actions.push('export'); return status; },
async clearPluginLogs() { actions.push('clear-confirmed-by-native-host'); return status; },
} });
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const switches = () => [...document.querySelectorAll<HTMLButtonElement>('[role="switch"]')];
try {
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
const configure = button('Configure');
configure.focus();
await act(async () => configure.click());
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
assert.equal(switches()[0].getAttribute('aria-checked'), 'false');
assert.equal(switches()[0].disabled, false, 'StrictMode initialization completes');
assert.equal(switches()[1].disabled, true);
assert.match(document.body.textContent!, /no upload or sync/);
assert.match(document.body.textContent!, /Dropped: 2/);
await act(async () => switches()[0].click());
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
await act(async () => switches()[1].click());
assert.equal(settings.length, 1, 'opening disclosure does not enable verbose logs');
assert.match(document.body.textContent!, /cannot make arbitrary text safe/);
await act(async () => button('Cancel').click());
assert.equal(settings.length, 1, 'cancelling risk consent leaves verbose off');
await act(async () => switches()[1].click());
await act(async () => button('Enable temporarily').click());
assert.deepEqual(settings[1], { saveToDisk: true, verbose: true });
await act(async () => button('Open folder').click());
await act(async () => button('Export').click());
await act(async () => button('Clear logs…').click());
assert.deepEqual(actions, ['open', 'export', 'clear-confirmed-by-native-host']);
failConfigure = true;
await act(async () => switches()[0].click());
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
assert.equal(switches()[0].getAttribute('aria-checked'), 'false', 'failed configuration refreshes actual OFF host state');
assert.equal(switches()[1].getAttribute('aria-checked'), 'false');
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
await act(async () => button('Close').click());
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, configure);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { usePluginLogs } from '../../../application/state/usePluginLogs';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { ConfirmDialog } from '../../ui/confirm-dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
import { SettingRow, Toggle } from '../settings-ui';
/** Host settings row; detailed controls stay inside the standard host dialog. */
export function PluginLogSettings() {
const { t, resolvedLocale } = useI18n();
const zh = resolvedLocale.startsWith('zh');
const { status, error, busy, run } = usePluginLogs();
const [open, setOpen] = useState(false);
const [confirmVerbose, setConfirmVerbose] = useState(false);
const title = zh ? '本地插件日志' : 'Local plugin logs';
const saveLabel = zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally';
const verboseLabel = zh ? '包含插件详细诊断文字' : 'Include verbose plugin text';
const disclosure = zh
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
return <div>
<Dialog open={open} onOpenChange={setOpen}>
<SettingRow label={title} description={zh ? '选择是否在此设备保存审计记录和诊断日志。' : 'Choose whether to keep audit records and diagnostics on this device.'}>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground" role="status">{status ? (status.saveToDisk ? t('common.enabled') : t('common.disabled')) : error ? (zh ? '不可用' : 'Unavailable') : (zh ? '载入中' : 'Loading')}</span>
<DialogTrigger asChild><Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm">{zh ? '配置' : 'Configure'}</Button></DialogTrigger>
</div>
</SettingRow>
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{zh ? '默认不保存。仅保存在此设备,不上传或同步。权限面板中的有限安全历史独立保留。' : 'Saving is off by default. Files stay on this device with no upload or sync. The bounded security history in plugin permissions is separate.'}</DialogDescription>
</DialogHeader>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="divide-y divide-border">
<SettingRow label={saveLabel} description={zh ? '保存宿主确认的操作和授权结果,不保存敏感正文。' : 'Save host-observed operations and authorization outcomes without sensitive bodies.'}>
<Toggle ariaLabel={saveLabel} checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(saveToDisk) => void run('configure', { saveToDisk, verbose: false })} />
</SettingRow>
<SettingRow label={verboseLabel} description={zh ? '插件文字可能包含敏感数据;只为排查问题临时开启。' : 'Plugin text may contain sensitive data. Enable temporarily for troubleshooting.'}>
<Toggle ariaLabel={verboseLabel} checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(verbose) => verbose ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />
</SettingRow>
</div>
{status && <p className="text-xs text-muted-foreground">{zh ? `最多 ${status.maxBytes / 1048576} MiB,最长保留 ${status.retentionDays} 天。丢弃 ${status.droppedRecords} 条,写入错误 ${status.writeErrors} 次。` : `Up to ${status.maxBytes / 1048576} MiB, up to ${status.retentionDays} days. Dropped: ${status.droppedRecords}. Write errors: ${status.writeErrors}.`}</p>}
<DialogFooter className="gap-2 sm:justify-between">
<div className="flex flex-wrap gap-2">
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
</div>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={() => setOpen(false)}>{t('common.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog hostSecuritySurface open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
</div>;
}
@@ -17,7 +17,7 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
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, useEffect } = await import('react');
const { default: React, act, useEffect, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
@@ -69,6 +69,28 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
const browse = useRef<HTMLButtonElement | null>(null);
const opener = useRef<HTMLElement | null>(null);
return <>
<button ref={browse}>Fallback browse</button>
<button onClick={(event) => { opener.current = event.currentTarget; setOpen(true); }}>External plugin settings</button>
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={browse}><input aria-label="Controlled search" /></PluginManagementDialog>
</>;
}
await act(async () => root.render(<I18nProvider locale="en"><ControlledEntry /></I18nProvider>));
const external = [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === 'External plugin settings')!;
external.focus();
await click(external);
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.activeElement, external, 'controlled entry restores the actual row opener');
await click(external);
external.disabled = true;
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.activeElement?.textContent, 'Fallback browse', 'a disabled or removed opener uses the stable browse action');
} finally {
await act(async () => root.unmount());
dom.window.close();
@@ -4,18 +4,25 @@ import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
export function PluginManagementDialog({ children }: {
export function PluginManagementDialog({ children, trigger, open: controlledOpen, onOpenChange, restoreFocusTo, fallbackFocusTo }: {
trigger?: React.ReactNode | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
restoreFocusTo?: React.RefObject<HTMLElement | null>;
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
</DialogTrigger>
{trigger !== null && <DialogTrigger asChild>
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
</DialogTrigger>}
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
@@ -24,6 +31,13 @@ export function PluginManagementDialog({ children }: {
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
} else {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
target.focus({ preventScroll: true });
}
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
@@ -88,7 +88,11 @@ test('README failure leaves the permissions and source visible, before the failu
selected={entries[0].id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /settings.read/);
assert.match(html, /not a grant/);
assert.match(html, /Declared supported capabilities default to Allow/);
assert.match(html, /Ask or Deny/);
assert.match(html, /Host validation and resource scope always apply/);
assert.match(html, /Host validation and the selected permission mode still apply/);
assert.doesNotMatch(html, /Check the actual scope in the permission prompt/);
assert.match(html, /Source example/);
assert.ok(html.indexOf('settings.read') < html.indexOf('Network unavailable'));
assert.match(html, /Retry/);
@@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/
import { PluginInfo } from './PluginInfo';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { ArrowLeft } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
@@ -82,7 +83,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
<PluginAuthorRow entry={item} sourceId={sourceId} />
<p className="line-clamp-3 break-words text-sm leading-5 text-muted-foreground">{item.description}</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"><span>{item.version} · {item.license}</span><span className="text-foreground">{t('settings.plugins.manager.details')}</span></div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded bg-muted/40 px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
@@ -91,7 +92,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
</div>}
{entry && <article key={entry.id} aria-label={entry.name} className="plugin-marketplace-detail mx-auto w-full max-w-[920px] min-w-0 space-y-6 focus:outline-none">
<Button variant="ghost" size="sm" onClick={back}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.market.back')}</Button>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><PluginAuthorRow entry={entry} sourceId={sourceId} /><p className="text-xs text-muted-foreground">{entry.version} · {entry.license}</p>
{installedEntry && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.installed')} · {installedEntry.activeVersion} · {t(installedEntry.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</p>}
</div>
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
@@ -19,9 +19,16 @@ test('package detail renders manifest resources as text and explains advanced au
} as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageDetails plugin={plugin} /></I18nProvider>);
assert.match(html, /full Node filesystem and network authority/);
assert.match(html, /Broker permissions cannot restrict this access/);
assert.match(html, /Development preview only/);
assert.match(html, /https:\/\/example.com/);
assert.match(html, /&lt;script&gt;/);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /not a grant/);
assert.match(html, /Declared supported capabilities default to Allow/);
assert.match(html, /Ask or Deny/);
assert.match(html, /Host validation and resource scope always apply/);
assert.match(html, /this capability may expose private data/);
assert.match(html, /mode can be changed in permission settings/);
assert.doesNotMatch(html, /before granting/);
assert.match(html, /aaaaaaaaaaaaaaaa/);
});
@@ -3,6 +3,7 @@ import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
import React, { useRef, useState } from 'react';
import { PluginPermissionPanel } from './PluginPermissionPanel';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
@@ -49,9 +50,9 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const rootRef = useRef<HTMLElement>(null);
@@ -103,7 +104,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
</Button>
</div>
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
@@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { Button } from '../../ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
import { SettingRow } from '../settings-ui';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
@@ -36,6 +38,17 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
void refresh();
return () => { requestGeneration.current++; };
}, [refresh]);
const changeMode = async (permission: string, mode: PluginPermissionMode) => {
if (pending.current) return;
pending.current = true; setBusy(true); setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.setPluginPermissionMode || !snapshot?.policyRevision) throw new Error(t('settings.plugins.permissions.unavailable'));
await bridge.setPluginPermissionMode(pluginId, permission, mode, snapshot.policyRevision);
await onChanged();
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { await refresh(true); pending.current = false; setBusy(false); }
};
const revoke = async () => {
if (pending.current) return;
pending.current = true;
@@ -61,9 +74,18 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.note')}</p>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
{snapshot && <>
{(snapshot.policies ?? []).map((policy) => <SettingRow key={policy.permission}
label={describePluginPermission(policy.permission, resolvedLocale).label}
description={<span className="whitespace-pre-wrap break-all">{describePluginPermission(policy.permission, resolvedLocale).description}{' · '}{policy.resources.join(', ')}</span>}>
<Select value={policy.mode} disabled={busy} onValueChange={(mode) => void changeMode(policy.permission, mode as PluginPermissionMode)}>
<SelectTrigger className="w-28" aria-label={describePluginPermission(policy.permission, resolvedLocale).label}><SelectValue /></SelectTrigger>
<SelectContent>{(['allow', 'ask', 'deny'] as const).map((mode) => <SelectItem key={mode} value={mode}>{t(`settings.plugins.permissions.${mode}`)}</SelectItem>)}</SelectContent>
</Select>
</SettingRow>)}
{snapshot.grants.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.empty')}</p>}
{snapshot.grants.map((grant, index) => <div key={index} className="rounded-md bg-muted/40 p-3 text-xs">
<p className="font-medium">{describePluginPermission(grant.permission, resolvedLocale).label}</p>
@@ -76,7 +98,7 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
<summary className="cursor-pointer">{t('settings.plugins.permissions.audit')}</summary>
<ol className="mt-2 space-y-1">{snapshot.audit.map((entry, index) => <li key={index} className="break-words">
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Date(entry.createdAt).toLocaleString(resolvedLocale)}</time>{' · '}
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}{entry.mode ? ` · ${t(`settings.plugins.permissions.${entry.mode}`)}` : ''}{entry.action ? ` · ${entry.action}` : ''}
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
async function fixture() {
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', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
install('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
install('cancelAnimationFrame', (id: number) => frames.delete(id));
const scrolled: HTMLElement[] = [];
dom.window.HTMLElement.prototype.scrollIntoView = function () { scrolled.push(this); };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
let installed = plugin;
let delayed = false;
const pending: (() => void)[] = [];
const listeners = new Set<() => void>();
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
async listPlugins() { return [installed]; },
onPluginContributionsChanged(callback: () => void) { listeners.add(callback); return () => listeners.delete(callback); },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions(query: NetcattyPluginContributionQuery) {
if (delayed && query.scopeIds) await new Promise<void>((resolve) => pending.push(resolve));
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] };
},
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
} });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
const perform = async (action: () => void) => { await act(async () => { action(); await settle(); }); };
await perform(() => root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>));
const gear = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(gear && !gear.disabled);
const flushFrames = async (count = 3) => {
for (let i = 0; i < count; i++) await act(async () => {
const current = [...frames.values()]; frames.clear();
for (const callback of current) callback(i * 16);
});
};
return {
gear, scrolled, perform, flushFrames,
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
open: async (slow = false) => { delayed = slow; await perform(() => { gear.focus(); gear.click(); }); },
release: async () => { delayed = false; await perform(() => { for (const resolve of pending.splice(0)) resolve(); }); },
selectTab: async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
await perform(() => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
},
close: async () => { await perform(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); },
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
refresh: async () => { await perform(() => { for (const callback of [...listeners]) callback(); }); },
dispose: async () => {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
},
};
}
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
const f = await fixture();
try {
await f.open();
assert.notEqual(document.activeElement, f.section());
await f.flushFrames();
assert.deepEqual(f.scrolled, [f.section()]);
assert.equal(document.activeElement, f.section());
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
input.focus();
await f.refresh(); await f.flushFrames();
assert.equal(document.activeElement, input, 'later contribution refresh cannot steal focus');
assert.equal(f.scrolled.length, 1);
await f.close();
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
test('gear entry waits for slow contributions before targeting the existing settings section', async () => {
const f = await fixture();
try {
await f.open(true); await f.flushFrames();
assert.equal(f.section(), null); assert.equal(f.scrolled.length, 0);
await f.release(); await f.flushFrames();
assert.equal(f.scrolled.length, 1); assert.equal(document.activeElement, f.section());
} finally { await f.dispose(); }
});
test('closing between animation frames cancels an already scheduled gear focus', async () => {
const f = await fixture();
try {
await f.open(); await f.flushFrames(1);
await f.close(); await f.flushFrames();
assert.equal(f.scrolled.length, 0);
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
const f = await fixture();
try {
await f.open(true);
if (cancellation === 'tab') await f.selectTab('Official');
if (cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
if (cancellation === 'owner') await f.replaceOwner();
if (cancellation === 'close') await f.close();
await f.release();
if (cancellation === 'tab') await f.selectTab('Installed');
if (cancellation === 'tab' || cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.equal(f.scrolled.length, 0, 'late data or returning to the plugin cannot revive the cancelled intent');
if (cancellation === 'close') {
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
}
} finally { await f.dispose(); }
});
test('normal Browse and detail entry retain their existing detail focus', async () => {
const f = await fixture();
try {
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Browse')!;
await f.perform(() => { browse.focus(); browse.click(); });
await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
await f.close(); assert.equal(document.activeElement, browse);
} finally { await f.dispose(); }
});
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
// Uses the real settings primitives and verifies that the host manager owns state.
test('landing rows expose real sources/status/settings and quick toggles preserve host decisions', async () => {
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', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const root = createRoot(document.getElementById('root')!);
const plugins = [{
id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0',
source: { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com' },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, description: 'Synthetic description', contributes: { settings: [{ id: 'com.example.alpha.value', label: 'Value', control: 'text', scope: 'application' }] } },
runtime: { status: 'running', kind: 'browser', lastError: null, quarantinedAt: null },
}, {
id: 'com.example.beta', enabled: false, activeVersion: '2.0.0',
source: { id: 'third-party:synthetic', kind: 'third-party', name: 'Synthetic source', origin: 'https://synthetic.example.com' },
manifest: { manifestVersion: 1, id: 'com.example.beta', name: 'Beta', version: '2.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
}] as NetcattyInstalledPlugin[];
const changes: Array<[string, boolean]> = [];
const opened: Array<string | null> = [];
let refreshes = 0;
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null as string | null, credentialStorageAvailable: true,
async refresh() { refreshes++; }, async setEnabled(id: string, enabled: boolean) { changes.push([id, enabled]); }, async importPackage() {}, async uninstall() {} };
Object.assign(dom.window, { netcatty: { async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; } } });
const render = async (value = manager, settingsAvailable = new Set(['com.example.alpha'])) => act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={value} settingsAvailable={settingsAvailable} onManage={(id) => opened.push(id)} /></I18nProvider>));
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const byLabel = (label: string) => document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!;
try {
await render();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
assert.match(document.body.textContent!, /Running/);
assert.match(document.body.textContent!, /Source: Official/);
assert.match(document.body.textContent!, /Synthetic source/);
assert.doesNotMatch(document.body.textContent!, /Save host audit logs locally|trust all|safe mode|telemetry/i);
await act(async () => button('Browse').click());
await act(async () => byLabel('Settings for Alpha').click());
assert.deepEqual(opened, [null, 'com.example.alpha']);
await act(async () => byLabel('Disable Alpha').click());
assert.deepEqual(changes, [['com.example.alpha', false]]);
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true', 'no optimistic flip before host approval');
await render({ ...manager, error: 'Synthetic permission denied' });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic permission denied/);
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true');
await act(async () => byLabel('Refresh installed plugins').click());
assert.equal(refreshes, 1);
await render({ ...manager, statusKnown: false });
assert.equal(byLabel('Disable Alpha').disabled, true);
await act(async () => byLabel('Disable Alpha').click());
assert.equal(changes.length, 1);
await render(manager, new Set());
assert.equal(byLabel('Settings for Alpha').disabled, true, 'unavailable settings cannot open an empty destination');
await render({ ...manager, plugins: [] });
assert.match(document.body.textContent!, /No plugins installed/);
await render({ ...manager, plugins: [], statusKnown: false });
assert.match(document.body.textContent!, /Loading/);
await render({ ...manager, plugins: [], available: false });
assert.match(document.body.textContent!, /unavailable in this session/);
await render({ ...manager, plugins: [{ ...plugins[0], source: undefined }] });
assert.match(document.body.textContent!, /Source unknown/);
assert.doesNotMatch(document.body.textContent!, /Source: Local/);
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,91 @@
import React, { useCallback, useMemo, useState } from 'react';
import { RefreshCw, Settings2 } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import type { usePluginManager } from '../../../application/state/usePluginManager';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
import { PluginLogSettings } from './PluginLogSettings';
type Manager = ReturnType<typeof usePluginManager>;
export function PluginSettingsLanding({ manager, settingsAvailable, settingsError, managerButtonRef, updates, onManage }: {
manager: Manager;
settingsAvailable: ReadonlySet<string>;
settingsError?: string;
managerButtonRef?: React.Ref<HTMLButtonElement>;
updates?: ReturnType<typeof usePluginUpdateCheck>;
onManage: (pluginId: string | null, opener: HTMLElement) => void;
}) {
const { t, resolvedLocale } = useI18n();
const [search, setSearch] = useState('');
const localized = useCallback((value: string | Record<string, string> | undefined) => typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '', [resolvedLocale]);
const nameOf = (plugin: NetcattyInstalledPlugin) => localized(plugin.manifest?.displayName) || plugin.manifest?.name || plugin.id;
const visible = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
return manager.plugins.filter((plugin) => [localized(plugin.manifest?.displayName), plugin.manifest?.name, plugin.id, plugin.manifest?.publisher, localized(plugin.manifest?.description), plugin.source?.name].some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
}, [manager.plugins, search, localized]);
const statusOf = (plugin: NetcattyInstalledPlugin) => {
if (!manager.statusKnown) return t('settings.plugins.permissions.statusUnknown');
const state = plugin.runtime.status;
if (['running', 'starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
if (state !== 'stopped') return t('settings.plugins.landing.status.unknown');
return t(plugin.enabled ? 'settings.plugins.landing.status.idle' : 'settings.plugins.manager.disabled');
};
const sourceOf = (plugin: NetcattyInstalledPlugin) => {
const source = plugin.source;
if (!source) return t('settings.plugins.landing.sourceUnknown');
const kind = t(source.kind === 'official' ? 'settings.plugins.sources.official' : source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local');
return `${t('settings.plugins.sources.source')}: ${kind}${source.kind === 'third-party' ? ` · ${source.name}` : ''}`;
};
return <>
<SettingsSection title={t('settings.plugins.landing.options')}>
<SettingCard divided>
<SettingRow label={t('settings.plugins.landing.manager')} description={t('settings.plugins.landing.managerDescription')}>
<Button ref={managerButtonRef} className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={(event) => onManage(null, event.currentTarget)}>{t('settings.plugins.landing.browse')}</Button>
</SettingRow>
<PluginLogSettings />
</SettingCard>
</SettingsSection>
<SettingsSection title={`${t('settings.plugins.manager.installed')} · ${manager.plugins.length}`} actions={<>
{updates && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={updates.busy || manager.busy || !manager.available || !manager.statusKnown || manager.plugins.length === 0} onClick={() => void updates.check()}>{t(updates.busy ? 'settings.plugins.landing.checkingUpdates' : 'settings.plugins.landing.checkUpdates')}</Button>}
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy} aria-label={t('settings.plugins.landing.refresh')} onClick={() => void manager.refresh({ clearError: true }).catch(() => {})}><RefreshCw size={16} /></Button>
</>}>
{updates?.error && <p role="alert" className="text-sm text-destructive">{updates.error}</p>}
{updates?.result && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.landing.updateSummary').replace('{checked}', String(updates.result.checked)).replace('{updates}', String(updates.result.updates.length)).replace('{skipped}', String(updates.result.skipped))}{updates.result.failedSources > 0 && ` ${t('settings.plugins.landing.updateSourcesFailed').replace('{count}', String(updates.result.failedSources))}`} {t('settings.plugins.landing.updateReview')}</p>}
{settingsError && <p role="alert" className="text-sm text-destructive">{settingsError}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.statusKnown && !manager.error && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.loading')}</p>}
{manager.statusKnown && !manager.available && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.unavailable')}</p>}
{manager.statusKnown && manager.available && manager.plugins.length === 0 && <p className="text-sm text-muted-foreground">{t('settings.plugins.landing.empty')}</p>}
{manager.plugins.length > 0 && <>
<Input aria-label={t('settings.plugins.landing.search')} placeholder={t('settings.plugins.landing.search')} value={search} onChange={(event) => setSearch(event.target.value)} />
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <SettingCard divided>
{visible.map((plugin) => {
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>} description={<>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<span className="block break-words">{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</>}>
<div className="flex items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
</div>
</SettingRow>;
})}
</SettingCard>}
</>}
</SettingsSection>
</>;
}
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('real Settings landing leaves views visible while idle; log and Ask dialogs suspend and restore them', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="native-view-mount"></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', 'Element', 'Node', 'NodeFilter', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'DocumentFragment', 'CustomEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
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 { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { PluginPermissionHost } = await import('../../plugins/PluginPermissionHost');
const { observePluginHostSecurityVisibility } = await import('../../../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../../../electron/plugins/permissionDecisionHost.cjs');
type Prompt = NetcattyPluginPermissionPrompt | { requestId: string; closed: true };
const listeners = new Set<(event: Prompt) => void>();
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: Prompt) => { for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const host = new PluginPermissionDecisionHost({ getWindows: () => [owner], isLocked: () => false });
let logStatus = { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return logStatus; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { logStatus = { ...logStatus, ...value }; return logStatus; },
onPluginPermission(listener: (event: Prompt) => void) { listeners.add(listener); return () => listeners.delete(listener); },
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
} });
const manager = { plugins: [], available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true,
async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let visible = true;
const transitions: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(document.getElementById('native-view-mount')!, (next) => { visible = next; transitions.push(next); });
const root = createRoot(document.getElementById('root')!);
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((element) => element.textContent === label)!;
const flushVisibility = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={manager} settingsAvailable={new Set()} onManage={() => {}} /><PluginPermissionHost /></I18nProvider>));
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.ok(button('Configure'), 'real idle logging row is present');
assert.equal(visible, true, 'idle Settings landing must not hide the native child');
assert.deepEqual(transitions, []);
await act(async () => button('Configure').click());
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'actual logging security dialog must hide the child');
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[0].click());
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[1].click());
await flushVisibility();
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.equal(visible, false, 'the verbose security confirmation must also keep the child hidden');
await act(async () => button('Cancel').click());
await flushVisibility();
assert.equal(visible, false, 'cancelling verbose returns to the protected logging dialog');
await act(async () => button('Close').click());
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(visible, true, 'closing logs must restore the child while its idle row remains');
let pending!: Promise<{ decision: string }>;
await act(async () => {
pending = host.request({ requestId: 'settings-visibility-ask', pluginId: 'com.example.settings-visibility', pluginName: 'Settings visibility fixture', pluginVersion: '1.0.0',
permission: 'settings.read', resources: ['*'], reason: 'Read own synthetic setting' });
});
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'Ask must still hide the child above the real landing');
await act(async () => { button('Deny').click(); await pending; });
await flushVisibility();
assert.equal((await pending).decision, 'deny');
assert.equal(visible, true, 'dismissing Ask must restore the same child');
assert.deepEqual(transitions, [false, true, false, true]);
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('manual update action coalesces clicks, never runs on mount, and reports incomplete source checks honestly', async () => {
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', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { usePluginUpdateCheck } = await import('../../../application/state/usePluginUpdateCheck');
const root = createRoot(document.getElementById('root')!);
const source: NetcattyPluginSource = { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com', enabled: true };
const plugin = { id: 'com.example.alpha', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64), source,
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } } as NetcattyInstalledPlugin;
const plugins = [plugin];
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true, async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let catalogReads = 0; let mutations = 0; let offline = false;
let release: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
Object.assign(dom.window, { netcatty: {
async listPlugins() { return plugins; }, async listPluginSources() { return [source]; },
async listPluginCatalog(id: string) { assert.equal(id, source.id); catalogReads++; await gate; return { entries: [{ id: plugin.id, version: '1.1.0', artifact: { url: 'https://plugins.example.com/synthetic.ncpkg', sha256: 'b'.repeat(64) } }], source: offline ? 'cached' : 'registry', error: offline ? 'synthetic offline' : null }; },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async installCatalogPlugin() { mutations++; }, async setPluginEnabled() { mutations++; }, async checkForUpdates() { mutations++; },
} });
function Page() { const updates = usePluginUpdateCheck(plugins); return <PluginSettingsLanding manager={manager} updates={updates} settingsAvailable={new Set()} onManage={() => {}} />; }
const check = () => [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Check for updates')!;
try {
await act(async () => root.render(<I18nProvider locale="en"><Page /></I18nProvider>));
assert.equal(catalogReads, 0, 'rendering settings does not schedule checks');
await act(async () => { const button = check(); button.click(); button.click(); });
assert.equal(catalogReads, 1);
assert.equal([...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Checking…')!.disabled, true);
await act(async () => release());
assert.match(document.body.textContent!, /Published updates: 1/);
assert.match(document.body.textContent!, /Version 1.1.0 available/);
assert.equal(mutations, 0);
await act(async () => window.dispatchEvent(new Event('netcatty:plugin-packages-changed')));
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/, 'source/package changes invalidate prior results');
offline = true;
await act(async () => check().click());
assert.match(document.body.textContent!, /Checked: 0/);
assert.match(document.body.textContent!, /Not checked: 1/);
assert.match(document.body.textContent!, /Unavailable sources: 1/);
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/);
assert.equal(mutations, 0);
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('landing settings entry targets the declared plugin and a manager tab roundtrip clears selection consistently', async () => {
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', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
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);
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const root = createRoot(document.getElementById('root')!);
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
async listPlugins() { return [plugin]; },
async setPluginEnabled() { throw new Error('Synthetic management failure'); },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions() { return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', description: 'Synthetic plugin', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] }; },
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
} });
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
const selectTab = async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
assert.ok(tab, name);
await act(async () => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await settle(); });
};
try {
await act(async () => { root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>); await settle(); });
const settings = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(settings);
assert.equal(settings.disabled, false);
await act(async () => { settings.focus(); settings.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await selectTab('Official');
await selectTab('Installed');
assert.equal(document.querySelector('[data-plugin-detail]'), null, 'returning to Installed shows the grid, not a stale deep-link detail');
assert.equal(document.querySelector('input[aria-label="Alpha setting"]'), null);
const card = document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!;
await act(async () => { card.click(); await settle(); });
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, settings);
const toggle = document.querySelector<HTMLButtonElement>('button[aria-label="Disable Alpha"]')!;
await act(async () => { toggle.click(); await settle(); });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic management failure/);
assert.equal(toggle.getAttribute('aria-checked'), 'true', 'failed mutation retains actual host state');
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh installed plugins"]')!;
await act(async () => { refresh.click(); await settle(); });
assert.equal(document.querySelector('[role=alert]'), null, 'explicit successful refresh clears an acknowledged manager error');
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+57 -14
View File
@@ -1,11 +1,13 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import React, { useEffect, useMemo, useState } from 'react';
import { PluginSettingsLanding } from './PluginSettingsLanding';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
@@ -285,11 +287,15 @@ export function PluginSettingField({
);
}
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
handoff: (action: () => void) => void;
manager: ReturnType<typeof usePluginManager>;
initialSelectedId: string | null;
active: boolean;
}) {
const { t } = useI18n();
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
@@ -302,6 +308,31 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
scopeIds,
}), [scopeIds]);
const contributions = usePluginContributions(query);
const settingsSection = useRef<HTMLElement | null>(null);
const installed = manager.plugins.find((plugin) => plugin.id === selectedInstalled);
const ownerKey = JSON.stringify(installed && [installed.id, installed.activeVersion, installed.archiveSha256,
installed.source?.id, installed.source?.kind, installed.source?.origin]);
const gearEntry = useRef(initialSelectedId ? { pluginId: initialSelectedId, ownerKey } : null);
useLayoutEffect(() => {
const intent = gearEntry.current;
if (!intent) return;
if (!active || pluginView !== 'installed' || selectedInstalled !== intent.pluginId || ownerKey !== intent.ownerKey) {
gearEntry.current = null;
return;
}
const section = settingsSection.current;
if (contributions.loading || contributions.error || !contributions.available || !section) return;
// Let dialog autofocus settle first, including when contributions arrive late.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (gearEntry.current !== intent || !section.isConnected) return;
gearEntry.current = null;
section.scrollIntoView({ block: 'start' });
section.focus({ preventScroll: true });
});
});
return () => cancelAnimationFrame(frame);
}, [active, pluginView, selectedInstalled, ownerKey, contributions.loading, contributions.error, contributions.available, contributions.snapshot]);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
@@ -311,7 +342,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
@@ -327,7 +358,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
@@ -352,7 +383,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
return (
<section key={plugin.id} className="space-y-3" aria-labelledby={`plugin-settings-${plugin.id}`}>
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
<div>
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
@@ -394,15 +425,27 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
export default function SettingsPluginsTab() {
const { t } = useI18n();
const manager = usePluginManager();
const updates = usePluginUpdateCheck(manager.plugins);
const [open, setOpen] = useState(false);
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
const opener = useRef<HTMLElement | null>(null);
const managerButton = useRef<HTMLButtonElement | null>(null);
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.visible))
.map((plugin) => plugin.id)), [contributions.snapshot]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
<SettingsAnchor anchorId="plugins-root" className="space-y-6">
<PluginSettingsLanding manager={manager} updates={updates} managerButtonRef={managerButton} settingsAvailable={settingsAvailable} settingsError={contributions.error?.message} onManage={(pluginId, element) => {
opener.current = element;
setInitialSelectedId(pluginId);
setOpen(true);
}} />
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
</PluginManagementDialog>
</SettingsAnchor>
</SettingsTabContent>
);
+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,
};
}
+3 -1
View File
@@ -17,6 +17,7 @@ interface ConfirmDialogProps {
confirmLabel: string;
busy?: boolean;
destructive?: boolean;
hostSecuritySurface?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
@@ -28,6 +29,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
confirmLabel,
busy = false,
destructive = false,
hostSecuritySurface = false,
onOpenChange,
onConfirm,
}: ConfirmDialogProps) {
@@ -35,7 +37,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogContent data-host-security-surface={hostSecuritySurface ? "" : undefined} className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">{title}</DialogTitle>
</DialogHeader>
@@ -0,0 +1,189 @@
# Unified plugin acceptance candidate
This file records the frozen 677 native baseline. For the subsequent permission
policy and AI candidate, use [the new integration record](policy-ai-integration.md)
and its package manifest. Historical consent flows below are not the revised UX.
The dedicated branch is `integration/plugin-native-acceptance`. Its Draft PR
records the exact current head and CI runs. Native AI, database, Host/Vault, and
settings/logging checks use this candidate rather than separate feature trees.
Only the dedicated acceptance branch is published by this integration task;
the reported feature checkpoints are preserved through ordinary merges.
The previous candidate `174c525cd45bdc1152e9feb21839a288706d3bd1` passed
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40381685282),
[four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40381685946),
and [ET CI](https://github.com/binaricat/Netcatty/actions/runs/40381684810).
No actionable CI regression was found. Its full test suite records 15,375 tests:
15,325 passed, 50 skipped, zero failed or cancelled.
The next candidate includes the published settings focus, AI budget/session/
selected-Note recovery, and terminal Solo/Focus ownership corrections. The parent
reports both correction reviews scoped clean at their exact source heads. Those
reviews do not establish aggregate package verification or native acceptance.
No uncommitted owner work is imported, and native activation is not performed
by this coding task.
## Included source checkpoints
| Slice | Frozen checkpoint | Integration evidence |
| --- | --- | --- |
| Main | `f0897c3c1711f60350f8d613696d387bcac464ed` | Includes #3991, #3983, #3992, and #3995 |
| Byte streams, source identity, connected AI, examples | #3993 `37df67256e7eedc1050f186bcf401dc376cb5e71` | Ordinary merge; both transport P2 fixes retained |
| Host/Vault actions | #3990 `730fa643ba0589b8919368657dabc83428f577be` | Ordinary merge; metadata/action authority stays separate |
| Settings and logging UI | #3994 `823db94cf34503417e3b7c19c6daab379f60530f` | Ordinary merge; retains dc6 alias repair and adds the natively verified gear-to-settings focus path |
| AI corrections | #3986 `ba09461676f52b6b1853385ba61845b48cf5f5ac` | Ordinary merges preserve budget/model recovery and final `9e7c9df58` explicit Note re-selection; same reviewer reports the bounded correction clean |
| Selected-terminal continuation | #3996 `8e7c28fe1bdbcdd76816d33ec926ba8e60eefe64` | Ordinary merge; independent correction review reports Solo/Focus ownership resolved; native acceptance pending |
#3995 was squash-merged into main as `f0897c3c`. Its PR tip was rechecked at
`921f51756eca564cef3d30152807a4649c2a0d3b`; both contain the same TERM-SSHD test
blob `cdfc067e6ca3b4f6b7b3d6e78834072ed84837b4`. The candidate keeps main's
deterministic first/alias OPEN-order cases instead of the older plugin-branch
test-only repair. No production transfer cancellation change is introduced.
## Resolve the shared integration files
- Root package and lockfile retain both the plugin workspace versions and
main's pinned `@noble/ciphers` dependency.
- The assistant retains its six-file staging build. The Host/Vault branch's
older assistant preparation does not replace it.
- `hostService.cjs` retains byte-stream, AI/context, Host/Vault, and logging
construction and registration. Shutdown cleans up the existing services and
closes logs in `finally`. Constructor-failure cleanup retains each service.
- The AI host remains mounted in `application/app/AppView.tsx`. The Host action
host remains inside the Vault/session providers in `App.tsx`. Trusted preload
methods, source-bound authority, and canonical schema definitions are retained.
- Canonical contract generation produces no drift after merging the shared
schema. This worktree's `@netcatty/plugin-contract` and `@netcatty/plugin-sdk`
resolve to its own `packages/` directories, not an older worktree.
- The terminal merge unions canonical terminal and Vault prompt fields, trusted
preload methods, feature registration, and lifecycle cleanup. Terminal target
capture reuses the existing admission method and authorization guard. The
generic interaction UI continues to ignore Vault-only requests; Vault retains
its own handler. Inert terminal draft review returns a boolean receipt, never
edited text or terminal input.
- The installed Host test fixture now supports multiple lock listeners, matching
the production app-lock bridge's `Set` subscription contract. The old fixture
stored one callback and timed out after both Host and AI services subscribed.
Production lock behavior is unchanged.
## Local evidence
Checks use Node 24.19.0 and a private task temp root through the existing temp
bridge. HOME, endpoint policy, and sandbox settings are unchanged.
| Check | Local result |
| --- | --- |
| Registered plugin runtime | 676 tests: 674 passed, one missing-Electron failure, one native skip |
| Final AI broker/context and real-handle sample panel | 27 passed, including explicit Note re-selection |
| Contract/SDK/CLI | 91 passed, zero failures |
| Host installed entrypoint/action isolation after terminal merge | 40 passed; earlier baseline Host/action/lock group passed 82 after correcting the subscription fixture |
| Terminal selection, pane ownership/lifecycle, draft UI, palette failure | 19 passed |
| Plugin settings/market/logging UI after gear correction | 36 passed, zero failures |
| Registered database/terminal examples | Five passed, one opt-in Docker skip |
| Vite lazy-settings HTTP/native-ESM probe | Passed after settings focus correction; dc6 alias repair retained |
| Main TERM-SSHD file | All 32 cases passed, including both controlled arrival orders |
| Contract/capability generation and drift, Codex schema | Passed |
| Full lint | Zero errors, seven existing warnings |
| Production build | Passed on the assembled source tree |
| Five sample packages | Validated; two builds of each match byte for byte |
The initial connected group is retained as a failed run: 39 passed, nine failed,
and two cancelled. The mounted AI chooser test failed because this local
`--ignore-scripts` dependency installation lacks the Electron binary. The Host
lock case timed out because of the single-listener test fixture; later subtests
were cancelled. The corrected Host group passes as recorded above.
The combined Vite/main regression run records 33 passed and seven failed. Its
Vite probe and all TERM-SSHD cases passed. The SSH algorithm import and six
ChaCha/Electron cases could not load the same missing Electron binary. The
normal pinned Electron installer was attempted with a writable task-local cache
and failed with `TypeError: fetch failed`. No alternate download route, native
rebuild bypass, Electron shim, or sandbox change was used. These local failures
remain separate from the exact-head CI results recorded in the Draft PR.
The final registered runtime failure is the installed terminal reference's
worker-reconnect case: the real worker imports the unavailable Electron binary.
The same case failed for this prerequisite before the final AI merge. The other
674 registered cases pass. The final focused AI suite passes all 27 cases; the
earlier connected adapter run retains two passes and one missing-Electron
failure. These failures are not relabeled as passing or suppressed.
## Reproducible packages and exact permissions
All package versions below are `0.1.0`. These hashes are for this candidate's
SDK and source tree. Older #3993 database archives have different hashes and
must not be substituted. The assistant and Host samples contain their READMEs;
the database packages contain only manifest, browser bundle, and three panel
assets. These handoff archives were not installed or activated in the native
GUI. The automated installed-entrypoint tests use temporary synthetic fixtures;
the Rfam candidate was not installed or granted permissions in any runtime.
| Package ID | Files | SHA-256 |
| --- | --- | --- |
| `com.netcatty.assistant` | 6 | `c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395` |
| `com.netcatty.database-minimal` | 5 | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` |
| `com.netcatty.database-rfam-readonly` | 5 | `aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3` |
| `com.netcatty.host-actions` | 3 | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` |
| `com.netcatty.terminal-selection-draft` | 2 | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` |
The assistant matches the final owner's reported archive hash. Both database
archives and the terminal sample changed after rebuilding against the combined
SDK. The terminal source and CLI are unchanged from `8e7c28fe`; the integrated
SDK has additional exports. The measured terminal content hash is
`99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c`.
These archives replace earlier acceptance artifacts. Repeat-build equality and
CLI validation do not grant native permissions or establish independent
aggregate/package security verification. Host activation remains blocked by
the separate automatic-review denial.
| Package | Required feature | Required permissions | Optional permissions/resources |
| --- | --- | --- | --- |
| Assistant | `ai.model-invocation`, `context.selected-read` | `commands`, `views` | `ai.invoke`, `context.selected.list`, `context.selected.read` |
| Owned database | `network.byte-streams` | `commands`, `views` | `network.stream`: exactly `tcp://10.77.0.20:3306` |
| Public Rfam | `network.byte-streams` | `commands`, `views` | `network.stream`: exactly `tcp://mysql-rfam-public.ebi.ac.uk:4497` |
| Host actions | `vault.host-actions`, `commands.interactive` | `commands`, `menus` | `vault.metadata`, `vault.write`, `ui.interact` |
| Terminal draft | `terminal.selection-sharing`, `terminal.input-drafts`, `commands.interactive` | `commands`, `menus` | `terminal.selection.read`, `terminal.input.propose`, each for one explicit operation |
The owned database address is packaging-only. A different approved fixture
endpoint requires a new package and hash. No route from the GUI to the coding
container's private MariaDB is assumed. The GUI's missing Docker runtime remains
a fixture prerequisite, not a reason to change loopback/own-interface checks.
Reproduction commands after building the plugin packages:
```sh
node examples/plugins/assistant/build.mjs
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant.ncpkg
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-owned.ncpkg
node examples/plugins/database-rfam-readonly/prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 /tmp/database-rfam-readonly.ncpkg
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-actions --out /tmp/host-actions.ncpkg
node examples/plugins/terminal-selection-draft/prepare.mjs /tmp/terminal-selection-draft.ncpkg
```
## Native, review, and deployment gates
| Gate | Status and next evidence |
| --- | --- |
| Exact-head test/build CI | Recorded in the dedicated Draft PR; earlier feature-head CI is historical evidence |
| Native settings UI | Parent reports dc6 layout/controls and 823 gear-to-settings focus passed; those source-head results do not certify every flow on the integrated head |
| Native AI sample | Final source correction review is scoped clean at `ba094616`; integrated native acceptance remains pending and activation is not performed here. Expiry requires fresh host selection, explicit dropdown choice, content grant and text review; no silent re-share or inference retry |
| Native owned database | Pending a supported disposable fixture path; owned live regression remains opt-in and was not run here |
| Public Rfam | Prepared only; no service DNS lookup, connection, query, installation, or permission activation. Parent coordinates exact hash/endpoint authorization. Only protocol 4.1/native-password is supported; auth/TLS compatibility is unverified and no fallback is added |
| Native Host/Vault | Parent reports installation/details passed. Activation consent was denied by automatic review. Functional Host flows remain unrun pending specific user approval. Do not retry that activation or change permissions to bypass it |
| Selected-terminal continuation | Correction review scoped clean at `8e7c28fe`; integrated checks recorded above; native acceptance pending. Input review remains inert and returns only a boolean receipt |
| AI independent security review | The same worker reconsidered the earlier restriction once with newer maintainer evidence and completed review without recurrence. Parent reports the final bounded correction review clean at `ba094616`, including generation guards and old-handle invalidity. This task does not repeat that review or claim independent aggregate/package verification |
| Main merge/release/deployment | Not authorized by this task; candidate remains Draft |
## Native handoff
Use the exact head from the Draft PR and the current five-package manifest/hash
record. Source-head review and native settings evidence remain separately
attributed. Do not substitute earlier assistant or database archives. No native
Host activation retry, automatic re-sharing, or inference retry is part of this
integration work.
Native evidence records the exact candidate commit, package hashes, source and
permission prompts, denial/retry/cancel/revoke behavior, screenshots, and video.
The coding executor's Node/DOM harnesses and Vite probe are not native sandbox
certification. Database protocol and fixed query code remain in the examples.
@@ -0,0 +1,153 @@
[
{
"name": "assistant",
"id": "com.netcatty.assistant",
"version": "0.1.0",
"fileCount": 6,
"bytes": 14638,
"sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
"features": {
"required": [
"ai.model-invocation",
"context.selected-read"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
"ai.invoke",
"context.selected.list",
"context.selected.read"
]
},
"package": "assistant.ncpkg",
"repeatIdentical": true,
"preparedOnly": true,
"contentSha256": "9a4efe442cd1c35ec8f61e3e2e4ccbb4fa97addf168b0b8b6ac85bac79c39ca9"
},
{
"name": "database-owned",
"id": "com.netcatty.database-minimal",
"version": "0.1.0",
"fileCount": 5,
"bytes": 11660,
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"features": {
"required": [
"network.byte-streams"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
{
"permission": "network.stream",
"resources": [
"tcp://10.77.0.20:3306"
],
"reason": "Connect only to the prepared disposable fixture."
}
]
},
"package": "database-owned.ncpkg",
"repeatIdentical": true,
"preparedOnly": true,
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d"
},
{
"name": "database-rfam-readonly",
"id": "com.netcatty.database-rfam-readonly",
"version": "0.1.0",
"fileCount": 5,
"bytes": 11003,
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"features": {
"required": [
"network.byte-streams"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
{
"permission": "network.stream",
"resources": [
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
],
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
}
]
},
"package": "database-rfam-readonly.ncpkg",
"repeatIdentical": true,
"preparedOnly": true,
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162"
},
{
"name": "host-actions",
"id": "com.netcatty.host-actions",
"version": "0.1.0",
"fileCount": 3,
"bytes": 4633,
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"features": {
"required": [
"vault.host-actions",
"commands.interactive"
]
},
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"vault.metadata",
"vault.write",
"ui.interact"
]
},
"package": "host-actions.ncpkg",
"repeatIdentical": true,
"preparedOnly": true,
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c"
},
{
"name": "terminal-selection-draft",
"id": "com.netcatty.terminal-selection-draft",
"version": "0.1.0",
"fileCount": 2,
"bytes": 2954,
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"features": {
"required": [
"terminal.selection-sharing",
"terminal.input-drafts",
"commands.interactive"
]
},
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"terminal.selection.read",
"terminal.input.propose"
]
},
"package": "terminal-selection-draft.ncpkg",
"repeatIdentical": true,
"preparedOnly": true,
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c"
}
]
+202 -45
View File
@@ -2,23 +2,29 @@
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 base later advanced to `f1410f8bb`; its source/stream changes are incorporated
by ordinary merge. Three overlaps retain this branch's permission explanations,
bounded-stream assertions, and six-file assistant staging. The source database
and sidecar changes come from their owning task, without local modifications.
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 +38,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,8 +53,18 @@ This branch does not claim that workflow is complete.
## Implemented authority
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
`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`,
`resolveBudgetOwner`, and `selectModel` adapters. The service derives the stable
budget owner from the authenticated source key and opaque private owner. It never
accepts a plugin-supplied budget key or falls back to a bare plugin ID. Strict
runtime binding objects still govern handle access; their replacement does not
reset request or token budgets. It keeps chats and turns in memory. It stores no chat
history and makes no database changes. Data owners are opaque host identities;
no private storage is keyed by `pluginId`.
@@ -67,7 +86,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 +107,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 +166,144 @@ 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 |
| `netcatty-ai-fixed-final-tests.log` | Before the later review corrections, `173ef5abb`: 15,248 tests, 15,205 passed, 0 failed, 0 cancelled, 43 skipped |
| `netcatty-ai-review-final-runtime.log` | After both review corrections: 614 tests, 612 passed, 0 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-review-final-tests.log` | Review-corrected head `d81896b0d`: 15,252 tests, 15,209 passed, 0 failed, 0 cancelled, 43 skipped, before the later base merge |
| `netcatty-ai-review-merged-tests.log` | Integrated head `07eb68c5e`: 15,255 tests, 15,212 passed, 0 failed, 0 cancelled, 43 skipped, before the selected-Note expiry correction |
| `netcatty-ai-note-expiry-runtime.log` | Selected-Note expiry correction: 625 tests, 624 passed, 0 failed, 0 cancelled, 1 skipped |
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 review was initially
blocked. After receiving newer exact maintainer security-task evidence, the same
worker completed its bounded read-only review of `173ef5abb` without a repeated
restriction or alternate route/model. It confirmed the earlier repair and found
two additional P2 defects: runtime-bound budget keys and the sample's expired
chat recovery.
Commit `ee5a4a406` separates stable source budgets from strict runtime handle
identity. Its fake-clock regression uses the actual source-binding class,
replaces runtime binding objects, tests request and reserved-token budgets,
isolates another source with the same public plugin ID, and checks the minute
boundary. Both budget subtests fail on the reviewed source and pass after repair.
Missing or invalid host budget identities fail closed.
Commit `e1cb95ee8` reopens an expired sample chat before an explicit Send and adds
**Refresh model** for changed-model or failed sessions. Refresh preserves the
draft and does not send inference. A failed send/read is never automatically
retried. Stop or page teardown invalidates pending opens and closes late sessions.
Four controlled-time DOM regressions fail before the repair and pass afterward,
including an uncertain inference outcome and Stop during reopening. They use no
network or paid provider. Failed-before logs are
`/tmp/netcatty-ai-budget-review-before.log` and
`/tmp/netcatty-ai-panel-review-before.log`; passing logs use the corresponding
`-after.log` names. Correction review independently confirmed the stable budget
fix, but found the remaining selected-Note expiry path at `07eb68c5e`.
The corrected broker suite passes all 17 tests, the sample's controlled-time
panel suite passes all 4, and the connected provider/UI suite passes all 4. Full
lint still reports zero errors and seven existing warnings; codegen drift and
the production build pass. Two independent package builds reproduce SHA-256
`c0f733697b8f43a8591f39c02aeea5d8b7b4bd544b642ed87e758e62d98696a5`
for that earlier sample checkpoint.
Commit `9e7c9df58` adds explicit **Choose reference** recovery. The panel retains
summary expiry locally, blocks an expired selection before Send, and never sends
that local metadata as an authorization field. Refresh model never re-shares a
Note. Re-selection opens the existing host chooser and resets the dropdown to
No reference; the user selects the fresh handle and clicks Send explicitly.
The service's identical-selection cache now reuses only complete live summaries.
After an explicit host re-selection, expired/revoked handles remain invalid and
fresh eligible handles are issued. No authority lifetime or revocation rule is
changed. Summary selection does not read text. Every new content handle still
requires the existing per-context grant and exact-text review.
The new fake-clock panel regression uses the actual host service and selected
context authority with a real synthetic Note handle. It covers expiry, model
refresh without re-sharing, explicit re-selection, denial of the fresh content
grant, cancellation of text review, and one successful explicit send with the
draft preserved. It repeats the expiry/re-selection with both chat and Note
lifetimes expired, then succeeds without a manual model refresh. A separate
service regression reproduces the formerly empty
same-Note cache after expiry. Both fail on `07eb68c5e`; failed-before evidence is
`/tmp/netcatty-ai-note-expiry-panel-before.log` and
`/tmp/netcatty-ai-note-expiry-service-before.log`. The combined panel/service
suite now passes all 10 tests; the connected installed UI/provider suite passes
all 4. The replacement six-file artifact has SHA-256
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
Independent verification of the latest correction and native acceptance remain
pending. CI is not a substitute for either.
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.
+108
View File
@@ -0,0 +1,108 @@
# Plugin model transport cold start
Commit `092438803bed96753bfe68cb78da9ca1137dee25` removes an independent startup
dependency on the internal AI sidebar. It does not establish the cause or outcome
of the original native failure; that still requires an authorized native retest.
## Reproduced boundary
At native candidate `677aac33b1eec9dbd4a367417a49c964cdea63e7`, the plugin model
selector reads the saved host provider, while main's provider registry starts
empty. The existing sidebar synchronizes that registry when visible or sending.
The plugin startup path did not. A nondefault configured loopback port can
therefore fail main's provider allowlist before HTTP transport starts.
The revised `components/PluginAIContextHost.test.tsx` installs the browser sample,
mounts the trusted host component, uses the real application preload, and routes
model requests through `registerProviderHandlers` and its actual allowlist. It
does not mount the internal sidebar or manually alter the allowlist. Credential
injection uses synthetic fixture markers; the HTTP edge and deterministic local
fixture use the existing transport. No paid service, real key, Note, or host is
used. This remains DOM/in-process evidence, not native sandbox acceptance.
Failed-before evidence is `/tmp/netcatty-ai-cold-provider-before-final.log`:
```text
ok: false
allowlistRejected: true
statusCode: undefined
```
The corrected regression passes and preserves both the selected fixture provider
and a second host-configured synthetic provider. It checks that provider markers
and configuration do not enter plugin-facing model/context envelopes. Expected
SDK error output is suppressed during this diagnostic so request payloads and
headers are not logged.
## Correction
Before publishing a selected model handle, the trusted renderer synchronizes the
current host-owned provider settings through the existing `aiSyncProviders`
application API. It checks that the selected model revision matches the snapshot
being synchronized. The runtime rechecks activity, cancellation, model revision,
and pending-selection ownership after the await. Pending selections count toward
the model-handle cap; release and shutdown invalidate late results.
Plugin requests supply no URL, credential, or provider configuration. The existing
main sender validation, endpoint allowlist, source binding, and model invocation
budgets are unchanged. No new schema feature, plugin permission, or consent
dialog is added. The assistant package is unchanged and retains SHA-256
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
Production integration touches `configuredModel.ts`, `trustedHostRuntime.ts`, and
`usePluginAIContextHost.ts`. The hook's import and `prepareModel` option may
overlap host-selection work; preserve both when integrating the separate policy
task. Permission-engine, consent-dialog, and sample-UI changes are outside this
correction.
## Native and policy status
The native open/close sidebar comparison was incomplete. Execution auto-review
denied the Application-scope `context.selected.list` grant; that action did not
execute. No post-sidebar inference was performed, the prompt expired, and the
sample was disabled while the worker cleaned up. This is not native confirmation
of the startup cause. The original `677aac33` app remains frozen for comparison;
this fix does not authorize retrying the denied execution action.
In the earlier permission engine, **Application** grants are memory-only for the
application lifetime; **Always** grants are persisted. An explicitly authorized
Application grant can fit bounded temporary QA. A one-use prompt on every stream
read is not an unavoidable target UX. The separate policy task implements the
new direction: default Allow for declared supported capabilities, per-plugin
Allow/Ask/Deny settings, audit, and in-app Ask dialogs. That product change is
separate from execution-tool authorization and must not be used to evade a tool
denial. This transport correction does not redesign that policy.
Native outcome, independent verification, and the sample's light-theme appearance
follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid
model invocation, main merge, or release was performed for this correction.
## Node 22 CI observer correction
The new cold-start test failed on exact head `9ca70f329` in test run
`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped.
The sole failure was the empty SDK/renderer-error assertion receiving one
`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is
not an unrelated baseline failure.
The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`.
Reproduction on the exact CI version identified the safe category
`setup:node-sqlite-experimental`, before any model operation. The existing
`node:sqlite` import schedules its ExperimentalWarning on the next tick via
`console.error`. The newly added observer was installed before that warning
drained. The same test passed on Node 24, which did not emit that warning.
The test now lets pending module warnings reach normal stderr before installing
the SDK/renderer observer. The warning remains visible, and the zero-runtime-error
assertion is unchanged. Safe phase/error categories make future failures
diagnosable without request payloads or headers. No production behavior, timeout,
permission, or cancellation assertion changed. Both Node versions pass the
focused test; the Node 22 parallel fixture/UI/provider/preload group passes all
16 tests with concurrency 4.
Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails
before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the
SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log`
records the parallel check. The appearance-only sample change is independently
published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
@@ -0,0 +1,125 @@
# Managed-window Ask acceptance candidate
The dedicated branch is `integration/plugin-ask-routing-acceptance`. Its Draft
PR records the exact frozen integration head, CI and private renderer artifact
handoff. It starts from frozen #4001 at
`02049102b6732778adcd0f33b0a0ade6b4752327`; the old source checkout, QA workspace,
profile and renderer artifact remain unchanged.
## Exact inputs and ownership
The only new input is policy owner's reviewed Ask correction
`89beeb93749c751f255beb6d0e0f95d23bdd9a7b`, imported by ordinary merge
`1b21c41fa26c0173084c7ae75e640ef3274285dd`. There were no merge conflicts or
manual production edits. These seven production files exactly match that input:
| File | Owned production delta |
| --- | --- |
| `electron/bridges/windowManager.cjs` | Enumerate managed app windows, including Settings |
| `electron/main/registerBridges.cjs` | Supply the managed permission-window getter |
| `electron/plugins/hostService.cjs` | Pass that getter to the existing decision host |
| `electron/plugins/permissionDecisionHost.cjs` | Bind ready/focused/visible/unlocked managed renderer selection and request lifecycle to the chosen receiver |
| `electron/plugins/pluginBridge.cjs` | Guard permission readiness/replies by managed app window and main frame; reject plugin/foreign/subframe senders |
| `components/SettingsPage.tsx` | Mount the existing permission host for standalone Settings |
| `components/plugins/PluginPermissionHost.tsx` | Mark Ask with the existing host-security surface so attached native views yield |
The input also owns the Settings-installed-entrypoint regression, renderer test
updates and policy/security documentation. AI d33 startup wiring, sample assets,
the combined cold-start assertions and all earlier policy/transport history are
preserved from 020. `ModelSelector.tsx` is unchanged from 020; the separate
model-selector failure-loop repair is not imported. Diagnostic #3999 remains
excluded. No broker resource/source rules, stream restrictions, package/lock
inputs, native dependencies or sandbox configuration change in this integration.
## Frozen 020 native evidence
The parent closed that QA session and released the GUI. Its partial outcome is:
| Native case on 020 | Result |
| --- | --- |
| Default Allow cold Send and completion | Passed |
| Stop, AI Deny, restore without automatic replay | Passed |
| Themes in new views | Passed |
| Ask visible dialog | Failed: no visible dialog, timeout, zero new model requests |
| Ask acceptance/cancel and content-read matrix | Not run; remain pending |
The input's independent source-only delta review is scoped clean. Its installed
SDK/registered IPC test reproduces the old Settings-owned failure and passes with
the correction using represented Electron objects. That is not native Ask
success. No new native acceptance is claimed by this coding executor.
Earlier terminal solo/split core passes with corrected fixtures, unreliable/
unverified Focus and one QA Enter in A during a palette attempt remain historical
evidence. There is no global zero-input or full terminal native-pass claim. The
old Rfam run failed socket transport before connect resolved, without reaching
authentication/SELECT; its cause and auth/TLS compatibility remain unverified.
The next native priority is the new Ask path, not more old-baseline loops.
## Checks and retained CI history
| Local check | Result |
| --- | --- |
| Registered runtime | 696 tests: 695 passed, one native skip, zero failed/cancelled |
| Contract/SDK/CLI | 91 passed |
| Offline examples | Five passed, one Docker opt-in skip |
| Mounted cold-start AI/permission/selected-summary/Host group | 21 passed |
| Settings and security-surface visibility group | 37 passed |
| Contract/capability/Codex generation and drift | Clean |
| Lint | Zero errors, seven existing warnings |
| Five sample archives | Validated; two builds each are identical and match frozen 020 |
The exact integration production build, renderer inventory and final CI are
recorded in the Draft PR and artifact provenance. Local native packaging and
unpackaged GUI acceptance are not claimed. This environment uses locked
dependencies and standard patches; the official pinned Electron binary uses its
documented cache/proxy support. No header workaround, native shim, HOME change
or sandbox change is introduced.
Input89 [test CI 40453446775](https://github.com/binaricat/Netcatty/actions/runs/40453446775)
succeeded on the parent's single infrastructure-only rerun: 15,342 passed, 50
skipped, zero failed/cancelled. The original runner interruption remains evidence;
this worker did not duplicate its retry. Input89
[four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40453446781)
passed with release jobs skipped. Frozen 020's successful full/platform CI and
all earlier diagnosed/unexplained failures remain separate predecessor evidence.
The [sample manifest](ask-routing-packages.json) records exact IDs, versions,
features, permissions/resources and archive/content hashes. All five archives
are byte-identical to 020, including Assistant
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
No archive was activated by this executor. Its unchanged owner README retains
old review wording; the current Allow/Ask/Deny procedure governs acceptance.
## Production-renderer preparation and native Ask recipe
1. Create a new isolated checkout at this Draft's exact head and a separate
disposable synthetic QA profile. Keep the old 020 workspace/profile intact.
2. Match `package.json` and `package-lock.json` hashes to the renderer artifact's
provenance and the independently normally rebuilt native dependencies. They
are unchanged from 020. Prepare matching host workspace packages normally.
**The complete backend checkout must match the new head:** the frontend-only
bundle does not include the new CJS window-routing code or native binaries.
3. Materialize the exact private Library file/version in the consumer's own
environment. Verify its archive SHA-256 and every dist file with the supplied
inventory/verification script. Producer-local paths are not portable.
4. Use those verified production assets as a whole `dist/` in the matching
checkout, with the ordinary Electron entrypoint, no Vite environment and
normal sandbox/helper settings. This is native **unpackaged production-
renderer QA**, not packaged-binary certification. Process-loss causality
remains unclassified; no native build/header workaround is supplied.
5. Recreate only the synthetic provider/Note/sample setup. Verify Default Allow
remains functional; then set the plugin operation to Ask in the active main
or standalone Settings window. Keep the selected receiver visible, focused,
ready and unlocked; opening Ask must not force focus back to main.
6. Record visible readable Ask and native-view yielding. Check cancellation and
acceptance as separate explicit operations: cancel must send no model request;
accept must authorize only the current bounded operation. Complete the
content-read Allow/Ask/Deny matrix and lifecycle invalidation cases. Do not
treat the earlier zero-request timeout as an Ask pass.
7. Record exact head, archive/sample hashes, request counters, prompt ownership,
screenshots/video and any unrun cases. Report a model-selector failure
separately rather than folding its other owner's repair into this candidate.
Hosting/distribution/deployment and remaining DB/terminal acceptance gates stay
parent-coordinated. No main merge, Ready transition or release is part of this
handoff.
@@ -0,0 +1,197 @@
[
{
"name": "assistant",
"id": "com.netcatty.assistant",
"version": "0.1.0",
"fileCount": 6,
"bytes": 17214,
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"contentSha256": "c3e31e9613422bf9a05ccaa5b6f234304208f1a8298b1c1ee19262ba88af6cd0",
"features": {
"required": [
"ai.model-invocation",
"context.selected-read"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
"ai.invoke",
"context.selected.list",
"context.selected.read"
]
},
"package": "assistant.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
"changedEntriesFromFrozen677": [
"README.md",
"panel.css",
"panel.js"
],
"previous7856Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom020": []
},
{
"name": "database-owned",
"id": "com.netcatty.database-minimal",
"version": "0.1.0",
"fileCount": 5,
"bytes": 11660,
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d",
"features": {
"required": [
"network.byte-streams"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
{
"permission": "network.stream",
"resources": [
"tcp://10.77.0.20:3306"
],
"reason": "Connect only to the prepared disposable fixture."
}
]
},
"package": "database-owned.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFromFrozen677": [],
"previous7856Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom020": []
},
{
"name": "database-rfam-readonly",
"id": "com.netcatty.database-rfam-readonly",
"version": "0.1.0",
"fileCount": 5,
"bytes": 11003,
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162",
"features": {
"required": [
"network.byte-streams"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
{
"permission": "network.stream",
"resources": [
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
],
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
}
]
},
"package": "database-rfam-readonly.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFromFrozen677": [],
"previous7856Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom020": []
},
{
"name": "host-actions",
"id": "com.netcatty.host-actions",
"version": "0.1.0",
"fileCount": 3,
"bytes": 4633,
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c",
"features": {
"required": [
"vault.host-actions",
"commands.interactive"
]
},
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"vault.metadata",
"vault.write",
"ui.interact"
]
},
"package": "host-actions.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFromFrozen677": [],
"previous7856Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom020": []
},
{
"name": "terminal-selection-draft",
"id": "com.netcatty.terminal-selection-draft",
"version": "0.1.0",
"fileCount": 2,
"bytes": 2954,
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c",
"features": {
"required": [
"terminal.selection-sharing",
"terminal.input-drafts",
"commands.interactive"
]
},
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"terminal.selection.read",
"terminal.input.propose"
]
},
"package": "terminal-selection-draft.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFromFrozen677": [],
"previous7856Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom020": []
}
]
+160
View File
@@ -0,0 +1,160 @@
# Test the selected Note assistant on an isolated desktop
For the permission-policy/AI acceptance candidate, first use
[its integration record](policy-ai-integration.md) and exact package manifest.
The older permission-dialog sequence below is historical baseline evidence;
apply the current Allow/Ask/Deny semantics when testing the new candidate.
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
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
This replaces the earlier `c7d784d5...` artifact with the sample-only appearance
update. The host provider cold-start correction remains a separate app change.
## 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.
12. Type a synthetic draft and click **Refresh model** while idle. The model
session must reopen, the draft must remain, and the fixture request count
must not increase. Change the selected configured model in host Settings,
refresh again, and check the displayed model before explicitly sending.
Refresh and failed requests must never automatically resend inference.
13. Keep a selected synthetic Note until its five-minute handle expires. Send
must explain that the reference expired without reading text or calling the
fixture. Refresh model must not share the Note again. Click **Choose
reference**, confirm the Note in the host chooser, select its fresh reference
in the dropdown, and click Send yourself. Deny the fresh content permission
once and cancel text review once before allowing a successful fixture run.
The draft must survive all steps, and no request may be silently retried.
14. With the sample idle, switch the host between light and dark and change its
accent. The page, controls, muted surfaces, text, focus ring, and Send button
must follow the existing view environment tokens without model requests or
new permission prompts. Capture both appearances on the same host window.
This check does not change or bypass the separately owned permission policy.
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
panel and actual host service/selected-context authority clocks and advances past
300,000 ms using a real selected Note handle, without network calls. It checks
fresh host selection, old-handle rejection, independent content permission and
exact-text review, and a successful explicit send. The broker budget
regression controls its clock across runtime replacement and the minute boundary.
Do not change the host clock or production limits to reproduce those cases in
native QA.
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 review was initially
blocked; after newer exact maintainer security-task evidence, the same worker
completed its bounded read-only review at `173ef5abb`, with no repeated restriction
or alternate route/model. Two additional P2 findings were reproduced and repaired
in `ee5a4a406` (stable source budgets) and `e1cb95ee8` (sample session recovery).
Correction review independently confirmed the stable source-budget fix, then
identified the remaining selected-Note expiry path at `07eb68c5e`. Commit
`9e7c9df58` repairs that path with explicit reference re-selection and fresh
handles after host confirmation. The real-Note panel and service regressions
failed before repair and pass afterward. Independent verification of this latest
correction remains pending. CI and native evidence do not replace it.
+58
View File
@@ -0,0 +1,58 @@
# Plugin author presentation
The marketplace displays the catalog's author next to a small avatar. Installed
views reuse the author row. Missing, loading, unavailable, or invalid imagery
uses a bundled placeholder. An avatar is presentation metadata, not proof of an
authenticated GitHub publisher.
The registry binds an image to its reviewed displayed-author subject. Repository
ownership alone does not establish that subject. The current first-party author
is the Netcatty project, not an assumed personal GitHub identity. Until an image
pin is reviewed, its avatar remains null.
## Avatar delivery
Delivery v2 extends the reserved `avatar` field to null or
`{path, sha256, size}`. The path is exactly `/assets/<sha256>.png` or `.webp`.
Size is a positive integer no greater than 256KiB. The delivery digest and existing
id/version/repository/commit tuple bind the reference. Catalog and index identity
fields do not change. Source-only delivery v1 remains supported.
Only the main process fetches the selected source's same-origin asset. Existing
URL, credential, redirect, timeout, byte-count, and SHA-256 checks apply. The host
rechecks the source and selection after asynchronous work. The existing isolated
icon rasterizer validates and converts PNG or WebP to a bounded PNG data URI,
using its current queue, pending-work map, and cache. No renderer URL, arbitrary
file path, GitHub API, or GitHub avatar URL is accepted. Visible rows load lazily.
Installed presentation uses already validated catalog metadata only when source,
version, package digest, and manifest match the active installation. It does not
fetch a catalog just because the installed list opens. Without an exact match,
the declared publisher and placeholder remain visible, without a maintenance
badge. No installed-data migration or persistent identity cache is added.
## Official maintenance
The borderless gold pill means that the Netcatty project maintains the plugin.
It does not certify the plugin's safety or grant permissions. It is separate
from the official marketplace source label.
The main process requires the built-in official source and one of these explicit
first-party ID and repository pairs:
- `com.netcatty.starter`, `binaricat/Netcatty`
- `com.netcatty.hello`, `binaricat/Netcatty`
A local import, a third-party copy, an ID prefix, author text, manifest publisher,
README, or membership in the official marketplace cannot create this badge.
The renderer consumes the host result through the existing trusted-app IPC
boundary. No new plugin permission or plugin RPC is introduced.
## Validation scope
Focused registered tests cover source spoofing, exact installed identity,
hash-bound images, invalid metadata and decoding, redirects, source removal,
selection changes, cache reuse, lazy loading, stale UI cleanup, placeholders,
and borderless light/dark theme classes. These tests do not establish native
layout, actual account identity, live CDN deployment, or successful network access.
Actual app screenshots and native acceptance remain separately scheduled.
@@ -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.
+36 -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,15 @@ 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). The original AI
worker later reconsidered the earlier review restriction once with newer
maintainer evidence and completed the review. Published fixes and remaining
verification/native holds are tracked in the current
[acceptance matrix](acceptance-candidate.md). This integration task does not
repeat that review.
## Database example
@@ -106,6 +115,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.
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more