mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 18:32:39 +00:00
Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56fefdd03f | ||
|
|
b7e4abb30b | ||
|
|
f715a0764d | ||
|
|
63f0572e0b | ||
|
|
ea619555ac | ||
|
|
ba4d3fc223 | ||
|
|
18377a1fd9 | ||
|
|
056164157f | ||
|
|
2433cf0130 | ||
|
|
1b21c41fa2 | ||
|
|
89beeb9374 | ||
|
|
02049102b6 | ||
|
|
cfa13680e6 | ||
|
|
79a9bd6df1 | ||
|
|
7856c664c3 | ||
|
|
7d8a354404 | ||
|
|
da4e80946d | ||
|
|
5d2ff1cb86 | ||
|
|
9aa899059f | ||
|
|
2eee561847 | ||
|
|
d33c0193cc | ||
|
|
643e71b17b | ||
|
|
e325742abf | ||
|
|
9ca70f3290 | ||
|
|
092438803b | ||
|
|
677aac33b1 | ||
|
|
24254ed737 | ||
|
|
bbcf9c01c3 | ||
|
|
ba09461676 | ||
|
|
54b6cd401f | ||
|
|
9e7c9df58e | ||
|
|
824c3838e9 | ||
|
|
35b65ef1d7 | ||
|
|
8e7c28fe1b | ||
|
|
07eb68c5e7 | ||
|
|
d81896b0d3 | ||
|
|
e1cb95ee87 | ||
|
|
ee5a4a4067 | ||
|
|
3e5d450a1e | ||
|
|
174c525cd4 | ||
|
|
3cfc7db371 | ||
|
|
6cc7ab9501 | ||
|
|
4cbb34517a | ||
|
|
2278681787 | ||
|
|
31d61e3040 | ||
|
|
52d4af2a86 | ||
|
|
f0897c3c17 | ||
|
|
8d826594d3 | ||
|
|
37df67256e | ||
|
|
110448afb8 | ||
|
|
f1410f8bb1 | ||
|
|
a4999fdf1d | ||
|
|
730fa643ba | ||
|
|
e907336ef5 | ||
|
|
6a4fb178cd | ||
|
|
e6f44460ac | ||
|
|
fffe5a9fca | ||
|
|
7189864c7c | ||
|
|
267bcbfb8b | ||
|
|
17c48b4d79 | ||
|
|
173ef5abbb | ||
|
|
0ba3ea6069 | ||
|
|
06dd46f94f | ||
|
|
739cab2965 | ||
|
|
42a9826603 | ||
|
|
cecfa3a47e | ||
|
|
0a590b4ebc | ||
|
|
da0c79ff0d | ||
|
|
a6e0cb8995 | ||
|
|
69c2cc8d1f | ||
|
|
ccfe41f39a | ||
|
|
bb2fc729b8 | ||
|
|
c29fc95531 | ||
|
|
b0386a5769 | ||
|
|
d5cb8cfd3b | ||
|
|
dc45d03c7b | ||
|
|
58c6a216ac | ||
|
|
9290debe91 | ||
|
|
6cf931ac4d | ||
|
|
c4433d4961 | ||
|
|
264f1d983c | ||
|
|
35e3f9f99f | ||
|
|
4882e0501c | ||
|
|
c3a5f810ce | ||
|
|
dd41f9d8d0 | ||
|
|
6392ac9532 | ||
|
|
afe831bfc5 | ||
|
|
3cdc97782e | ||
|
|
eb2c4b1611 | ||
|
|
392a094297 | ||
|
|
f300d583b2 | ||
|
|
70d58a0cad | ||
|
|
b92177ec42 | ||
|
|
54481b6306 | ||
|
|
7e81bece9e | ||
|
|
04ee9afa7f | ||
|
|
efe5d4530d | ||
|
|
32e75b6ace | ||
|
|
4a831f41c2 | ||
|
|
141148ae0d | ||
|
|
8d15a29bc6 | ||
|
|
fcb6723c3d | ||
|
|
519c24cc4c |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -12,7 +12,9 @@ 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';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
@@ -87,11 +89,13 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<PluginPermissionHost />
|
||||
<PluginHostInteractionHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
<PluginHostActionHost />
|
||||
</SessionPublisher>
|
||||
</VaultPublisher>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -26,6 +26,8 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
|
||||
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
|
||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
||||
import {
|
||||
@@ -856,6 +858,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
|
||||
<PluginAgentHost sessions={sessions} />
|
||||
</div>
|
||||
|
||||
{/* Global "quick add / edit snippet" modal, triggered by the
|
||||
|
||||
@@ -1288,21 +1288,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",
|
||||
@@ -1345,7 +1357,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",
|
||||
@@ -1354,7 +1366,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:",
|
||||
@@ -1406,5 +1418,20 @@ Highlight the focused split pane:
|
||||
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
|
||||
'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.create': 'Review host draft',
|
||||
'plugins.hostActions.edit': 'Review host changes',
|
||||
'plugins.hostActions.connect': 'Confirm connection',
|
||||
'plugins.hostActions.fieldDiff': 'Changes to save',
|
||||
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
|
||||
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
|
||||
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
|
||||
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
|
||||
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
|
||||
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
|
||||
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
|
||||
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
|
||||
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -1288,9 +1288,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.",
|
||||
@@ -1302,7 +1314,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",
|
||||
@@ -1345,7 +1357,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",
|
||||
@@ -1354,7 +1366,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:",
|
||||
@@ -1406,5 +1418,20 @@ Resalta el panel dividido enfocado:
|
||||
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'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.create': 'Revisar borrador de equipo',
|
||||
'plugins.hostActions.edit': 'Revisar cambios del equipo',
|
||||
'plugins.hostActions.connect': 'Confirmar conexión',
|
||||
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
|
||||
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
|
||||
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
|
||||
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
|
||||
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
|
||||
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
|
||||
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
|
||||
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
|
||||
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -1157,6 +1157,14 @@ export const ruCoreMessages: Messages = {
|
||||
"plugins.interaction.share": "Передать плагину",
|
||||
"plugins.interaction.copy": "Копировать в буфер обмена",
|
||||
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
|
||||
"plugins.command.failed": "Не удалось выполнить команду плагина. Проверьте необходимые выбранные объекты и разрешения.",
|
||||
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
|
||||
"plugins.interaction.terminalTarget": "Selected terminal",
|
||||
"plugins.interaction.terminalPreview": "Preview terminal selection",
|
||||
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
|
||||
"plugins.interaction.draft": "Terminal input draft",
|
||||
"plugins.interaction.exactText": "Exact text with escaped controls",
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Соответствует текущему запуску",
|
||||
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
|
||||
"settings.plugins.permissions.title": "Сохранённые разрешения",
|
||||
@@ -1171,7 +1179,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 +1222,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 +1231,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:",
|
||||
@@ -1275,4 +1283,19 @@ export const ruCoreMessages: Messages = {
|
||||
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.continue': 'Продолжить',
|
||||
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
|
||||
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
|
||||
'plugins.hostActions.create': 'Проверить черновик хоста',
|
||||
'plugins.hostActions.edit': 'Проверить изменения хоста',
|
||||
'plugins.hostActions.connect': 'Подтвердить подключение',
|
||||
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
|
||||
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
|
||||
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
|
||||
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
|
||||
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
|
||||
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
|
||||
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
|
||||
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
|
||||
};
|
||||
@@ -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': 'Прокси через хосты',
|
||||
|
||||
@@ -1203,21 +1203,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": "可选",
|
||||
@@ -1260,7 +1272,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": "重试",
|
||||
@@ -1269,7 +1281,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 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
@@ -1321,5 +1333,20 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
||||
'plugins.authentication.continue': '继续',
|
||||
'plugins.hostActions.select': '分享所选主机的元数据',
|
||||
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
|
||||
'plugins.hostActions.create': '预览主机草稿',
|
||||
'plugins.hostActions.edit': '预览主机修改',
|
||||
'plugins.hostActions.connect': '确认连接',
|
||||
'plugins.hostActions.fieldDiff': '即将保存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
|
||||
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
|
||||
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
|
||||
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
|
||||
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
|
||||
|
||||
};
|
||||
@@ -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': '通过主机代理',
|
||||
|
||||
@@ -1206,21 +1206,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": "可選",
|
||||
@@ -1240,7 +1252,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 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
@@ -1288,7 +1300,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": "重試",
|
||||
@@ -1324,4 +1336,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
||||
'plugins.authentication.continue': '繼續',
|
||||
'plugins.hostActions.select': '分享所選主機的中繼資料',
|
||||
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
|
||||
'plugins.hostActions.create': '預覽主機草稿',
|
||||
'plugins.hostActions.edit': '預覽主機修改',
|
||||
'plugins.hostActions.connect': '確認連線',
|
||||
'plugins.hostActions.fieldDiff': '即將儲存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
|
||||
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
|
||||
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
|
||||
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
|
||||
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
|
||||
};
|
||||
@@ -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,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
|
||||
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
|
||||
|
||||
function fixture() {
|
||||
const resources = new PluginHostActionResources();
|
||||
let active = true, authorized = 0, connections = 0, writes = 0;
|
||||
let beforeRead: (() => void) | undefined;
|
||||
let beforeCommit: (() => void) | undefined;
|
||||
let duringAuthorize: (() => void) | undefined;
|
||||
let storedSources: ManagedSource[] | undefined;
|
||||
const vault: PluginHostActionVault = {
|
||||
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
|
||||
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
|
||||
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
|
||||
const nextSources = sources(storedSources ?? vault.managedSources);
|
||||
beforeCommit?.();
|
||||
await authorize?.();
|
||||
writes++; vault.hosts = next;
|
||||
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
resources.observe(vault.hosts);
|
||||
const target = resources.metadata(synthetic.id);
|
||||
const options = {
|
||||
getVault: () => vault, resources,
|
||||
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
|
||||
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
|
||||
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
|
||||
};
|
||||
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
|
||||
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
|
||||
storedSources(value: ManagedSource[]) { storedSources = value; },
|
||||
};
|
||||
}
|
||||
|
||||
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
|
||||
});
|
||||
|
||||
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].password, synthetic.password);
|
||||
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
|
||||
assert.equal(f.vault.hosts[0].label, 'Reviewed');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
|
||||
const f = fixture(); f[point](() => f.cancel());
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
|
||||
const f = fixture();
|
||||
f.beforeRead(() => {
|
||||
if (change === 'remove') f.vault.hosts = [];
|
||||
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
|
||||
if (change === 'scripts') f.vault.snippets = [];
|
||||
if (change === 'locked') f.cancel();
|
||||
});
|
||||
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
|
||||
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
test('create produces a saved SSH draft without executable or credential injection', async () => {
|
||||
const f = fixture();
|
||||
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
|
||||
assert.equal(await executePluginHostAction(action, f.options), 'saved');
|
||||
const created = f.vault.hosts[1];
|
||||
assert.notEqual(created.id, synthetic.id);
|
||||
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
|
||||
});
|
||||
|
||||
const managedSources: ManagedSource[] = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
|
||||
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
|
||||
const f = fixture();
|
||||
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
|
||||
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
|
||||
f.vault.readPersistedHosts = async () => [structuredClone(host)];
|
||||
f.options.resources.observe(f.vault.hosts);
|
||||
const target = f.options.resources.metadata(host.id);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
|
||||
});
|
||||
|
||||
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
const action: PluginHostAction = kind === 'create'
|
||||
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
|
||||
: { kind, target: f.target, draft: { group: 'B/Child' } };
|
||||
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
|
||||
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
|
||||
assert.equal(f.vault.managedSources, managedSources);
|
||||
});
|
||||
|
||||
test('a managed-source catalog replacement during preparation prevents the write', async () => {
|
||||
const f = fixture();
|
||||
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
|
||||
const f = fixture(); f.storedSources(managedSources);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
|
||||
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
|
||||
import type { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { AppVaultRuntime } from './appRuntimeBridge';
|
||||
|
||||
// Application action model. Public RPC types live only in plugin-contract.
|
||||
export type PluginHostAction =
|
||||
| { kind: 'create'; draft: PluginHostDraftFields }
|
||||
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
|
||||
| { kind: 'connect'; target: { resourceId: string; revision: string } };
|
||||
|
||||
export interface PluginHostActionVault {
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
isInitialized: boolean;
|
||||
readPersistedHosts: () => Promise<Host[]>;
|
||||
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
|
||||
}
|
||||
|
||||
export interface PluginHostActionExecution {
|
||||
getVault: () => PluginHostActionVault;
|
||||
resources: PluginHostActionResources;
|
||||
assertCurrent: () => void;
|
||||
authorizeCommit: () => Promise<void>;
|
||||
connect: (host: Host) => unknown;
|
||||
}
|
||||
|
||||
const sameHost = (left: Host, right: Host) => (
|
||||
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
|
||||
);
|
||||
|
||||
/** Execute only a host-reviewed action. authorizeCommit claims separate,
|
||||
* one-use main-process action authority after the final asynchronous work. */
|
||||
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
|
||||
const current = () => {
|
||||
options.assertCurrent();
|
||||
const vault = options.getVault();
|
||||
if (!vault.isInitialized) throw new Error('Vault is unavailable');
|
||||
options.resources.observe(vault.hosts);
|
||||
return vault;
|
||||
};
|
||||
const initialVault = current();
|
||||
const reviewedGroups = initialVault.groupConfigs;
|
||||
const reviewedSnippets = initialVault.snippets;
|
||||
const reviewedSources = initialVault.managedSources;
|
||||
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
|
||||
const assertSelected = () => {
|
||||
const vault = current();
|
||||
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
|
||||
throw new Error('Selected host changed');
|
||||
}
|
||||
// A connection preview includes inherited and global automation. Any
|
||||
// settings change while the preview is open requires fresh confirmation.
|
||||
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
|
||||
return vault;
|
||||
};
|
||||
const persisted = await initialVault.readPersistedHosts();
|
||||
assertSelected();
|
||||
if (selected) {
|
||||
const persistedHost = persisted.find((host) => host.id === selected.id);
|
||||
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
|
||||
}
|
||||
if (action.kind === 'connect') {
|
||||
await options.authorizeCommit();
|
||||
assertSelected();
|
||||
// The existing host connection handler owns authentication, saved scripts,
|
||||
// and sessions. Its return value never reaches the sandboxed plugin.
|
||||
options.connect(selected!);
|
||||
return 'requested';
|
||||
}
|
||||
const nextHost = action.kind === 'create'
|
||||
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
|
||||
: applyPluginHostEdit(selected!, action.draft);
|
||||
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
|
||||
throw new Error('Managed SSH config changes require the Vault host editor');
|
||||
}
|
||||
const nextHosts = action.kind === 'create'
|
||||
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
|
||||
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
|
||||
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
|
||||
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
|
||||
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
|
||||
const keepSources = (sources: ManagedSource[]) => {
|
||||
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
|
||||
return sources;
|
||||
};
|
||||
const result = await initialVault.commitVaultImportTransaction(
|
||||
nextHosts, updateGroups, keepSources,
|
||||
undefined, persisted, undefined,
|
||||
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
|
||||
);
|
||||
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
|
||||
return 'saved';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
|
||||
|
||||
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const metadata = resources.metadata(first.id);
|
||||
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
|
||||
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
|
||||
resources.observe([first]);
|
||||
assert.equal(resources.resolve(first.id, metadata.revision), first);
|
||||
resources.observe([{ ...first }]);
|
||||
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
|
||||
const replaced = resources.metadata(first.id);
|
||||
resources.observe([]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
resources.observe([first]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
});
|
||||
|
||||
test('group choices are explicit current members and never expand to later members', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const selection = [resources.metadata(first.id)];
|
||||
resources.observe([first, { ...first, id: 'synthetic-2' }]);
|
||||
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
|
||||
assert.equal(resources.resolve(first.id, selection[0].revision), first);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
/** Renderer-local revisions track replacement as well as visible edits. They
|
||||
* are never authorization handles; only the main-process authority issues one. */
|
||||
export class PluginHostActionResources {
|
||||
private entries = new Map<string, { host: Host; revision: string }>();
|
||||
|
||||
observe(hosts: readonly Host[]) {
|
||||
const next = new Map<string, { host: Host; revision: string }>();
|
||||
for (const host of hosts) {
|
||||
const existing = this.entries.get(host.id);
|
||||
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
|
||||
}
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
resolve(id: string, expectedRevision: string): Host {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
metadata(id: string) {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) throw new Error('Selected host is unavailable');
|
||||
const host = entry.host;
|
||||
return {
|
||||
resourceId: host.id, revision: entry.revision,
|
||||
label: host.label, hostname: host.hostname, username: host.username,
|
||||
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
|
||||
group: host.group ?? '', tags: [...host.tags],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,88 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TerminalSession } from '../../domain/models';
|
||||
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
|
||||
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
type Choice = { items: PluginAgentHostTerminal[]; confirm: (ids: string[]) => void; cancel: () => void };
|
||||
|
||||
// All inventory remains in trusted application UI. Only confirmed metadata is
|
||||
// passed to the main-owned worker bindings; focus never determines a target.
|
||||
export function usePluginAgentHost(sessions: TerminalSession[]) {
|
||||
const latest = useRef(sessions); latest.current = sessions;
|
||||
const [choice, setChoice] = useState<Choice | null>(null);
|
||||
useEffect(() => {
|
||||
const api = netcattyBridge.get();
|
||||
if (!api?.onPluginAgentHostRequest || !api.setPluginAgentHostReady || !api.respondPluginAgentHost) return;
|
||||
const bridge = { subscribe: api.onPluginAgentHostRequest.bind(api), ready: api.setPluginAgentHostReady.bind(api), respond: api.respondPluginAgentHost.bind(api) };
|
||||
let mounted = true;
|
||||
let chooser: { runtimeId: string; cancel: () => void } | null = null;
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAgentRuntime({
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A target selection is already pending')); return; }
|
||||
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
|
||||
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
|
||||
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
|
||||
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
|
||||
}));
|
||||
const finish = (ids: string[] | null) => {
|
||||
if (chooser?.cancel !== cancel) return;
|
||||
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
|
||||
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
|
||||
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
|
||||
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
|
||||
reject(Error('Selected terminal is no longer connected')); return;
|
||||
}
|
||||
resolve(selected as PluginAgentHostTerminal[]);
|
||||
};
|
||||
const cancel = () => finish(null);
|
||||
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
|
||||
setChoice({ items, confirm: finish, cancel });
|
||||
if (signal.aborted) cancel();
|
||||
}),
|
||||
});
|
||||
const unsubscribe = bridge.subscribe((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
const pending = requests.get(request.payload.requestId);
|
||||
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
|
||||
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
|
||||
}
|
||||
if (request.kind === 'runtime.release') {
|
||||
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
|
||||
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
|
||||
runtime.releaseRuntime(request.runtimeId); return;
|
||||
}
|
||||
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, 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, controller.signal).then(
|
||||
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
|
||||
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
|
||||
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
|
||||
).catch(() => {}).finally(() => requests.delete(request.requestId));
|
||||
});
|
||||
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
|
||||
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
|
||||
const configurationChanged = (event: Event) => {
|
||||
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
|
||||
if (key === null || (key && configurationKeys.has(key))) runtime.invalidateConfiguration();
|
||||
};
|
||||
window.addEventListener('pagehide', pageHide);
|
||||
window.addEventListener('storage', configurationChanged); window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
|
||||
void bridge.ready(true).catch(() => {});
|
||||
return () => {
|
||||
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
|
||||
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
|
||||
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
|
||||
void bridge.ready(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
return { choice };
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppHandlers } from '../app/appHandlersBridge';
|
||||
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
|
||||
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
|
||||
|
||||
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
|
||||
type Review = {
|
||||
prompt: VaultHostPrompt;
|
||||
candidates: Host[];
|
||||
candidateRevisions: Map<string, string>;
|
||||
customGroups: string[];
|
||||
host?: Host;
|
||||
groups: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
scriptsRun: boolean;
|
||||
claimed: boolean;
|
||||
completionToken?: string;
|
||||
};
|
||||
|
||||
export function usePluginHostActions() {
|
||||
const [review, setReview] = useState<Review | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const current = useRef<Review | null>(null);
|
||||
const resources = useRef(new PluginHostActionResources());
|
||||
const responding = useRef(false);
|
||||
|
||||
const getVault = useCallback(() => {
|
||||
const vault = getAppVaultRuntime();
|
||||
const lock = getAppAppLockRuntime();
|
||||
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
|
||||
const actionState = vault.readCurrentHostActionState();
|
||||
resources.current.observe(actionState.hosts);
|
||||
return { ...vault, ...actionState };
|
||||
}, []);
|
||||
|
||||
const assertCurrent = useCallback((entry: Review) => {
|
||||
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
|
||||
const vault = getVault();
|
||||
const target = entry.prompt.vaultTarget;
|
||||
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
|
||||
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
|
||||
}, [getVault]);
|
||||
|
||||
const fail = useCallback(async (entry: Review) => {
|
||||
if (current.current !== entry) return;
|
||||
setError(true);
|
||||
try {
|
||||
await netcattyBridge.get()?.respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
|
||||
});
|
||||
} catch { /* A stopped runtime or closed window already invalidates it. */ }
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(async (entry: Review) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.respondPluginInteraction) return fail(entry);
|
||||
setBusy(true);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const target = entry.prompt.vaultTarget;
|
||||
let action: PluginHostAction;
|
||||
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
|
||||
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
|
||||
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
|
||||
} else throw new Error('Host action is invalid');
|
||||
await executePluginHostAction(action, {
|
||||
getVault, resources: resources.current,
|
||||
assertCurrent: () => assertCurrent(entry),
|
||||
authorizeCommit: async () => {
|
||||
assertCurrent(entry);
|
||||
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
|
||||
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
|
||||
entry.completionToken = receipt.completionToken;
|
||||
entry.claimed = true;
|
||||
assertCurrent(entry);
|
||||
},
|
||||
connect: (host) => {
|
||||
const connect = getAppHandlers()?.handleConnectToHost;
|
||||
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
|
||||
return connect(host);
|
||||
},
|
||||
});
|
||||
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
|
||||
} catch { await fail(entry); }
|
||||
}, [assertCurrent, fail, getVault]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginInteraction) return;
|
||||
const unsubscribe = bridge.onPluginInteraction((event) => {
|
||||
if ('closed' in event) {
|
||||
if (current.current?.prompt.requestId !== event.requestId) return;
|
||||
current.current = null; setReview(null); setBusy(false); responding.current = false;
|
||||
return;
|
||||
}
|
||||
if (!event.request.kind.startsWith('vault.')) return;
|
||||
const prompt = event as VaultHostPrompt;
|
||||
try {
|
||||
if (prompt.phase === 'commit') {
|
||||
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;
|
||||
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
|
||||
void execute(entry);
|
||||
return;
|
||||
}
|
||||
const vault = getVault();
|
||||
const candidates = [...vault.hosts];
|
||||
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
|
||||
const entry: Review = {
|
||||
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
|
||||
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
|
||||
managedSources: vault.managedSources,
|
||||
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
|
||||
claimed: 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(() => {});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
const entry = current.current;
|
||||
current.current = null;
|
||||
unsubscribe();
|
||||
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
|
||||
};
|
||||
}, [execute, fail, getVault]);
|
||||
|
||||
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
|
||||
const entry = current.current;
|
||||
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
|
||||
responding.current = true; setBusy(true); setError(false);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
|
||||
const revision = entry.candidateRevisions.get(id);
|
||||
if (!revision) throw new Error('Host was not part of the current selection');
|
||||
resources.current.resolve(id, revision);
|
||||
return resources.current.metadata(id);
|
||||
}) : undefined;
|
||||
await netcattyBridge.require().respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
|
||||
...(selected === undefined ? {} : { resources: selected }),
|
||||
...(draft === undefined ? {} : { draft }),
|
||||
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
|
||||
});
|
||||
} catch { await fail(entry); }
|
||||
finally {
|
||||
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
|
||||
}
|
||||
}, [assertCurrent, fail, selectedIds]);
|
||||
|
||||
return { review, selectedIds, setSelectedIds, busy, error, respond };
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginHostInteractions() {
|
||||
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
|
||||
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
|
||||
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const current = useRef<HostInteractionPrompt | null>(null);
|
||||
const current = useRef<UiPrompt | null>(null);
|
||||
const pending = useRef(false);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -17,8 +19,18 @@ export function usePluginHostInteractions() {
|
||||
if (current.current?.requestId !== event.requestId) return;
|
||||
current.current = null; setPrompt(null);
|
||||
} else {
|
||||
current.current = event; setPrompt(event);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
|
||||
if (event.request.kind.startsWith('vault.')) return;
|
||||
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);
|
||||
});
|
||||
@@ -36,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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -776,6 +776,13 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
}, [waitForPendingVaultWrites]);
|
||||
|
||||
// Host-owned action guards need the synchronous mutation snapshot, including
|
||||
// edits that React has not published yet. This accessor never crosses RPC.
|
||||
const readCurrentHostActionState = useCallback(() => ({
|
||||
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
|
||||
managedSources: managedSourcesRef.current,
|
||||
}), []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setKeys(cleaned);
|
||||
@@ -1248,6 +1255,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
lock?: VaultLockHandle | null,
|
||||
assertAuthorized?: () => void | Promise<void>,
|
||||
): Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
@@ -1322,6 +1330,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
encryptHosts(cleanedHosts),
|
||||
encryptGroupConfigs(nextGroupConfigs),
|
||||
]);
|
||||
// Optional plugin draft guard: runtime/source/revoke/lock authority must
|
||||
// still hold after encryption, immediately before the existing atomic
|
||||
// persistence boundary. Ordinary imports retain their existing behavior.
|
||||
if (assertAuthorized) await assertAuthorized();
|
||||
if (
|
||||
version !== hostsWriteVersion.current
|
||||
|| groupConfigsVersion !== groupConfigsWriteVersion.current
|
||||
@@ -1570,6 +1582,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
sshInputCompatibility: false,
|
||||
order: getNextVaultOrder(hosts),
|
||||
};
|
||||
|
||||
@@ -2519,6 +2532,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
readPersistedHosts,
|
||||
readCurrentHostActionState,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
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('installed global Chat UI reuses actual Catty runtime, configured HTTP transport and selected worker dispatch', { timeout: 30_000 }, async () => {
|
||||
// This Node/JSDOM harness proves installed app wiring, not a native sandbox or
|
||||
// real model/Claude acceptance. No real shell, remote host or credentials.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-global-agent-ui-'));
|
||||
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = directory;
|
||||
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 { PluginAgentHost } = await import('./plugins/PluginAgentHost');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
|
||||
const keys = await import('../infrastructure/config/storageKeys');
|
||||
const { createGlobalAgentProviderFixture } = await import('../scripts/plugin-global-agent-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 { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { registerCattyExecHandlers } = require('../electron/bridges/aiBridge/cattyExecHandlers.cjs');
|
||||
const { registerAgentProcessHandlers } = require('../electron/bridges/aiBridge/agentProcessHandlers.cjs');
|
||||
const { registerPrivacyHandlers } = require('../electron/bridges/aiBridge/privacyHandlers.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs'); const mcp = require('../electron/bridges/mcpServerBridge.cjs');
|
||||
const privacy = require('../electron/shared/aiPrivacyService.cjs');
|
||||
const { server, counts } = createGlobalAgentProviderFixture({ delayMs: 30 });
|
||||
const oldLock = getAppAppLockRuntime(); const root = createRoot(document.getElementById('root')!);
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined; let panel: JSDOM | undefined;
|
||||
const errors: string[] = []; const originalError = console.error;
|
||||
let phase = 'setup';
|
||||
// Flush the known Node sqlite import warning before observing safe categories.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
console.error = (value: unknown) => { errors.push(`${phase}:${typeof value === 'string' && value === '[Plugin Agent] Turn unavailable' ? 'DELEGATED_TURN_UNAVAILABLE' : typeof value === 'string' && /not wrapped in act/.test(value) ? 'REACT_ACT_WARNING' : 'SDK_OR_RENDERER_ERROR'}`); };
|
||||
try {
|
||||
privacy.configure({ enabled: false }); 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 handlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
const ipcMain = { handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler), removeHandler: (name: string) => handlers.delete(name) };
|
||||
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
|
||||
const trustedReplies: unknown[] = []; const pluginReplies: unknown[] = []; const workerCalls: string[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, structuredClone(payload)) });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const owner = (id: string) => id === 'synthetic-a' ? 7 : id === 'synthetic-b' ? 8 : null;
|
||||
const worker = { getSessionOwnerWebContentsId: owner, getPluginSessionBinding: (id: string, actual: number) => actual === owner(id) ? { epoch: 'fixture-worker', generation: id === 'synthetic-a' ? 1 : 2 } : null,
|
||||
send: () => {}, request: async (method: string, payload: { sessionId: string }, options?: { beforeExecute?: () => void }) => {
|
||||
options?.beforeExecute?.(); if (method === 'netcatty:ai:exec') { workerCalls.push(payload.sessionId); return { ok: true, stdout: 'Synthetic worker output', stderr: '', exitCode: 0 }; } return [];
|
||||
} };
|
||||
mcp.init({ sessions: new Map(), terminalWorkerManager: worker, cliDiscoveryFilePath: path.join(directory, 'discovery.json') }); mcp.setPermissionMode('observer');
|
||||
const common = { ipcMain, validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender, mcpServerBridge: mcp, aiPrivacyService: privacy };
|
||||
const providerContext = { ...common, require: createRequire(path.join(rootPath, 'electron/bridges/aiBridge.cjs')),
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const provider = providerContext.providerConfigs.find((entry) => entry.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, value.replace('__IPC_SECURED__', provider?.apiKey ?? '')])) };
|
||||
}, shouldSkipTLSVerify: () => false, streamRequest: aiBridge._streamRequestForTests,
|
||||
// The production stream function owns this map. Turn release below uses
|
||||
// its actual main lease; direct SDK abort still calls the ordinary route.
|
||||
activeStreams: new Map() };
|
||||
registerProviderHandlers(providerContext); registerCattyExecHandlers(common); registerAgentProcessHandlers(common); registerPrivacyHandlers(common);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { requestId: string; url: string }; assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.match(request.requestId, /^agent-model_/);
|
||||
}
|
||||
const handler = handlers.get(channel); assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:plugins:agent-host-response') trustedReplies.push(payload);
|
||||
return result;
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} }); Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'fixture-provider', name: 'Synthetic fixture', providerId: 'custom', enabled: true,
|
||||
baseURL: endpoint, defaultModel: 'global-agent-fixture', apiKey: 'FIXTURE_ONLY_API_MARKER' }]));
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_PROVIDER, 'fixture-provider');
|
||||
service = createPluginHostService({ app: { getAppPath: () => rootPath, getPath: () => directory, getVersion: () => '0.0.0' }, env: {},
|
||||
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
|
||||
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
|
||||
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(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
|
||||
{ id: 'synthetic-a', hostId: 'host-a', hostLabel: 'Workspace A fixture', hostname: 'a.invalid', username: 'fixture', status: 'connected' },
|
||||
{ id: 'synthetic-b', hostId: 'host-b', hostLabel: 'Workspace B fixture', hostname: 'b.invalid', username: 'fixture', status: 'connected' },
|
||||
]} /></I18nProvider>));
|
||||
const waitFor = async (condition: () => boolean) => { const until = Date.now() + 8_000; while (!condition()) {
|
||||
if (Date.now() >= until) throw Error(`Global Agent UI fixture timed out; accepted=${counts.requests}, rejected=${counts.rejected}, effects=${workerCalls.length}, status=${panel?.window.document.getElementById('status')?.textContent}`);
|
||||
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 (name: string, params: unknown) => {
|
||||
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
|
||||
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
const element = (id: string) => panel!.window.document.getElementById(id)!;
|
||||
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
|
||||
await act(async () => { for (const checkbox of document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) checkbox.click(); });
|
||||
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
|
||||
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
|
||||
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
|
||||
const prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic selected-target task';
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
phase = 'complete'; await act(async () => { send(); }); await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.deepEqual(workerCalls, ['synthetic-b']); assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
|
||||
assert.equal(mcp.getPermissionMode(), 'observer'); assert.equal(counts.requests, 2); assert.equal(counts.rejected, 0);
|
||||
assert.match(element('usage').textContent!, /input tokens/);
|
||||
phase = 'stop'; prompt.value = 'fixture:wait'; await act(async () => { send(); });
|
||||
await waitFor(() => element('transcript').textContent!.includes('Synthetic waiting output'));
|
||||
await act(async () => element('stop').click()); await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stopped = element('transcript').textContent; await new Promise((resolve) => setTimeout(resolve, 75)); assert.equal(element('transcript').textContent, stopped);
|
||||
phase = 'deny'; const before = counts.requests; const plugin = service.database.getActivePlugin('com.netcatty.globalagent');
|
||||
service.permissionEngine.setMode(plugin, 'ai.agent.actions', 'deny', service.permissionEngine.getPolicyRevision(plugin));
|
||||
await act(async () => { send(); }); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, before); assert.deepEqual(workerCalls, ['synthetic-b']);
|
||||
phase = 'restart-history';
|
||||
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
|
||||
await service.manager.setEnabled('com.netcatty.globalagent', false);
|
||||
await service.manager.setEnabled('com.netcatty.globalagent', true);
|
||||
await act(async () => element('refresh').click()); await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
const conversation = element('conversation') as HTMLSelectElement; conversation.value = reference.conversationRef;
|
||||
await act(async () => conversation.dispatchEvent(new panel!.window.Event('change')));
|
||||
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
|
||||
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
|
||||
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
|
||||
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
|
||||
assert.match(element('transcript').textContent!, /Synthetic global Agent result/, 'owned core history survives runtime restart');
|
||||
prompt.value = 'Synthetic model-only continuation'; await act(async () => { send(); });
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(counts.requests, before + 1, 'Actions Deny allows a tool-free configured model turn after reopening');
|
||||
assert.deepEqual(workerCalls, ['synthetic-b']);
|
||||
assert.doesNotMatch(JSON.stringify(pluginReplies), /FIXTURE_ONLY_API_MARKER|apiKey|externalSessionId|plugin-agent:|opaque-owner|mcpServers|permissionMode/);
|
||||
assert.doesNotMatch(JSON.stringify(trustedReplies), /FIXTURE_ONLY_API_MARKER|apiKey/);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
|
||||
await act(async () => root.unmount()); await service?.manager.shutdown();
|
||||
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
|
||||
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, cp, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import type { ConnectionLog, Host, ManagedSource } from '../domain/models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import type { HostInteractionEvent, HostInteractionPrompt, VaultHostSelectionResult, VaultHostActionResult, VaultHostRef } from '@netcatty/plugin-contract';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('installed sample entrypoint uses real SDK, source authority, signed host dialogs and renderer action guards', { timeout: 45_000 }, async (t) => {
|
||||
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', 'HTMLButtonElement', 'Element', 'SVGElement', '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);
|
||||
install('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginHostActionHost } = await import('./plugins/PluginHostActionHost');
|
||||
const { handleConnectToHostImpl } = await import('../application/app/AppHandlers');
|
||||
const runtime = await import('../application/state/appRuntimeBridge');
|
||||
const { registerAppHandlers } = await import('../application/app/appHandlersBridge');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'netcatty-host-entry-'));
|
||||
const listeners = new Set<(event: unknown) => void>();
|
||||
const prompts: HostInteractionPrompt[] = [];
|
||||
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;
|
||||
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: (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();
|
||||
service.interactionService.clock = () => requestClock;
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
onPluginInteraction(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
|
||||
respondPluginInteraction: (response: unknown) => service.interactionService.respond(sender, response),
|
||||
} });
|
||||
const first = sanitizeHost({ id: 'synthetic-first', label: 'Synthetic first', hostname: 'first.invalid', username: 'test', port: 22, protocol: 'ssh', os: 'linux', tags: ['synthetic'], group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script' });
|
||||
const second = sanitizeHost({ ...first, id: 'synthetic-second', label: 'Synthetic second', hostname: 'second.invalid', password: undefined, startupCommand: undefined });
|
||||
let hosts: Host[] = [first, second];
|
||||
let persisted = hosts.map((host) => structuredClone(host));
|
||||
let beforeCommit: (() => Promise<void>) | undefined;
|
||||
let saves = 0;
|
||||
const connections: Host[] = [];
|
||||
const vault = {
|
||||
hosts, customGroups: ['Synthetic'], groupConfigs: [], snippets: [], managedSources: [] as ManagedSource[], isInitialized: true,
|
||||
readCurrentHostActionState: () => ({ hosts, groupConfigs: vault.groupConfigs, snippets: vault.snippets, managedSources: vault.managedSources }),
|
||||
async readPersistedHosts() { return persisted.map((host) => structuredClone(host)); },
|
||||
async commitVaultImportTransaction(next: Host[], groups: (groups: string[]) => string[], sources: (sources: ManagedSource[]) => ManagedSource[], _configs: unknown, _expected: unknown, _lock: unknown, authorize: () => Promise<void>) {
|
||||
const nextSources = sources(vault.managedSources);
|
||||
await beforeCommit?.();
|
||||
await authorize();
|
||||
saves++; hosts = next; persisted = next.map((host) => structuredClone(host));
|
||||
return { status: 'persisted', groups: groups(vault.customGroups), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
const publish = () => {
|
||||
runtime.registerAppVaultRuntime(vault as never);
|
||||
runtime.registerAppAppLockRuntime({ initialized: true, locked } as never);
|
||||
};
|
||||
publish();
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const waitFor = async (predicate: () => boolean) => {
|
||||
for (let count = 0; count < 80; count++) { await act(async () => { await settle(); }); if (predicate()) return; }
|
||||
assert.fail(`Expected host UI state; current text: ${document.body.textContent}`);
|
||||
};
|
||||
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
|
||||
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
|
||||
const begin = async (name: string) => {
|
||||
requestClock += 60_001;
|
||||
let promise!: Promise<VaultHostSelectionResult | VaultHostActionResult>;
|
||||
await act(async () => { promise = service.contributionService.executeCommand(`com.netcatty.host-actions.${name}`); void promise.catch(() => {}); await settle(); });
|
||||
await waitFor(() => document.querySelector('[role="dialog"]') !== null);
|
||||
return { promise };
|
||||
};
|
||||
const chooseGroup = async () => {
|
||||
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
|
||||
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
|
||||
};
|
||||
try {
|
||||
await service.manager.initialize();
|
||||
const packageRoot = path.join(directory, 'source');
|
||||
await cp(new URL('../examples/plugins/host-actions/', import.meta.url), packageRoot, { recursive: true });
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
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>));
|
||||
|
||||
let selectedTarget: VaultHostRef;
|
||||
await t.test('group selection shares only explicit current members and grants no connection', async () => {
|
||||
const run = await begin('select');
|
||||
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
|
||||
hosts = [...hosts, sanitizeHost({ ...second, id: 'synthetic-future', hostname: 'future.invalid' })];
|
||||
persisted = hosts.map((host) => structuredClone(host));
|
||||
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
|
||||
const result = await run.promise;
|
||||
assert.ok('hosts' in result);
|
||||
assert.equal(result.hosts.length, 2); assert.equal(result.groups[0].members.length, 2);
|
||||
assert.equal(result.hosts.some((host) => host.metadata.hostname === 'future.invalid'), false);
|
||||
assert.equal(result.hosts[0].target.scope, 'metadata');
|
||||
selectedTarget = result.hosts[0].target;
|
||||
for (const text of ['synthetic-password', 'synthetic-script', 'resourceId', 'private-session-id']) assert.equal(JSON.stringify(result).includes(text), false);
|
||||
assert.equal(connections.length, 0); assert.equal(saves, 0);
|
||||
});
|
||||
|
||||
await t.test('registered boundaries reject AI text, caller owners, tampered targets and stale expected revisions', async () => {
|
||||
const identity = service.runtimeSupervisor.getRuntimeIdentity('com.netcatty.host-actions');
|
||||
const routes = service.rpcRegistry.createRoutes({ ...identity, manifest: service.database.getActivePlugin(identity.pluginId).manifest });
|
||||
const transport = { signal: new AbortController().signal };
|
||||
for (const target of [
|
||||
{ handle: 'text-only', version: 'v1', scope: 'host' },
|
||||
{ ...selectedTarget, handle: 'synthetic-tampered-selection-handle-0000' },
|
||||
]) await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target, expectedRevision: selectedTarget.revision }, transport), /invalid|expired|changed/);
|
||||
await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target: selectedTarget, expectedRevision: 'stale' }, transport), /expired|changed/);
|
||||
await assert.rejects(routes.requestHandlers['vault.requestHostDraft']({ privateOwner: 'spoof', draft: { hostname: 'synthetic.invalid' } }, transport), /invalid/);
|
||||
assert.equal(service.interactionService.pending.size, 0); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('cancel creates no draft or connection', async () => {
|
||||
const run = await begin('create'); await click(button('Cancel'));
|
||||
assert.deepEqual(await run.promise, { cancelled: true }); assert.equal(saves, 0); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('reviewed create saves only the synthetic SSH draft', async () => {
|
||||
const run = await begin('create');
|
||||
assert.match(document.body.textContent ?? '', /Changes to save/);
|
||||
await click(button('Save Host'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'saved' });
|
||||
assert.equal(saves, 1);
|
||||
const saved = hosts.find((host) => host.hostname === 'synthetic.invalid')!;
|
||||
assert.equal(saved.protocol, 'ssh'); assert.equal(saved.password, undefined); assert.equal(saved.startupCommand, undefined);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace'] as const) await t.test(`${change} after selection prevents a stale edit`, async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => document.body.textContent?.includes('Changes to save') === true);
|
||||
hosts = change === 'remove' ? [second] : [{ ...first }, second];
|
||||
await click(button('Save Host'));
|
||||
await assert.rejects(run.promise, /changed|cancelled|complete/);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('connection confirmation discloses saved scripts before dispatch and returns no session', async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const run = await begin('connect'); await chooseGroup();
|
||||
await waitFor(() => document.body.textContent?.includes('Saved startup, connect or output scripts may run') === true);
|
||||
assert.equal(connections.length, 0); assert.equal(document.body.textContent?.includes('synthetic-script'), false);
|
||||
await click(button('Connect'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
|
||||
assert.equal(connections.length, 1); assert.equal(connections[0], first);
|
||||
});
|
||||
|
||||
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(); for (const listener of lockListeners) listener({ locked: true }); await settle(); });
|
||||
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
|
||||
locked = false; publish();
|
||||
});
|
||||
|
||||
await t.test('revoke during asynchronous preparation prevents the write', async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
let release!: () => void; let preparing = false;
|
||||
beforeCommit = async () => { preparing = true; await new Promise<void>((resolve) => { release = resolve; }); };
|
||||
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.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 () => {
|
||||
const run = await begin('create');
|
||||
const original = service.database.getRetainedPluginSource;
|
||||
service.database.getRetainedPluginSource = () => ({ id: 'changed-source', kind: 'local', origin: null });
|
||||
try { await click(button('Save Host')); await assert.rejects(run.promise, /source binding/); }
|
||||
finally { service.database.getRetainedPluginSource = original; }
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
});
|
||||
|
||||
await t.test('managed A to B stays in the Vault editor and cannot acquire file-write effects', async () => {
|
||||
const candidate = sanitizeHost({ ...first, group: 'A', managedSourceId: 'synthetic-source-a' });
|
||||
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
|
||||
vault.managedSources = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined);
|
||||
const group = Array.from(document.querySelectorAll('label')).find((label) => label.querySelector('span')?.textContent === 'Group')?.querySelector('input');
|
||||
assert.ok(group);
|
||||
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
await act(async () => { setValue.call(group, 'B'); group.dispatchEvent(new dom.window.Event('input', { bubbles: true })); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /"A"\s*\u2192\s*"B"/u);
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
|
||||
assert.equal(button('Save Host').disabled, true);
|
||||
await click(button('Save Host')); assert.equal(saves, 1);
|
||||
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
|
||||
assert.equal(hosts[0].group, 'A'); assert.equal(hosts[0].managedSourceId, 'synthetic-source-a');
|
||||
vault.managedSources = [];
|
||||
});
|
||||
|
||||
await t.test('creating into a managed group is explicitly unavailable in the small draft UI', async () => {
|
||||
vault.managedSources = [{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'Samples', lastSyncedAt: 0 }];
|
||||
const run = await begin('create');
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
|
||||
assert.equal(button('Save Host').disabled, true);
|
||||
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
|
||||
assert.equal(saves, 1); vault.managedSources = [];
|
||||
});
|
||||
|
||||
for (const transport of [
|
||||
{ name: 'Mosh', expected: 'mosh', overrides: { moshEnabled: true } },
|
||||
{ name: 'ET', expected: 'et', overrides: { etEnabled: true } },
|
||||
{ name: 'implicit localhost shell', expected: 'local', overrides: { hostname: 'localhost' } },
|
||||
] as const) await t.test(`${transport.name} confirmation matches the real connection handler`, async () => {
|
||||
const candidate = sanitizeHost({ ...second, id: `synthetic-${transport.expected}`, label: `Synthetic ${transport.expected}`, ...transport.overrides });
|
||||
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const dispatched: Host[] = [];
|
||||
const logs: Array<Pick<ConnectionLog, 'protocol'>> = [];
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => handleConnectToHostImpl(() => ({
|
||||
resolveEffectiveHost: (saved: Host) => applyGroupDefaults(saved, saved.group ? resolveGroupDefaults(saved.group, vault.groupConfigs) : {}),
|
||||
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
|
||||
systemInfoRef: { current: { username: 'synthetic-local-user', hostname: 'synthetic-device' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'synthetic-user' }),
|
||||
connectToHost: (resolved: Host) => { dispatched.push(resolved); return 'synthetic-transport-session'; },
|
||||
addConnectionLog: (entry: Pick<ConnectionLog, 'protocol'>) => logs.push(entry),
|
||||
}), host) });
|
||||
const run = await begin('connect');
|
||||
try {
|
||||
await chooseGroup(); await waitFor(() => button('Connect') !== undefined);
|
||||
const confirmation = document.querySelector('[role="dialog"]')?.textContent ?? '';
|
||||
assert.match(confirmation, new RegExp(`\\(${transport.expected}\\)`));
|
||||
assert.equal(confirmation.includes('Local execution: this opens a shell on this device.'), transport.expected === 'local');
|
||||
if (transport.expected === 'local') assert.doesNotMatch(confirmation, /test@localhost:22/u);
|
||||
assert.equal(dispatched.length, 0);
|
||||
await click(button('Connect'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
|
||||
assert.equal(dispatched.length, 1);
|
||||
assert.equal(logs[0].protocol, transport.expected);
|
||||
} finally {
|
||||
if (button('Cancel')) await click(button('Cancel'));
|
||||
await run.promise.catch(() => {});
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
|
||||
}
|
||||
});
|
||||
|
||||
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, 2);
|
||||
});
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
await service.manager.shutdown();
|
||||
runtime.registerAppVaultRuntime(null); runtime.registerAppAppLockRuntime(null); registerAppHandlers(null);
|
||||
dom.window.close(); await rm(directory, { 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,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { PluginHostDraftPreview } from './plugins/PluginHostDraftPreview';
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
|
||||
import type { Host } from '../domain/models';
|
||||
import type { PluginHostDraftFields } from '../domain/pluginHostActions';
|
||||
|
||||
test('host-owned draft preview shows a field diff and submits only reviewed editable fields', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const host: Host = { id: 'synthetic', label: 'Before', hostname: 'host.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-password', startupCommand: 'synthetic-script' };
|
||||
const submitted: PluginHostDraftFields[] = [];
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview before={host} draft={{ label: 'After' }} busy={false} onConfirm={(draft) => submitted.push(draft)} /></I18nProvider>);
|
||||
assert.equal(dom.document.querySelectorAll('input').length, 3);
|
||||
assert.match(renderer.container.textContent ?? '', /Before.*After/u);
|
||||
assert.equal(renderer.container.innerHTML.includes('synthetic-password'), false);
|
||||
assert.equal(renderer.container.innerHTML.includes('synthetic-script'), false);
|
||||
const save = dom.document.querySelector('button');
|
||||
assert.ok(save);
|
||||
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.deepEqual(submitted, [{ label: 'After', group: '', tags: [] }]);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('invalid create draft cannot be confirmed', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview draft={{ hostname: 'invalid host' }} busy={false} onConfirm={() => { throw new Error('Invalid draft was submitted'); }} /></I18nProvider>);
|
||||
assert.ok(dom.document.querySelector('[role="alert"]'));
|
||||
const save = dom.document.querySelector('button');
|
||||
assert.ok(save?.disabled);
|
||||
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface SelectHostPanelContentProps {
|
||||
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onConfirm: () => void;
|
||||
confirmDisabled?: boolean;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
@@ -79,6 +80,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onConfirm,
|
||||
confirmDisabled = false,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
@@ -643,7 +645,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={selectedHostIds.length === 0}
|
||||
disabled={confirmDisabled || selectedHostIds.length === 0}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{multiSelect
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { TerminalSession } from '../../domain/models';
|
||||
import { usePluginAgentHost } from '../../application/state/usePluginAgentHost';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
export function PluginAgentHost({ sessions }: { sessions: TerminalSession[] }) {
|
||||
const { choice } = usePluginAgentHost(sessions);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
|
||||
<DialogContent data-host-security-surface="" onOpenAutoFocus={() => setSelected([])}>
|
||||
<DialogHeader><DialogTitle>Choose terminals for this Agent conversation</DialogTitle>
|
||||
<DialogDescription>The conversation is independent of the active tab or workspace. Only the terminals selected here are eligible targets. Tool actions follow this plugin's Allow / Ask / Deny setting.</DialogDescription></DialogHeader>
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto">
|
||||
{choice?.items.map((item) => <label key={item.sessionId} className="flex gap-2 text-sm">
|
||||
<input type="checkbox" checked={selected.includes(item.sessionId)} disabled={selected.length >= 32 && !selected.includes(item.sessionId)}
|
||||
onChange={(event) => setSelected((ids) => event.target.checked ? [...ids, item.sessionId] : ids.filter((id) => id !== item.sessionId))} />
|
||||
<span>{item.label} <span className="text-muted-foreground">{item.hostname}</span></span>
|
||||
</label>)}
|
||||
{choice?.items.length === 0 && <p className="text-sm text-muted-foreground">No connected terminals. You can continue without targets.</p>}
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
|
||||
<Button onClick={() => choice?.confirm(selected)}>Use selected terminals</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { usePluginHostActions } from '../../application/state/usePluginHostActions';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { SelectHostPanelContent } from '../SelectHostPanelContent';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { PluginHostDraftPreview } from './PluginHostDraftPreview';
|
||||
|
||||
export function PluginHostActionHost() {
|
||||
const { t } = useI18n();
|
||||
const state = usePluginHostActions();
|
||||
const entry = state.review;
|
||||
if (!entry) return null;
|
||||
const request = entry.prompt.request;
|
||||
const selection = request.kind === 'vault.selectHosts';
|
||||
const connection = request.kind === 'vault.requestConnect';
|
||||
const effective = entry.host ? applyGroupDefaults(entry.host, entry.host.group ? resolveGroupDefaults(entry.host.group, entry.groups) : {}) : undefined;
|
||||
const protocol = effective ? resolveEffectiveTerminalProtocol(effective) : undefined;
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
|
||||
<DialogContent className={selection ? 'max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden' : 'sm:max-w-[560px] max-h-[85vh] overflow-y-auto'} hideCloseButton>
|
||||
<DialogHeader className={selection ? 'px-4 pt-4 pb-2 shrink-0 border-b border-border/60' : undefined}>
|
||||
<DialogTitle>{t(selection ? 'plugins.hostActions.select' : connection ? 'plugins.hostActions.connect' : entry.host ? 'plugins.hostActions.edit' : 'plugins.hostActions.create')}</DialogTitle>
|
||||
<DialogDescription className="break-all">{entry.prompt.pluginId} · {entry.prompt.pluginVersion}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selection ? <>
|
||||
<p className="px-4 pt-3 text-xs text-muted-foreground">{t('plugins.hostActions.selectionNote')}</p>
|
||||
<p className="px-4 pt-2 text-sm font-medium break-words">{request.title}</p>
|
||||
{state.selectedIds.length > 50 && <p role="alert" className="px-4 text-sm text-destructive">{t('plugins.hostActions.selectionLimit')}</p>}
|
||||
<SelectHostPanelContent hosts={entry.candidates} customGroups={entry.customGroups} selectedHostIds={state.selectedIds} multiSelect className="flex-1 min-h-0" confirmDisabled={state.busy || state.selectedIds.length > 50} onSelect={(host) => state.setSelectedIds((ids) => ids.includes(host.id) ? ids.filter((id) => id !== host.id) : [...ids, host.id])} onSelectionChange={state.setSelectedIds} onConfirm={() => { if (!state.busy && state.selectedIds.length <= 50) void state.respond('submit'); }} />
|
||||
</> : connection && effective ? <>
|
||||
<p className="break-all text-sm font-medium">{effective.label}</p>
|
||||
<p className="break-all text-sm">{protocol !== 'local' && effective.username ? `${effective.username}@` : ''}{effective.hostname}{protocol !== 'local' && effective.port ? `:${effective.port}` : ''} ({protocol})</p>
|
||||
{protocol === 'local' && <p className="text-sm font-medium" role="note">{t('plugins.hostActions.localExecution')}</p>}
|
||||
<p className="text-sm" role="note">{t(entry.scriptsRun ? 'plugins.hostActions.scriptsRun' : 'plugins.hostActions.noScripts')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.connectionNote')}</p>
|
||||
</> : request.kind === 'vault.requestHostDraft' ? <PluginHostDraftPreview key={entry.prompt.requestId} before={entry.host} draft={request.draft} managedSources={entry.managedSources} busy={state.busy} onConfirm={(draft) => void state.respond('submit', draft)} /> : null}
|
||||
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.failed')}</p>}
|
||||
<DialogFooter className={selection ? 'p-3 border-t' : undefined}>
|
||||
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
|
||||
{connection && <Button disabled={state.busy} onClick={() => void state.respond('submit')}>{t('common.connect')}</Button>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, pluginHostFieldDiff, PLUGIN_HOST_CREATE_FIELDS, PLUGIN_HOST_EDIT_FIELDS, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
/** Host-owned editable draft and exact normalized field diff. The containing
|
||||
* dialog owns confirmation, expiry and cancellation; this body has no IPC. */
|
||||
export function PluginHostDraftPreview({ before, draft, busy, onConfirm, managedSources = [] }: {
|
||||
before?: Host;
|
||||
draft: PluginHostDraftFields;
|
||||
busy: boolean;
|
||||
onConfirm: (draft: PluginHostDraftFields) => void;
|
||||
managedSources?: ManagedSource[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [fields, setFields] = useState<Record<string, string>>(() => ({
|
||||
label: draft.label ?? before?.label ?? '',
|
||||
hostname: draft.hostname ?? '', username: draft.username ?? '',
|
||||
port: String(draft.port ?? 22), group: draft.group ?? before?.group ?? '',
|
||||
tags: (draft.tags ?? before?.tags ?? []).join(', '),
|
||||
}));
|
||||
const preview = useMemo(() => {
|
||||
const value = {
|
||||
label: fields.label, group: fields.group,
|
||||
tags: fields.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
...(!before ? { hostname: fields.hostname, username: fields.username, port: Number(fields.port) } : {}),
|
||||
};
|
||||
try {
|
||||
const after = before ? applyPluginHostEdit(before, value) : normalizePluginHostDraft(value, 'preview');
|
||||
return { value: {
|
||||
label: after.label, group: after.group ?? '', tags: after.tags,
|
||||
...(!before ? { hostname: after.hostname, username: after.username, port: after.port } : {}),
|
||||
}, diff: pluginHostFieldDiff(before, after), managed: pluginHostDraftTouchesManagedSource(after, managedSources) };
|
||||
} catch { return null; }
|
||||
}, [before, fields, managedSources]);
|
||||
return <div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS).map((field) => <label key={field} className="space-y-1 text-sm">
|
||||
<span>{t(`hostForm.field.${field}`)}</span>
|
||||
<Input value={fields[field]} type={field === 'port' ? 'number' : 'text'} min={field === 'port' ? 1 : undefined} max={field === 'port' ? 65535 : undefined} maxLength={field === 'tags' ? 32_896 : 1024} autoComplete="off" disabled={busy} onChange={(event) => setFields((previous) => ({ ...previous, [field]: event.target.value }))} />
|
||||
</label>)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.defaultsWarning')}</p>
|
||||
{preview?.managed && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.managedUnsupported')}</p>}
|
||||
{preview ? <div className="max-h-48 overflow-auto rounded-md border p-3">
|
||||
<p className="mb-2 text-sm font-medium">{t('plugins.hostActions.fieldDiff')}</p>
|
||||
<dl className="space-y-2 text-xs">
|
||||
{preview.diff.map((change) => <div key={change.field} className="break-all">
|
||||
<dt className="font-medium">{t(`hostForm.field.${change.field}`)}</dt>
|
||||
<dd>{JSON.stringify(change.before)} → {JSON.stringify(change.after)}</dd>
|
||||
</div>)}
|
||||
</dl>
|
||||
</div> : <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.invalidDraft')}</p>}
|
||||
<Button className="w-full" disabled={busy || !preview || preview.managed || preview.diff.length === 0} onClick={() => { if (preview && !preview.managed) onConfirm(preview.value); }}>{t('hostForm.saveHost')}</Button>
|
||||
</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,42 @@
|
||||
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?.action && <div className="space-y-1">
|
||||
<p className="text-sm">{zh ? '请求内容(控制字符以转义形式显示)' : 'Requested action (control characters shown as escapes)'}</p>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border p-2 text-xs">{prompt.displayContext.action.text}</pre>
|
||||
</div>}
|
||||
{(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>;
|
||||
}
|
||||
@@ -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/);
|
||||
|
||||
@@ -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, /<script>/);
|
||||
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,8 @@ import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge'
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
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>
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,309 @@
|
||||
# Plugin AI and selected context: implementation checkpoint
|
||||
|
||||
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
Branch: `feat/plugin-ai-context`.
|
||||
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.
|
||||
|
||||
## Public boundaries
|
||||
|
||||
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 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
|
||||
expected version. Summary enumeration never calls the content reader. Supported
|
||||
selected scopes are workspace, tab, session, host, and Note. There is no inventory
|
||||
or arbitrary entity-ID lookup.
|
||||
|
||||
## File ownership
|
||||
|
||||
| Owner | Files or responsibility |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
||||
The existing `feat/plugin-terminal-sharing` commit
|
||||
`3cdc97782ef3262969eee9d540e03ebe6fdee210` is preserved. Its
|
||||
`terminal-sharing-wip.md` describes incomplete selection publication and review.
|
||||
This branch does not claim that workflow is complete.
|
||||
|
||||
## Implemented authority
|
||||
|
||||
`PluginAISourceBinding` authenticates the current browser runtime before reading
|
||||
`getActivePlugin().source`, `getRetainedPluginSource`, `privateOwner`, and
|
||||
`publicPluginId`. Source binding is rechecked after asynchronous work. The source
|
||||
database and sync implementation are unchanged. Source origin does not imply
|
||||
that a package is officially maintained.
|
||||
|
||||
`ModelInvocationBroker` requires host-provided `resolveDataOwner`,
|
||||
`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`.
|
||||
|
||||
Each handle is bound to a data owner, a runtime ID, and a plugin version.
|
||||
Every read rechecks runtime authority, the current model, handle lifetime, and
|
||||
selected context versions. Revocation aborts pending turns. Late provider results
|
||||
are discarded. Another source with the same plugin ID cannot read, cancel, or
|
||||
close the original source's handles.
|
||||
|
||||
`SelectedContextAuthority.share` is a trusted host operation. A plugin must never
|
||||
call it directly. Its descriptor accepts only a scope, a title, a version, a text
|
||||
reader, and a current-selection guard. Unknown Host fields are rejected. Content
|
||||
has a 16 KiB cap, excludes NUL, and rejects PEM private keys. The production host
|
||||
reader must project safe fields explicitly and exclude passwords, private keys,
|
||||
TOTP values, identity credentials, startup commands, and environment variables.
|
||||
Free-form Notes can contain secrets that field projection cannot detect. The
|
||||
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. 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.
|
||||
|
||||
No AgentRuntime, internal prompt, full MCP catalog, tool executor, Node runtime,
|
||||
raw host IPC, or global renderer store is exposed to plugins. A model tool request
|
||||
ends the turn without executing an action. Notes edits and terminal input are
|
||||
outside this checkpoint. No command is written to a terminal or followed by Enter.
|
||||
|
||||
The default broker limits are defined in `MODEL_INVOCATION_LIMITS` and
|
||||
`SELECTED_CONTEXT_LIMITS`. They include concurrent handles, turn and chat lifetime,
|
||||
input and output bytes, output token caps, event count, requests per minute, and
|
||||
reserved tokens per minute. Input token reservation uses UTF-8 bytes plus 1,024
|
||||
bytes for framing and the output token cap. This is a conservative estimate, not
|
||||
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.
|
||||
|
||||
## Connected host path
|
||||
|
||||
`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.
|
||||
|
||||
`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
|
||||
|
||||
The domain suites and transport suite use synthetic data only. The transport
|
||||
suite starts a deterministic OpenAI-compatible HTTP server on loopback and
|
||||
checks this path:
|
||||
|
||||
`ModelInvocationBroker` -> `createPluginModelAdapter` -> `createModelFromConfig`
|
||||
-> `createBridgeFetchForSDK` -> existing `_streamRequestForTests` main transport
|
||||
-> local fixture -> text and usage.
|
||||
|
||||
This is not real model inference or paid API testing. It uses no paid keys, real
|
||||
Notes, hosts, or chat history. The credential value is a fixed synthetic marker;
|
||||
the existing model factory replaces it with its IPC placeholder. The test does
|
||||
not exercise native credential storage or production credential injection.
|
||||
|
||||
The tests cover source, runtime, and version denial; separate summaries and
|
||||
content reads; context invalidation; provider cancellation and late results;
|
||||
deadlines; request, token, event, input, output, and stream-byte limits; and
|
||||
allowlisted response serialization. Raw provider diagnostics remain inside the
|
||||
host.
|
||||
|
||||
Commands:
|
||||
|
||||
```sh
|
||||
node --test electron/plugins/modelInvocation.test.cjs
|
||||
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 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.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Byte stream integration checkpoint
|
||||
|
||||
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
|
||||
|
||||
Canonical definitions are in `packages/plugin-contract/schema/plugin-contract.schema.json`.
|
||||
`network.byte-streams` negotiates the capability. The separate `network.stream`
|
||||
permission requires explicit `tcp://host:port` or `tls://host:port` resources.
|
||||
Wildcard declarations and grants cannot authorize raw streams. HTTP grants and
|
||||
payload limits are unchanged.
|
||||
|
||||
The SDK exposes `context.network.connect({ endpoint }, cancellationToken?)`.
|
||||
The returned connection has `endpoint`, `readable`, `writable`, `close()`, and
|
||||
`dispose()`. The control methods are `network.stream.connect`,
|
||||
`network.stream.attach`, and `network.stream.close`. Descriptors, request
|
||||
shapes, feature names, limits, generated SDK types, and host validators share
|
||||
the canonical schema. Unknown request properties are rejected.
|
||||
|
||||
The registered service authenticates the host runtime before source lookup.
|
||||
Runtime startup captures the existing opaque source owner. Every asynchronous
|
||||
commit revalidates the runtime, active and retained source, and opaque owner
|
||||
through the source task's APIs. Caller-supplied owner/source parameters are
|
||||
rejected. Audit commits also verify the source binding.
|
||||
|
||||
Connection and stream IDs are host-issued and bound to that runtime and owner.
|
||||
The SDK prepares its incoming waiter before attach starts the host producer.
|
||||
Both directions reuse the existing binary stream router and credit windows.
|
||||
Input credit returns after the socket write callback. Output backpressure
|
||||
bounds the producer. The router has one optional producer `onClose` hook;
|
||||
peer cancellation releases the socket even while it is idle.
|
||||
|
||||
The host validates all DNS answers and pins a literal IP. Production rejects
|
||||
loopback, link-local, unspecified, multicast, mapped local addresses, selected
|
||||
IPv6 tunnel forms, and the machine's own interface addresses. The production
|
||||
host constructor never forwards the core's explicit local-test override.
|
||||
There is no proxy discovery, reconnect, UDP, listen, generic IPC, shell, Node
|
||||
plugin runtime, or filesystem escape.
|
||||
|
||||
Limits are four connections per runtime, 64 globally, 64 KiB chunks and socket
|
||||
buffer thresholds, and 8 MiB combined bytes per connection, plus the existing
|
||||
runtime byte quota. Connect, idle, and lifetime deadlines are 10, 30, and 120
|
||||
seconds. Close tombstones are capped at 256 small identity records. Revocation,
|
||||
runtime stop, disable, shutdown, and stream cancellation release resources.
|
||||
|
||||
TLS requires trust and original-hostname validation with TLS 1.2 or later.
|
||||
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.
|
||||
|
||||
## 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`
|
||||
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 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
|
||||
|
||||
`examples/plugins/database-minimal` is an ordinary browser plugin. Its developer
|
||||
preparation tool creates an `.ncpkg` for one exact disposable private IPv4
|
||||
endpoint. The output contains a browser bundle, a normal manifest, and view
|
||||
assets. The preparation tool and its Node dependencies are not in the plugin.
|
||||
|
||||
Only the example contains the database protocol: common protocol 4.1,
|
||||
`mysql_native_password`, and
|
||||
`SELECT value FROM plugin_demo.example_result LIMIT 1`.
|
||||
The view accepts session-only fixture credentials and clears its password
|
||||
field before dispatch. It does not save view state, settings, logs, or secrets.
|
||||
The host never exposes SSH or AI credentials. JavaScript does not promise
|
||||
memory zeroization. The account's server-side SELECT grant enforces read-only
|
||||
access. The fixed query is not a SQL security classifier.
|
||||
|
||||
The example has no query editor, schema browser, pool, arbitrary SQL, or host
|
||||
connection-management subsystem. See the example README for native setup.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Both source-reviewed races have executable real-loopback regressions here.
|
||||
Delayed final-byte consumption previously lost EOF after the socket closed.
|
||||
Double-microtask cancellation previously created an untracked late socket.
|
||||
Each regression failed before its respective fix and passed afterward.
|
||||
- The broker tests use real TCP/TLS sockets, including trusted fixture CA,
|
||||
untrusted certificate and wrong-hostname denial. Connect and idle deadline
|
||||
cases use mock clocks around real sockets or delayed DNS.
|
||||
- The installed entrypoint test builds and installs a normal package, executes
|
||||
its actual browser entry module through SDK/RPC/binary streams, and exercises
|
||||
grant denial, exact resources, unknown-field rejection, close, revoke, and
|
||||
disable. It also exercises registered AI/context SDK routes with a synthetic
|
||||
trusted adapter. Its runtime harness is Node and is explicitly not a browser
|
||||
sandbox or OS sandbox test.
|
||||
- The disposable MariaDB test checks a synthetic SELECT result and server-side
|
||||
INSERT denial. It creates an internal network, publishes no host ports, and
|
||||
removes the container and network. Database transport is plain TCP inside
|
||||
that disposable network, not database TLS.
|
||||
|
||||
The integration fixes own-machine comparison across IPv4 and mapped IPv6.
|
||||
Its regression mocks interface lookup and uses a fake dialer. It does not retry
|
||||
the reviewer's failed EPERM interface lookup. Other private peers remain allowed.
|
||||
|
||||
Cancellation now discards buffered browser bytes, settles producer-cancelled
|
||||
credit once, and retains terminal producer state only until outstanding credit
|
||||
arrives. Existing sequence, schema, and negotiated-window checks still reject
|
||||
unknown, duplicate, out-of-order, and excess credit. Incoming cleanup runs even
|
||||
if a cancellation refund cannot be sent. This uses the existing stream lifecycle
|
||||
and runtime shutdown, without another scheduler.
|
||||
|
||||
Normal native BrowserWindow and screenshot/video acceptance remain pending.
|
||||
The native GUI machine lacks Docker and is occupied with SSH acceptance. See
|
||||
the handoff for fixture prerequisites. Do not disable the Chromium sandbox or
|
||||
relax endpoint restrictions to obtain that evidence.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Test integrated byte streams on the native desktop
|
||||
|
||||
This record describes #3993 and its earlier package hashes. For the unified
|
||||
main, transport/AI, Host/Vault, and settings/logging candidate, use
|
||||
[the dedicated acceptance matrix](acceptance-candidate.md). Rebuild and verify
|
||||
that candidate's package hashes before native testing.
|
||||
|
||||
Use `integration/plugin-byte-stream-handoff` from its integration PR. Record the
|
||||
exact commit with `git rev-parse HEAD`. The branch preserves transport #3988
|
||||
(`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`), AI integration #3986
|
||||
(`173ef5abbb1f8cea4e2c409c5791186926b778e1`), and source identity #3981
|
||||
(`4213d8e6726c44e7a479418ece3adf16ea494782`) through ordinary merges.
|
||||
|
||||
## Prepare the desktop and fixture
|
||||
|
||||
Wait until the approved native GUI machine is free from SSH acceptance work.
|
||||
Use a fresh disposable app profile, a working display, and the platform's normal
|
||||
Electron sandbox. Keep the existing endpoint policy and system security settings.
|
||||
|
||||
The database pass requires a disposable MariaDB fixture at a reachable private
|
||||
IPv4 address. The address must not belong to the GUI machine. The current native
|
||||
GUI machine lacks Docker, so fixture provisioning is a prerequisite. Use an
|
||||
already approved separate Docker host or disposable database machine with an
|
||||
existing permitted network path. Do not install new software or create local
|
||||
forwarding exceptions as part of this recipe. If no fixture is available, record
|
||||
the database pass as pending.
|
||||
|
||||
There is also a separately labeled
|
||||
[public Rfam candidate](../../examples/plugins/database-rfam-readonly/README.md).
|
||||
It fixes one public hostname, public account, database, and `SELECT 1 AS value`.
|
||||
It has only been prepared and checked offline. The parent must obtain exact
|
||||
package and endpoint authorization before any install, permission activation,
|
||||
or connection. Its authentication and TLS requirements remain unverified. This
|
||||
candidate does not replace the owned fixture regression or prove that the GUI
|
||||
can reach the coding container's private database.
|
||||
|
||||
The registered automated fixture uses
|
||||
`mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873`.
|
||||
It runs on an internal Docker network, publishes no host ports, generates a
|
||||
disposable password, and removes its container and network. On a prepared Linux
|
||||
executor with that recognized image, run:
|
||||
|
||||
```sh
|
||||
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
|
||||
```
|
||||
|
||||
That test exercises the protocol, broker, and installed package's SDK/RPC path
|
||||
in a Node harness. It does not prove native GUI behavior. For the GUI fixture,
|
||||
have its operator create only
|
||||
synthetic data and a dedicated SELECT-only account. Run this SQL using the
|
||||
fixture's administration channel, replacing the password placeholder with a
|
||||
fresh disposable password that is never included in screenshots or logs:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE plugin_demo;
|
||||
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
|
||||
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
|
||||
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<fresh-disposable-password>';
|
||||
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
```
|
||||
|
||||
Use protocol 4.1 with `mysql_native_password`. The example uses plain TCP only
|
||||
within the approved isolated fixture network. Verify INSERT denial independently
|
||||
through the fixture's client. The plugin intentionally offers only its fixed read.
|
||||
|
||||
## Build and identify the packages
|
||||
|
||||
Use Node 22 or later and the lockfile. Run these commands in sequence:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run test:plugin-runtime
|
||||
npm run test:plugin-contract
|
||||
npm run test:plugin-examples
|
||||
npm run generate:plugin-contract
|
||||
npm run check:plugin-contract
|
||||
npm run generate:capability-tools
|
||||
npm run check:codex-app-server-schema
|
||||
npm run lint
|
||||
npm run build
|
||||
git diff --exit-code
|
||||
```
|
||||
|
||||
For the reproducible packaging-only example, use the fixed synthetic endpoint:
|
||||
|
||||
```sh
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example-repeat.ncpkg
|
||||
cmp /tmp/database-example.ncpkg /tmp/database-example-repeat.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
|
||||
sha256sum /tmp/database-example.ncpkg
|
||||
```
|
||||
|
||||
The two five-file packages built on Node 24.19.0 with the locked dependencies
|
||||
match byte for byte. The current owned-fixture package SHA-256 is
|
||||
`588cb6766616c2f3057c7c8df1c20dbac947e0d5e9d01b2a928c7581b00a69c5`.
|
||||
The earlier `6a4fb178` package used hash `88c14283…dbc1af4`. The example-only
|
||||
protocol now has separate fixed owned-fixture and Rfam entrypoints; the owned
|
||||
fixture database, query, and opt-in live regression are retained.
|
||||
The six-file assistant package retains SHA-256
|
||||
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
|
||||
|
||||
The preparation tool removes temporary build paths from its minified browser
|
||||
bundle. Rebuild with the actual fixture endpoint before native testing. Validate
|
||||
and record that package's new hash. Preparation does not dial the endpoint.
|
||||
|
||||
Build the assistant and follow its existing
|
||||
[native QA recipe](assistant-native-qa.md). Use its deterministic local provider,
|
||||
synthetic credential marker, and synthetic Note. No paid model call is required.
|
||||
The original AI worker subsequently completed the bounded review after a single
|
||||
reconsideration using newer maintainer evidence. The parent reports its final
|
||||
bounded correction review clean at `ba094616`: source budgets and explicit
|
||||
selected-Note re-selection are repaired. The [acceptance matrix](acceptance-candidate.md)
|
||||
records aggregate checks and remaining native gates. These transport tests do
|
||||
not substitute for native consent or acceptance.
|
||||
|
||||
## Record the installed browser path
|
||||
|
||||
1. Launch the integrated app on the approved desktop with a new disposable
|
||||
`--user-data-dir`. Capture the full app window and record the exact commit.
|
||||
2. In Settings > Plugins, import the database package. Capture its ID, version,
|
||||
package hash, source identity, browser entry, and exact endpoint permission.
|
||||
The import must leave the plugin disabled and optional access ungranted.
|
||||
3. Enable it and open **Disposable database example**. Deny the first endpoint
|
||||
request. Capture the failure and verify the runtime remains usable.
|
||||
4. Retry with only the disposable fixture credentials and allow the exact
|
||||
endpoint. Capture the one-row result and the source-aware permission prompt.
|
||||
The password field must clear when the request starts.
|
||||
5. Record a video of a delayed fixture operation followed by **Cancel**. Use a
|
||||
delay offered by the fixture operator. Do not add a host protocol scheduler
|
||||
or change the query. Confirm the request stops and a fresh read still works.
|
||||
6. Revoke `network.stream` while a connection is active. Confirm the panel reports
|
||||
failure and a new request prompts again. Disable, re-enable, close the view,
|
||||
and restart the app. No old connection or result may resume.
|
||||
7. Record the assistant's denial, exact-text review, streamed result, usage,
|
||||
Stop, and post-revocation recovery through its linked recipe. Use the same
|
||||
integrated head and preserve its separate permission descriptions.
|
||||
8. Remove only the synthetic plugins and profile. Have the fixture operator
|
||||
remove the disposable account, data, container, and network. Stop the synthetic
|
||||
model provider.
|
||||
|
||||
## Record the remaining evidence
|
||||
|
||||
Save the exact head, app platform/version, package hashes, ordered commands and
|
||||
exit codes, screenshots, and video paths. Distinguish a skipped Docker test,
|
||||
synthetic Node runtime integration, and actual native sandbox acceptance.
|
||||
|
||||
The address regression uses a mocked `os.networkInterfaces()` result and a fake
|
||||
dialer. It never retries the reviewer's EPERM interface lookup. The cancellation
|
||||
regression uses the registered package, host service, SDK, RPC, and real loopback
|
||||
echo fixture under the existing test-only override. This override is unavailable
|
||||
through the production service constructor. Neither test certifies the native
|
||||
BrowserWindow sandbox or a live database pass on the GUI machine.
|
||||
|
||||
The integration executor completed these checks in order on 2026-10-26:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `npm run test:plugin-runtime` | 627 tests: 626 passed, 1 native skip, 0 failed |
|
||||
| `npm run test:plugin-contract` | 90 passed, 0 skipped, 0 failed |
|
||||
| `npm run test:plugin-examples` | 4 passed, 1 opt-in Docker skip, 0 failed |
|
||||
| Plugin contract generate and drift check | Passed, no generated changes |
|
||||
| Capability tool generation and drift check | Passed, no generated changes |
|
||||
| Codex App Server schema check | Passed |
|
||||
| `npm run lint` | Passed, 0 errors and 7 warnings outside the transport changes |
|
||||
| `npm run build` | Passed, Vite production build completed in 40.23 seconds |
|
||||
| Database repeat packaging and both package validations | Passed |
|
||||
|
||||
The late transport merge moves the opt-in database test into the example directory.
|
||||
Run its panel and protocol tests explicitly with
|
||||
`node --test examples/plugins/database-minimal/*.test.cjs`. The database test
|
||||
still requires explicit opt-in and the native Electron suite remains separate.
|
||||
This executor has Docker, but the pinned MariaDB image
|
||||
is not cached. No image was installed and no new live database run is claimed.
|
||||
Native GUI acceptance remains pending on the occupied desktop. Full repository
|
||||
tests and platform packages run in the integration PR's exact-head CI. Record
|
||||
their final workflow conclusions separately from these local results.
|
||||
|
||||
After the late transport merge and Rfam candidate preparation, the registered
|
||||
runtime suite has 626 passed and one native skip, contract/SDK/CLI has 90 passed,
|
||||
and `npm run test:plugin-examples` has four passed and one Docker skip. The
|
||||
example suite is now registered in CI. Public protocol checks use only synthetic
|
||||
packet bytes and package preparation; no Rfam connection or permission was
|
||||
activated. Final CI belongs to the current #3993 head, not the older `6a4fb178`
|
||||
head whose two workflows already succeeded.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,74 @@
|
||||
# Global Agent continuation: bounded QA recipe
|
||||
|
||||
This is a separate increment after the plugin platform foundation. The transport
|
||||
review checkpoint is #4012 at `ea619555`; its isolated correction is `63f0572e0`.
|
||||
The runtime continuation carries the correction through an ordinary feature-branch
|
||||
merge. It is not merged to main or accepted for release.
|
||||
|
||||
## Reproduce the installable sample
|
||||
|
||||
From the exact runtime head reported in the handoff:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:plugin-runtime-deps
|
||||
node examples/plugins/global-agent/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/global-agent/.package --out /tmp/netcatty-global-agent.ncpkg
|
||||
sha256sum /tmp/netcatty-global-agent.ncpkg
|
||||
```
|
||||
|
||||
The reproducible sample SHA-256 is
|
||||
`36993b1d566814d3b5304337fab73ff464e422d93ba52909e95e3ceedcad2a6a`.
|
||||
Two independent package builds matched. Build scripts and tests are excluded
|
||||
from the six-file installed package.
|
||||
|
||||
## Native fixture acceptance (parent-scheduled GUI only)
|
||||
|
||||
1. Use an isolated synthetic profile, no real Notes, hosts, histories or keys.
|
||||
Start `node scripts/plugin-global-agent-provider-fixture.mjs --port 9218`.
|
||||
It performs no inference or outbound request and logs no bodies/headers.
|
||||
2. Through normal host settings, configure an OpenAI-compatible Chat Completions
|
||||
provider at `http://127.0.0.1:9218/v1`, model `global-agent-fixture`, with the
|
||||
fixture-only literal `FIXTURE_ONLY_API_MARKER`. Do not use real credentials,
|
||||
paid services, allowlist edits, debugger injection or the builtin AI sidebar.
|
||||
3. Install the `.ncpkg` in Settings → Plugins and open its contributed Global
|
||||
Agent Chat view. Verify light/dark/accent updates using existing theme tokens.
|
||||
4. Choose Catty. Choose no targets, or only owned synthetic terminal fixtures,
|
||||
then Open conversation. The fixture's default tool is `get_environment`; it
|
||||
never requests a terminal command. Record Send → streamed result/usage and
|
||||
`GET /fixture/status` counters. This proves a deterministic pipeline, not
|
||||
real model inference.
|
||||
5. Send `fixture:wait`, wait for output, press Stop. Verify aborted count rises
|
||||
once and no late text appears. Capture screenshot/video with fixture labels.
|
||||
6. Exercise Actions Ask using the existing in-app policy dialog: approve and
|
||||
deny the scoped environment tool separately. Actions Deny permits model-only
|
||||
chat with no tools. Agent-use Deny prevents dispatch. A policy change revokes
|
||||
existing handles; reselect/reopen explicitly before another Send.
|
||||
7. Close/reopen and disable/re-enable only this synthetic plugin. Reselect its
|
||||
targets and reopen its owned conversation. History should survive, while the
|
||||
private per-open actor ID is fresh. At five-minute expiry, the sample asks for
|
||||
explicit reselection and keeps the draft, without resending or renewing scope.
|
||||
8. Revoke/disable or lock while streaming and verify cleanup/no late events.
|
||||
Disconnect/re-own a selected synthetic worker during selection and a turn;
|
||||
stale selections must fail instead of binding a replacement connection.
|
||||
|
||||
Do not activate a configured real Claude backend in this fixture recipe. Claude
|
||||
MCP's code/handler boundary is covered with synthetic tests; authenticated service
|
||||
and genuine CLI acceptance remain unverified and require separately approved
|
||||
access. Other backends are explicitly unsupported/unavailable. No background
|
||||
service, blanket OS/Vault access, merge or release is implied.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Installed Catty path uses actual `AgentRuntime`, driver, main provider admission,
|
||||
source policy and worker dispatcher, with JSDOM/in-process plugin transport and
|
||||
synthetic workers. It proves selected B dispatch, stream, Stop, revocation and
|
||||
history after runtime restart. This is not a native BrowserWindow sandbox test.
|
||||
- Main/core tests cover source/session/resource substitution, scope expiry,
|
||||
approval waits, worker lifecycle, cancellation, late completion, stable source
|
||||
budgets, source-isolated history and no key serialization. Filesystem API reads
|
||||
cannot access the core history root. Explicitly granted terminal commands still
|
||||
have that terminal's native OS authority.
|
||||
- No GUI ran in this executor. Native screenshots/video and independent runtime
|
||||
review remain parent-owned gates. The exact-head CI result must be reported
|
||||
separately from focused local evidence.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Global Agent plugin bridge (work in progress)
|
||||
|
||||
This stack starts from `integration/plugin-ask-routing-acceptance` at
|
||||
`2433cf0130d1c3274c56c39e5765349c2945de01`. The parent confirmed this dependency;
|
||||
its native Ask acceptance and integrated CI are still pending. This branch does
|
||||
not modify the frozen candidate, #3986, #3998, or #4002.
|
||||
|
||||
The user approved a global conversation independent of a terminal or workspace,
|
||||
with a plugin-provided chat UI and selectable existing Agent backends. Core owns
|
||||
Agent execution, history, target authority, native approvals, credentials and
|
||||
audit. Global is not unrestricted inventory access or a background service.
|
||||
|
||||
## Stage 1: dispatch authority
|
||||
|
||||
`electron/plugins/agent/delegatedScope.cjs` is a host-only authority. It does not
|
||||
register plugin APIs, start an Agent or implement an orchestration loop.
|
||||
|
||||
- `issue(context, hostSnapshot)` authenticates with the existing source binding.
|
||||
Host snapshots contain backend identity/revision, target kind/id/owner/revision,
|
||||
synchronous current-owner checks, and a supported capability subset. The public
|
||||
result contains only an opaque handle and expiry. Plugin parameters must never
|
||||
be used as `hostSnapshot`.
|
||||
- `begin(handle, context)` returns an internal turn ticket and AbortController.
|
||||
- `dispatch(ticket, context, resolvedAction, execute)` runs the common host policy
|
||||
adapter once, checks current target revisions after any approval wait, invokes
|
||||
the real dispatcher, and rejects late results. `resolvedAction` must come from
|
||||
canonical host capability/argument resolution, not an unchecked plugin object.
|
||||
- `assertTurn` guards outgoing events. `finish` and `stop` invalidate the exact
|
||||
ticket. Runtime/plugin release, lock/hide invalidation and shutdown abort work.
|
||||
- Handles expire in five minutes. There are at most eight per runtime owner and
|
||||
64 overall, with at most 32 explicit resources per scope. Expiry never silently
|
||||
refreshes a selection or revives an old turn.
|
||||
|
||||
The focused tests use the actual `PluginAISourceBinding` with synthetic host
|
||||
registries, two terminal owners representing different workspaces, controlled
|
||||
time, and deferred approval/native completion. They cover source/runtime/target
|
||||
substitution, focus changes, revision changes across approval, policy denial,
|
||||
Stop, release, lock, late completion and bounds. They do not yet prove actual MCP
|
||||
or AgentRuntime dispatch; those remain integration gates.
|
||||
|
||||
Initial execution failed before test discovery because the new worktree has no
|
||||
dependencies (`ajv/dist/2020` missing). Running with the existing local worktree's
|
||||
read-only CommonJS dependency path passes all 12 cases. No provider, CLI Agent,
|
||||
paid credentials or GUI was used.
|
||||
|
||||
The adjacent run then passed 49 tests and failed one installed-entrypoint test
|
||||
because ESM could not resolve `@netcatty/plugin-cli` from the new worktree. Standard
|
||||
`npm ci --ignore-scripts` and `build:plugin-runtime-deps` repaired the setup. These
|
||||
failures are setup evidence, not failed-before product regressions.
|
||||
|
||||
The core conversation repository stores the existing global `AISession` document
|
||||
shape in a host-only directory, namespaced by the hash of source/private-owner.
|
||||
Native session identity and compaction remain private. Commits serialize a cloned
|
||||
snapshot, revalidate provenance before atomic rename, and retain previous history
|
||||
when that check fails. Files use mode 0600 and no-follow reads. The repository
|
||||
does not register a plugin storage API or change the source database schema.
|
||||
|
||||
The canonical schema and SDK now declare the proposed session interface and
|
||||
permissions. Runtime methods and the supported-feature advertisement are not yet
|
||||
registered. This stage must not be described as an installable working Agent
|
||||
bridge. Focused scope/history/wire and permission-copy tests pass 20 cases;
|
||||
contract/SDK/CLI tests pass 91. Focused lint and generated drift checks pass.
|
||||
|
||||
## Independent stage-1 correction
|
||||
|
||||
The independent review of exact `056164157f34ea779ed6cf51199909082345eca4`
|
||||
confirmed two helper defects before any runtime acceptance claim: source failure
|
||||
during the initial owner check rejected late work without aborting the active
|
||||
native signal; Stop could acquire a replacement turn after awaiting identity.
|
||||
|
||||
Both reproduce against that exact helper using the real `PluginAISourceBinding`:
|
||||
the focused before run is 13 passed / 2 failed. The correction revokes the captured
|
||||
scope when its own host-authenticated identity fails, and captures the intended
|
||||
turn before Stop's first await. A foreign runtime cannot use a failed lookup to
|
||||
revoke another caller's scope. Focused after: 15 passed / 0 failed. The complete
|
||||
foundation/permission group passes 23 on Node 24.19.0 and Node 22.23.3; focused lint
|
||||
and diff checks pass. Logs: `/tmp/netcatty-global-agent-review-before.log`,
|
||||
`/tmp/netcatty-global-agent-review-after.log`,
|
||||
`/tmp/netcatty-global-agent-review-node22.log`.
|
||||
|
||||
The first CI run at 0561641, test `40459522087`, Linux job `122040565759`, failed
|
||||
after an explicit runner shutdown and operation cancellation, with no assertion
|
||||
failure in its log and no complete repository test total. Package workflow
|
||||
`40459521971` and both Windows jobs passed. The parent authorized one job rerun;
|
||||
it was requested once, producing Linux job `122048349748`, still running at this
|
||||
checkpoint. Original runner failure remains historical evidence. Neither that
|
||||
rerun nor the helper fix establishes runtime, native or independent-review
|
||||
acceptance; the correction still needs reviewer recheck.
|
||||
|
||||
### Follow-up: cancellation is not source revocation
|
||||
|
||||
The independent recheck confirmed the first two repairs and found a remaining
|
||||
Stop race: an aborted Stop(A) RPC could enter the generic owner-failure cleanup
|
||||
after B had started, revoking B's scope. The same failure was reproduced at both
|
||||
owner-validation await paths using the actual source binding. Exact-18377 source
|
||||
with the new 18-case scope suite yields 16 passed / 2 failed; evidence is retained
|
||||
in `/tmp/netcatty-global-agent-stop-rpc-exact183-before.log`.
|
||||
|
||||
The minimal follow-up distinguishes this request's abort/deadline from verified
|
||||
owner or resource failure, rethrows the access failure, and leaves the existing
|
||||
turn-capture rule intact. Cancellation of Stop(A) cannot revoke B. A separate
|
||||
regression confirms that a verified source mismatch still aborts native work
|
||||
even when the RPC is concurrently cancelled. Foreign caller protections remain.
|
||||
The full foundation/permission group passes 26 tests on Node 22.23.3 and Node
|
||||
24.19.0; focused lint and diff checks pass. Logs:
|
||||
`/tmp/netcatty-global-agent-stop-rpc-final.log` and
|
||||
`/tmp/netcatty-global-agent-stop-rpc-node22.log`. Independent recheck is pending.
|
||||
|
||||
The authorized original-head Linux rerun `122048349748` ended cancelled, with an
|
||||
operation-cancelled log and no assertion failure. No second rerun was requested.
|
||||
At 18377, test workflow `40462595842`, Linux job `122050816182`, also received an
|
||||
explicit runner shutdown; `PluginLogSettings.test.tsx` was SIGKILLed immediately
|
||||
afterward. That interrupted stage reports 34 passed / 1 failed, not a complete
|
||||
repository total or an assertion failure. Both Windows jobs passed. No blind
|
||||
rerun of that job was made. Package workflow `40462595846` was still running when
|
||||
checked. These records are not substituted for exact-head green CI.
|
||||
|
||||
## Planned public contract and adapters
|
||||
|
||||
### Independent transport correction
|
||||
|
||||
The exact `ea619555` internal-handler reproductions fail all four targeted cases:
|
||||
both no-session and empty-selection environment reads serialize global tool hints;
|
||||
stale chat-wide cleanup cancels replacement B; and caller-supplied builtin chat
|
||||
text can downgrade cancellation of a main-owned delegated request. The outer
|
||||
sender gate still accepts only the current trusted main window. These are trusted
|
||||
application lifecycle defects, not arbitrary plugin IPC exposure or installed
|
||||
runtime exploit evidence.
|
||||
|
||||
Cancellation now resolves ownership from the main request map, rejects a
|
||||
conflicting caller chat, checks the recorded sender and current turn lease, and
|
||||
cancels only that native request. The legacy chat-wide cleanup IPC is unavailable
|
||||
for reserved delegated conversations; main-owned exact-turn release remains the
|
||||
cleanup authority. All delegated environment return paths omit global tool hints.
|
||||
|
||||
An additional Node 22 regression is independently reproduced: inline arrow
|
||||
properties at the beginning of `module.exports` prevent namespace detection of
|
||||
existing exports such as `init`, breaking the real Catty attachment harness.
|
||||
Identifier exports preserve that existing interface. Correction tests pass 94 on
|
||||
Node 22.23.3, including the actual Catty attachment consumer; focused lint passes.
|
||||
Failed-before logs are `/tmp/netcatty-global-agent-transport-review-four-failed-before.log`
|
||||
and `/tmp/netcatty-global-agent-ea619-namespace-before.log`. The transport PR's
|
||||
review checkpoint remains `ea619555` until the parent integrates/rechecks this
|
||||
separate correction. No native/backend/full-CI acceptance is implied.
|
||||
|
||||
### Stage 2A: trusted native dispatch boundary
|
||||
|
||||
The independent bounded recheck of foundation `ba4d3fc` passed all 20 reviewer
|
||||
cases. This resolves its reported helper findings; it does not establish full CI,
|
||||
runtime, backend or native acceptance.
|
||||
|
||||
The additive transport slice gives managed subprocesses a separate per-turn MCP
|
||||
credential, permanently binds their TCP connection to that turn, filters their
|
||||
tool list, and routes actual tool execution through the source-bound host policy
|
||||
adapter. Catty's trusted-app IPC enters the same dispatcher. Branded approval
|
||||
contexts skip the legacy Agent approval only after that host adapter grants the
|
||||
action; process-global permission mode is unchanged. Selected environment reads
|
||||
return the scoped session metadata without querying global tunnel inventory.
|
||||
|
||||
Managed Claude is restricted to its existing MCP mode with builtin tools disabled,
|
||||
strict MCP configuration and no inherited settings sources. The main native entry
|
||||
rejects unverified backends and Skills mode for reserved delegated conversations.
|
||||
Each model stream and native SDK request carries a main-owned turn tag; releasing
|
||||
that lease aborts its HTTP/native work. Old credentials, cancellation requests and
|
||||
disposers cannot acquire or cancel the next turn.
|
||||
|
||||
The focused boundary suite passes 116 cases, including an actual local TCP/main
|
||||
handler with synthetic workers, plus two new actual-HTTP/renderer-transport lease
|
||||
tests. These are deterministic transport tests, not real model inference or
|
||||
authenticated Claude service/CLI acceptance. Public runtime registration, core
|
||||
conversation integration and the custom chat sample are still separate work in
|
||||
progress; this transport slice alone is not a usable plugin Agent feature.
|
||||
|
||||
At exact `ba4d3fc`, package CI `40464058910` passed; test workflow `40464058908`
|
||||
is red. Linux job `122055141575` failed a real assertion in
|
||||
`PluginManagementDialog.interaction.test.tsx:71`: the external view handoff had
|
||||
not run when asserted. That stage reports 34 passed / 1 failed; earlier runtime
|
||||
and contract groups passed 720 and 5. Both Windows jobs passed. No blind rerun was
|
||||
requested. This unresolved assertion must not be classified as runner shutdown.
|
||||
|
||||
The wider initial local transport run reports 697 passed / 10 failed / 3 skipped.
|
||||
All ten failures are Cursor discovery expectations after `npm ci --ignore-scripts`
|
||||
left SQLite's native binding absent, making the bundled Cursor SDK import fail.
|
||||
The standard rebuild first failed using unwritable default caches; rebuilding
|
||||
with task-local npm/node-gyp cache paths succeeded. The failed totals are retained
|
||||
in `/tmp/netcatty-global-agent-transport-expanded.log`; the after-setup run is
|
||||
recorded separately. No GUI or provider credentials were used.
|
||||
|
||||
After the dependency repair, the expanded transport/native-handler/preload/SDK
|
||||
suite passes 709 tests, 0 failures, 3 skips. Log:
|
||||
`/tmp/netcatty-global-agent-transport-expanded-after-setup.log`. Focused lint passes.
|
||||
|
||||
The proposed feature is `ai.agent-sessions`, with `ai.agent.use` for conversation
|
||||
operations and `ai.agent.actions` for actual Agent tool actions. Permission mode
|
||||
comes from the existing source-bound Allow/Ask/Deny engine. These are additive
|
||||
names, separate from the existing tools-disabled `ai.invoke` broker.
|
||||
|
||||
The API will provide backend listing, explicit target selection, owned chat
|
||||
listing/opening, sending, bounded event reads, history, Stop and close. Backend
|
||||
references are opaque host-issued configuration revisions. Conversation references
|
||||
identify only histories owned by the calling source/private-owner; runtime handles
|
||||
are separate and cannot substitute builtin chat IDs or native SDK resume IDs.
|
||||
Messages cannot supply permissionMode, CLI/env/MCP configuration or owner fields.
|
||||
|
||||
Core will reuse `AISession`, `AgentRuntime`, both existing turn drivers, event and
|
||||
compaction handling, SDK resume identity validation, and `stopAgentTurn`. The first
|
||||
eligible backends are Catty and configured Claude Code in MCP mode. Other backend
|
||||
entries must return explicit unsupported/unconfigured status until their native
|
||||
tool and approval boundaries are verified. Codex SDK's current unrestricted native
|
||||
configuration must not be treated as eligible merely because it has a driver.
|
||||
|
||||
The terminal worker already exposes a suitable main-owned resource binding:
|
||||
`getPluginSessionBinding(sessionId, ownerWebContentsId)` returns worker epoch and
|
||||
session generation. Use this for dispatch-time targets; do not use menu IDs,
|
||||
focused tab, display labels, or a renderer-provided session ID as authority.
|
||||
|
||||
The MCP dispatcher and Catty direct execution route need the same delegated
|
||||
session guard. A delegated call must not update process-global permission mode,
|
||||
or request both an Agent approval and a plugin approval for the same action.
|
||||
Policy changes, disable/restart/source replacement, lock and host disconnection
|
||||
must cancel scoped native work and invalidate outgoing event tickets. Existing
|
||||
Agent trace and plugin audit/log facilities will receive redacted correlation
|
||||
metadata, not credentials, provider diagnostics or full unreviewed tool payloads.
|
||||
|
||||
## Remaining acceptance gates
|
||||
|
||||
### Separate runtime/sample implementation checkpoint
|
||||
|
||||
The runtime continuation wires the nine `context.agent` methods through the
|
||||
registered default host, dedicated trusted-app preload/IPC and `PluginAgentHost`
|
||||
mounted in `AppView`. It reuses the existing `AgentRuntime`, Catty/external turn
|
||||
drivers, core `AISession` documents, compaction, model/provider transport, native
|
||||
cancellation, selected worker epoch/generation checks and permission/audit engine.
|
||||
No source database, Vault history or builtin conversation ownership change is
|
||||
included. Plugin input cannot supply internal session IDs, source/owner, CLI/env,
|
||||
MCP configuration or permission mode.
|
||||
|
||||
The first eligible profiles are configured Catty and Claude MCP. Other known
|
||||
backends report unsupported/unavailable. Claude has builtin tools disabled,
|
||||
strict MCP, no inherited settings sources, at most 12 native turns and no native
|
||||
session persistence/resume; its existing driver rehydrates from core-owned history.
|
||||
This code path has handler/driver/MCP fixture coverage. Authenticated Claude
|
||||
service/native CLI acceptance has not run and must not be inferred from mocks.
|
||||
|
||||
Actions use one source-bound Allow / Ask / Deny decision path. Actions Deny permits
|
||||
model-only conversation with an empty tool profile; Agent-use Deny blocks host
|
||||
dispatch. Explicit target choice is independent of focus/workspace. Worker
|
||||
lifecycle changes cancel a pending chooser and revoke stale target leases;
|
||||
source/runtime/policy/lock invalidation aborts native work and rejects late events.
|
||||
Core history is source/private-owner namespaced and read/write protected through
|
||||
the plugin filesystem broker. A user-granted terminal command still has that
|
||||
terminal's native OS authority; this is not an OS filesystem sandbox.
|
||||
|
||||
Bounds include six sends/source/minute, eight scopes/runtime and 64 total, 256
|
||||
retained turn handles, 32 tool actions and 32 Catty HTTP calls per turn, 12 Catty
|
||||
iterations, a 120-second turn lifetime, 60-second model HTTP/15-second streaming
|
||||
idle deadlines, 128 KiB HTTP responses, 256 KiB projected turn output and 4 KiB
|
||||
text events. Usage is projected; no hard total native-Claude token budget is
|
||||
claimed. Handles expire after five minutes without silent target reselection.
|
||||
|
||||
The installable `examples/plugins/global-agent` sample provides backend/owned
|
||||
conversation choice, explicit targets, history, Send/Stop and existing host theme
|
||||
tokens. Its deterministic local provider fixture performs no model inference,
|
||||
paid API calls, real terminal execution or request/body/header logging. The
|
||||
installed Node/JSDOM pipeline test uses actual Catty orchestration, configured
|
||||
HTTP admission and main dispatch to synthetic workers. It proves one selected-B
|
||||
tool cycle, streamed result, Stop and revoked-handle denial; it is not native
|
||||
browser sandbox or real model acceptance. A failed-before JSON-snapshot regression
|
||||
is retained at `/tmp/netcatty-global-agent-snapshot-failed-before.log`.
|
||||
|
||||
Recorded validation: plugin runtime 732 passed/0 failed/1 skipped; affected group
|
||||
105 passed/0 failed/3 skipped; examples 6 passed/0 failed/1 skipped; repaired setup
|
||||
paths 284 passed. Build, codegen and lint passed before the final review corrections.
|
||||
Typecheck is still red: 774 diagnostics on both unchanged foundation and this
|
||||
continuation, with equal per-file/error-code counts; it is not a clean typecheck.
|
||||
The repaired Node 22 full run reports 15,354 passed/9 failed/46 skipped. Eight are
|
||||
missing xterm install patches, subsequently applied; the ninth is the transport
|
||||
CJS namespace regression fixed separately. Exact final-head validation is pending.
|
||||
|
||||
The earlier pre-setup full run remains pending after its timed-out unpatched FIDO
|
||||
test worker kept a socket open. Reading `/proc/319596/cwd` to confirm that worker's
|
||||
identity was denied (EACCES); no signal was sent and no alternate route attempted.
|
||||
Its log is retained without invented final totals. `pack:dir` first failed using
|
||||
the unwritable default npm cache; a standard task-local cache repaired that step,
|
||||
then native rebuild failed because installed `@electron/rebuild` hardcodes
|
||||
`/home/agent/.electron-gyp` outside writable roots. No home override or sandbox
|
||||
bypass was attempted. Native screenshots/video and independent runtime review
|
||||
remain parent-owned gates; nothing is merged or released.
|
||||
|
||||
Final focused Node 22 validation passes 205 cases, including all nine failure
|
||||
paths from the broad run after the documented setup/correction changes. The
|
||||
installed pipeline additionally proves disable/re-enable, owned history reopening
|
||||
and a fresh model-only turn under Actions Deny. `conversationRef` is durable;
|
||||
each open receives a fresh private runtime actor so revoked privacy/native/tool
|
||||
state cannot be revived. A typed, host-only close control clears its owned
|
||||
runtime caches after that actor settles. Build, lint (seven existing warnings)
|
||||
and generated drift checks pass. Typecheck still has the same 774 per-file/code
|
||||
diagnostic counts as the unchanged foundation. Logs:
|
||||
`/tmp/netcatty-global-agent-lifecycle-final-tests.log`,
|
||||
`/tmp/netcatty-global-agent-lifecycle-final-build.log`, and
|
||||
`/tmp/netcatty-global-agent-lifecycle-final-typecheck.log`.
|
||||
|
||||
The native recipe and reproducible package hash are in
|
||||
[`global-agent-qa.md`](global-agent-qa.md). Remaining gates are independent
|
||||
transport/runtime review, CI on the final integrated head, parent-scheduled native
|
||||
screenshots/video, genuine Claude compatibility acceptance with separately
|
||||
approved access, and native packaging on a machine with supported writable
|
||||
rebuild caches. The initial full-run pending worker and previous red totals above
|
||||
remain recorded. No GUI ownership, merge or release is granted to this executor.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Host and Vault actions
|
||||
|
||||
Feature: `vault.host-actions`. The SDK registers three methods under
|
||||
`context.vault`. Netcatty owns the picker, field-diff review, and connection
|
||||
confirmation. Plugin business logic stays in the offline browser sandbox.
|
||||
|
||||
Base: source integration #3981 at
|
||||
`4213d8e6726c44e7a479418ece3adf16ea494782`.
|
||||
The published stream/AI branch point `c4433d496` is preserved through an ordinary
|
||||
merge. Its owner was still completing integration checks when it was published.
|
||||
|
||||
## Reusable boundaries
|
||||
|
||||
| Concern | Existing contract | Reuse |
|
||||
| --- | --- | --- |
|
||||
| Host picker | `components/SelectHostPanelContent.tsx` | Controlled `selectedHostIds` and `onSelectionChange`; group toggles resolve to explicit host IDs. |
|
||||
| Typed drafts | `ImporterHostDraft` in `packages/plugin-contract/schema/plugin-contract.schema.json` | Create allowlist: label, hostname, username, port, group, tags. Edit allowlist: label, group, tags. |
|
||||
| Validation and preview | `normalizePluginImporterRecords`, `buildVaultHostFromDraft` | Validate the raw address, normalize the importer draft, validate the normalized result, and show the normalized field diff. |
|
||||
| Vault writes | `useVaultState.commitVaultImportTransaction` | Existing shared Vault lock, expected host snapshot, encryption, stale-write checks, and journaled persistence. Optional authorization guard runs after encryption. |
|
||||
| Connection | `AppSideEffects.handleConnectToHost` | Existing saved-settings, group defaults, authentication, logging, and session creation flow. |
|
||||
| Script disclosure | `applyGroupDefaults`, `hasHostConnectAutomation`, `snippetAppliesToOutputTrigger` | Boolean disclosure covers inherited startup commands, global/group/host connect scripts, unresolved bindings, host output triggers, and applicable global output scripts. Script contents remain host-owned. |
|
||||
| Lifecycle | `PluginHostInteractionService`, `PluginPermissionEngine`, runtime route `assertActive` | Existing window ownership, focus, lock, signed replies, cancellation, and revocation patterns. |
|
||||
| Source identity | `PluginDatabase` | `getActivePlugin`, `getRetainedPluginSource`, `privateOwner`, and `publicPluginId`; no caller-supplied owner or bare-ID fallback. |
|
||||
|
||||
## Public methods
|
||||
|
||||
| Method | Parameters | Result |
|
||||
| --- | --- | --- |
|
||||
| `vault.selectHosts` | `{ title: string }` | `{ cancelled: true }`, or `{ cancelled: false, hosts, groups }`. |
|
||||
| `vault.requestHostDraft` | Create: `{ draft: { label?, hostname, username?, port?, group?, tags? } }`. Edit: `{ target, expectedRevision, draft: { label?, group?, tags? } }`. | Cancelled, or `{ cancelled: false, status: "saved" }`. |
|
||||
| `vault.requestConnect` | `{ target, expectedRevision }` | Cancelled, or `{ cancelled: false, status: "requested" }`; no session identifier or content. |
|
||||
|
||||
Selected metadata contains label, hostname, username, optional port, protocol,
|
||||
group path, and tags. Each host result contains `{ target, metadata }`. Host IDs
|
||||
remain behind the typed target reference; its revision is the expected revision.
|
||||
Group metadata describes the explicitly selected current member handles. The
|
||||
grant never expands when another host joins a group.
|
||||
|
||||
Selection grants metadata access only. Each draft save and each connection
|
||||
requires its own host-owned confirmation. A connection confirmation displays the
|
||||
saved target and whether saved scripts can run. It does not grant terminal input,
|
||||
terminal output, or session access.
|
||||
|
||||
All requests are bounded and cancel on runtime stop, source replacement, revoke,
|
||||
Vault lock, owner window replacement, expiry, or user cancellation. The host
|
||||
revalidates authority and expected revision after asynchronous work and before
|
||||
the Vault write or connection dispatch.
|
||||
|
||||
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
|
||||
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
|
||||
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
|
||||
existing 128-character single-line limit. Draft field lengths use
|
||||
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
|
||||
|
||||
Creates produce saved SSH drafts. An empty group clears the saved group. Edits
|
||||
preserve connection endpoints and credentials. Changing a group can change
|
||||
inherited authentication, proxies, and scripts; the draft review states this.
|
||||
Drafts involving managed SSH-config hosts or SSH destinations inside managed
|
||||
groups are explicitly unavailable in this small API. The review disables saving
|
||||
and directs the user to the Vault host editor. The execution adapter enforces the
|
||||
same restriction before claiming action authority, using the existing
|
||||
`applyVaultHostUpdate` ownership rules. It also checks the live and persisted
|
||||
managed-source catalog for changes before writing. This API does not initiate
|
||||
managed-file sync or expose plugin file access.
|
||||
|
||||
The API excludes Vault and renderer stores, the internal MCP catalog, plaintext
|
||||
credentials, private keys, TOTP configuration, startup scripts, and arbitrary
|
||||
execution. Host selection and action UI live in Netcatty. Plugin business logic
|
||||
stays in the sandboxed plugin.
|
||||
|
||||
## Typed target and action authority
|
||||
|
||||
`VaultHostRef` is a signed `host.selection` reference with `scope: "metadata"`,
|
||||
an opaque handle, a revision, and an expiry. It identifies an exact chosen host.
|
||||
It does not authorize editing, connecting, terminal input, or terminal output.
|
||||
|
||||
References bind source, private owner, plugin version, runtime, window owner and
|
||||
generation, exact host ID, revision, and expiry. They live for five minutes at
|
||||
most, with at most 500 retained references. Another selection replaces that
|
||||
runtime's previous references. Lock, revoke, window replacement, and runtime stop
|
||||
invalidate the affected references. Removal or replacement of a host makes its
|
||||
renderer revision stale, even if its visible fields are identical.
|
||||
|
||||
`PluginAISourceBinding` supplies the existing host-only source identity primitive.
|
||||
It revalidates after asynchronous authentication. AI selected-context handles are
|
||||
text-disclosure handles and cannot target these actions.
|
||||
|
||||
The permission engine gates selection with `vault.metadata`, drafts with
|
||||
`vault.write`, and connection requests with `ui.interact`. Every save or connection
|
||||
also requires a separate host-owned confirmation and a signed one-use action
|
||||
claim. A stored permission grant cannot bypass this review.
|
||||
|
||||
After review, the renderer prepares the existing transaction or connection
|
||||
request. It claims action authority after the final asynchronous preparation,
|
||||
immediately before persistence or dispatch. This claim is the action's commit
|
||||
point. Completion only acknowledges the committed action. Automation changes
|
||||
during connection review require fresh confirmation.
|
||||
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
|
||||
the launcher, including Mosh/ET flags and implicit localhost shells. Local
|
||||
execution displays an explicit on-device shell notice and omits the remote SSH
|
||||
username and port.
|
||||
|
||||
## Sample and validation
|
||||
|
||||
`examples/plugins/host-actions` adds four commands through the existing command
|
||||
palette slot. Its create draft uses `synthetic.invalid`; it requests no network,
|
||||
filesystem, terminal-content, or credential permissions.
|
||||
|
||||
`components/PluginHostActions.entrypoint.test.tsx` installs that sample and drives
|
||||
its actual entrypoint, SDK, RPC registry, source binding, signed replies,
|
||||
production React dialog, and renderer execution adapter. It covers explicit
|
||||
selection, cancel, stale revisions, locked Vault, host removal and replacement,
|
||||
revoke, source replacement, runtime removal, and connection confirmation. Registry
|
||||
checks reject text handles, tampered references, caller owners, and mismatched
|
||||
expected revisions. Only synthetic resources are used. The Node transport fixture
|
||||
does not certify the operating-system sandbox.
|
||||
Focused action tests compare Mosh, ET, and localhost confirmations with the real
|
||||
`handleConnectToHostImpl` dispatcher while replacing terminal creation with a
|
||||
synthetic recorder. Managed A-to-B/outside moves, managed destinations, ordinary
|
||||
unmanaged saves, and catalog changes use synthetic source records; no SSH config
|
||||
files are read or written.
|
||||
|
||||
Native screenshots and video run on the parent's existing cloud GUI. This branch
|
||||
does not change Electron sandbox or helper permissions. Tests use synthetic hosts
|
||||
and credentials. No real host connection or credential access is authorized.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Reconcile the late transport checkpoint
|
||||
|
||||
The integration owner is #3993, branch
|
||||
`integration/plugin-byte-stream-handoff`. Its independently rechecked head
|
||||
`6a4fb178cde73b57f78b1711a4e1f9a0f394b4fb` passed both
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40365457418) and
|
||||
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40365457024).
|
||||
|
||||
The original transport owner later published #3988 at
|
||||
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its common baseline with #3993 is
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The late commit has that baseline
|
||||
and source checkpoint `4213d8e6726c44e7a479418ece3adf16ea494782` as parents.
|
||||
The source checkpoint is already an ancestor of #3993.
|
||||
|
||||
## Keep the remaining transport changes
|
||||
|
||||
The late delta is not entirely superseded. An ordinary merge retains its
|
||||
published history and these useful changes:
|
||||
|
||||
- Attach cleanup cancels a producer returned after runtime release exactly
|
||||
once. The new regression fails against the old #3993 service and passes
|
||||
with the late service change. The test uses a loopback fixture and a fake
|
||||
stream producer. It does not enumerate real interfaces.
|
||||
- Service tests cover source replacement before permission publication and
|
||||
during a pending read. A router test checks one close notification for idle
|
||||
peer cancellation. The installed entrypoint also checks consumer cancellation.
|
||||
- CLI and SDK tests cover the public canonical byte-stream requests. Shared
|
||||
manifest checks require the corresponding AI/context feature declarations.
|
||||
No AI runtime, broker, adapter, or renderer implementation is reviewed here.
|
||||
- Database-specific testing moves out of `electron/plugins` into the minimal
|
||||
example. The opt-in test also installs its real package and checks the
|
||||
SDK/RPC path and audit omission. Its live pass at `f1410f8` remains historical
|
||||
evidence, not a new live pass on the current integration head.
|
||||
- The example panel test checks password clearing and text-only result output.
|
||||
|
||||
## Resolve overlap in favor of the integrated behavior
|
||||
|
||||
| Overlap | Resolution |
|
||||
| --- | --- |
|
||||
| Marketplace source files and lockfile | Already identical to the late checkpoint |
|
||||
| Own-machine address comparison | Retain #3993's IPv4 and mapped IPv6 normalization and regression |
|
||||
| Cancelled buffered and in-flight credit | Retain #3993's stream lifecycle fix and regressions |
|
||||
| Service test file | Combine the normalization test with all three late lifecycle/source tests |
|
||||
| Installed entrypoint test | Keep retained-byte revocation coverage and add the late consumer-cancel case |
|
||||
| Shared permission descriptions | Retain the newer connected AI checkpoint's descriptions |
|
||||
| Assistant preparation | Retain the newer six-file staging build |
|
||||
| Contract test | Retain the repaired optional-stream expectation and positive exact-resource assertion |
|
||||
| Database preparation | Both sides minify the bundle. Keep the existing deterministic preparation |
|
||||
| Native instructions | Keep the GUI's actual prerequisites. Its missing Docker and unsupported path to the coding container are not resolved by the late branch's local Docker recipe |
|
||||
|
||||
The original reports are retained in
|
||||
[the historical acceptance record](byte-stream-acceptance.md). Neither their old
|
||||
AI wiring status nor their environment-specific broad-test failures replace the
|
||||
connected implementation and exact-head CI evidence on #3993. No main merge,
|
||||
force-push, endpoint-policy relaxation, AI internal review retry, or release is
|
||||
part of this reconciliation.
|
||||
|
||||
## Check the reconciled source
|
||||
|
||||
The merged transport source passed 627 registered runtime tests: 626 passed and
|
||||
one native Electron test skipped. Contract/SDK/CLI tests passed 90 of 90. The
|
||||
example panel passed; its opt-in database test remained skipped. The late attach
|
||||
regression failed against the old #3993 service with two cancel calls instead
|
||||
of one, then passed after the merge.
|
||||
|
||||
A subsequent example-only Rfam candidate reuses the limited browser protocol
|
||||
through two fixed entrypoints. Its offline tests check the original owned
|
||||
fixture database/query and the fixed public database/query, plus rejection of
|
||||
an unsupported auth plugin. No general query API is exported and no host
|
||||
database code is introduced. The owned MariaDB live regression remains opt-in.
|
||||
@@ -0,0 +1,260 @@
|
||||
# Plugin Allow / Ask / Deny handoff
|
||||
|
||||
This change is stacked on frozen PR #3997 at
|
||||
`677aac33b1eec9dbd4a367417a49c964cdea63e7`. Its source checkout and native QA
|
||||
checkout were not changed. No merge, release, live database connection, native
|
||||
activation or real user configuration change was performed here.
|
||||
|
||||
## Behavior and storage
|
||||
|
||||
- Declared supported capabilities default to Allow, with no installation
|
||||
checklist or required-permission preflight dialogs.
|
||||
- Settings uses existing `SettingRow` and Select components. It shows each
|
||||
declaration's resource scope and effective Allow / Ask / Deny mode.
|
||||
- Policies use `plugin_host_preferences`, keyed by host source owner and
|
||||
capability. They survive restart and updates within the same source, including
|
||||
reintroduced capabilities. New capabilities default to Allow. Existing grants
|
||||
still use the original package/declaration principal; they were not widened.
|
||||
- Explicit source reset clears that source's policies and private plugin data.
|
||||
Another source receives independent defaults. Unrelated Vault, host, settings
|
||||
and user data are preserved. No plugin-policy migration was added.
|
||||
- Policy edits clear grants, cancel pending/in-flight work and invalidate
|
||||
handles. Settings edits require the exact host-issued source/version/policy
|
||||
revision, so stale controls cannot change a replacement source or override a
|
||||
newer Deny.
|
||||
- Ask decisions use the existing in-app Dialog/Button components, reached only
|
||||
through trusted managed-app-window preload IPC. A one-use response token binds the
|
||||
request; close, cancel, lock, blur/hide, reload, renderer crash and shutdown
|
||||
invalidate it. Plugin DOM clicks confer no host gesture authority.
|
||||
- Host selection, model/context selection and draft editing remain workflows.
|
||||
Ask is integrated into their existing host UI where applicable. Allow removes
|
||||
permission-only sharing and connection confirmations while retaining exact
|
||||
target validation and action tokens. Terminal drafts never send PTY input.
|
||||
- AI start combines model permission and exact selected-content scopes into one
|
||||
Ask decision. Its receipt cannot cross an RPC operation. Broker-bound stream
|
||||
reads/stop/close do not ask per chunk. Same-origin network redirects reuse the
|
||||
current operation decision; different origins still require declared bounds
|
||||
and their own authorization.
|
||||
- Policy edits, decisions, use/revoke and operation outcomes remain auditable.
|
||||
Audit records omit note/terminal bodies, model payloads, credentials and tokens.
|
||||
Optional local log persistence remains user-controlled.
|
||||
|
||||
See [the security boundary](security-and-permissions.md) for the storage and
|
||||
update rules. The separate AI cold-start provider-sync fix is not included;
|
||||
`configuredModel.ts`, `trustedHostRuntime.ts` and `usePluginAIContextHost.ts`
|
||||
were left untouched for parent integration.
|
||||
|
||||
## Review corrections after the initial 643e71b checkpoint
|
||||
|
||||
The initial commit and its screenshots remain in history. Registered regressions
|
||||
reproduce three concrete issues on that exact tree and pass with these repairs:
|
||||
|
||||
- Selected-summary Ask now delegates its decision to the existing host chooser.
|
||||
The first and only interaction is target selection; Allow uses that same
|
||||
necessary chooser, while Deny opens none and releases no summaries.
|
||||
- The registry creates a fresh host-only operation identity for every RPC.
|
||||
Internal model contexts retain it when replacing a cancellation signal, so a
|
||||
combined model/content Ask receipt survives that derivation. Receipts also
|
||||
bind plugin, runtime, source, principal and policy revision, and cannot cross
|
||||
operations. Current declaration, policy and selection checks still run.
|
||||
- Changing the privileged development-only `runtime.advanced` policy to Ask or
|
||||
Deny disables/stops the activation through the existing manager mutation queue
|
||||
before the policy IPC completes. A source-owner check inside that queue prevents
|
||||
an old source's teardown from disabling a replacement. Browser sandbox policy
|
||||
and development admission guards remain unchanged.
|
||||
|
||||
Ask now presents the current host-selected model label and Note title/scope.
|
||||
Exact versions remain bound internally. Labels come from the checked broker chat
|
||||
and owned selected summaries;
|
||||
plugins cannot supply operation identities or display fields through RPC params.
|
||||
Opaque selected handles remain internal, and display metadata is not added to
|
||||
audit records. The chooser copy reflects Allow / Ask / Deny without claiming a
|
||||
mandatory second text review. Spanish keys preserve the existing locale parity
|
||||
contract. The quota and expiry regression now uses independent requests to avoid
|
||||
its former 20ms timer race.
|
||||
|
||||
Updated component previews use a realistic opaque selected handle internally:
|
||||
|
||||
| Light Ask | Dark Ask |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
These remain synthetic Chromium component previews, not native QA or a recording.
|
||||
No Library item was created. The original four screenshots below are retained as
|
||||
initial-checkpoint evidence.
|
||||
|
||||
## Latest checks and remaining environment blockers
|
||||
|
||||
- A registered regression reproduces `Plugin permission decision provider failed`
|
||||
on exact `79a9bd6` when a Settings-owned view activates a real `settings.get`
|
||||
API configured Ask while main is unfocused. The follow-up uses the existing
|
||||
managed-window registry (including Settings), per-renderer readiness and the
|
||||
same in-app component. It does not focus main or expand plugin-frame trust.
|
||||
The actual Settings factory, installed package/SDK activation, view command
|
||||
IPC and permission IPC now pass main/Settings/foreign/background, subframe,
|
||||
independent readiness and hide/close/stale-token checks. Native Electron
|
||||
objects are represented in this regression; this is not native GUI acceptance.
|
||||
Focused boundary group: **67 passed**; renderer/native-view visibility group:
|
||||
**three passed**; runtime aggregate: **695 passed/one native skip**. The initial
|
||||
renderer assertion incorrectly expected a view to reappear while the separate
|
||||
permission-settings security surface remained visible; the corrected test
|
||||
checks both overlapping surfaces and restoration after both close.
|
||||
Production build passed (69 seconds). Full lint passes with the same seven
|
||||
warnings after moving a leftover screenshot harness out of the source tree;
|
||||
its initial lint failure is retained. TypeScript remains baseline-equivalent
|
||||
at 774 diagnostics, with zero added/removed after path normalization.
|
||||
- Subsequent exact `da4e809` workflows **40444934631** (tests) and
|
||||
**40444934738** (packages) passed. The full suite reports **15,392 tests:
|
||||
15,342 passed, 50 skipped, zero failures/cancellations**. The runtime group
|
||||
reports 694 passed/one native skip, and package settings 35 passed. All four
|
||||
platform package jobs passed; release jobs were skipped. This does not replace
|
||||
manual plugin native acceptance or erase the local failures below.
|
||||
- A focused wording follow-up removes the implication of mandatory prompts from
|
||||
the package detail's sensitive/unbounded scope text in English, Spanish,
|
||||
Russian and both Chinese locales. The Russian declaration note now also
|
||||
reflects default Allow instead of pre-activation permission ceremonies.
|
||||
The complete local CI boundary command passes after this copy change:
|
||||
contract generation is current, runtime 694 passed/one skip, examples five
|
||||
passed/one skip, and package settings 35 passed. Locale parity six passed;
|
||||
changed-file lint has no errors/warnings.
|
||||
- Exact `2eee561` packaging workflow **40443396402 passed** on Linux x64,
|
||||
Linux arm64, macOS and Windows; release jobs were skipped. Its first Linux
|
||||
test job **121988800130** stopped on a runner shutdown signal, with no observed
|
||||
assertion failure. Parent requested one retry. Fresh job **121992430185**
|
||||
passed the 694-test runtime aggregate (one native skip) and failed two
|
||||
package-detail assertions still expecting the superseded "not a grant" copy.
|
||||
The full suite was skipped. Those assertions now verify default Allow,
|
||||
adjustable Ask/Deny and retained host/resource validation; development-runtime
|
||||
disclosure and HTML escaping remain checked. No production behavior changes
|
||||
accompany this test correction. Original interruption/failure logs are kept.
|
||||
- Registered runtime recheck: **695 tests, 694 passed, zero failed/cancelled, one
|
||||
native skip**. Separate final registered receipt/authority group: **21 passed**.
|
||||
- Serial mounted renderer/Host/selected-summary/panel/locale group: **35 passed**.
|
||||
A preceding concurrent run timed out during build/lint load; its failure log is
|
||||
retained. No timeout result is reclassified as a pass.
|
||||
- Advanced-runtime manager/source lifecycle and AI group: **35 passed**.
|
||||
Receipt/declaration group: **32 passed**. Final engine/registry/manager/host
|
||||
boundary group: **71 passed**. Final registered AI audit/receipt check: **one
|
||||
passed**; an earlier 15-second timeout while the preview was active is retained.
|
||||
The serial rerun passed in five seconds. Contract/SDK/CLI: **91 passed**.
|
||||
- Final production build passed in **45 seconds**. Full lint has zero errors/seven
|
||||
existing warnings. Full TypeScript remains **774 diagnostics**, identical to
|
||||
frozen 677 after path normalization, with zero added/removed diagnostics.
|
||||
- The normal official Electron installer succeeded using its documented proxy
|
||||
support and a task-local cache. Exact **42.11.12** executes in Node mode; no
|
||||
shared libraries are missing. This is not native GUI acceptance.
|
||||
- Exact-header setup via the installed node-gyp 12.4.0 and the unchanged rebuild
|
||||
tool's official URL failed. The proxy returns **403 Forbidden** for
|
||||
`https://www.electronjs.org/headers/v42.11.12/node-v42.11.12-headers.tar.gz`.
|
||||
That download stopped; no mirror, alternate host, checksum bypass, dependency
|
||||
shim, native-rebuild skip, HOME change or sandbox change was used. Normal
|
||||
postinstall/native rebuild and desktop packaging remain incomplete.
|
||||
- A private task-local `TMPDIR` uses Netcatty's existing safe-temp resolution to
|
||||
its normal `Netcatty` subdirectory; HOME remains unchanged. This fixes the
|
||||
earlier fallback-to-unwritable-home prerequisite without changing app code.
|
||||
- A **diagnostic full-suite run on the evolving worktree**, after those environment
|
||||
repairs, reported **15,059 tests: 14,922 passed, 92 failed, two cancelled, 43
|
||||
skipped**. A stuck FIDO child was terminated after its timeout; this is not a
|
||||
successful completed exact-head aggregate. Frozen-baseline focused runs
|
||||
reproduce its FIDO timeout and **28 failures** across native imports, Cursor
|
||||
fixtures and repository postinstall patch checks, plus the earlier six
|
||||
Markdown comparison assertions. Other failures remain unclassified. The
|
||||
permission locale parity and obsolete forced-review expectations found by that
|
||||
run were corrected and pass focused checks. Original failed/incomplete runs
|
||||
below are preserved.
|
||||
|
||||
The independent AI cold-start production files remain untouched. This branch
|
||||
updates only policy expectations in `components/PluginAIContextHost.test.tsx`;
|
||||
parent integration must retain the AI owner's actual-main-allowlist and provider
|
||||
sync assertions when merging that test file.
|
||||
|
||||
## Local evidence
|
||||
|
||||
These are local code/test results, not native acceptance:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime aggregate, concurrency 2 | 693 tests: 691 passed, one failed, one native skip; no cancellations. Sole failure imports the missing Electron binary on real worker reconnect. |
|
||||
| Engine, policy matrix, trusted prompt and policy IPC group | 79 passed; subsequent shutdown guard test: 9 passed. |
|
||||
| Mounted in-app permission controls and Host/terminal workflows | 22 passed. Includes Host Allow/Ask/Deny, policy change during save preparation, exact targets and stale replies. |
|
||||
| Registered AI/context + policy group | 7 passed, including normal Allow, combined Ask, Deny and revocation. |
|
||||
| Source/version/revision edit guards and registered byte-stream group | 47 passed. |
|
||||
| Contract/SDK/CLI suite | 91 passed. Four prepared sample archives validate. |
|
||||
| Lint | Zero errors, seven existing warnings. |
|
||||
| Production build | Passed; final `pack:dir` build also passed in 76 seconds. |
|
||||
| Contract/capability/Codex generation | Regenerated/checks clean; no capability-tool diff. |
|
||||
| Full TypeScript check | Frozen baseline and implementation each have 774 diagnostics; zero added/removed after normalizing worktree paths. |
|
||||
| Full `npm test` | Incomplete: stopped after three unchanged bridge child processes remained pending. Reported 9,891 tests, 9,359 passed, 40 failed, 478 cancelled, 14 skipped. This is not a passing aggregate. |
|
||||
| Desktop `pack:dir` | Unpassed: native rebuild attempts `/home/agent/.electron-gyp`, outside this workspace's writable roots. Earlier pinned Electron installation failed with `fetch failed`. No shim, alternate runtime or sandbox change was used. |
|
||||
|
||||
Frozen-baseline focused runs reproduce the two large-history SFTP recovery
|
||||
failures, six Markdown comparison failures, and missing-Electron adapter/serial
|
||||
failures observed in the broad run. Other broad-run failures remain unclassified;
|
||||
unchanged code alone does not prove a baseline failure. One earlier runtime
|
||||
aggregate hit the existing 15-second byte-stream fixture timeout during concurrent
|
||||
builds; its focused run and the final aggregate above pass that case.
|
||||
|
||||
Logs and archives are in `/workspace/permission-evidence/` in this execution
|
||||
environment. The final task report identifies any remaining blockers and checks.
|
||||
Exact-head CI and native acceptance remain pending parent coordination.
|
||||
|
||||
## Component screenshots
|
||||
|
||||
Chromium rendered the actual React components with built-in light/dark tokens
|
||||
and synthetic host bridge data. These images demonstrate layout and state only;
|
||||
they do not certify Electron behavior.
|
||||
|
||||
| Controls | Ask |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## Prepared sample packages
|
||||
|
||||
All packages were built locally; none was activated in a native app. No owned or
|
||||
public database service was contacted. The owned database package is scoped to
|
||||
the previously specified disposable fixture endpoint `tcp://10.77.0.20:3306`.
|
||||
|
||||
| Archive | SHA-256 |
|
||||
| --- | --- |
|
||||
| assistant.ncpkg | `c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395` |
|
||||
| host-actions.ncpkg | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` |
|
||||
| terminal-selection-draft.ncpkg | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` |
|
||||
| database-owned.ncpkg | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` |
|
||||
|
||||
Reproduction uses the unchanged sample build tools:
|
||||
|
||||
```sh
|
||||
node examples/plugins/assistant/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant.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
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-owned.ncpkg
|
||||
```
|
||||
|
||||
## Parent-serialized native recheck
|
||||
|
||||
1. Use a fresh isolated app profile and the exact next integrated head. Import and
|
||||
enable a sample: no installation permission ceremony should appear.
|
||||
2. In Allow, select hosts in the host chooser, create/edit via the normal draft
|
||||
editor, and connect using a current selected reference without a separate
|
||||
plugin confirmation. Ordinary SSH authentication and host-key checks remain.
|
||||
3. Select a Note/context and model, start and read a turn in Allow. Then switch
|
||||
AI/content to Ask: one scoped decision covers the turn and exact references,
|
||||
with no per-chunk or second content-sharing dialog.
|
||||
4. Check terminal Allow sharing and the editable inert draft; Ask shows one share
|
||||
preview; Deny releases no selection and cannot reopen old handles.
|
||||
5. Check network Allow/Ask/Deny at the declared endpoint. Preserve all endpoint,
|
||||
DNS, redirect, private-network and control-plane rejection checks.
|
||||
6. Flip policy while a chooser, draft, model read or network operation awaits.
|
||||
Old results/tokens must fail. Switch back to Allow and start a fresh operation.
|
||||
7. Restart/update the same source with an explicit Deny; it must remain Deny.
|
||||
Check distinct plugins and sources, stale settings edits and explicit source
|
||||
reset. Review bounded audit events and optional local logging for redaction.
|
||||
8. Open a plugin view from standalone Settings and exercise an Ask-gated API
|
||||
during activation and from its own view command. Ask must be reachable in
|
||||
Settings without focusing main or losing the view. Close/hide/reload that
|
||||
window, switch to an unrelated window, and try a stale response: old authority
|
||||
must be cancelled, with no background-window fallback or plugin-frame reply.
|
||||
|
||||
No merge, release or native acceptance is authorized by this handoff.
|
||||
@@ -0,0 +1,189 @@
|
||||
# Permission-policy and AI acceptance candidate
|
||||
|
||||
This records frozen 020 and its predecessor evidence. For the later managed-
|
||||
window Ask correction, use [the isolated Ask integration record](ask-routing-integration.md).
|
||||
020 native Default Allow/Stop/Deny/restore/themes passed; Ask had no visible
|
||||
dialog and timed out with zero new model requests. Ask acceptance/cancel and
|
||||
the content-read matrix remain unrun until that new candidate is checked.
|
||||
|
||||
Use the dedicated `integration/plugin-policy-ai-acceptance` branch. Its Draft PR
|
||||
records the exact published head and aggregate CI. Frozen #3997 at
|
||||
`677aac33b1eec9dbd4a367417a49c964cdea63e7` remains the historical native baseline;
|
||||
neither that branch nor main is changed by this integration.
|
||||
|
||||
This is a candidate for parent-coordinated acceptance. It is not a claim of
|
||||
completed native acceptance, deployment readiness or permission activation.
|
||||
|
||||
## Preserved inputs
|
||||
|
||||
| Input | Exact checkpoint | Integration |
|
||||
| --- | --- | --- |
|
||||
| Frozen integration | `677aac33b1eec9dbd4a367417a49c964cdea63e7` | Starting point; includes stream/source/Host/settings and prior terminal history |
|
||||
| Permission policy #4000 | `79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9` | Initial ordinary merge `9aa899059` preserved 2eee561; follow-up ordinary merge `cfa13680e` preserves the authoritative scope/sensitivity copy and tests |
|
||||
| AI #3986 | `d33c0193ccaedc1a10aed9da495f040250121bef` | Ordinary merge `5d2ff1cb8`; cold-start wiring, themed sample and Node 22 warning-drain test correction |
|
||||
| Terminal production | #3996 `8e7c28fe1bdbcdd76816d33ec926ba8e60eefe64` | Preserve the prior renderer eligibility/publication path; policy-owned interaction changes remain from #4000 |
|
||||
|
||||
Diagnostic #3999 and its tests/docs-only `3fdaa357` continuation are not merged.
|
||||
No terminal runtime fix is required by the fixture-classification finding.
|
||||
Database protocol and fixed SQL remain solely in the minimal examples.
|
||||
|
||||
## Direct integration decisions
|
||||
|
||||
The initial merge conflict was `components/PluginAIContextHost.test.tsx`. The
|
||||
combined test keeps policy's absence of a second native content-sharing review
|
||||
and AI's actual main allowlist, two-provider synchronization, streamed result,
|
||||
Stop/lock cleanup and empty SDK-error assertion. The Node 22 warning drain is
|
||||
retained. The obsolete forced-review cancellation branch is superseded by the
|
||||
current in-app Ask/cancellation tests; no transport assertion is dropped.
|
||||
|
||||
No production file needed a manual conflict resolution. Policy production files
|
||||
remain identical to the policy input; the three AI startup wiring files and
|
||||
sample assets remain identical to d33c0193. The stream service/router and prior
|
||||
terminal renderer eligibility/publication files are unchanged from frozen 677.
|
||||
|
||||
Integration commit `7d8a354404d68071d0924532ffe74c713ace6cb9` updates two stale
|
||||
disclosure assertions in `PluginPackageManager.test.tsx` and
|
||||
`PluginMarketplace.interaction.test.tsx`. They expected the old words
|
||||
`not a grant`; the new assertions require default Allow, Ask/Deny controls and
|
||||
retained host validation/resource scope. Existing escaping, advanced-authority,
|
||||
source and README-failure assertions remain intact. Production copy is unchanged.
|
||||
|
||||
The later authoritative 79a9bd6 policy merge overlapped those two tests. Their
|
||||
final contents are byte-identical to 79a9bd6: all three earlier policy assertions
|
||||
remain, together with the owner's additional unbounded-resource, sensitivity,
|
||||
escaping/source-order and advanced-authority checks. No assertion was dropped.
|
||||
This follow-up imports the owner's five-locale wording changes; it makes no
|
||||
manual production conflict edit and leaves the shared AI test intact.
|
||||
|
||||
## Checks and retained failures
|
||||
|
||||
| Local check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime | 695 tests: 694 passed, one native skip, zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one explicit Docker opt-in skip |
|
||||
| Serial mounted AI/permission/selected-summary/Host/provider/panel group at 7856 | 31 passed |
|
||||
| Final settings, terminal interaction, cold-start host and locale group | 90 passed |
|
||||
| Contract/capability/Codex generation and drift | Passed |
|
||||
| Full lint | Zero errors, seven existing warnings |
|
||||
| Production build | Passed; Vite 35.50 seconds |
|
||||
| Five sample archives | Validated; repeat builds byte-identical |
|
||||
|
||||
The initial settings group records 34 passes and two failures on the stale copy
|
||||
assertions. That run is retained separately from the corrected 36/36 result.
|
||||
|
||||
Policy input [test CI 40443396430](https://github.com/binaricat/Netcatty/actions/runs/40443396430)
|
||||
first received an explicit runner shutdown. The parent requested one retry;
|
||||
this integration task did not duplicate it. Retry Linux job `121992430185`
|
||||
passed registered runtime (694/one skip) and examples (five/one skip), then
|
||||
failed those same two `not a grant` assertions: settings group 33 passed/two
|
||||
failed. It did not reach the full suite. The candidate includes the isolated
|
||||
test correction above; do not relabel the input CI as passing. Both input Windows
|
||||
jobs and [policy package CI](https://github.com/binaricat/Netcatty/actions/runs/40443396402)
|
||||
passed. Parent reports the independent policy correction reviews scoped clean,
|
||||
including both P2s and the three UI findings, with 71 focused/synthetic checks.
|
||||
|
||||
The policy owner subsequently corrected these tests in predecessor
|
||||
`da4e80946d2f3fb48f205aae2a732252b2f51fca`. Its
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40444934631) passed
|
||||
15,342 cases with 50 skipped and no failures/cancellations, and its
|
||||
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40444934738)
|
||||
passed. The final 79a9bd6 wording head's
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40446529092) passed;
|
||||
its [package workflow](https://github.com/binaricat/Netcatty/actions/runs/40446529100)
|
||||
is tracked separately in the Draft PR. No input retry was duplicated here.
|
||||
|
||||
Integration predecessor `7856c664c3c2255a21badef2758bb29311b27a01` passed its
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40445809589): 15,344
|
||||
passed, 50 skipped, zero failed/cancelled, plus both Windows jobs and native
|
||||
automated checks. Its [four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40445809590)
|
||||
also passed. These are retained predecessor results; the new exact head needs
|
||||
its own aggregate CI.
|
||||
|
||||
AI input [test CI](https://github.com/binaricat/Netcatty/actions/runs/40439111241)
|
||||
and [package CI](https://github.com/binaricat/Netcatty/actions/runs/40439111215)
|
||||
passed. Parent reports its three isolated changes scoped clean. Its prior 9ca
|
||||
Node 22 warning-observer failure remains documented in `ai-provider-startup.md`;
|
||||
the warning drain does not ignore errors or remove the empty-error assertion.
|
||||
Frozen 677's earlier unexplained MCP assertion remains separate history.
|
||||
|
||||
This executor used locked dependencies with install scripts disabled, then the
|
||||
standard repository dependency patches. The official pinned Electron binary
|
||||
installed through its documented proxy support and executes in Node mode.
|
||||
No native-header download, alternate mirror, native shim, HOME change or
|
||||
sandbox change was used. Normal native postinstall/rebuild, local full-suite
|
||||
success and local desktop packaging are not claimed. Exact candidate CI is the
|
||||
aggregate full-suite and platform-packaging verification surface.
|
||||
|
||||
## Exact packages and provenance
|
||||
|
||||
The machine-readable [package record](policy-ai-packages.json) contains complete
|
||||
IDs, versions, required features, permission/resource declarations, archive and
|
||||
content hashes, and frozen-baseline comparisons. All five versions are 0.1.0.
|
||||
|
||||
| Sample | SHA-256 | Comparison with frozen 677 |
|
||||
| --- | --- | --- |
|
||||
| Assistant | `7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274` | Matches the AI owner's new themed archive; only README.md, panel.css and panel.js differ from 677 |
|
||||
| Owned database | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` | Byte-identical |
|
||||
| Rfam | `aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3` | Byte-identical |
|
||||
| Host actions | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` | Byte-identical |
|
||||
| Terminal draft | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` | Byte-identical |
|
||||
|
||||
After the authoritative 79 follow-up, all five archives were rebuilt twice,
|
||||
validated and compared byte for byte with the 7856 candidate: all five remain
|
||||
identical. No sample permission/resource declaration changed. The host's new policy changes
|
||||
effective authorization behavior; identical package bytes do not certify that
|
||||
new host behavior. The old Assistant c7d784d5 archive remains historical evidence,
|
||||
not the themed archive. No handoff archive was activated by this coding executor.
|
||||
The owner-identical Assistant README retains earlier permission-review wording;
|
||||
use this candidate's Allow/Ask/Deny procedure below for acceptance. That packaged
|
||||
copy is a documentation limitation, not a requirement for repeated dialogs.
|
||||
|
||||
## Native preparation and remaining gates
|
||||
|
||||
Use the exact candidate head from its Draft PR in a separate disposable native
|
||||
checkout/profile. Keep normal platform sandbox/helper settings. Build and hash:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build
|
||||
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
|
||||
```
|
||||
|
||||
Validate each archive with the plugin CLI and compare against the machine record.
|
||||
The owned DB address is packaging-only. A different approved fixture endpoint
|
||||
requires a new archive and hash. Preparation does not dial either endpoint.
|
||||
|
||||
- Verify default Allow for declared supported capabilities without a mandatory
|
||||
first-install permission checklist or repeated permission-only dialogs. Verify
|
||||
per-plugin Ask/Deny, one scoped Ask receipt, readable host-selected labels,
|
||||
lifecycle invalidation and redacted audit. Preserve necessary target selection
|
||||
and inert draft editing, exact resources, source isolation and sandboxing.
|
||||
- Verify the assistant's cold startup through the normal host without opening
|
||||
the AI sidebar. Native inference on frozen 677 returned unavailable; the
|
||||
independent startup regression did not establish that native error's cause.
|
||||
Use only the deterministic synthetic provider and Note recipe, with current
|
||||
policy semantics. Native success is not claimed before that recheck.
|
||||
- Frozen 677 Host core passed per parent; old consent recordings describe the
|
||||
prior policy. They do not certify the new permission implementation.
|
||||
- Terminal's source-level finding is fixture-sensitive-input fail-closed:
|
||||
`fixture-A> ` is untrusted; `fixture@owned-A:~$ ` is confirmed shell. Parent
|
||||
reports corrected-fixture solo/split core passed on unmodified 677. Focus
|
||||
remained unreliable/unverified, and QA sent one Enter in A during a palette
|
||||
attempt. Do not claim global zero input or full terminal native acceptance.
|
||||
Keep the inert draft's no-PTY-dispatch property distinct from that QA action.
|
||||
Exclude diagnostic instrumentation. Prior-policy results do not certify the
|
||||
new policy; the next native priority is final-policy and cold-start AI QA.
|
||||
- Native Rfam's single baseline run failed with sanitized -32014 socket error
|
||||
before connect resolved. Auth/SELECT were not reached; transport causality and
|
||||
auth/TLS compatibility remain unverified. No probe/fallback or restriction
|
||||
change is authorized by this coding task. Owned DB fixture prerequisites and
|
||||
parent coordination remain separate.
|
||||
- Record exact head, hashes, ordered prompt/operation outcomes, screenshots and
|
||||
video for the new candidate. Hosting/distribution/deployment remain pending;
|
||||
no main merge, Ready transition or release is part of this handoff.
|
||||
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user