Compare commits

...
Author SHA1 Message Date
陈奇 14ed19d2ed test(plugins): compose two-profile settings sidecar collect and hydration 2026-10-27 12:23:21 +00:00
陈奇 d40b0d3c25 docs(plugins): prepare Settings visibility and current-main acceptance handoff 2026-10-27 07:08:50 +00:00
陈奇 714e2397a4 Merge reviewed Settings native-view visibility correction 2026-10-27 06:59:39 +00:00
陈奇 c4aedfa9ed Merge current main owner handoff and prompt fixes into isolated plugin acceptance 2026-10-27 06:59:39 +00:00
陈奇 1551fa8355 fix(plugins): suspend native views only for active log dialogs 2026-10-27 06:55:01 +00:00
a0cf2b487c fix(mcp): recognize Oh My Zsh ys two-line idle prompts (#4008)
The two-line idle prompt check only accepted a classic user@host host
line, so Oh My Zsh ys (`# user @ host ...` then a bare $/#) never became
ready and terminal_execute sent nothing. Match that host line narrowly
and still reject a lone marker.

Fixes #4007

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

Keep terminal-scoped AI chat on its surviving owner pane when dissolving a workspace; remint onto preferred only when the owner is gone.
2026-10-27 13:58:44 +08:00
陈奇 2433cf0130 docs(plugins): prepare isolated Ask routing QA and preserve 020 results 2026-10-27 05:46:36 +00:00
陈奇 1b21c41fa2 Merge reviewed managed-window Ask routing into isolated acceptance candidate 2026-10-27 05:31:36 +00:00
陈奇 89beeb9374 fix(plugins): route Ask to focused managed app windows 2026-10-27 05:06:38 +00:00
24 changed files with 1312 additions and 66 deletions

No files matched your search

+16 -1
View File
@@ -25,6 +25,7 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
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');
@@ -50,6 +51,9 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
},
} });
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) => {
@@ -65,14 +69,22 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
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 () => {
@@ -82,11 +94,14 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
}); 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()); host.shutdown(); engine.shutdown(); dom.window.close();
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); }
}
});
+2
View File
@@ -27,6 +27,7 @@ import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister"
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
import { usePluginContributions } from "../application/state/usePluginContributions";
import { PluginContributionHost } from "./plugins/PluginContributionHost";
import { PluginPermissionHost } from "./plugins/PluginPermissionHost";
import { matchesKeyBinding } from "../domain/models";
import { isPrimaryModifierWBinding } from "../application/state/windowCommandClose";
@@ -720,6 +721,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</Tabs>
</div>
<ExternalMcpApprovalsHost />
<PluginPermissionHost />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
</>
);
+1 -1
View File
@@ -13,7 +13,7 @@ export function PluginPermissionHost() {
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 className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
<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>
@@ -19,7 +19,7 @@ export function PluginLogSettings() {
const disclosure = zh
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
return <div data-host-security-surface="">
return <div>
<Dialog open={open} onOpenChange={setOpen}>
<SettingRow label={title} description={zh ? '选择是否在此设备保存审计记录和诊断日志。' : 'Choose whether to keep audit records and diagnostics on this device.'}>
<div className="flex items-center gap-3">
@@ -53,6 +53,6 @@ export function PluginLogSettings() {
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
<ConfirmDialog hostSecuritySurface open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
</div>;
}
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('real Settings landing leaves views visible while idle; log and Ask dialogs suspend and restore them', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="native-view-mount"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'DocumentFragment', 'CustomEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { PluginPermissionHost } = await import('../../plugins/PluginPermissionHost');
const { observePluginHostSecurityVisibility } = await import('../../../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../../../electron/plugins/permissionDecisionHost.cjs');
type Prompt = NetcattyPluginPermissionPrompt | { requestId: string; closed: true };
const listeners = new Set<(event: Prompt) => void>();
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: Prompt) => { for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const host = new PluginPermissionDecisionHost({ getWindows: () => [owner], isLocked: () => false });
let logStatus = { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return logStatus; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { logStatus = { ...logStatus, ...value }; return logStatus; },
onPluginPermission(listener: (event: Prompt) => void) { listeners.add(listener); return () => listeners.delete(listener); },
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
} });
const manager = { plugins: [], available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true,
async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let visible = true;
const transitions: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(document.getElementById('native-view-mount')!, (next) => { visible = next; transitions.push(next); });
const root = createRoot(document.getElementById('root')!);
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((element) => element.textContent === label)!;
const flushVisibility = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={manager} settingsAvailable={new Set()} onManage={() => {}} /><PluginPermissionHost /></I18nProvider>));
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.ok(button('Configure'), 'real idle logging row is present');
assert.equal(visible, true, 'idle Settings landing must not hide the native child');
assert.deepEqual(transitions, []);
await act(async () => button('Configure').click());
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'actual logging security dialog must hide the child');
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[0].click());
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[1].click());
await flushVisibility();
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.equal(visible, false, 'the verbose security confirmation must also keep the child hidden');
await act(async () => button('Cancel').click());
await flushVisibility();
assert.equal(visible, false, 'cancelling verbose returns to the protected logging dialog');
await act(async () => button('Close').click());
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(visible, true, 'closing logs must restore the child while its idle row remains');
let pending!: Promise<{ decision: string }>;
await act(async () => {
pending = host.request({ requestId: 'settings-visibility-ask', pluginId: 'com.example.settings-visibility', pluginName: 'Settings visibility fixture', pluginVersion: '1.0.0',
permission: 'settings.read', resources: ['*'], reason: 'Read own synthetic setting' });
});
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'Ask must still hide the child above the real landing');
await act(async () => { button('Deny').click(); await pending; });
await flushVisibility();
assert.equal((await pending).decision, 'deny');
assert.equal(visible, true, 'dismissing Ask must restore the same child');
assert.deepEqual(transitions, [false, true, false, true]);
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+3 -1
View File
@@ -17,6 +17,7 @@ interface ConfirmDialogProps {
confirmLabel: string;
busy?: boolean;
destructive?: boolean;
hostSecuritySurface?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
@@ -28,6 +29,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
confirmLabel,
busy = false,
destructive = false,
hostSecuritySurface = false,
onOpenChange,
onConfirm,
}: ConfirmDialogProps) {
@@ -35,7 +37,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogContent data-host-security-surface={hostSecuritySurface ? "" : undefined} className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">{title}</DialogTitle>
</DialogHeader>
@@ -0,0 +1,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": []
}
]
@@ -23,7 +23,7 @@ activation or real user configuration change was performed here.
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 main-window preload IPC. A one-use response token binds the
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.
@@ -85,6 +85,50 @@ initial-checkpoint evidence.
## Latest checks and remaining environment blockers
- The native candidate `2433cf0130d1c3274c56c39e5765349c2945de01` retains the
same idle logging-row marker as `89beeb9` (source blob
`4dd5a7cea4ae5a6addc5db5c162038d8fa44075c`). Actual Settings landing plus the
existing observer reproduces native-child suppression with no dialog open.
This source defect is separate from the worker's earlier GPU exit and does
not alone establish the cause of the recorded blank native body.
- The scoped correction removes only the idle-row marker. Actual logging and
Ask dialogs remain marked. A real nested verbose confirmation test also
reproduced premature restoration when its parent became accessibility-hidden;
that confirmation now opts into the existing marker on its own dialog content.
The observer, window trust, permissions and data access are unchanged. The real
landing regression verifies idle visible, each security dialog hidden, and
restored on dismissal. Focused group **six passed**; Settings boundary group
**36 passed**. Both pre-fix failures are retained outside the source tree.
Production build and full lint pass (zero lint errors/seven existing warnings).
Full TypeScript remains baseline-equivalent at 774 diagnostics with zero
added/removed. Exact-head CI and the next frozen native recheck are separate
gates; neither is inferred from these local tests.
- QA-only **Settings Ask QA 0.1.0** uses its supported `location: settings` entry
and public SDK to read only its synthetic own setting after an explicit click.
Exact archive SHA-256:
`9502a58a4804862e4b13de9fc0d1baf6ad8c2bc681c08b5c0664b552ef0aafc4`.
Its HTML/CSS/JS each return 200 with proper MIME/CSP through the actual plugin
protocol. It contains no raw IPC/test hook or external access. No GUI or running
app was changed by this correction; parent will build a new frozen candidate
after current cleanup and repeat native Settings acceptance.
- 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
@@ -233,5 +277,10 @@ node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/da
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.
@@ -1,5 +1,11 @@
# 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;
@@ -37,9 +37,19 @@ before commits and content delivery, and remain invalid after a Deny-to-Allow
flip. A late prompt reply cannot revive old work. The user must start a fresh
operation after changing policy.
Ask uses Netcatty's existing in-app Dialog, typography and theme. Only the trusted
main window can receive requests or return scoped single-use tokens; close,
cancel, timeout, lock, hide, reload, crash and stale runtime/source fail closed.
Ask uses Netcatty's existing in-app Dialog, typography and theme. Its recipient
must be a ready, focused, visible, unlocked top-level app renderer in the managed
window registry, including the standalone Settings window. Arbitrary windows,
plugin view contents and subframes cannot register or respond. Each single-use
token and cancellation lifecycle remains bound to the receiving window; another
renderer's readiness changes cannot cancel or inherit that request. No main-window
focus handoff is needed. Close, cancel, timeout, lock, hide/blur, reload, crash
and stale runtime/source fail closed. The dialog uses the existing host security
surface marker so attached native plugin views yield while it is visible and
restore through their existing lifecycle on dismissal.
Ordinary landing rows do not suspend native views. The logging dialog and its
verbose confirmation are marked security surfaces; the confirmation opts into
the existing marker because it temporarily accessibility-hides its parent dialog.
A plugin DOM click never becomes a host gesture. Choosers, drafts, terminal and
clipboard sharing use their existing host-owned action tokens. Ask integrates
with that workflow instead of opening a second capability dialog. Allow skips
@@ -0,0 +1,140 @@
# Settings visibility and current-main acceptance candidate
This isolated branch, `integration/plugin-settings-main-acceptance`, starts at
frozen #4004 `2433cf0130d1c3274c56c39e5765349c2945de01`. Its Draft PR and private
renderer provenance record the final exact head and CI. Frozen2433 source,
artifact, QA workspace/profile and earlier evidence remain intact.
## Inputs and production ownership
Both inputs were ordinarily merged, without conflicts or manual production edits:
| Input | Import | Production files and behavior |
| --- | --- | --- |
| Current main `a0cf2b487c9ff0b0472d622e66bc5ee20de32c71` | Merge `c4aedfa9ed8c9f2647ac26f0e6e144edab341c2d` | `domain/workspaceAiScopeHandoff.ts` keeps inherited AI chat on a surviving owner when a workspace dissolves (#4003/e3e2dcfe); `electron/bridges/ai/shellUtils.cjs` narrowly recognizes Oh My Zsh ys two-line idle prompts (#4008/a0cf2b48) |
| Policy `1551fa8355021b5a811d737d16f81e8070f99f5d` | Merge `714e2397a43dc6c083dce8afeeb7a0475de11384` | `PluginLogSettings.tsx` removes the idle-row security marker and opts the nested verbose confirmation into it; `confirm-dialog.tsx` adds the optional host-only marker with default false |
All four production files exactly match their respective owners. Main has only
those two new commits since common base f0897c3c. Neither changed file overlaps
the prior plugin changes. The new prompt utility is not imported by plugin
production modules; plugin sensitive-input, selection and source guards remain
unchanged. Existing prompt and live-PTY-byte suffix tests still reject bare
markers and pending input. These are synthetic tests, not a new native SSH pass.
The input also owns its real Settings landing regression and documentation.
The existing observer, Ask marker, IPC/window authority, sandbox, resource/source
controls, AI d33 wiring, schemas, sample bytes and package/lock inputs are
unchanged. `ModelSelector.tsx` equals2433: separate #4002 is not included. Global
Agent/custom Chat #4006 and terminal diagnostic #3999 are excluded. No main merge,
Ready transition, original-PR closure or release is part of this handoff.
The parent reports the1551-vs89 independent delta review scoped clean: idle
Settings no longer requests suspension, the real logging dialog remains marked,
its nested verbose portal protects itself while the parent is accessibility-
hidden, closing the nested dialog leaves the parent protected, and closing all
dialogs permits restoration. This source-only result is not native resolution
of the recorded Settings blank body.
## Frozen2433 native results
The parent supplied these results; this coding executor did not run the GUI:
| Case | Result on2433 |
| --- | --- |
| Main Ask visible | Passed |
| Cancel | Passed; zero provider POSTs |
| Accept | Passed; one provider POST |
| Stream chunks | Passed; no repeated permission prompts |
| Content Deny | Passed |
| Real five-minute chat expiry | Passed |
| Settings sample body | Blank; not accepted |
The idle-row suppression defect is source-confirmed. The correction requires a
new native Settings recheck; no native success is inferred from the DOM regression.
A later GPU exit9 and incomplete cleanup were reported separately. Its cause is
unclassified; no source hotpatch or sandbox/GPU workaround was used here.
Consumer verification of2433's Library renderer is complete: the parent checked
the matching full source/backend and all1001 renderer hashes, with native inputs
unchanged. The earlier producer download failure remains historical evidence,
not an outstanding consumer transfer blocker. That verified artifact still
certifies2433 only; it does not include1551 or the new main renderer behavior.
Earlier020 Default Allow cold Send/completion, Stop/Deny/restore and themes,
677 Host core, and corrected-fixture terminal solo/split results remain scoped
historical evidence. Terminal Focus is unreliable/unverified and QA sent one
Enter inA during a palette attempt: no global zero-input/full-terminal-pass claim.
The historical Rfam attempt failed socket transport before connect resolved;
authentication/SELECT were not reached and cause/auth/TLS compatibility remain
unverified. Final source-policy, DB, placement/lifecycle and actual hosting/CDN
gates remain separately reconciled by the parent.
## Local checks and sample provenance
| Check | Result |
| --- | --- |
| Registered plugin runtime | 695 passed, one native skip; zero failed/cancelled |
| Contract/SDK/CLI | 91 passed |
| Offline examples | Five passed, one Docker opt-in skip |
| Changed main owner/scope/prompt/suffix and terminal lifecycle group | 139 passed |
| Actual mounted AI/permission/selected-context/Host group | 21 passed |
| Settings boundary and security visibility group | 38 passed |
| Contract/capability/Codex generation and committed drift | Clean |
| Lint | Zero errors, seven existing warnings |
| Five sample archives | Validated, built twice and byte-identical to2433 |
The final exact-head normal production build, renderer inventory, bounded
credential check, CI and Codex review are recorded in the Draft PR/artifact
handoff. Local full-suite, native packaging and GUI success are not claimed.
Locked dependencies, standard patches and the official pinned Electron binary
were prepared normally; no header, HOME, sandbox or native shim workaround.
[Exact sample IDs, permissions/resources and hashes](settings-main-packages.json)
preserve all prior provenance, including Assistant7943d4c3, ownedDBf01c6a24,
Rfamaaca5866, Hostcf04f82d and terminal4c9c2388. Whole archive equality was
asserted, rather than inferred from unchanged source. The owned10.77.0.20 endpoint
is packaging-only, not a running fixture. No package was activated or service
queried by this executor.
The owner's separate QA-only Settings Ask sample has reported SHA-256
`9502a58a4804862e4b13de9fc0d1baf6ad8c2bc681c08b5c0664b552ef0aafc4`.
It is not one of these five repository examples and was not rebuilt here. The
native owner must retain its exact verified manifest/source/permissions and
fixture provenance; no package ID, permission expansion or hash equivalence is
invented by this integration.
## Native preparation and bounded recheck
1. After the parent's GUI/Nginx slot is released, use a NEW isolated checkout of
the final exact head and a separate synthetic QA profile. Preserve the old2433
workspace/profile and recordings. Materialize the new private Library artifact
in the consumer's own workspace and verify archive and every renderer hash.
2. Use the complete matching backend checkout: frontend-only dist does not carry
the CJS prompt helper or native dependencies. Match package/lock hashes to
independently normally rebuilt native inputs and prepare workspace packages
normally. Install dist as a whole; use the ordinary Electron launch without
Vite and with normal sandbox/helper settings. This is unpackaged native
production-renderer QA, not packaged-binary certification.
3. Open the verified Settings sample through its existing supported settings
entry. Check the idle body is visible/clickable with the logging row present.
Open logging and the nested verbose confirmation separately; native content
must yield to each, remain hidden after cancelling the nested confirmation,
and restore only after the parent closes. Do not enable real verbose logging
or collect real content for this check.
4. In the ready/focused/visible/unlocked Settings owner, exercise its synthetic
own-setting Ask operation. Record visible Ask, explicit cancel/accept, exact
current operation outcome and restoration. Recheck main Ask briefly. Preserve
the2433 main/content/expiry counters and any unrun matrix cases separately.
5. Use owned synthetic A/B AI chats: form a workspace, prefer B, dissolve while A
survives and confirm both histories stay with their owners. Remove A and
check bounded fallback to B. Confirm handoff alone does not invoke a model or
re-share content. Exercise selected-target invalidation across the transition
with inert drafts and no PTY input. Do not treat earlier Focus uncertainty as
resolved by this main domain fix.
6. Record exact source/artifact/sample identities, screenshots/video, request
counters, cleanup and unrun cases. No fallback endpoint/probe, real credentials,
paid API, security relaxation or unrecognized installation is authorized here.
If main advances after this pinned a0cf snapshot, compare the new delta and assess
its affected checks before landing. The current candidate deliberately does not
chase unrelated changes while native preparation is in progress.
@@ -0,0 +1,217 @@
[
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom2433": []
},
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom2433": []
},
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom2433": []
},
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom2433": []
},
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom2433": []
}
]
+46 -6
View File
@@ -108,7 +108,7 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when its pane is
});
});
test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pane also survives', () => {
test('handoffDissolvedWorkspaceAIScope keeps inherited chat on its surviving owner pane', () => {
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: {
'workspace:ws-1': 'chat-a',
@@ -132,12 +132,52 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pa
preferredTerminalId: 'term-b',
});
assert.equal(result.changed, true);
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-a'], null);
assert.equal(result.sessions[0]?.scope.targetId, 'term-b');
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
assert.equal(result.sessions[0]?.scope.type, 'terminal');
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
assert.equal(result.sessions[0]?.updatedAt, 1);
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
assert.deepEqual(result.panelViewByScope['terminal:term-b'], {
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
mode: 'session',
sessionId: 'chat-a',
});
assert.equal(result.panelViewByScope['terminal:term-b'], undefined);
});
test('handoffDissolvedWorkspaceAIScope keeps owner history when the other pane is preferred', () => {
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: {
'workspace:ws-1': 'chat-a',
'terminal:term-b': 'chat-b',
},
sessions: [
{
id: 'chat-a',
scope: { type: 'terminal', targetId: 'term-a', hostIds: ['host-a'] },
updatedAt: 1,
},
{
id: 'chat-b',
scope: { type: 'terminal', targetId: 'term-b', hostIds: ['host-b'] },
updatedAt: 1,
},
],
workspaceId: 'ws-1',
terminalIds: ['term-a', 'term-b'],
preferredTerminalId: 'term-b',
panelViewByScope: {
'workspace:ws-1': { mode: 'session', sessionId: 'chat-a' },
},
});
assert.equal(result.changed, true);
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
assert.equal(result.sessions[1]?.id, 'chat-b');
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
mode: 'session',
sessionId: 'chat-a',
});
+31 -19
View File
@@ -78,13 +78,15 @@ export function seedWorkspaceAIActiveSessionFromMembers(input: {
}
/**
* When a workspace tab dissolves, copy its active chat onto the preferred
* surviving terminal before the workspace scope key is pruned. Also remint
* workspace-scoped chats and the handed-off active chat when it still lives
* under a different terminal (including one that also survives dissolve),
* clear that previous terminal selection so ownership filters do not hide the
* chat, and seed a session panel view so terminal scopes do not fall back to
* a blank draft.
* When a workspace tab dissolves, copy its active chat onto a surviving
* terminal before the workspace scope key is pruned. An inherited chat that
* is already terminal-scoped to a pane still in `terminalIds` stays on that
* owner, even when focus/preferred is a different survivor. Otherwise the
* target is the preferred survivor, or the first remaining terminal when the
* original pane is gone. Workspace-scoped chats are reminted onto that
* target, duplicate terminal map entries for the same chat are cleared, and
* a session panel view is seeded so terminal scopes do not fall back to a
* blank draft.
*/
export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoffLike>(input: {
activeSessionIdMap: AIActiveSessionIdMap;
@@ -99,7 +101,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
changed: boolean;
} {
const preferredTerminalId = (
const fallbackTerminalId = (
input.preferredTerminalId
&& input.terminalIds.includes(input.preferredTerminalId)
? input.preferredTerminalId
@@ -109,6 +111,17 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
const workspaceActive = input.activeSessionIdMap[workspaceKey];
const hasWorkspaceActive = typeof workspaceActive === 'string' && workspaceActive.length > 0;
const activeSession = hasWorkspaceActive
? input.sessions.find((session) => session.id === workspaceActive)
: undefined;
const survivingOwnerTerminalId = (
activeSession?.scope.type === 'terminal'
&& activeSession.scope.targetId
&& input.terminalIds.includes(activeSession.scope.targetId)
? activeSession.scope.targetId
: null
);
const handoffTargetTerminalId = survivingOwnerTerminalId ?? fallbackTerminalId;
const previousPanelViewByScope = (
input.panelViewByScope as Record<string, AIPanelViewHandoffLike> | undefined
) ?? {};
@@ -118,8 +131,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
};
let mapChanged = false;
if (preferredTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
if (handoffTargetTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
let map = nextMap;
let changed = false;
if (map[terminalKey] !== workspaceActive) {
@@ -145,24 +158,23 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
let sessionsChanged = false;
const nextSessions = input.sessions.map((session) => {
if (!preferredTerminalId) return session;
if (!handoffTargetTerminalId) return session;
const isWorkspaceScoped = (
session.scope.type === 'workspace'
&& session.scope.targetId === input.workspaceId
);
// Inherited member chats stay terminal-scoped under the original pane.
// Prefer the focused survivor even when that original pane also survives
// dissolve, otherwise B's history ownership filter excludes A's chat.
// Keep an inherited chat on its owner when that pane survives. Remint
// only when the handoff target is a different terminal (original pane gone).
const isInheritedActiveChatOnOtherTerminal = (
hasWorkspaceActive
&& session.id === workspaceActive
&& session.scope.type === 'terminal'
&& Boolean(session.scope.targetId)
&& session.scope.targetId !== preferredTerminalId
&& session.scope.targetId !== handoffTargetTerminalId
);
if (!isWorkspaceScoped && !isInheritedActiveChatOnOtherTerminal) return session;
if (session.scope.type === 'terminal' && session.scope.targetId === preferredTerminalId) {
if (session.scope.type === 'terminal' && session.scope.targetId === handoffTargetTerminalId) {
return session;
}
@@ -172,7 +184,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
scope: {
...session.scope,
type: 'terminal',
targetId: preferredTerminalId,
targetId: handoffTargetTerminalId,
},
updatedAt: Date.now(),
};
@@ -182,8 +194,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
const nextPanelViewByScope: Record<string, AIPanelViewHandoffLike> = {
...previousPanelViewByScope,
};
if (preferredTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
if (handoffTargetTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
const workspacePanelView = previousPanelViewByScope[workspaceKey];
const terminalPanelView = previousPanelViewByScope[terminalKey];
const nextView: AIPanelViewHandoffLike = (
+28 -10
View File
@@ -71,9 +71,17 @@ const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
// without a prompt marker on this line. Oh My Zsh `ys` uses a different host
// line (`# user @ host ...`), matched separately below. A bare `$`/`#` alone
// is not a prompt.
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
// Oh My Zsh `ys` status line: a decorative `#`, then `user @ host` with
// spaces around `@`, then an optional remainder (directory, git, venv, time,
// exit code). Example: `# user @ host in ~ [12:34:56]`. Arbitrary text before
// a bare `$`/`#` still does not match.
const OH_MY_ZSH_YS_HOST_LINE_PATTERN = /^#\s+[^\s@]+\s+@\s+[^\s]+(?:\s+[^\n\r]+)?$/;
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
// Require a hostname and a home/absolute path, excluding prompt delimiters
// within the path so partially entered commands do not count as idle prompts.
@@ -124,12 +132,15 @@ function extractTrailingIdlePrompt(output) {
return lastLine;
}
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
// CR-to-LF normalize above turns that into blank lines between the host
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
// uses the same normalize, and hasExpectedPromptSuffix compares this
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
// Two-line PS1. Classic bash is `export PS1="\u@\h: \w\n$ "`. Oh My Zsh
// `ys` paints `# user @ host ...` and then a bare `$` or `#`. Bash emits
// the newline as CR and the PTY's ONLCR adds another, so the bytes are
// `\r\r\n`. The CR-to-LF normalize above turns that into blank lines
// between the host line and `$`/`#`. Keep those blanks in the cache:
// getFreshIdlePrompt uses the same normalize, and hasExpectedPromptSuffix
// compares this CR-as-LF form against live PTY bytes. A lone `$`/`#` must
// not satisfy endsWith; the previous non-empty line has to be a recognized
// host line (classic `user@host[:path]` or Oh My Zsh `ys`).
if (rightTrimmed === "$" || rightTrimmed === "#") {
let hostIndex = lines.length - 1;
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
@@ -137,7 +148,13 @@ function extractTrailingIdlePrompt(output) {
}
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
// Same optional "(name) " env prefix as isDefaultPosixPromptLine (#3947).
// Recognition only; the returned prompt keeps the visible prefix.
const previousHostLine = previousRightTrimmed.replace(/^\([^()\n\r]+\)\s+/, "");
if (
POSIX_PROMPT_HOST_LINE_PATTERN.test(previousHostLine)
|| OH_MY_ZSH_YS_HOST_LINE_PATTERN.test(previousHostLine)
) {
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
}
}
@@ -202,8 +219,9 @@ function trackSessionIdlePrompt(session, chunk) {
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// `user@host[:path][#$]`, two-line classic `user@host[:path]` or Oh My Zsh
// `ys` (`# user @ host ...`) + `$`/`#`, RHEL `[user@host path][#$]`, or
// Alpine `host:path[#$]`);
// a remote shell switch into another shell, an oh-my-posh / starship / custom
// PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
+77
View File
@@ -262,6 +262,83 @@ test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
});
test("extractTrailingIdlePrompt recognizes an Oh My Zsh ys two-line idle prompt", () => {
// ys paints `# user @ host ...` then a bare `$` or `#`. Spaces around `@`
// and the leading decorative `#` are required; a lone marker is not a prompt.
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ "),
"# user @ host in ~ [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n# user @ host in ~ [12:34:56]\n# "),
"# user @ host in ~ [12:34:56]\n# ",
);
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~/proj on git:main [12:34:56]\n$ "),
"# user @ host in ~/proj on git:main [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ "),
"# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ ",
);
// Same CR / CRLF blank lines as the classic two-line PS1.
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\r\n$ "),
"# user @ host in ~ [12:34:56]\n\n\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\n# "),
"# user @ host in ~ [12:34:56]\n\n# ",
);
// Optional "(name) " env prefix is stripped only for recognition (#3947).
assert.equal(
extractTrailingIdlePrompt("(venv) # user @ host in ~ [12:34:56]\n$ "),
"(venv) # user @ host in ~ [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("(venv) user@host: /home/foo\n$ "),
"(venv) user@host: /home/foo\n$ ",
);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
assert.equal(extractTrailingIdlePrompt("user @ host in ~\n$ "), "");
assert.equal(extractTrailingIdlePrompt("# user host in ~ [12:34:56]\n$ "), "");
assert.equal(extractTrailingIdlePrompt("# user@host in ~ [12:34:56]\n$ "), "");
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ ls"), "");
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56] $"), "");
assert.equal(extractTrailingIdlePrompt("echo $"), "");
assert.equal(extractTrailingIdlePrompt("price is 5$"), "");
assert.equal(extractTrailingIdlePrompt("(venv) not a host line\n$ "), "");
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oh My Zsh ys two-line prompt", () => {
const session = {};
const prompt = "# user @ host in ~ [12:34:56]\n$ ";
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
assert.equal(getFreshIdlePrompt(session), prompt);
const extra = {};
trackSessionIdlePrompt(extra, "# user @ host in ~/proj on git:main [12:34:56]\r\r\n$ ");
assert.equal(
getFreshIdlePrompt(extra),
"# user @ host in ~/proj on git:main [12:34:56]\n\n\n$ ",
);
const root = {};
trackSessionIdlePrompt(root, "# user @ host in ~ [12:34:56]\n# ");
assert.equal(getFreshIdlePrompt(root), "# user @ host in ~ [12:34:56]\n# ");
const bare = {};
assert.equal(trackSessionIdlePrompt(bare, "$ "), "");
assert.equal(getFreshIdlePrompt(bare), "");
assert.equal(trackSessionIdlePrompt(bare, "not a host line\n$ "), "");
assert.equal(getFreshIdlePrompt(bare), "");
});
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
});
+7
View File
@@ -342,6 +342,12 @@ function getAppContentWindowList() {
return Array.from(appContentWindows).filter((win) => isWindowUsable(win));
}
// Settings has its own reusable window lifecycle, outside app-content editors.
// Enumerate only managed app renderers; remote/pop-up/plugin contents are absent.
function getManagedAppWindowList() {
return [...new Set([...getAppContentWindowList(), getSettingsWindow()])].filter((win) => isWindowUsable(win));
}
function getDirtyEditorWindowList() {
pruneAppContentWindows();
return Array.from(dirtyEditorWindows).filter((win) => isWindowUsable(win));
@@ -1555,6 +1561,7 @@ module.exports = {
getMainWindow,
getMainWindows: getMainWindowList,
getAppContentWindows: getAppContentWindowList,
getManagedAppWindows: getManagedAppWindowList,
getDirtyEditorWindows: getDirtyEditorWindowList,
getMainWindowCount,
isMainWindow,
+1
View File
@@ -163,6 +163,7 @@ function createBridgeRegistrar(context) {
electron: electronModule,
safeStorage,
getInteractionWindow: () => win,
getPermissionWindows: () => getWindowManager().getManagedAppWindows(),
getTerminalWorkerManager: () => terminalWorkerManager,
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
+1 -1
View File
@@ -131,7 +131,7 @@ function createPluginHostService(options) {
moduleResources,
});
const { PluginPermissionDecisionHost } = require("./permissionDecisionHost.cjs");
permissionDecisionHost = new PluginPermissionDecisionHost({ getWindow: options.getInteractionWindow,
permissionDecisionHost = new PluginPermissionDecisionHost({ getWindows: options.getPermissionWindows, getWindow: options.getInteractionWindow,
isLocked: options.isAppLocked, subscribeLock: options.subscribeAppLock });
const permissionEngine = new PluginPermissionEngine({
database,
+24 -19
View File
@@ -6,48 +6,51 @@ const { describePluginPermission } = require("../../domain/pluginPermissionDescr
const { escapePermissionText } = require("./nativePermissionDecision.cjs");
const CHANNEL = "netcatty:plugins:permission-prompt";
/** Main-window-only Ask decisions. Tokens are bound to one pending request and
* consumed before any asynchronous work. Plugin windows never own this link. */
/** Ask belongs to a ready, focused managed app renderer. Plugin views never
* own this link. Each token and cancellation lifecycle stays with its window. */
class PluginPermissionDecisionHost {
constructor(options) {
this.getWindow = options.getWindow;
this.getWindows = options.getWindows ?? (() => [options.getWindow?.()].filter(Boolean));
this.isLocked = options.isLocked ?? (() => true);
this.pending = new Map();
this.owner = null;
this.owners = new Map();
this.closed = false;
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.cancelAll(); });
}
setReady(sender, ready) {
if (this.closed) throw permissionDenied("Permission UI is closed");
if (sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.() || typeof ready !== "boolean") {
const window = this.getWindows().find((candidate) => candidate?.webContents === sender);
if (!window || window.isDestroyed?.() || sender.isDestroyed?.() || typeof ready !== "boolean") {
throw permissionDenied("Permission UI sender is invalid");
}
this.cancelAll();
this.cleanup?.(); this.cleanup = null;
this.owner = ready ? sender : null;
this.releaseOwner(sender);
if (ready) {
const window = this.getWindow();
const cancel = () => this.cancelAll();
const release = () => { this.cancelAll(); this.owner = null; };
const cancel = () => this.cancelOwner(sender);
const release = () => this.releaseOwner(sender);
const navigate = (details, _url, inPlace, mainFrame) => {
if ((details?.isMainFrame ?? mainFrame) !== false && (details?.isSameDocument ?? inPlace) !== true) release();
};
sender.on?.("destroyed", release); sender.on?.("render-process-gone", release); sender.on?.("did-start-navigation", navigate);
window.on?.("hide", cancel); window.on?.("blur", cancel);
this.cleanup = () => {
window.on?.("hide", cancel); window.on?.("blur", cancel); window.on?.("closed", release);
this.owners.set(sender, { window, cleanup: () => {
sender.removeListener?.("destroyed", release); sender.removeListener?.("render-process-gone", release); sender.removeListener?.("did-start-navigation", navigate);
window.removeListener?.("hide", cancel); window.removeListener?.("blur", cancel);
};
window.removeListener?.("hide", cancel); window.removeListener?.("blur", cancel); window.removeListener?.("closed", release);
} });
}
return true;
}
get owner() {
return this.getWindows().find((window) => this.owners.has(window?.webContents)
&& !window.isDestroyed?.() && window.isFocused?.() && window.isVisible?.())?.webContents ?? null;
}
assertOwner(sender = this.owner) {
const window = this.getWindow?.();
if (this.closed || !sender || sender !== this.owner || window?.webContents !== sender || sender.isDestroyed?.()
const window = this.owners.get(sender)?.window;
if (this.closed || !sender || !window || !this.getWindows().includes(window) || window.webContents !== sender || sender.isDestroyed?.()
|| window.isDestroyed?.() || !window.isFocused?.() || !window.isVisible?.() || this.isLocked()) {
throw permissionDenied("Permission UI requires the focused unlocked main window");
throw permissionDenied("Permission UI requires a focused unlocked app window");
}
}
@@ -102,7 +105,9 @@ class PluginPermissionDecisionHost {
return true;
}
cancelOwner(sender) { for (const entry of [...this.pending.values()]) if (entry.sender === sender) entry.finish("cancel"); }
releaseOwner(sender) { this.cancelOwner(sender); this.owners.get(sender)?.cleanup(); this.owners.delete(sender); }
cancelAll() { for (const entry of [...this.pending.values()]) entry.finish("cancel"); }
shutdown() { if (this.closed) return; this.closed = true; this.cancelAll(); this.cleanup?.(); this.unsubscribeLock?.(); this.owner = null; }
shutdown() { if (this.closed) return; this.closed = true; for (const sender of [...this.owners.keys()]) this.releaseOwner(sender); this.unsubscribeLock?.(); }
}
module.exports = { PluginPermissionDecisionHost, PLUGIN_PERMISSION_PROMPT_CHANNEL: CHANNEL };
@@ -0,0 +1,141 @@
"use strict";
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const windowManager = require("../bridges/windowManager.cjs");
const { createPluginHostService } = require("./hostService.cjs");
const { registerPluginBridge, createTrustedPluginBridgeSender } = require("./pluginBridge.cjs");
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
// Real manager, registered host/view RPCs, SDK activation and managed Settings
// creation. Only Electron's native window/contents boundary is represented here.
test("Settings-owned installed view can Ask on activation and commands without a main-window focus handoff", { timeout: 45_000 }, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-permission-windows-"));
const handlers = new Map(), windows = [], owners = new Map();
let nextId = 1, service;
class Contents extends EventEmitter {
constructor() { super(); this.id = nextId++; this.messages = []; this.url = "app://netcatty/index.html"; this.mainFrame = { url: this.url, parent: null }; this.destroyed = false; }
isDestroyed() { return this.destroyed; }
isCrashed() { return false; }
getURL() { return this.url; }
setWindowOpenHandler() {}
async loadURL(url) { this.url = url; this.mainFrame.url = url; }
send(channel, payload) { this.messages.push({ channel, payload }); this.emit(channel, payload); }
focus() {}
close() { this.destroyed = true; this.emit("destroyed"); }
}
class Window extends EventEmitter {
constructor() {
super(); this.webContents = new Contents(); owners.set(this.webContents, this); windows.push(this);
this.destroyed = false; this.visible = false; this.focused = false; this.children = [];
this.contentView = { addChildView: (view) => this.children.push(view), removeChildView: (view) => { this.children = this.children.filter((item) => item !== view); } };
}
static fromWebContents(sender) { return owners.get(sender) ?? null; }
isDestroyed() { return this.destroyed; }
isVisible() { return this.visible; }
isFocused() { return this.focused; }
show() { this.visible = true; }
focus() { for (const window of windows) if (window !== this && window.focused) { window.focused = false; window.emit("blur"); } this.focused = true; this.emit("focus"); }
hide() { this.visible = false; this.focused = false; this.emit("hide"); }
destroy() { this.destroyed = true; this.focused = false; this.emit("closed"); this.webContents.close(); }
async loadURL(url) { await this.webContents.loadURL(url); }
getBounds() { return { x: 0, y: 0, width: 980, height: 720 }; }
setBackgroundColor() {}
setOpacity() {}
}
class ContentsView {
constructor() { this.webContents = new Contents(); }
setBounds() {}
setVisible(visible) { this.visible = visible; }
}
const ipcMain = Object.assign(new EventEmitter(), { handle: (channel, handler) => handlers.set(channel, handler), removeHandler: (channel) => handlers.delete(channel) });
const electron = { ipcMain, BrowserWindow: Window, WebContentsView: ContentsView, shell: {},
session: { fromPartition: () => Object.assign(new EventEmitter(), { protocol: { handle() {}, unhandle() {} }, setProxy: async () => {}, setPermissionCheckHandler() {}, setPermissionRequestHandler() {}, setDevicePermissionHandler() {}, webRequest: { onBeforeRequest() {} } }) } };
const main = new Window(); main.show(); main.focus(); windowManager.registerMainWindow(main);
const settings = await windowManager.openSettingsWindow(electron, { isDev: false, isMac: false, preload: "synthetic-preload.cjs", sourceWindow: main });
assert.equal(windowManager.getSettingsWindow(), settings); assert.equal(settings.isFocused(), true); assert.equal(main.isFocused(), false);
// The fallback permits running this regression on the original 79 tree;
// production uses the managed registry's new combined getter.
const getWindows = () => windowManager.getManagedAppWindows?.() ?? [...windowManager.getAppContentWindows(), windowManager.getSettingsWindow()].filter(Boolean);
service = createPluginHostService({ app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" }, electron, env: {},
getInteractionWindow: () => main, getPermissionWindows: getWindows, isAppLocked: () => false });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
registerPluginBridge(ipcMain, { ...service, isTrustedSender: createTrustedPluginBridgeSender() });
const event = (sender) => ({ sender, senderFrame: sender.mainFrame });
const call = (sender, name, payload) => handlers.get(`netcatty:plugins:${name}`)(event(sender), payload);
const ready = (window, value = true) => call(window.webContents, "permission-ready", { ready: value });
const promptCount = (window) => window.webContents.messages.filter((item) => item.channel === "netcatty:plugins:permission-prompt" && !item.payload.closed).length;
const nextPrompt = (window) => new Promise((resolve) => {
const listener = (prompt) => { if (!prompt.closed) { window.webContents.removeListener("netcatty:plugins:permission-prompt", listener); resolve(prompt); } };
window.webContents.on("netcatty:plugins:permission-prompt", listener);
});
const response = (prompt, decision = "allow") => ({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
const id = "com.netcatty.permission-window-test";
t.after(async () => {
await service.manager.shutdown(); windowManager.unregisterMainWindow(main); windowManager.closeSettingsWindow();
await fs.rm(root, { recursive: true, force: true });
});
await service.manager.initialize(); await ready(main);
// Original implementation rejects this registration and subsequently fails
// the activation with provider-failed while Settings owns focus.
await ready(settings).catch(() => {});
const manifest = { manifestVersion: 1, id, name: "permission-window-test", version: "1.0.0", publisher: "netcatty",
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "browser.js" },
activationEvents: [`onView:${id}.panel`], permissions: { required: ["settings.read", "commands", "views"] },
contributes: { settings: [{ id: `${id}.greeting`, label: "Greeting", control: "text", scope: "application", default: "synthetic greeting" }],
commands: [{ id: `${id}.read`, title: "Read own setting" }], views: [{ id: `${id}.panel`, title: "Settings-owned panel", location: "panel", entry: "panel.html" }] } };
const staging = path.join(root, "package"); await fs.mkdir(staging);
await fs.writeFile(path.join(staging, "netcatty.plugin.json"), JSON.stringify(manifest));
await fs.writeFile(path.join(staging, "panel.html"), "<!doctype html><p>Synthetic view</p>");
await fs.writeFile(path.join(staging, "browser.js"), `export default { async activate(context) {
await context.settings.get(${JSON.stringify(`${id}.greeting`)});
context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(`${id}.read`)}, () => context.settings.get(${JSON.stringify(`${id}.greeting`)})));
} };`);
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(root, "fixture.ncpkg"); await buildPluginPackage(staging, archive); await service.manager.install(archive, { enable: true });
const plugin = service.database.getActivePlugin(id); await service.permissionEngine.setMode(plugin, "settings.read", "ask");
const openedPrompt = nextPrompt(settings);
const opening = call(settings.webContents, "open-view", { viewId: `${id}.panel`, scopeId: "window:settings", bounds: { x: 0, y: 0, width: 300, height: 200 } });
const prompt = await Promise.race([openedPrompt, opening.then(() => { throw new Error("Activation skipped its settings Ask"); })]);
assert.equal(prompt.permission, "settings.read"); assert.equal(promptCount(main), 0);
assert.equal(main.isFocused(), false, "Ask must not steal focus or close the originating view");
await call(settings.webContents, "permission-response", response(prompt));
const opened = await opening;
const instance = service.viewHost.instances.get(opened.instanceId);
assert.equal(instance.owner, settings); assert.equal(instance.state, "open");
const read = () => handlers.get("netcatty-plugin-view:execute-command")({ sender: instance.contents }, { command: `${id}.read` });
const asked = nextPrompt(settings); const reading = read(); const commandPrompt = await asked;
await ready(main, false); // Another renderer's teardown cannot cancel Settings.
assert.equal(service.permissionDecisionHost.pending.size, 1);
const foreign = new Window(); foreign.show(); // Same app URL, but unmanaged.
await assert.rejects(ready(foreign), /sender is invalid/);
await assert.rejects(call(instance.contents, "permission-response", response(commandPrompt)), /Untrusted/);
const childFrame = { url: settings.webContents.getURL(), parent: settings.webContents.mainFrame };
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: childFrame }, { ready: true }), /frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: null }, { ready: true }), /frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-response")({ sender: settings.webContents, senderFrame: childFrame }, response(commandPrompt)), /frame/);
await call(settings.webContents, "permission-response", response(commandPrompt)); assert.equal(await reading, "synthetic greeting");
await ready(main); main.show(); main.focus();
const mainPrompt = nextPrompt(main); const mainRead = call(main.webContents, "execute-command", { command: `${id}.read` });
await call(main.webContents, "permission-response", response(await mainPrompt)); assert.equal(await mainRead, "synthetic greeting");
await ready(main, false);
await assert.rejects(read(), /permission|decision/i); // Never fall back to a ready background Settings window.
await ready(main);
main.hide(); foreign.focus();
const before = promptCount(main) + promptCount(settings);
await assert.rejects(read(), /permission|decision/i);
assert.equal(promptCount(main) + promptCount(settings), before, "foreign/background focus cannot receive or redirect a prompt");
settings.show(); settings.focus();
const hidingPrompt = nextPrompt(settings); const hiddenRead = read(); const hiddenRejected = assert.rejects(hiddenRead, /denied|cancel/i);
const hiddenToken = response(await hidingPrompt); settings.hide(); await hiddenRejected;
settings.show(); settings.focus();
assert.equal(await call(settings.webContents, "permission-response", hiddenToken), false, "reopening Settings cannot revive an old decision");
const closingPrompt = nextPrompt(settings); const cancelled = read(); const rejected = assert.rejects(cancelled, /denied|cancel/i);
const stale = response(await closingPrompt); settings.destroy(); await rejected;
assert.equal(service.permissionDecisionHost.pending.size, 0); assert.equal(service.viewHost.instances.size, 0);
await assert.rejects(call(settings.webContents, "permission-response", stale), /focused unlocked|Untrusted/);
});
+6 -2
View File
@@ -713,8 +713,12 @@ function registerPluginBridge(ipcMain, options) {
await options.logService.ready;
return options.logService[action](payload);
});
handle(CHANNELS.permissionReady, async (_manager, payload, event) => options.permissionDecisionHost?.setReady(event.sender, payload?.ready));
handle(CHANNELS.permissionResponse, async (_manager, payload, event) => options.permissionDecisionHost?.respond(event.sender, payload));
const permissionSender = (event) => {
if ("senderFrame" in event && (!event.senderFrame || event.senderFrame !== event.sender?.mainFrame)) throw new Error("Untrusted permission UI frame");
return event.sender;
};
handle(CHANNELS.permissionReady, async (_manager, payload, event) => options.permissionDecisionHost?.setReady(permissionSender(event), payload?.ready));
handle(CHANNELS.permissionResponse, async (_manager, payload, event) => options.permissionDecisionHost?.respond(permissionSender(event), payload));
handle(CHANNELS.setPermissionMode, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
const plugin = options.database?.getActivePlugin(pluginId);
@@ -0,0 +1,90 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginDatabase } = require("./database.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
const { registerPluginBridge, CHANNELS } = require("./pluginBridge.cjs");
const { OFFICIAL_SOURCE, thirdPartySource } = require("./sourceIdentity.cjs");
const id = "com.example.sync-composition";
const synced = `${id}.synced`, local = `${id}.local`;
const manifest = {
manifestVersion: 1, id, name: "Synthetic sync composition", version: "1.0.0", publisher: "example",
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" },
contributes: { settings: [
{ id: synced, label: "Synced", control: "text", scope: "application", default: "default-sync", sync: true },
{ id: local, label: "Local", control: "text", scope: "application", default: "default-local", sync: false },
] },
};
function install(database, source = OFFICIAL_SOURCE, enabled = true) {
database.installVersion({ pluginId: id, version: manifest.version, manifest, source,
archiveSha256: "a".repeat(64), packageRelativePath: "synthetic/no-executable-package" }, { enable: enabled });
}
function profile(context, label, clock) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `netcatty-sync-composition-${label}-`));
const database = new PluginDatabase(path.join(root, "plugins.sqlite"), { clock: () => clock });
context.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
const runtimeSupervisor = {
start: async () => { throw new Error("composition test must not execute a plugin"); },
request: async () => { throw new Error("composition test must not call a plugin"); },
notify: async () => {},
};
const contributions = new PluginContributionService({ database, runtimeSupervisor, getLocale: () => "en" });
const sidecars = new PluginSyncSidecarService({ database, contributionService: contributions });
const handlers = new Map();
registerPluginBridge({ handle: (name, handler) => handlers.set(name, handler), on() {} }, {
manager: { initialize: async () => {}, database }, contributionService: contributions,
syncSidecarService: sidecars, isTrustedSender: (event) => event?.trusted === true,
});
return { database, contributions, sidecars,
collect: () => handlers.get(CHANNELS.syncSidecarsCollect)({ trusted: true }),
apply: (bundle) => handlers.get(CHANNELS.syncSidecarsApply)({ trusted: true }, bundle),
untrustedApply: (bundle) => handlers.get(CHANNELS.syncSidecarsApply)({ trusted: false }, bundle) };
}
test("two real profiles collect/apply/hydrate only opted-in source-bound settings through registered host entries", async (context) => {
for (const state of ["enabled", "disabled", "missing", "other-source"]) {
await context.test(state, async (context) => {
const a = profile(context, "sender", 200), b = profile(context, "recipient", 100);
install(a.database);
await a.contributions.updateSetting(id, synced, "synthetic-shared-value");
await a.contributions.updateSetting(id, local, "synthetic-device-only-value");
const bundle = await a.collect();
assert.equal(bundle.entries.filter((entry) => entry.kind === "settings").length, 1);
assert.equal(bundle.entries.some((entry) => entry.value === "synthetic-device-only-value"), false);
const owner = a.database.privateOwner(id);
assert.equal(bundle.entries[0].pluginId, owner);
if (state !== "missing") install(b.database,
state === "other-source" ? thirdPartySource("Synthetic community", "https://community.example.com") : OFFICIAL_SOURCE,
state !== "disabled");
await assert.rejects(b.untrustedApply(bundle), /Untrusted/);
assert.equal(b.database.listAllSyncSidecars().length, 0, "untrusted sender never applies remote rows");
const result = await b.apply(bundle);
assert.equal(result.applied, true);
assert.equal(b.database.getSetting(id, local, "application", "application"), undefined);
if (state === "enabled") {
assert.equal(await b.contributions.getSetting(id, synced), "synthetic-shared-value");
assert.equal(await b.contributions.getSetting(id, local), "default-local");
} else {
assert.equal(b.database.getSetting(id, synced, "application", "application"), undefined,
"missing, disabled or different-source plugins cannot receive active settings");
assert.ok(b.database.listAllSyncSidecars().some((entry) => entry.pluginId === owner), "remote row is retained without execution");
await b.sidecars.hydrateInstalledPluginSettings(id);
assert.equal(b.database.getSetting(id, synced, "application", "application"), undefined);
if (state === "disabled") b.database.setEnabled(id, true);
if (state === "missing") install(b.database);
await b.sidecars.hydrateInstalledPluginSettings(id);
assert.equal(await b.contributions.getSetting(id, synced), state === "other-source" ? "default-sync" : "synthetic-shared-value");
if (state === "other-source") assert.notEqual(b.database.privateOwner(id), owner);
}
const collected = await b.collect();
assert.equal(collected.entries.some((entry) => entry.value === "synthetic-device-only-value"), false);
assert.equal(await a.contributions.getSetting(id, local), "synthetic-device-only-value", "sender's local-only setting stays local");
});
}
});