Compare commits

...
Author SHA1 Message Date
陈奇 a36a906292 fix(plugins): negotiate directory enumeration only with a secure adapter 2026-10-27 12:23:20 +00:00
陈奇 05a773b426 docs(plugins): clarify default directory enumeration boundary 2026-10-27 11:25:54 +00:00
陈奇 d40b0d3c25 docs(plugins): prepare Settings visibility and current-main acceptance handoff 2026-10-27 07:08:50 +00:00
陈奇 714e2397a4 Merge reviewed Settings native-view visibility correction 2026-10-27 06:59:39 +00:00
陈奇 c4aedfa9ed Merge current main owner handoff and prompt fixes into isolated plugin acceptance 2026-10-27 06:59:39 +00:00
陈奇 1551fa8355 fix(plugins): suspend native views only for active log dialogs 2026-10-27 06:55:01 +00:00
a0cf2b487c fix(mcp): recognize Oh My Zsh ys two-line idle prompts (#4008)
The two-line idle prompt check only accepted a classic user@host host
line, so Oh My Zsh ys (`# user @ host ...` then a bare $/#) never became
ready and terminal_execute sent nothing. Match that host line narrowly
and still reject a lone marker.

Fixes #4007

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

Keep terminal-scoped AI chat on its surviving owner pane when dissolving a workspace; remint onto preferred only when the owner is gone.
2026-10-27 13:58:44 +08:00
23 changed files with 746 additions and 52 deletions

No files matched your search

@@ -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>
+7
View File
@@ -450,6 +450,13 @@ new child to an opened parent directory without a path race.
`readDirectory()` likewise keeps its stable SDK/RPC method but fails closed
unless the main process supplies a native adapter whose inode checks and entry
enumeration are bound to the same directory handle.
The host advertises `filesystem.directory-enumeration` only when it supplies that
adapter. Declare the feature as required, or optional and check
`context.enabledFeatures.has(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)`.
The default host supplies no adapter and does not enable this feature. Both the
SDK and direct RPC return `unsupported` before a permission request or path probe.
An enabled feature still requires the declared `filesystem.read` directory
resource and the normal permission, root, symlink and same-handle checks.
Permission names already use the phase-3 enforcement boundaries: clipboard
read/write, terminal metadata/output/input and input/output interception, Vault
@@ -85,6 +85,32 @@ 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
@@ -47,6 +47,9 @@ 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 -7
View File
@@ -207,6 +207,12 @@ class PluginFilesystemBroker {
};
}
validatePath(params) { return assertPathParams(params); }
validateDirectory(params) {
if (typeof this.openDirectoryHandle !== "function") {
throw new PluginRpcError(RPC_ERRORS.unsupported, "Secure plugin directory enumeration is unavailable on this runtime");
}
return assertPathParams(params);
}
describeReadAuthorization(params, resourceKind = "exact") {
const value = assertPathParams(params);
@@ -329,17 +335,11 @@ class PluginFilesystemBroker {
}
async readDirectory(params, context) {
const value = assertPathParams(params);
const value = this.validateDirectory(params);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
assertAuthorizedPath(context, "filesystem.read", canonical);
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
if (!this.openDirectoryHandle) {
throw new PluginRpcError(
RPC_ERRORS.unsupported,
"Secure plugin directory enumeration is unavailable on this runtime",
);
}
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
if (
!directory
@@ -7158,6 +7158,9 @@
"TerminalInputDraftFeature": {
"const": "terminal.input-drafts"
},
"FilesystemDirectoryEnumerationFeature": {
"const": "filesystem.directory-enumeration"
},
"HostTerminalInputProposalRequest": {
"type": "object",
"required": [
+4 -1
View File
@@ -36,6 +36,7 @@ const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./ho
const { PluginHostVaultService } = require("./hostVaultService.cjs");
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$defs.HostVaultFeature.const;
const DIRECTORY_ENUMERATION_FEATURE = require("./generated/plugin-contract.schema.json").$defs.FilesystemDirectoryEnumerationFeature.const;
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginProtocol } = require("./pluginProtocol.cjs");
const { PluginViewHost } = require("./pluginViewHost.cjs");
@@ -99,7 +100,9 @@ function createPluginHostService(options) {
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])]
.filter((feature) => feature !== DIRECTORY_ENUMERATION_FEATURE);
if (typeof options.openPluginDirectoryHandle === "function") supportedFeatures.push(DIRECTORY_ENUMERATION_FEATURE);
const database = new PluginDatabase(paths.database);
let interactionService;
let byteStreamService;
+26
View File
@@ -335,6 +335,8 @@ test("host service wires a handle-bound directory adapter without changing the R
},
});
context.after(() => service.manager.shutdown());
assert.ok(service.runtimeSupervisor.supportedFeatures.includes("filesystem.directory-enumeration"));
assert.ok(service.packageStore.supportedFeatures.includes("filesystem.directory-enumeration"));
const pluginManifest = {
id: "com.example.directory",
name: "directory",
@@ -481,3 +483,27 @@ test("Deny for the privileged development runtime stops its activation through t
assert.equal(stops, 1); assert.equal(current, identity); assert.equal(service.database.getActivePlugin(manifest.id).enabled, true);
service.database.privateOwner = privateOwner;
});
test("production host composition does not advertise or prompt for unavailable directory enumeration", async (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-directory-availability-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
let prompts = 0;
const feature = "filesystem.directory-enumeration";
// Same adapter-free composition as main/registerBridges; even an explicit extra
// supportedFeatures entry cannot claim an adapter that was never supplied.
const service = createPluginHostService({ ...createOptions(root), supportedFeatures: [feature],
requestPermissionDecision: async () => { prompts++; throw new Error("must not prompt"); } });
context.after(() => service.manager.shutdown());
assert.equal(service.runtimeSupervisor.supportedFeatures.includes(feature), false);
assert.equal(service.packageStore.supportedFeatures.includes(feature), false);
let authorizations = 0, probes = 0;
service.permissionEngine.authorize = async () => { authorizations++; throw new Error("must not authorize"); };
service.filesystemBroker.fileSystem = new Proxy({}, { get() { probes++; throw new Error("must not probe"); } });
const manifest = { id: "com.example.unavailable-directory", version: "1.0.0",
permissions: { optional: [{ permission: "filesystem.read", resources: [root] }] } };
const routes = service.rpcRegistry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version,
runtimeId: "runtime-unavailable", runtimeKind: "browser", manifest });
await assert.rejects(routes.requestHandlers["filesystem.readDirectory"]({ path: root }, transportContext()),
(error) => error.code === -32012 && /unavailable/.test(error.message));
assert.equal(authorizations, 0); assert.equal(prompts, 0); assert.equal(probes, 0);
});
+7 -3
View File
@@ -5,7 +5,7 @@ import {
PLUGIN_ERROR_WIRE_CODES,
pluginErrorToRpcError,
} from "@netcatty/plugin-sdk";
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS, PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
import { connectByteStream } from "./byteStreamClient.mjs";
@@ -412,8 +412,12 @@ function createPluginContext(config, client, runtimeApi) {
...options,
}).then(() => undefined),
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
.then((result) => result.entries),
readDirectory: async (directoryPath) => {
if (!config.enabledFeatures?.includes(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)) {
throw new PluginError("unsupported", "Secure plugin directory enumeration is unavailable on this runtime");
}
return (await client.request("filesystem.readDirectory", { path: directoryPath })).entries;
},
};
const companions = {
start: async (companionId) => {
+22 -2
View File
@@ -78,7 +78,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "0.1.0-internal",
enabledFeatures: [],
enabledFeatures: ["filesystem.directory-enumeration"],
},
async loadPlugin() {
return {
@@ -128,7 +128,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
const initialized = await host.request("plugin.initialize", {
netcattyVersion: "1.0.0",
apiVersion: "0.1.0-internal",
supportedFeatures: [],
supportedFeatures: ["filesystem.directory-enumeration"],
});
assert.equal(initialized.pluginId, "com.example.peer");
const unhandledStream = await host.streams.openOutgoing("unhandled", 1024);
@@ -616,3 +616,23 @@ test("runtime peer routes connection and importer Provider operation maps", asyn
await peer.dispose();
host.close();
});
test("actual SDK directory call rejects an unnegotiated feature without sending host RPC", async (context) => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const { port1, port2 } = new MessageChannel();
let requests = 0;
const host = new PluginRpcRouter({ pluginId: "com.example.no-directory", send: (message) => port1.postMessage(message),
handlers: { "filesystem.readDirectory": async () => { requests++; return { entries: [] }; }, "log.write": async () => null } });
port1.on("message", (message) => host.accept(message));
const peer = await startPluginRuntime({ port: port2,
config: { pluginId: "com.example.no-directory", pluginVersion: "1.0.0", netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", enabledFeatures: [] },
async loadPlugin() { return { default: { async activate(pluginContext) {
assert.equal(pluginContext.enabledFeatures.has("filesystem.directory-enumeration"), false);
await assert.rejects(pluginContext.filesystem.readDirectory("/not-probed"),
(error) => error.code === "unsupported" && /unavailable/.test(error.message));
} } }; } });
context.after(async () => { await peer.dispose(); host.close(); port1.close(); port2.close(); });
await host.request("plugin.initialize", { netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", supportedFeatures: [] });
await host.request("plugin.activate", {});
assert.equal(requests, 0);
});
+1 -1
View File
@@ -127,7 +127,7 @@ function registerSecurePluginCapabilities(registry, options) {
options.filesystemBroker.readDirectory(params, context)
), {
metadata: { capability: "filesystem", mutating: false, permission: "filesystem.read" },
validateParams: (params) => options.filesystemBroker.validatePath(params),
validateParams: (params) => options.filesystemBroker.validateDirectory(params),
authorization: (params) => options.filesystemBroker.describeReadAuthorization(params, "directory"),
});
registry.registerRequest("filesystem.writeFile", (params, context) => (
@@ -4976,6 +4976,9 @@
"TerminalInputDraftFeature": {
"const": "terminal.input-drafts"
},
"FilesystemDirectoryEnumerationFeature": {
"const": "filesystem.directory-enumeration"
},
"HostTerminalInputProposalRequest": {
"type": "object",
"required": [
@@ -194,6 +194,8 @@ export type CredentialRef = {
export type FeatureId = string;
export type FilesystemDirectoryEnumerationFeature = "filesystem.directory-enumeration";
export type HostClipboardReadRequest = {
kind: "clipboard.read";
title: string;
+1
View File
@@ -69,3 +69,4 @@ export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satis
export const PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE = "terminal.selection-sharing" as const satisfies import("./generated/plugin-contract.js").TerminalSelectionSharingFeature;
export const PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE = "terminal.input-drafts" as const satisfies import("./generated/plugin-contract.js").TerminalInputDraftFeature;
export const PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE = "filesystem.directory-enumeration" as const satisfies import("./generated/plugin-contract.js").FilesystemDirectoryEnumerationFeature;
+2
View File
@@ -711,6 +711,7 @@ export interface PluginFilesystemClient {
overwrite: true;
}>): Promise<void>;
stat(path: string): Promise<PluginFilesystemStat>;
/** Requires filesystem.directory-enumeration and an authorized filesystem.read directory. */
readDirectory(path: string): Promise<readonly PluginFilesystemEntry[]>;
}
@@ -981,3 +982,4 @@ export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";