mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db0f90751 | ||
|
|
ba4d3fc223 | ||
|
|
18377a1fd9 | ||
|
|
056164157f | ||
|
|
2433cf0130 | ||
|
|
1b21c41fa2 | ||
|
|
89beeb9374 |
No files matched your search
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -68,6 +68,11 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
|
||||
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
|
||||
await click(trigger);
|
||||
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
|
||||
// Radix schedules close-focus cleanup after the React close commit. The
|
||||
// settle inside click's act() can precede that commit; observe the actual
|
||||
// handoff instead of assuming the cleanup timer already ran.
|
||||
const handoffDeadline = Date.now() + 2_000;
|
||||
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
|
||||
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
|
||||
function ControlledEntry() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Managed-window Ask acceptance candidate
|
||||
|
||||
The dedicated branch is `integration/plugin-ask-routing-acceptance`. Its Draft
|
||||
PR records the exact frozen integration head, CI and private renderer artifact
|
||||
handoff. It starts from frozen #4001 at
|
||||
`02049102b6732778adcd0f33b0a0ade6b4752327`; the old source checkout, QA workspace,
|
||||
profile and renderer artifact remain unchanged.
|
||||
|
||||
## Exact inputs and ownership
|
||||
|
||||
The only new input is policy owner's reviewed Ask correction
|
||||
`89beeb93749c751f255beb6d0e0f95d23bdd9a7b`, imported by ordinary merge
|
||||
`1b21c41fa26c0173084c7ae75e640ef3274285dd`. There were no merge conflicts or
|
||||
manual production edits. These seven production files exactly match that input:
|
||||
|
||||
| File | Owned production delta |
|
||||
| --- | --- |
|
||||
| `electron/bridges/windowManager.cjs` | Enumerate managed app windows, including Settings |
|
||||
| `electron/main/registerBridges.cjs` | Supply the managed permission-window getter |
|
||||
| `electron/plugins/hostService.cjs` | Pass that getter to the existing decision host |
|
||||
| `electron/plugins/permissionDecisionHost.cjs` | Bind ready/focused/visible/unlocked managed renderer selection and request lifecycle to the chosen receiver |
|
||||
| `electron/plugins/pluginBridge.cjs` | Guard permission readiness/replies by managed app window and main frame; reject plugin/foreign/subframe senders |
|
||||
| `components/SettingsPage.tsx` | Mount the existing permission host for standalone Settings |
|
||||
| `components/plugins/PluginPermissionHost.tsx` | Mark Ask with the existing host-security surface so attached native views yield |
|
||||
|
||||
The input also owns the Settings-installed-entrypoint regression, renderer test
|
||||
updates and policy/security documentation. AI d33 startup wiring, sample assets,
|
||||
the combined cold-start assertions and all earlier policy/transport history are
|
||||
preserved from 020. `ModelSelector.tsx` is unchanged from 020; the separate
|
||||
model-selector failure-loop repair is not imported. Diagnostic #3999 remains
|
||||
excluded. No broker resource/source rules, stream restrictions, package/lock
|
||||
inputs, native dependencies or sandbox configuration change in this integration.
|
||||
|
||||
## Frozen 020 native evidence
|
||||
|
||||
The parent closed that QA session and released the GUI. Its partial outcome is:
|
||||
|
||||
| Native case on 020 | Result |
|
||||
| --- | --- |
|
||||
| Default Allow cold Send and completion | Passed |
|
||||
| Stop, AI Deny, restore without automatic replay | Passed |
|
||||
| Themes in new views | Passed |
|
||||
| Ask visible dialog | Failed: no visible dialog, timeout, zero new model requests |
|
||||
| Ask acceptance/cancel and content-read matrix | Not run; remain pending |
|
||||
|
||||
The input's independent source-only delta review is scoped clean. Its installed
|
||||
SDK/registered IPC test reproduces the old Settings-owned failure and passes with
|
||||
the correction using represented Electron objects. That is not native Ask
|
||||
success. No new native acceptance is claimed by this coding executor.
|
||||
|
||||
Earlier terminal solo/split core passes with corrected fixtures, unreliable/
|
||||
unverified Focus and one QA Enter in A during a palette attempt remain historical
|
||||
evidence. There is no global zero-input or full terminal native-pass claim. The
|
||||
old Rfam run failed socket transport before connect resolved, without reaching
|
||||
authentication/SELECT; its cause and auth/TLS compatibility remain unverified.
|
||||
The next native priority is the new Ask path, not more old-baseline loops.
|
||||
|
||||
## Checks and retained CI history
|
||||
|
||||
| Local check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime | 696 tests: 695 passed, one native skip, zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one Docker opt-in skip |
|
||||
| Mounted cold-start AI/permission/selected-summary/Host group | 21 passed |
|
||||
| Settings and security-surface visibility group | 37 passed |
|
||||
| Contract/capability/Codex generation and drift | Clean |
|
||||
| Lint | Zero errors, seven existing warnings |
|
||||
| Five sample archives | Validated; two builds each are identical and match frozen 020 |
|
||||
|
||||
The exact integration production build, renderer inventory and final CI are
|
||||
recorded in the Draft PR and artifact provenance. Local native packaging and
|
||||
unpackaged GUI acceptance are not claimed. This environment uses locked
|
||||
dependencies and standard patches; the official pinned Electron binary uses its
|
||||
documented cache/proxy support. No header workaround, native shim, HOME change
|
||||
or sandbox change is introduced.
|
||||
|
||||
Input89 [test CI 40453446775](https://github.com/binaricat/Netcatty/actions/runs/40453446775)
|
||||
succeeded on the parent's single infrastructure-only rerun: 15,342 passed, 50
|
||||
skipped, zero failed/cancelled. The original runner interruption remains evidence;
|
||||
this worker did not duplicate its retry. Input89
|
||||
[four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40453446781)
|
||||
passed with release jobs skipped. Frozen 020's successful full/platform CI and
|
||||
all earlier diagnosed/unexplained failures remain separate predecessor evidence.
|
||||
|
||||
The [sample manifest](ask-routing-packages.json) records exact IDs, versions,
|
||||
features, permissions/resources and archive/content hashes. All five archives
|
||||
are byte-identical to 020, including Assistant
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
No archive was activated by this executor. Its unchanged owner README retains
|
||||
old review wording; the current Allow/Ask/Deny procedure governs acceptance.
|
||||
|
||||
## Production-renderer preparation and native Ask recipe
|
||||
|
||||
1. Create a new isolated checkout at this Draft's exact head and a separate
|
||||
disposable synthetic QA profile. Keep the old 020 workspace/profile intact.
|
||||
2. Match `package.json` and `package-lock.json` hashes to the renderer artifact's
|
||||
provenance and the independently normally rebuilt native dependencies. They
|
||||
are unchanged from 020. Prepare matching host workspace packages normally.
|
||||
**The complete backend checkout must match the new head:** the frontend-only
|
||||
bundle does not include the new CJS window-routing code or native binaries.
|
||||
3. Materialize the exact private Library file/version in the consumer's own
|
||||
environment. Verify its archive SHA-256 and every dist file with the supplied
|
||||
inventory/verification script. Producer-local paths are not portable.
|
||||
4. Use those verified production assets as a whole `dist/` in the matching
|
||||
checkout, with the ordinary Electron entrypoint, no Vite environment and
|
||||
normal sandbox/helper settings. This is native **unpackaged production-
|
||||
renderer QA**, not packaged-binary certification. Process-loss causality
|
||||
remains unclassified; no native build/header workaround is supplied.
|
||||
5. Recreate only the synthetic provider/Note/sample setup. Verify Default Allow
|
||||
remains functional; then set the plugin operation to Ask in the active main
|
||||
or standalone Settings window. Keep the selected receiver visible, focused,
|
||||
ready and unlocked; opening Ask must not force focus back to main.
|
||||
6. Record visible readable Ask and native-view yielding. Check cancellation and
|
||||
acceptance as separate explicit operations: cancel must send no model request;
|
||||
accept must authorize only the current bounded operation. Complete the
|
||||
content-read Allow/Ask/Deny matrix and lifecycle invalidation cases. Do not
|
||||
treat the earlier zero-request timeout as an Ask pass.
|
||||
7. Record exact head, archive/sample hashes, request counters, prompt ownership,
|
||||
screenshots/video and any unrun cases. Report a model-selector failure
|
||||
separately rather than folding its other owner's repair into this candidate.
|
||||
|
||||
Hosting/distribution/deployment and remaining DB/terminal acceptance gates stay
|
||||
parent-coordinated. No main merge, Ready transition or release is part of this
|
||||
handoff.
|
||||
@@ -0,0 +1,197 @@
|
||||
[
|
||||
{
|
||||
"name": "assistant",
|
||||
"id": "com.netcatty.assistant",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 6,
|
||||
"bytes": 17214,
|
||||
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"contentSha256": "c3e31e9613422bf9a05ccaa5b6f234304208f1a8298b1c1ee19262ba88af6cd0",
|
||||
"features": {
|
||||
"required": [
|
||||
"ai.model-invocation",
|
||||
"context.selected-read"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
"ai.invoke",
|
||||
"context.selected.list",
|
||||
"context.selected.read"
|
||||
]
|
||||
},
|
||||
"package": "assistant.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
|
||||
"changedEntriesFromFrozen677": [
|
||||
"README.md",
|
||||
"panel.css",
|
||||
"panel.js"
|
||||
],
|
||||
"previous7856Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "database-owned",
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11660,
|
||||
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://10.77.0.20:3306"
|
||||
],
|
||||
"reason": "Connect only to the prepared disposable fixture."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-owned.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "database-rfam-readonly",
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11003,
|
||||
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
|
||||
],
|
||||
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-rfam-readonly.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "host-actions",
|
||||
"id": "com.netcatty.host-actions",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 3,
|
||||
"bytes": 4633,
|
||||
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c",
|
||||
"features": {
|
||||
"required": [
|
||||
"vault.host-actions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"vault.metadata",
|
||||
"vault.write",
|
||||
"ui.interact"
|
||||
]
|
||||
},
|
||||
"package": "host-actions.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "terminal-selection-draft",
|
||||
"id": "com.netcatty.terminal-selection-draft",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 2,
|
||||
"bytes": 2954,
|
||||
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c",
|
||||
"features": {
|
||||
"required": [
|
||||
"terminal.selection-sharing",
|
||||
"terminal.input-drafts",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"terminal.selection.read",
|
||||
"terminal.input.propose"
|
||||
]
|
||||
},
|
||||
"package": "terminal-selection-draft.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom020": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
# Global Agent plugin bridge (work in progress)
|
||||
|
||||
This stack starts from `integration/plugin-ask-routing-acceptance` at
|
||||
`2433cf0130d1c3274c56c39e5765349c2945de01`. The parent confirmed this dependency;
|
||||
its native Ask acceptance and integrated CI are still pending. This branch does
|
||||
not modify the frozen candidate, #3986, #3998, or #4002.
|
||||
|
||||
The user approved a global conversation independent of a terminal or workspace,
|
||||
with a plugin-provided chat UI and selectable existing Agent backends. Core owns
|
||||
Agent execution, history, target authority, native approvals, credentials and
|
||||
audit. Global is not unrestricted inventory access or a background service.
|
||||
|
||||
## Stage 1: dispatch authority
|
||||
|
||||
`electron/plugins/agent/delegatedScope.cjs` is a host-only authority. It does not
|
||||
register plugin APIs, start an Agent or implement an orchestration loop.
|
||||
|
||||
- `issue(context, hostSnapshot)` authenticates with the existing source binding.
|
||||
Host snapshots contain backend identity/revision, target kind/id/owner/revision,
|
||||
synchronous current-owner checks, and a supported capability subset. The public
|
||||
result contains only an opaque handle and expiry. Plugin parameters must never
|
||||
be used as `hostSnapshot`.
|
||||
- `begin(handle, context)` returns an internal turn ticket and AbortController.
|
||||
- `dispatch(ticket, context, resolvedAction, execute)` runs the common host policy
|
||||
adapter once, checks current target revisions after any approval wait, invokes
|
||||
the real dispatcher, and rejects late results. `resolvedAction` must come from
|
||||
canonical host capability/argument resolution, not an unchecked plugin object.
|
||||
- `assertTurn` guards outgoing events. `finish` and `stop` invalidate the exact
|
||||
ticket. Runtime/plugin release, lock/hide invalidation and shutdown abort work.
|
||||
- Handles expire in five minutes. There are at most eight per runtime owner and
|
||||
64 overall, with at most 32 explicit resources per scope. Expiry never silently
|
||||
refreshes a selection or revives an old turn.
|
||||
|
||||
The focused tests use the actual `PluginAISourceBinding` with synthetic host
|
||||
registries, two terminal owners representing different workspaces, controlled
|
||||
time, and deferred approval/native completion. They cover source/runtime/target
|
||||
substitution, focus changes, revision changes across approval, policy denial,
|
||||
Stop, release, lock, late completion and bounds. They do not yet prove actual MCP
|
||||
or AgentRuntime dispatch; those remain integration gates.
|
||||
|
||||
Initial execution failed before test discovery because the new worktree has no
|
||||
dependencies (`ajv/dist/2020` missing). Running with the existing local worktree's
|
||||
read-only CommonJS dependency path passes all 12 cases. No provider, CLI Agent,
|
||||
paid credentials or GUI was used.
|
||||
|
||||
The adjacent run then passed 49 tests and failed one installed-entrypoint test
|
||||
because ESM could not resolve `@netcatty/plugin-cli` from the new worktree. Standard
|
||||
`npm ci --ignore-scripts` and `build:plugin-runtime-deps` repaired the setup. These
|
||||
failures are setup evidence, not failed-before product regressions.
|
||||
|
||||
The core conversation repository stores the existing global `AISession` document
|
||||
shape in a host-only directory, namespaced by the hash of source/private-owner.
|
||||
Native session identity and compaction remain private. Commits serialize a cloned
|
||||
snapshot, revalidate provenance before atomic rename, and retain previous history
|
||||
when that check fails. Files use mode 0600 and no-follow reads. The repository
|
||||
does not register a plugin storage API or change the source database schema.
|
||||
|
||||
The canonical schema and SDK now declare the proposed session interface and
|
||||
permissions. Runtime methods and the supported-feature advertisement are not yet
|
||||
registered. This stage must not be described as an installable working Agent
|
||||
bridge. Focused scope/history/wire and permission-copy tests pass 20 cases;
|
||||
contract/SDK/CLI tests pass 91. Focused lint and generated drift checks pass.
|
||||
|
||||
## Independent stage-1 correction
|
||||
|
||||
The independent review of exact `056164157f34ea779ed6cf51199909082345eca4`
|
||||
confirmed two helper defects before any runtime acceptance claim: source failure
|
||||
during the initial owner check rejected late work without aborting the active
|
||||
native signal; Stop could acquire a replacement turn after awaiting identity.
|
||||
|
||||
Both reproduce against that exact helper using the real `PluginAISourceBinding`:
|
||||
the focused before run is 13 passed / 2 failed. The correction revokes the captured
|
||||
scope when its own host-authenticated identity fails, and captures the intended
|
||||
turn before Stop's first await. A foreign runtime cannot use a failed lookup to
|
||||
revoke another caller's scope. Focused after: 15 passed / 0 failed. The complete
|
||||
foundation/permission group passes 23 on Node 24.19.0 and Node 22.23.3; focused lint
|
||||
and diff checks pass. Logs: `/tmp/netcatty-global-agent-review-before.log`,
|
||||
`/tmp/netcatty-global-agent-review-after.log`,
|
||||
`/tmp/netcatty-global-agent-review-node22.log`.
|
||||
|
||||
The first CI run at 0561641, test `40459522087`, Linux job `122040565759`, failed
|
||||
after an explicit runner shutdown and operation cancellation, with no assertion
|
||||
failure in its log and no complete repository test total. Package workflow
|
||||
`40459521971` and both Windows jobs passed. The parent authorized one job rerun;
|
||||
it was requested once, producing Linux job `122048349748`, still running at this
|
||||
checkpoint. Original runner failure remains historical evidence. Neither that
|
||||
rerun nor the helper fix establishes runtime, native or independent-review
|
||||
acceptance; the correction still needs reviewer recheck.
|
||||
|
||||
### Follow-up: cancellation is not source revocation
|
||||
|
||||
The independent recheck confirmed the first two repairs and found a remaining
|
||||
Stop race: an aborted Stop(A) RPC could enter the generic owner-failure cleanup
|
||||
after B had started, revoking B's scope. The same failure was reproduced at both
|
||||
owner-validation await paths using the actual source binding. Exact-18377 source
|
||||
with the new 18-case scope suite yields 16 passed / 2 failed; evidence is retained
|
||||
in `/tmp/netcatty-global-agent-stop-rpc-exact183-before.log`.
|
||||
|
||||
The minimal follow-up distinguishes this request's abort/deadline from verified
|
||||
owner or resource failure, rethrows the access failure, and leaves the existing
|
||||
turn-capture rule intact. Cancellation of Stop(A) cannot revoke B. A separate
|
||||
regression confirms that a verified source mismatch still aborts native work
|
||||
even when the RPC is concurrently cancelled. Foreign caller protections remain.
|
||||
The full foundation/permission group passes 26 tests on Node 22.23.3 and Node
|
||||
24.19.0; focused lint and diff checks pass. Logs:
|
||||
`/tmp/netcatty-global-agent-stop-rpc-final.log` and
|
||||
`/tmp/netcatty-global-agent-stop-rpc-node22.log`. Independent recheck is pending.
|
||||
|
||||
The authorized original-head Linux rerun `122048349748` ended cancelled, with an
|
||||
operation-cancelled log and no assertion failure. No second rerun was requested.
|
||||
At 18377, test workflow `40462595842`, Linux job `122050816182`, also received an
|
||||
explicit runner shutdown; `PluginLogSettings.test.tsx` was SIGKILLed immediately
|
||||
afterward. That interrupted stage reports 34 passed / 1 failed, not a complete
|
||||
repository total or an assertion failure. Both Windows jobs passed. No blind
|
||||
rerun of that job was made. Package workflow `40462595846` was still running when
|
||||
checked. These records are not substituted for exact-head green CI.
|
||||
|
||||
## Planned public contract and adapters
|
||||
|
||||
The proposed feature is `ai.agent-sessions`, with `ai.agent.use` for conversation
|
||||
operations and `ai.agent.actions` for actual Agent tool actions. Permission mode
|
||||
comes from the existing source-bound Allow/Ask/Deny engine. These are additive
|
||||
names, separate from the existing tools-disabled `ai.invoke` broker.
|
||||
|
||||
The API will provide backend listing, explicit target selection, owned chat
|
||||
listing/opening, sending, bounded event reads, history, Stop and close. Backend
|
||||
references are opaque host-issued configuration revisions. Conversation references
|
||||
identify only histories owned by the calling source/private-owner; runtime handles
|
||||
are separate and cannot substitute builtin chat IDs or native SDK resume IDs.
|
||||
Messages cannot supply permissionMode, CLI/env/MCP configuration or owner fields.
|
||||
|
||||
Core will reuse `AISession`, `AgentRuntime`, both existing turn drivers, event and
|
||||
compaction handling, SDK resume identity validation, and `stopAgentTurn`. The first
|
||||
eligible backends are Catty and configured Claude Code in MCP mode. Other backend
|
||||
entries must return explicit unsupported/unconfigured status until their native
|
||||
tool and approval boundaries are verified. Codex SDK's current unrestricted native
|
||||
configuration must not be treated as eligible merely because it has a driver.
|
||||
|
||||
The terminal worker already exposes a suitable main-owned resource binding:
|
||||
`getPluginSessionBinding(sessionId, ownerWebContentsId)` returns worker epoch and
|
||||
session generation. Use this for dispatch-time targets; do not use menu IDs,
|
||||
focused tab, display labels, or a renderer-provided session ID as authority.
|
||||
|
||||
The MCP dispatcher and Catty direct execution route need the same delegated
|
||||
session guard. A delegated call must not update process-global permission mode,
|
||||
or request both an Agent approval and a plugin approval for the same action.
|
||||
Policy changes, disable/restart/source replacement, lock and host disconnection
|
||||
must cancel scoped native work and invalidate outgoing event tickets. Existing
|
||||
Agent trace and plugin audit/log facilities will receive redacted correlation
|
||||
metadata, not credentials, provider diagnostics or full unreviewed tool payloads.
|
||||
|
||||
## Remaining acceptance gates
|
||||
|
||||
- Canonical schema/SDK/runtime registration and the installed default host path.
|
||||
- Host-owned durable history and backend references, including backend switching.
|
||||
- Actual Catty and Claude MCP dispatch, policy and worker lifecycle regressions.
|
||||
- Synthetic streaming/cancellation fixtures; these are not real inference or
|
||||
actual authenticated Claude service acceptance.
|
||||
- Installable minimal chat UI with backend and explicit target choice.
|
||||
- Standard CI, independent review and parent-scheduled native evidence. No GUI
|
||||
ownership, merge or release is granted to this executor.
|
||||
@@ -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,24 @@ initial-checkpoint evidence.
|
||||
|
||||
## Latest checks and remaining environment blockers
|
||||
|
||||
- A registered regression reproduces `Plugin permission decision provider failed`
|
||||
on exact `79a9bd6` when a Settings-owned view activates a real `settings.get`
|
||||
API configured Ask while main is unfocused. The follow-up uses the existing
|
||||
managed-window registry (including Settings), per-renderer readiness and the
|
||||
same in-app component. It does not focus main or expand plugin-frame trust.
|
||||
The actual Settings factory, installed package/SDK activation, view command
|
||||
IPC and permission IPC now pass main/Settings/foreign/background, subframe,
|
||||
independent readiness and hide/close/stale-token checks. Native Electron
|
||||
objects are represented in this regression; this is not native GUI acceptance.
|
||||
Focused boundary group: **67 passed**; renderer/native-view visibility group:
|
||||
**three passed**; runtime aggregate: **695 passed/one native skip**. The initial
|
||||
renderer assertion incorrectly expected a view to reappear while the separate
|
||||
permission-settings security surface remained visible; the corrected test
|
||||
checks both overlapping surfaces and restoration after both close.
|
||||
Production build passed (69 seconds). Full lint passes with the same seven
|
||||
warnings after moving a leftover screenshot harness out of the source tree;
|
||||
its initial lint failure is retained. TypeScript remains baseline-equivalent
|
||||
at 774 diagnostics, with zero added/removed after path normalization.
|
||||
- Subsequent exact `da4e809` workflows **40444934631** (tests) and
|
||||
**40444934738** (packages) passed. The full suite reports **15,392 tests:
|
||||
15,342 passed, 50 skipped, zero failures/cancellations**. The runtime group
|
||||
@@ -233,5 +251,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,16 @@ 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.
|
||||
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
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// Presentation only. Admission and grants remain owned by the canonical schema
|
||||
// and permission engine. Tests require exact coverage of its permission enum.
|
||||
const PERMISSION_DESCRIPTIONS = {
|
||||
"ai.agent.use": [true, "Agent conversations", "Use configured host Agents and read this plugin's own chat history. Model or Agent service usage may incur costs.", "Agent 会话", "使用宿主已配置的 Agent,并读取此插件自己的聊天历史;模型或 Agent 服务可能产生费用。"],
|
||||
"ai.agent.actions": [true, "Agent actions", "Authorize Agent tools against explicitly selected targets under this plugin's policy. Commands can change remote systems.", "Agent 操作", "根据插件策略授权 Agent 工具操作明确选中的目标;命令可能更改远程系统。"],
|
||||
storage: [false, "Plugin storage", "Save and read this plugin's local data.", "插件存储", "保存和读取此插件自己的本地数据。"],
|
||||
"runtime.advanced": [true, "Full device access", "Run Node code with filesystem and network access outside broker restrictions. Developer mode only.", "完整设备访问", "运行 Node 代码,可直接访问文件系统和网络,不受代理权限限制。仅限开发者模式。"],
|
||||
"settings.read": [false, "Read plugin settings", "Read this plugin's configured preferences.", "读取插件设置", "读取此插件自己的设置。"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
|
||||
test("Agent wire accepts only opaque host configuration/scope refs, never raw backend authority", () => {
|
||||
const open = createDefinitionValidator("PluginAgentOpenParams");
|
||||
const params = { backendRef: randomUUID(), selectionHandle: randomUUID() };
|
||||
assert.equal(open(params), true);
|
||||
for (const [key, value] of Object.entries({ permissionMode: "auto", owner: "source:forged", privateOwner: "forged",
|
||||
source: "official", model: "chosen", agentId: "claude", apiKey: "synthetic-not-a-key", cli: "node",
|
||||
env: {}, mcpServers: {}, sessionIds: ["focused-session"], externalSessionId: "native-session" })) {
|
||||
assert.equal(open({ ...params, [key]: value }), false, key);
|
||||
}
|
||||
for (const key of ["backendRef", "selectionHandle", "conversationRef"]) {
|
||||
assert.equal(open({ ...params, [key]: "internal-or-path-id" }), false, key);
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent messages and events are bounded and do not serialize raw tools/configuration", () => {
|
||||
const send = createDefinitionValidator("PluginAgentSendParams");
|
||||
const params = { chatHandle: randomUUID(), text: "Synthetic user instruction" };
|
||||
assert.equal(send(params), true);
|
||||
assert.equal(send({ ...params, text: "x".repeat(16_385) }), false);
|
||||
assert.equal(send({ ...params, text: "a\0b" }), false);
|
||||
assert.equal(send({ ...params, turnId: "builtin" }), false);
|
||||
const event = createDefinitionValidator("PluginAgentEvent");
|
||||
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started" }), true);
|
||||
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started", args: { command: "raw" } }), false);
|
||||
assert.equal(event({ kind: "usage", inputTokens: 2, outputTokens: 4 }), true);
|
||||
assert.equal(event({ kind: "usage", inputTokens: -1, outputTokens: 4 }), false);
|
||||
assert.equal(event({ kind: "text", text: "x".repeat(4097) }), false);
|
||||
assert.equal(event({ kind: "provider-error", message: "private diagnostics" }), false);
|
||||
});
|
||||
|
||||
test("public chat/history summaries do not contain backend/native identifiers, prompts or tool results", () => {
|
||||
const chats = createDefinitionValidator("PluginAgentChatsResult");
|
||||
const chat = { conversationRef: randomUUID(), title: "Synthetic chat", backend: "claude", updatedAt: 1 };
|
||||
assert.equal(chats({ items: [chat] }), true);
|
||||
assert.equal(chats({ items: [{ ...chat, externalSessionId: "native" }] }), false);
|
||||
const history = createDefinitionValidator("PluginAgentHistoryResult");
|
||||
assert.equal(history({ items: [{ role: "assistant", text: "Summary" }] }), true);
|
||||
assert.equal(history({ items: [{ role: "tool", text: "Raw tool result" }] }), false);
|
||||
assert.equal(history({ items: [{ role: "assistant", text: "Summary", apiKey: "synthetic" }] }), false);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
||||
const MAX_BYTES = 1_048_576;
|
||||
const MAX_CONVERSATIONS = 64;
|
||||
|
||||
// Core-owned AISession documents, outside plugin filesystem/storage roots.
|
||||
// The caller must authenticate provenance before supplying the opaque owner.
|
||||
// Native resume identity, compaction and tool history stay in this repository;
|
||||
// only a separate public projection is returned through the plugin API.
|
||||
class PluginAgentConversationStore {
|
||||
constructor(directory) {
|
||||
if (!path.isAbsolute(directory)) throw new TypeError("Absolute host history directory required");
|
||||
this.directory = directory;
|
||||
this.pending = new Map();
|
||||
}
|
||||
|
||||
#ownerDirectory(owner) {
|
||||
if (!owner || typeof owner.privateOwner !== "string" || !owner.privateOwner
|
||||
|| typeof owner.sourceKey !== "string" || !owner.sourceKey) throw new TypeError("Host provenance required");
|
||||
return path.join(this.directory, createHash("sha256").update(JSON.stringify([owner.sourceKey, owner.privateOwner])).digest("hex"));
|
||||
}
|
||||
|
||||
#filename(owner, id) {
|
||||
if (typeof id !== "string" || !UUID.test(id)) throw new TypeError("Invalid owned conversation reference");
|
||||
return path.join(this.#ownerDirectory(owner), `${id}.json`);
|
||||
}
|
||||
|
||||
async #prepare(owner) {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
if (!(await fs.lstat(this.directory)).isDirectory() || (await fs.lstat(this.directory)).isSymbolicLink()) throw Error("History root unavailable");
|
||||
const directory = this.#ownerDirectory(owner);
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("History owner unavailable");
|
||||
return directory;
|
||||
}
|
||||
|
||||
#validate(record) {
|
||||
if (!record || typeof record !== "object" || !UUID.test(record.conversationRef)
|
||||
|| typeof record.id !== "string" || !record.id.startsWith("plugin-agent:")
|
||||
|| record.scope?.type !== "global" || record.scope.targetId !== undefined
|
||||
|| typeof record.agentId !== "string" || typeof record.title !== "string" || record.title.length > 160
|
||||
|| !Array.isArray(record.messages) || record.messages.length > 200
|
||||
|| !Number.isSafeInteger(record.createdAt) || !Number.isSafeInteger(record.updatedAt)) throw new TypeError("Invalid core Agent history");
|
||||
const text = JSON.stringify(record);
|
||||
if (Buffer.byteLength(text) > MAX_BYTES) throw new RangeError("Agent history budget exceeded");
|
||||
return text;
|
||||
}
|
||||
|
||||
async #serial(owner, operation) {
|
||||
const key = this.#ownerDirectory(owner);
|
||||
const previous = this.pending.get(key) ?? Promise.resolve();
|
||||
const current = previous.catch(() => {}).then(operation);
|
||||
this.pending.set(key, current);
|
||||
try { return await current; }
|
||||
finally { if (this.pending.get(key) === current) this.pending.delete(key); }
|
||||
}
|
||||
|
||||
async list(owner, assertCurrent) {
|
||||
await assertCurrent();
|
||||
const directory = await this.#prepare(owner);
|
||||
const files = await fs.readdir(directory);
|
||||
const records = [];
|
||||
for (const file of files) {
|
||||
const id = file.slice(0, -5);
|
||||
if (!file.endsWith(".json") || !UUID.test(id)) continue;
|
||||
if (records.length >= MAX_CONVERSATIONS) break;
|
||||
const record = await this.read(owner, id, assertCurrent);
|
||||
if (record) records.push(record);
|
||||
}
|
||||
await assertCurrent();
|
||||
return records.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
async read(owner, id, assertCurrent) {
|
||||
await assertCurrent();
|
||||
const filename = this.#filename(owner, id);
|
||||
await this.#prepare(owner);
|
||||
let file;
|
||||
try {
|
||||
// O_NOFOLLOW avoids following a replaced final path. Core owns ancestors.
|
||||
const { constants } = require("node:fs");
|
||||
file = await fs.open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile() || stat.size > MAX_BYTES) throw Error("Agent history unavailable");
|
||||
const record = JSON.parse(await file.readFile("utf8"));
|
||||
this.#validate(record);
|
||||
if (record.conversationRef !== id) throw Error("Agent history identity mismatch");
|
||||
await assertCurrent();
|
||||
return record;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") { await assertCurrent(); return null; }
|
||||
throw error;
|
||||
} finally { await file?.close(); }
|
||||
}
|
||||
|
||||
async save(owner, record, assertCurrent) {
|
||||
// Clone/serialize before any await so caller mutation cannot change commit.
|
||||
const text = this.#validate(record);
|
||||
const id = record.conversationRef;
|
||||
return this.#serial(owner, async () => {
|
||||
await assertCurrent();
|
||||
const directory = await this.#prepare(owner);
|
||||
const filename = this.#filename(owner, id);
|
||||
const entries = (await fs.readdir(directory)).filter((name) => name.endsWith(".json"));
|
||||
if (!entries.includes(`${id}.json`) && entries.length >= MAX_CONVERSATIONS) throw new RangeError("Agent conversation budget exceeded");
|
||||
const temporary = path.join(directory, `.${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(temporary, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
await assertCurrent();
|
||||
await fs.rename(temporary, filename);
|
||||
} finally { await fs.rm(temporary, { force: true }); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAgentConversationStore };
|
||||
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
|
||||
|
||||
const owner = (source) => ({ privateOwner: `opaque:${source}:same-plugin-id`, sourceKey: `source:${source}` });
|
||||
const current = async () => {};
|
||||
function record() {
|
||||
const conversationRef = randomUUID();
|
||||
return { conversationRef, id: `plugin-agent:${randomUUID()}`, title: "Synthetic chat", agentId: "catty",
|
||||
scope: { type: "global" }, messages: [{ id: randomUUID(), role: "user", content: "Synthetic only", timestamp: 1 }], createdAt: 1, updatedAt: 1 };
|
||||
}
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-agent-history-test-"));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
return { root, store: new PluginAgentConversationStore(path.join(root, "core-history")) };
|
||||
}
|
||||
|
||||
test("core history survives reopening, uses existing AISession shape and isolates the same plugin ID across sources", async (t) => {
|
||||
const { root, store } = await fixture(t); const chat = record();
|
||||
await store.save(owner("a"), chat, current);
|
||||
assert.deepEqual(await store.read(owner("a"), chat.conversationRef, current), chat);
|
||||
const [directory] = await fs.readdir(path.join(root, "core-history"));
|
||||
assert.doesNotMatch(directory, /opaque|same-plugin/);
|
||||
const mode = (await fs.stat(path.join(root, "core-history", directory, `${chat.conversationRef}.json`))).mode;
|
||||
if (process.platform !== "win32") assert.equal(mode & 0o777, 0o600);
|
||||
assert.equal(await store.read(owner("b"), chat.conversationRef, current), null);
|
||||
assert.deepEqual(await store.list(owner("b"), current), []);
|
||||
const reopened = new PluginAgentConversationStore(path.join(root, "core-history"));
|
||||
assert.deepEqual(await reopened.list(owner("a"), current), [chat]);
|
||||
});
|
||||
|
||||
test("source revoked before async rename leaves previous history intact and cleans temporary files", async (t) => {
|
||||
const { store, root } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
|
||||
const next = { ...chat, title: "Must not commit" }; let checks = 0;
|
||||
await assert.rejects(store.save(owner("a"), next, async () => { if (++checks === 2) throw Error("Revoked"); }));
|
||||
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, chat.title);
|
||||
const [directory] = await fs.readdir(path.join(root, "core-history"));
|
||||
assert.deepEqual(await fs.readdir(path.join(root, "core-history", directory)), [`${chat.conversationRef}.json`]);
|
||||
});
|
||||
|
||||
test("history snapshots cannot mutate across await, and traversal/builtin IDs/oversized records are rejected", async (t) => {
|
||||
const { store } = await fixture(t); const chat = record(); const savedTitle = chat.title;
|
||||
const saved = store.save(owner("a"), chat, current); chat.title = "Changed after call"; await saved;
|
||||
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, savedTitle);
|
||||
for (const id of ["../outside", "builtin-chat", "source:owner", ""]) await assert.rejects(store.read(owner("a"), id, current));
|
||||
await assert.rejects(store.save({ privateOwner: "bare-plugin" }, record(), current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), id: "builtin-chat" }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), scope: { type: "workspace", targetId: "active" } }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), messages: Array(201).fill({}) }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), messages: [{ content: "x".repeat(1_048_576) }] }, current));
|
||||
});
|
||||
|
||||
test("symlinked history roots and files do not become history authority", async (t) => {
|
||||
const { root, store } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
|
||||
const history = path.join(root, "core-history"); const [directory] = await fs.readdir(history);
|
||||
const filename = path.join(history, directory, `${chat.conversationRef}.json`);
|
||||
const unrelated = path.join(root, "unrelated.json"); await fs.writeFile(unrelated, JSON.stringify(chat));
|
||||
await fs.rm(filename); await fs.symlink(unrelated, filename);
|
||||
await assert.rejects(store.read(owner("a"), chat.conversationRef, current));
|
||||
const linked = path.join(root, "linked"); await fs.symlink(history, linked, "dir");
|
||||
await assert.rejects(new PluginAgentConversationStore(linked).list(owner("a"), current));
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
|
||||
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Delegated Agent scope is unavailable");
|
||||
const key = (target) => JSON.stringify([target.kind, target.id]);
|
||||
const isRequestCancellation = (error, context) => context.signal?.aborted === true
|
||||
&& (error === context.signal.reason || error?.name === "AbortError" || error?.name === "TimeoutError"
|
||||
|| error?.code === RPC_ERRORS.cancelled || error?.code === RPC_ERRORS.deadlineExceeded);
|
||||
|
||||
// Host-only authority, not a plugin RPC interface or an Agent engine. The host
|
||||
// supplies snapshots after resolving configured backends and choosing targets.
|
||||
// Plugins never supply owner, revision, capability policy or executable adapters.
|
||||
class DelegatedAgentScopes {
|
||||
constructor({ resolveOwner, assertHostActive, authorizeAction, clock = Date.now }) {
|
||||
for (const fn of [resolveOwner, assertHostActive, authorizeAction, clock]) {
|
||||
if (typeof fn !== "function") throw new TypeError("Host scope adapters are required");
|
||||
}
|
||||
this.resolveOwner = resolveOwner;
|
||||
this.assertHostActive = assertHostActive;
|
||||
this.authorizeAction = authorizeAction;
|
||||
this.clock = clock;
|
||||
this.scopes = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
async #owner(context) {
|
||||
if (this.closed) throw fail("unavailable");
|
||||
const owner = await this.resolveOwner(context);
|
||||
await this.assertHostActive(context);
|
||||
context.signal?.throwIfAborted();
|
||||
if (this.closed || await this.resolveOwner(context) !== owner) throw fail("permissionDenied");
|
||||
return owner;
|
||||
}
|
||||
|
||||
// The snapshot is host-owned. A public schema must only accept returned handles.
|
||||
async issue(context, { backend, targets, capabilities }) {
|
||||
const owner = await this.#owner(context);
|
||||
if (!backend || typeof backend.key !== "string" || typeof backend.revision !== "string"
|
||||
|| typeof backend.isCurrent !== "function" || !Array.isArray(targets) || targets.length > 32
|
||||
|| !Array.isArray(capabilities) || capabilities.length > 128
|
||||
|| capabilities.some((id) => typeof id !== "string" || !id || id.length > 160)) throw fail("invalidArgument");
|
||||
const captured = new Map();
|
||||
for (const target of targets) {
|
||||
if (!target || !["terminal", "host", "note"].includes(target.kind)
|
||||
|| typeof target.id !== "string" || !target.id || target.id.length > 256
|
||||
|| typeof target.revision !== "string" || !target.revision
|
||||
|| typeof target.owner !== "string" || !target.owner
|
||||
|| typeof target.isCurrent !== "function" || captured.has(key(target))) throw fail("invalidArgument");
|
||||
captured.set(key(target), Object.freeze({ ...target }));
|
||||
}
|
||||
const scope = { handle: randomUUID(), owner, backend: Object.freeze({ ...backend }), targets: captured,
|
||||
capabilities: new Set(capabilities), expiresAt: this.clock() + 300_000, activeTurn: null };
|
||||
this.#assertSnapshots(scope);
|
||||
if (await this.#owner(context) !== owner) throw fail("permissionDenied");
|
||||
this.#assertSnapshots(scope);
|
||||
for (const entry of this.scopes.values()) if (entry.expiresAt <= this.clock()) this.#revoke(entry);
|
||||
const existing = [...this.scopes.values()].filter((entry) => entry.owner === owner);
|
||||
if (existing.length >= 8 || this.scopes.size >= 64) throw fail("resourceExhausted");
|
||||
this.scopes.set(scope.handle, scope);
|
||||
return Object.freeze({ handle: scope.handle, expiresAt: scope.expiresAt });
|
||||
}
|
||||
|
||||
#assertSnapshots(scope) {
|
||||
// Current-owner/revision checks are synchronous registry lookups, so the
|
||||
// final dispatch check and native call have no asynchronous focus gap.
|
||||
if (scope.backend.isCurrent(scope.backend) !== true) throw fail("failedPrecondition");
|
||||
for (const target of scope.targets.values()) {
|
||||
if (target.isCurrent(target) !== true) throw fail("failedPrecondition");
|
||||
}
|
||||
}
|
||||
|
||||
async #resolve(handle, context) {
|
||||
const scope = this.scopes.get(handle);
|
||||
let owner;
|
||||
try { owner = await this.#owner(context); }
|
||||
catch (error) {
|
||||
// Context identity comes from the authenticated host transport. Revoke
|
||||
// only its own captured scope, never a victim referenced by another caller.
|
||||
if (!isRequestCancellation(error, context) && scope && scope.owner.runtimeId === context.runtimeId && scope.owner.pluginId === context.pluginId
|
||||
&& scope.owner.pluginVersion === context.pluginVersion) this.#revoke(scope);
|
||||
throw error;
|
||||
}
|
||||
if (!scope || scope.owner !== owner) throw fail("permissionDenied");
|
||||
if (scope.expiresAt <= this.clock()) { this.#revoke(scope); throw fail("permissionDenied"); }
|
||||
try {
|
||||
if (await this.#owner(context) !== owner || this.scopes.get(handle) !== scope
|
||||
|| scope.expiresAt <= this.clock()) throw fail("permissionDenied");
|
||||
this.#assertSnapshots(scope);
|
||||
} catch (error) {
|
||||
// A failed individual RPC has no authority to revoke another turn. Real
|
||||
// owner/target failure still invalidates the scope, even if a request also
|
||||
// happens to be cancelled at that time.
|
||||
if (!isRequestCancellation(error, context)) this.#revoke(scope);
|
||||
throw error;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
async begin(handle, context) {
|
||||
const scope = await this.#resolve(handle, context);
|
||||
if (scope.activeTurn) throw fail("alreadyExists");
|
||||
const ticket = Object.freeze({ scope, controller: new AbortController(), id: randomUUID() });
|
||||
scope.activeTurn = ticket;
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async assertTurn(ticket, context) {
|
||||
if (!ticket?.scope || ticket.controller.signal.aborted) throw fail("cancelled");
|
||||
const scope = await this.#resolve(ticket.scope.handle, context);
|
||||
if (scope.activeTurn !== ticket) throw fail("cancelled");
|
||||
ticket.controller.signal.throwIfAborted();
|
||||
return scope;
|
||||
}
|
||||
|
||||
// Called inside the actual tool dispatcher, after canonical argument/target
|
||||
// resolution. It must not be used merely when displaying the model tool list.
|
||||
async dispatch(ticket, context, action, execute) {
|
||||
const scope = await this.assertTurn(ticket, context);
|
||||
if (!action || !scope.capabilities.has(action.capabilityId) || !Array.isArray(action.targets)
|
||||
|| action.targets.length > 32 || typeof execute !== "function") throw fail("permissionDenied");
|
||||
const targets = action.targets.map((target) => {
|
||||
const captured = target && scope.targets.get(key(target));
|
||||
if (!captured || target.revision !== captured.revision || target.owner !== captured.owner) throw fail("permissionDenied");
|
||||
return captured;
|
||||
});
|
||||
const request = Object.freeze({ capabilityId: action.capabilityId, targets: Object.freeze(targets) });
|
||||
// The adapter combines plugin policy and native Agent restrictions once.
|
||||
// No caller can select a process-global permission mode through this API.
|
||||
await this.authorizeAction(context, scope.owner, request, ticket.controller.signal);
|
||||
await this.assertTurn(ticket, context);
|
||||
this.#assertSnapshots(scope);
|
||||
ticket.controller.signal.throwIfAborted();
|
||||
const result = await execute(Object.freeze({ signal: ticket.controller.signal,
|
||||
assertCurrent: () => this.assertTurn(ticket, context) }));
|
||||
await this.assertTurn(ticket, context);
|
||||
return result;
|
||||
}
|
||||
|
||||
finish(ticket) {
|
||||
if (!ticket || ticket.scope?.activeTurn !== ticket) return false;
|
||||
ticket.scope.activeTurn = null;
|
||||
ticket.controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(handle, context) {
|
||||
// Capture before the first await. A prior Stop cannot acquire authority over
|
||||
// a turn that replaces the one the caller intended to stop.
|
||||
const candidate = this.scopes.get(handle);
|
||||
const ticket = candidate?.activeTurn;
|
||||
const scope = await this.#resolve(handle, context);
|
||||
return scope === candidate ? this.finish(ticket) : false;
|
||||
}
|
||||
|
||||
#revoke(scope) {
|
||||
this.finish(scope.activeTurn);
|
||||
this.scopes.delete(scope.handle);
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const scope of this.scopes.values()) if (scope.owner.runtimeId === runtimeId) this.#revoke(scope);
|
||||
}
|
||||
|
||||
releasePlugin(pluginId) {
|
||||
for (const scope of this.scopes.values()) if (scope.owner.pluginId === pluginId) this.#revoke(scope);
|
||||
}
|
||||
|
||||
releaseAll() {
|
||||
for (const scope of this.scopes.values()) this.#revoke(scope);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
this.releaseAll();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DelegatedAgentScopes };
|
||||
@@ -0,0 +1,229 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { DelegatedAgentScopes } = require("./delegatedScope.cjs");
|
||||
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((finish) => { resolve = finish; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
let time = 1; let locked = false; let focus = "terminal-a";
|
||||
let source = { id: "source-a", kind: "third-party", origin: "https://example.invalid" };
|
||||
let privateOwner = "source:opaque-owner-a:com.example.assistant";
|
||||
let identity = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", runtimeKind: "browser" };
|
||||
let authorize = async () => {};
|
||||
const context = { ...identity, assertActive: async () => {} };
|
||||
const database = {
|
||||
getActivePlugin: () => ({ source, enabled: true, activeVersion: "1", runtime: { status: "running" } }),
|
||||
getRetainedPluginSource: () => source,
|
||||
privateOwner: () => privateOwner,
|
||||
publicPluginId: (value) => value === privateOwner ? identity.pluginId : null,
|
||||
};
|
||||
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => identity });
|
||||
let backendRevision = "configured-1";
|
||||
const currentTargets = new Map([
|
||||
["terminal-a", { owner: "window-1", revision: "session-a-v1" }],
|
||||
["terminal-b", { owner: "window-2", revision: "session-b-v1" }],
|
||||
]);
|
||||
const backend = { key: "host-configured-claude", revision: backendRevision,
|
||||
isCurrent: (snapshot) => snapshot.revision === backendRevision };
|
||||
const target = (id) => ({ kind: "terminal", id, ...currentTargets.get(id),
|
||||
isCurrent: (snapshot) => { const current = currentTargets.get(snapshot.id);
|
||||
return current?.owner === snapshot.owner && current?.revision === snapshot.revision; } });
|
||||
const scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => binding.resolve(ctx),
|
||||
assertHostActive: async () => { if (locked) throw Error("Locked"); },
|
||||
authorizeAction: (...args) => authorize(...args), clock: () => time });
|
||||
return { scopes, context, target, targets: currentTargets, database,
|
||||
issue: (targets = [target("terminal-a"), target("terminal-b")]) => scopes.issue(context,
|
||||
{ backend, targets, capabilities: ["terminal.execute", "session.environment"] }),
|
||||
action: (id = "terminal-a") => ({ capabilityId: "terminal.execute", targets: [target(id)] }),
|
||||
tick: (ms) => { time += ms; }, lock: () => { locked = true; scopes.releaseAll(); },
|
||||
focusTo: (id) => { focus = id; }, focus: () => focus,
|
||||
authorize: (fn) => { authorize = fn; }, backendTo: (value) => { backendRevision = value; },
|
||||
replaceSource: () => { source = { ...source, id: "source-b" }; privateOwner = "source:opaque-owner-b:com.example.assistant"; },
|
||||
runtimeTo: (id) => { identity = { ...identity, runtimeId: id }; },
|
||||
};
|
||||
}
|
||||
|
||||
test("host-issued scope exposes only opaque identity and expiry; cross-workspace focus does not change explicit targets", async () => {
|
||||
const f = fixture(); const ref = await f.issue();
|
||||
assert.deepEqual(Object.keys(ref).sort(), ["expiresAt", "handle"]);
|
||||
assert.doesNotMatch(JSON.stringify(ref), /window-|terminal-|source:|configured/);
|
||||
const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
f.focusTo("unselected-terminal");
|
||||
let calls = 0;
|
||||
assert.equal(await f.scopes.dispatch(ticket, f.context, f.action("terminal-b"), async () => ++calls), 1);
|
||||
assert.equal(f.focus(), "unselected-terminal");
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "terminal.execute", targets: [{ kind: "terminal", id: f.focus(), revision: "1", owner: "window-1" }] }, async () => ++calls));
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("forged runtime, cross-runtime session and source replacement cannot use a scope", async () => {
|
||||
const f = fixture(); const ref = await f.issue();
|
||||
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "forged" }));
|
||||
f.runtimeTo("r2");
|
||||
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "r2" }));
|
||||
f.runtimeTo("r1"); f.replaceSource();
|
||||
await assert.rejects(f.scopes.begin(ref.handle, f.context));
|
||||
});
|
||||
|
||||
test("one host policy decision; target owner/revision is revalidated after approval waits", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let decisions = 0; let effects = 0;
|
||||
f.authorize(async (_context, owner, action) => {
|
||||
decisions++; assert.equal(owner.privateOwner, "source:opaque-owner-a:com.example.assistant");
|
||||
assert.equal(action.targets[0].id, "terminal-a"); entered.resolve(); await release.promise;
|
||||
});
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
|
||||
await entered.promise;
|
||||
f.targets.set("terminal-a", { owner: "new-window", revision: "session-a-v2" });
|
||||
release.resolve(); await assert.rejects(result);
|
||||
assert.equal(decisions, 1); assert.equal(effects, 0); assert.equal(ticket.controller.signal.aborted, true);
|
||||
});
|
||||
|
||||
test("copied target ID with substituted owner or revision and undelegated capabilities fail before policy/execute", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
let decisions = 0; let effects = 0; f.authorize(async () => { decisions++; });
|
||||
for (const patch of [{ owner: "attacker" }, { revision: "old" }]) {
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "terminal.execute", targets: [{ ...f.target("terminal-a"), ...patch }] }, async () => effects++));
|
||||
}
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "vault.host.delete", targets: [] }, async () => effects++));
|
||||
assert.equal(decisions, 0); assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
test("Deny and revoked source during authorization never dispatch a tool", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
let effects = 0;
|
||||
f.authorize(async () => { throw Error("Denied"); });
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
|
||||
f.authorize(async () => { f.replaceSource(); });
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
|
||||
assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
test("Stop while approval waits cancels the exact turn and prevents invocation", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let effects = 0;
|
||||
f.authorize(async (_context, _owner, _action, signal) => {
|
||||
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise;
|
||||
});
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
|
||||
await entered.promise; assert.equal(await f.scopes.stop(ref.handle, f.context), true);
|
||||
release.resolve(); await assert.rejects(result); assert.equal(effects, 0);
|
||||
const next = await f.scopes.begin(ref.handle, f.context);
|
||||
assert.equal(f.scopes.finish(ticket), false); assert.equal(next.controller.signal.aborted, false);
|
||||
});
|
||||
|
||||
for (const reason of ["runtime", "plugin", "lock", "stop"]) {
|
||||
test(`${reason} cancellation drops late native results and invalidates the old event ticket`, async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred();
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
|
||||
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise; return "late";
|
||||
});
|
||||
await entered.promise;
|
||||
if (reason === "runtime") f.scopes.releaseRuntime(f.context.runtimeId);
|
||||
else if (reason === "plugin") f.scopes.releasePlugin(f.context.pluginId);
|
||||
else if (reason === "lock") f.lock();
|
||||
else await f.scopes.stop(ref.handle, f.context);
|
||||
release.resolve(); await assert.rejects(result); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("backend config changes and expiry invalidate scopes; host snapshots cannot silently refresh them", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
f.backendTo("new-config"); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
const g = fixture(); const next = await g.issue(); const active = await g.scopes.begin(next.handle, g.context);
|
||||
g.tick(300_000); await assert.rejects(g.scopes.assertTurn(active, g.context));
|
||||
assert.equal(active.controller.signal.aborted, true); assert.equal(g.scopes.scopes.size, 0);
|
||||
});
|
||||
|
||||
test("source replacement detected during owner authentication aborts already-running native work", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred();
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
|
||||
entered.resolve(signal); await release.promise; return "late native result";
|
||||
});
|
||||
const signal = await entered.promise;
|
||||
try {
|
||||
f.replaceSource();
|
||||
await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(signal.aborted, true, "source detection must abort native work, not only reject its result");
|
||||
assert.equal(f.scopes.scopes.size, 0);
|
||||
} finally { release.resolve(); await assert.rejects(result); }
|
||||
});
|
||||
|
||||
test("Stop captures turn A before owner validation and cannot cancel replacement turn B", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let blocked = false;
|
||||
const stopContext = { ...f.context, assertActive: async () => {
|
||||
if (!blocked) { blocked = true; entered.resolve(); await release.promise; }
|
||||
} };
|
||||
const stopping = f.scopes.stop(ref.handle, stopContext);
|
||||
await entered.promise; assert.equal(f.scopes.finish(first), true);
|
||||
const second = await f.scopes.begin(ref.handle, f.context);
|
||||
release.resolve();
|
||||
assert.equal(await stopping, false);
|
||||
assert.equal(second.controller.signal.aborted, false, "an earlier Stop must not cancel a later turn");
|
||||
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
|
||||
});
|
||||
|
||||
for (const phase of [1, 5]) {
|
||||
test(`cancelled Stop(A) during owner check ${phase} cannot revoke replacement turn B`, { timeout: 2_000 }, async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); const rpc = new AbortController(); let checks = 0;
|
||||
const stopContext = { ...f.context, signal: rpc.signal, assertActive: async () => {
|
||||
if (++checks === phase) { entered.resolve(); await release.promise; }
|
||||
rpc.signal.throwIfAborted();
|
||||
} };
|
||||
const stopping = f.scopes.stop(ref.handle, stopContext);
|
||||
await entered.promise; f.scopes.finish(first);
|
||||
const second = await f.scopes.begin(ref.handle, f.context);
|
||||
rpc.abort(phase === 1 ? new DOMException("Stop cancelled", "AbortError") : new DOMException("Stop deadline", "TimeoutError"));
|
||||
release.resolve();
|
||||
await assert.rejects(stopping, (error) => error === rpc.signal.reason);
|
||||
assert.equal(second.controller.signal.aborted, false, "a cancelled RPC does not revoke source authority or stop a later turn");
|
||||
assert.equal(f.scopes.scopes.size, 1);
|
||||
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
|
||||
});
|
||||
}
|
||||
|
||||
test("verified source failure still aborts native work when the RPC is concurrently cancelled", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const rpc = new AbortController(); const retained = f.database.getRetainedPluginSource;
|
||||
f.database.getRetainedPluginSource = () => {
|
||||
rpc.abort(new DOMException("Concurrent RPC cancellation", "AbortError"));
|
||||
return { ...retained(), id: "different-retained-source" };
|
||||
};
|
||||
await assert.rejects(f.scopes.assertTurn(ticket, { ...f.context, signal: rpc.signal }),
|
||||
(error) => error !== rpc.signal.reason && error.code === -32007);
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
assert.equal(f.scopes.scopes.size, 0);
|
||||
});
|
||||
|
||||
test("a foreign runtime cannot revoke a victim scope by failing authentication with its handle", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
await assert.rejects(f.scopes.stop(ref.handle, { ...f.context, runtimeId: "foreign-runtime" }));
|
||||
assert.equal(ticket.controller.signal.aborted, false);
|
||||
assert.equal(f.scopes.scopes.size, 1);
|
||||
assert.equal(await f.scopes.assertTurn(ticket, f.context), ticket.scope);
|
||||
});
|
||||
|
||||
test("bounded host scopes reject malformed snapshots without registering partial targets", async () => {
|
||||
const f = fixture(); await assert.rejects(f.issue([null])); assert.equal(f.scopes.scopes.size, 0);
|
||||
await assert.rejects(f.issue([f.target("terminal-a"), f.target("terminal-a")]));
|
||||
for (let i = 0; i < 8; i++) await f.issue();
|
||||
await assert.rejects(f.issue());
|
||||
f.tick(300_000); await f.issue(); assert.equal(f.scopes.scopes.size, 1);
|
||||
f.scopes.shutdown(); await assert.rejects(f.issue());
|
||||
});
|
||||
@@ -1771,6 +1771,8 @@
|
||||
"enum": [
|
||||
"storage",
|
||||
"ai.invoke",
|
||||
"ai.agent.use",
|
||||
"ai.agent.actions",
|
||||
"context.selected.list",
|
||||
"context.selected.read",
|
||||
"runtime.advanced",
|
||||
@@ -1815,6 +1817,8 @@
|
||||
"enum": [
|
||||
"storage",
|
||||
"ai.invoke",
|
||||
"ai.agent.use",
|
||||
"ai.agent.actions",
|
||||
"context.selected.list",
|
||||
"context.selected.read",
|
||||
"runtime.advanced",
|
||||
@@ -7186,6 +7190,402 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentFeature": {
|
||||
"const": "ai.agent-sessions"
|
||||
},
|
||||
"PluginAgentBackendKind": {
|
||||
"enum": [
|
||||
"catty",
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"codebuddy",
|
||||
"opencode",
|
||||
"grok",
|
||||
"mimo"
|
||||
]
|
||||
},
|
||||
"PluginAgentBackend": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backendRef",
|
||||
"label",
|
||||
"backend",
|
||||
"status",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"backendRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "#/$defs/PluginAgentBackendKind"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"supported",
|
||||
"unsupported",
|
||||
"unavailable"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"ready",
|
||||
"not-configured",
|
||||
"native-boundary-unverified",
|
||||
"configuration-unavailable"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentBackendsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentBackend"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTargetSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"kind": {
|
||||
"const": "terminal"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTargetsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"selectionHandle",
|
||||
"expiresAt",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"selectionHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentTargetSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentOpenParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backendRef",
|
||||
"selectionHandle"
|
||||
],
|
||||
"properties": {
|
||||
"backendRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"selectionHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle",
|
||||
"conversationRef",
|
||||
"backendLabel",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"backendLabel": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"conversationRef",
|
||||
"title",
|
||||
"backend",
|
||||
"updatedAt"
|
||||
],
|
||||
"properties": {
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "#/$defs/PluginAgentBackendKind"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentChatSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentSendParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 16384,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTurnRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnHandle"
|
||||
],
|
||||
"properties": {
|
||||
"turnHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTurnResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnHandle",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"turnHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentMessage": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"role",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"role": {
|
||||
"enum": [
|
||||
"user",
|
||||
"assistant"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 32768
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentHistoryResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 200,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentMessage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentEvent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "text"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 4096
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"capabilityId",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "activity"
|
||||
},
|
||||
"capabilityId": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"started",
|
||||
"completed",
|
||||
"denied"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"inputTokens",
|
||||
"outputTokens"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "usage"
|
||||
},
|
||||
"inputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"outputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "end"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"completed",
|
||||
"cancelled",
|
||||
"unavailable",
|
||||
"budget-exceeded"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
require("./agent/delegatedScope.test.cjs");
|
||||
require("./agent/conversationStore.test.cjs");
|
||||
require("./agent/contract.test.cjs");
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -1698,6 +1698,8 @@
|
||||
"enum": [
|
||||
"storage",
|
||||
"ai.invoke",
|
||||
"ai.agent.use",
|
||||
"ai.agent.actions",
|
||||
"context.selected.list",
|
||||
"context.selected.read",
|
||||
"runtime.advanced",
|
||||
@@ -1742,6 +1744,8 @@
|
||||
"enum": [
|
||||
"storage",
|
||||
"ai.invoke",
|
||||
"ai.agent.use",
|
||||
"ai.agent.actions",
|
||||
"context.selected.list",
|
||||
"context.selected.read",
|
||||
"runtime.advanced",
|
||||
@@ -5004,6 +5008,402 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentFeature": {
|
||||
"const": "ai.agent-sessions"
|
||||
},
|
||||
"PluginAgentBackendKind": {
|
||||
"enum": [
|
||||
"catty",
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"codebuddy",
|
||||
"opencode",
|
||||
"grok",
|
||||
"mimo"
|
||||
]
|
||||
},
|
||||
"PluginAgentBackend": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backendRef",
|
||||
"label",
|
||||
"backend",
|
||||
"status",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"backendRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "#/$defs/PluginAgentBackendKind"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"supported",
|
||||
"unsupported",
|
||||
"unavailable"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"ready",
|
||||
"not-configured",
|
||||
"native-boundary-unverified",
|
||||
"configuration-unavailable"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentBackendsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentBackend"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTargetSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"kind": {
|
||||
"const": "terminal"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTargetsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"selectionHandle",
|
||||
"expiresAt",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"selectionHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentTargetSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentOpenParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backendRef",
|
||||
"selectionHandle"
|
||||
],
|
||||
"properties": {
|
||||
"backendRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"selectionHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle",
|
||||
"conversationRef",
|
||||
"backendLabel",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"backendLabel": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"conversationRef",
|
||||
"title",
|
||||
"backend",
|
||||
"updatedAt"
|
||||
],
|
||||
"properties": {
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "#/$defs/PluginAgentBackendKind"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentChatSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentSendParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 16384,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTurnRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnHandle"
|
||||
],
|
||||
"properties": {
|
||||
"turnHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTurnResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnHandle",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"turnHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentMessage": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"role",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"role": {
|
||||
"enum": [
|
||||
"user",
|
||||
"assistant"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 32768
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentHistoryResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 200,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentMessage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentEvent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "text"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 4096
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"capabilityId",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "activity"
|
||||
},
|
||||
"capabilityId": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"started",
|
||||
"completed",
|
||||
"denied"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"inputTokens",
|
||||
"outputTokens"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "usage"
|
||||
},
|
||||
"inputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"outputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "end"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"completed",
|
||||
"cancelled",
|
||||
"unavailable",
|
||||
"budget-exceeded"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,7 +486,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
|
||||
|
||||
export type NetworkCredentialLeaseFeature = "network.credential-lease";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
@@ -554,6 +554,100 @@ export type PermissionSet = {
|
||||
optional?: Array<PermissionDeclaration>;
|
||||
};
|
||||
|
||||
export type PluginAgentBackend = {
|
||||
backendRef: string;
|
||||
label: string;
|
||||
backend: PluginAgentBackendKind;
|
||||
status: "supported" | "unsupported" | "unavailable";
|
||||
reason: "ready" | "not-configured" | "native-boundary-unverified" | "configuration-unavailable";
|
||||
};
|
||||
|
||||
export type PluginAgentBackendKind = "catty" | "claude" | "codex" | "copilot" | "cursor" | "codebuddy" | "opencode" | "grok" | "mimo";
|
||||
|
||||
export type PluginAgentBackendsResult = {
|
||||
items: Array<PluginAgentBackend>;
|
||||
};
|
||||
|
||||
export type PluginAgentChatRef = {
|
||||
chatHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAgentChatResult = {
|
||||
chatHandle: string;
|
||||
conversationRef: string;
|
||||
backendLabel: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginAgentChatsResult = {
|
||||
items: Array<PluginAgentChatSummary>;
|
||||
};
|
||||
|
||||
export type PluginAgentChatSummary = {
|
||||
conversationRef: string;
|
||||
title: string;
|
||||
backend: PluginAgentBackendKind;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type PluginAgentEvent = ({
|
||||
kind: "text";
|
||||
text: string;
|
||||
}) | ({
|
||||
kind: "activity";
|
||||
capabilityId: string;
|
||||
state: "started" | "completed" | "denied";
|
||||
}) | ({
|
||||
kind: "usage";
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}) | ({
|
||||
kind: "end";
|
||||
reason: "completed" | "cancelled" | "unavailable" | "budget-exceeded";
|
||||
});
|
||||
|
||||
export type PluginAgentFeature = "ai.agent-sessions";
|
||||
|
||||
export type PluginAgentHistoryResult = {
|
||||
items: Array<PluginAgentMessage>;
|
||||
};
|
||||
|
||||
export type PluginAgentMessage = {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PluginAgentOpenParams = {
|
||||
backendRef: string;
|
||||
selectionHandle: string;
|
||||
conversationRef?: string;
|
||||
};
|
||||
|
||||
export type PluginAgentSendParams = {
|
||||
chatHandle: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PluginAgentTargetsResult = {
|
||||
selectionHandle: string;
|
||||
expiresAt: number;
|
||||
items: Array<PluginAgentTargetSummary>;
|
||||
};
|
||||
|
||||
export type PluginAgentTargetSummary = {
|
||||
title: string;
|
||||
kind: "terminal";
|
||||
};
|
||||
|
||||
export type PluginAgentTurnRef = {
|
||||
turnHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAgentTurnResult = {
|
||||
turnHandle: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginAIBooleanResult = {
|
||||
ok: boolean;
|
||||
};
|
||||
@@ -839,7 +933,7 @@ export type PluginNetworkResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
export type PluginPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
|
||||
export type PluginSelectedContextContent = {
|
||||
handle: string;
|
||||
|
||||
@@ -55,6 +55,7 @@ export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MA
|
||||
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
|
||||
export const PLUGIN_BYTE_STREAM_FEATURE = "network.byte-streams" as const satisfies import("./generated/plugin-contract.js").ByteStreamFeature;
|
||||
export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const satisfies import("./generated/plugin-contract.js").PluginAIModelInvocationFeature;
|
||||
export const PLUGIN_AGENT_SESSIONS_FEATURE = "ai.agent-sessions" as const satisfies import("./generated/plugin-contract.js").PluginAgentFeature;
|
||||
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
|
||||
|
||||
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
|
||||
|
||||
@@ -685,6 +685,19 @@ export interface PluginSelectedContextClient {
|
||||
read(params: import("@netcatty/plugin-contract").PluginSelectedContextRef): Promise<import("@netcatty/plugin-contract").PluginSelectedContextContent>;
|
||||
}
|
||||
|
||||
/** Configurations, targets and native resume IDs stay in the trusted host. */
|
||||
export interface PluginAgentClient {
|
||||
listBackends(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentBackendsResult>;
|
||||
selectTargets(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentTargetsResult>;
|
||||
listChats(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatsResult>;
|
||||
openChat(params: import("@netcatty/plugin-contract").PluginAgentOpenParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatResult>;
|
||||
send(params: import("@netcatty/plugin-contract").PluginAgentSendParams): Promise<import("@netcatty/plugin-contract").PluginAgentTurnResult>;
|
||||
read(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAgentEvent>;
|
||||
history(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAgentHistoryResult>;
|
||||
stop(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
closeChat(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
}
|
||||
|
||||
export interface PluginByteConnection extends Disposable {
|
||||
readonly endpoint: string;
|
||||
readonly readable: PluginReadableByteStream;
|
||||
@@ -784,6 +797,7 @@ export interface PluginContext {
|
||||
readonly credentials: PluginCredentialBroker;
|
||||
readonly network: PluginNetworkClient;
|
||||
readonly ai: PluginAIClient;
|
||||
readonly agent: PluginAgentClient;
|
||||
readonly selectedContext: PluginSelectedContextClient;
|
||||
readonly filesystem: PluginFilesystemClient;
|
||||
readonly companions: PluginCompanionService;
|
||||
|
||||
Reference in new issue
Block a user