Compare commits

...
28 changed files with 2290 additions and 30 deletions

No files matched your search

+16 -1
View File
@@ -25,6 +25,7 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
const { observePluginHostSecurityVisibility } = await import('../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
@@ -50,6 +51,9 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
},
} });
const root = createRoot(document.getElementById('root')!);
const nativeMount = document.createElement('div'); document.body.append(nativeMount);
const nativeVisibility: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(nativeMount, (visible) => nativeVisibility.push(visible));
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === text)!;
const choose = async (text: string) => {
@@ -65,14 +69,22 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
let operation!: Promise<string>;
await act(async () => { operation = route({}, {}); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(nativeVisibility.at(-1), false, 'the existing native-view lifecycle must yield to the trusted Ask');
assert.match(document.querySelector('[role="dialog"]')!.textContent!, /https:\/\/example.com/);
assert.equal(effects, 1);
await act(async () => { button('Allow this operation').click(); await operation; await settle(); });
assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), false, 'visible permission settings remain a security surface after Ask closes');
await choose('Deny'); assert.equal(mode, 'deny');
await assert.rejects(route({}, {}), /disabled/); assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
await choose('Allow'); assert.equal(mode, 'allow');
await route({}, {}); assert.equal(effects, 3); assert.equal(document.querySelector('[role="dialog"]'), null);
// Model the settings editor closing before interacting with a native view.
document.querySelector('section[data-host-security-surface]')!.setAttribute('hidden', '');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), true);
let displayed!: Promise<unknown>;
const opaque = 'selected:0cfc16d6-3448-4333-82ef-dd4ffbddfdab:v1';
await act(async () => {
@@ -82,11 +94,14 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
}); await settle();
});
const text = document.querySelector('[role="dialog"]')!.textContent!;
assert.equal(nativeVisibility.at(-1), false, 'Ask alone must hide the attached plugin view');
assert.match(text, /QA fixture · deterministic-fixture/); assert.match(text, /Synthetic retry plan/);
assert.doesNotMatch(text, /selected:|0cfc16d6|bf304994/);
await act(async () => { button('Deny').click(); await displayed; await settle(); });
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
} finally {
await act(async () => root.unmount()); host.shutdown(); engine.shutdown(); dom.window.close();
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+2
View File
@@ -27,6 +27,7 @@ import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister"
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
import { usePluginContributions } from "../application/state/usePluginContributions";
import { PluginContributionHost } from "./plugins/PluginContributionHost";
import { PluginPermissionHost } from "./plugins/PluginPermissionHost";
import { matchesKeyBinding } from "../domain/models";
import { isPrimaryModifierWBinding } from "../application/state/windowCommandClose";
@@ -720,6 +721,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</Tabs>
</div>
<ExternalMcpApprovalsHost />
<PluginPermissionHost />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
</>
);
+1 -1
View File
@@ -13,7 +13,7 @@ export function PluginPermissionHost() {
const description = describePluginPermission(prompt.permission, resolvedLocale);
const resources = prompt.resources.filter((resource) => resource !== '*' && !resource.startsWith('selected:'));
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{description.label}</DialogTitle>
<DialogDescription className="break-words">{prompt.pluginName ?? prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
@@ -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": []
}
]
+161
View File
@@ -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
+2
View File
@@ -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.", "读取插件设置", "读取此插件自己的设置。"],
+7
View File
@@ -342,6 +342,12 @@ function getAppContentWindowList() {
return Array.from(appContentWindows).filter((win) => isWindowUsable(win));
}
// Settings has its own reusable window lifecycle, outside app-content editors.
// Enumerate only managed app renderers; remote/pop-up/plugin contents are absent.
function getManagedAppWindowList() {
return [...new Set([...getAppContentWindowList(), getSettingsWindow()])].filter((win) => isWindowUsable(win));
}
function getDirtyEditorWindowList() {
pruneAppContentWindows();
return Array.from(dirtyEditorWindows).filter((win) => isWindowUsable(win));
@@ -1555,6 +1561,7 @@ module.exports = {
getMainWindow,
getMainWindows: getMainWindowList,
getAppContentWindows: getAppContentWindowList,
getManagedAppWindows: getManagedAppWindowList,
getDirtyEditorWindows: getDirtyEditorWindowList,
getMainWindowCount,
isMainWindow,
+1
View File
@@ -163,6 +163,7 @@ function createBridgeRegistrar(context) {
electron: electronModule,
safeStorage,
getInteractionWindow: () => win,
getPermissionWindows: () => getWindowManager().getManagedAppWindows(),
getTerminalWorkerManager: () => terminalWorkerManager,
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
+47
View File
@@ -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));
});
+180
View File
@@ -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
}
]
}
}
}
+4
View File
@@ -0,0 +1,4 @@
"use strict";
require("./agent/delegatedScope.test.cjs");
require("./agent/conversationStore.test.cjs");
require("./agent/contract.test.cjs");
+1 -1
View File
@@ -131,7 +131,7 @@ function createPluginHostService(options) {
moduleResources,
});
const { PluginPermissionDecisionHost } = require("./permissionDecisionHost.cjs");
permissionDecisionHost = new PluginPermissionDecisionHost({ getWindow: options.getInteractionWindow,
permissionDecisionHost = new PluginPermissionDecisionHost({ getWindows: options.getPermissionWindows, getWindow: options.getInteractionWindow,
isLocked: options.isAppLocked, subscribeLock: options.subscribeAppLock });
const permissionEngine = new PluginPermissionEngine({
database,
+24 -19
View File
@@ -6,48 +6,51 @@ const { describePluginPermission } = require("../../domain/pluginPermissionDescr
const { escapePermissionText } = require("./nativePermissionDecision.cjs");
const CHANNEL = "netcatty:plugins:permission-prompt";
/** Main-window-only Ask decisions. Tokens are bound to one pending request and
* consumed before any asynchronous work. Plugin windows never own this link. */
/** Ask belongs to a ready, focused managed app renderer. Plugin views never
* own this link. Each token and cancellation lifecycle stays with its window. */
class PluginPermissionDecisionHost {
constructor(options) {
this.getWindow = options.getWindow;
this.getWindows = options.getWindows ?? (() => [options.getWindow?.()].filter(Boolean));
this.isLocked = options.isLocked ?? (() => true);
this.pending = new Map();
this.owner = null;
this.owners = new Map();
this.closed = false;
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.cancelAll(); });
}
setReady(sender, ready) {
if (this.closed) throw permissionDenied("Permission UI is closed");
if (sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.() || typeof ready !== "boolean") {
const window = this.getWindows().find((candidate) => candidate?.webContents === sender);
if (!window || window.isDestroyed?.() || sender.isDestroyed?.() || typeof ready !== "boolean") {
throw permissionDenied("Permission UI sender is invalid");
}
this.cancelAll();
this.cleanup?.(); this.cleanup = null;
this.owner = ready ? sender : null;
this.releaseOwner(sender);
if (ready) {
const window = this.getWindow();
const cancel = () => this.cancelAll();
const release = () => { this.cancelAll(); this.owner = null; };
const cancel = () => this.cancelOwner(sender);
const release = () => this.releaseOwner(sender);
const navigate = (details, _url, inPlace, mainFrame) => {
if ((details?.isMainFrame ?? mainFrame) !== false && (details?.isSameDocument ?? inPlace) !== true) release();
};
sender.on?.("destroyed", release); sender.on?.("render-process-gone", release); sender.on?.("did-start-navigation", navigate);
window.on?.("hide", cancel); window.on?.("blur", cancel);
this.cleanup = () => {
window.on?.("hide", cancel); window.on?.("blur", cancel); window.on?.("closed", release);
this.owners.set(sender, { window, cleanup: () => {
sender.removeListener?.("destroyed", release); sender.removeListener?.("render-process-gone", release); sender.removeListener?.("did-start-navigation", navigate);
window.removeListener?.("hide", cancel); window.removeListener?.("blur", cancel);
};
window.removeListener?.("hide", cancel); window.removeListener?.("blur", cancel); window.removeListener?.("closed", release);
} });
}
return true;
}
get owner() {
return this.getWindows().find((window) => this.owners.has(window?.webContents)
&& !window.isDestroyed?.() && window.isFocused?.() && window.isVisible?.())?.webContents ?? null;
}
assertOwner(sender = this.owner) {
const window = this.getWindow?.();
if (this.closed || !sender || sender !== this.owner || window?.webContents !== sender || sender.isDestroyed?.()
const window = this.owners.get(sender)?.window;
if (this.closed || !sender || !window || !this.getWindows().includes(window) || window.webContents !== sender || sender.isDestroyed?.()
|| window.isDestroyed?.() || !window.isFocused?.() || !window.isVisible?.() || this.isLocked()) {
throw permissionDenied("Permission UI requires the focused unlocked main window");
throw permissionDenied("Permission UI requires a focused unlocked app window");
}
}
@@ -102,7 +105,9 @@ class PluginPermissionDecisionHost {
return true;
}
cancelOwner(sender) { for (const entry of [...this.pending.values()]) if (entry.sender === sender) entry.finish("cancel"); }
releaseOwner(sender) { this.cancelOwner(sender); this.owners.get(sender)?.cleanup(); this.owners.delete(sender); }
cancelAll() { for (const entry of [...this.pending.values()]) entry.finish("cancel"); }
shutdown() { if (this.closed) return; this.closed = true; this.cancelAll(); this.cleanup?.(); this.unsubscribeLock?.(); this.owner = null; }
shutdown() { if (this.closed) return; this.closed = true; for (const sender of [...this.owners.keys()]) this.releaseOwner(sender); this.unsubscribeLock?.(); }
}
module.exports = { PluginPermissionDecisionHost, PLUGIN_PERMISSION_PROMPT_CHANNEL: CHANNEL };
@@ -0,0 +1,141 @@
"use strict";
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const windowManager = require("../bridges/windowManager.cjs");
const { createPluginHostService } = require("./hostService.cjs");
const { registerPluginBridge, createTrustedPluginBridgeSender } = require("./pluginBridge.cjs");
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
// Real manager, registered host/view RPCs, SDK activation and managed Settings
// creation. Only Electron's native window/contents boundary is represented here.
test("Settings-owned installed view can Ask on activation and commands without a main-window focus handoff", { timeout: 45_000 }, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-permission-windows-"));
const handlers = new Map(), windows = [], owners = new Map();
let nextId = 1, service;
class Contents extends EventEmitter {
constructor() { super(); this.id = nextId++; this.messages = []; this.url = "app://netcatty/index.html"; this.mainFrame = { url: this.url, parent: null }; this.destroyed = false; }
isDestroyed() { return this.destroyed; }
isCrashed() { return false; }
getURL() { return this.url; }
setWindowOpenHandler() {}
async loadURL(url) { this.url = url; this.mainFrame.url = url; }
send(channel, payload) { this.messages.push({ channel, payload }); this.emit(channel, payload); }
focus() {}
close() { this.destroyed = true; this.emit("destroyed"); }
}
class Window extends EventEmitter {
constructor() {
super(); this.webContents = new Contents(); owners.set(this.webContents, this); windows.push(this);
this.destroyed = false; this.visible = false; this.focused = false; this.children = [];
this.contentView = { addChildView: (view) => this.children.push(view), removeChildView: (view) => { this.children = this.children.filter((item) => item !== view); } };
}
static fromWebContents(sender) { return owners.get(sender) ?? null; }
isDestroyed() { return this.destroyed; }
isVisible() { return this.visible; }
isFocused() { return this.focused; }
show() { this.visible = true; }
focus() { for (const window of windows) if (window !== this && window.focused) { window.focused = false; window.emit("blur"); } this.focused = true; this.emit("focus"); }
hide() { this.visible = false; this.focused = false; this.emit("hide"); }
destroy() { this.destroyed = true; this.focused = false; this.emit("closed"); this.webContents.close(); }
async loadURL(url) { await this.webContents.loadURL(url); }
getBounds() { return { x: 0, y: 0, width: 980, height: 720 }; }
setBackgroundColor() {}
setOpacity() {}
}
class ContentsView {
constructor() { this.webContents = new Contents(); }
setBounds() {}
setVisible(visible) { this.visible = visible; }
}
const ipcMain = Object.assign(new EventEmitter(), { handle: (channel, handler) => handlers.set(channel, handler), removeHandler: (channel) => handlers.delete(channel) });
const electron = { ipcMain, BrowserWindow: Window, WebContentsView: ContentsView, shell: {},
session: { fromPartition: () => Object.assign(new EventEmitter(), { protocol: { handle() {}, unhandle() {} }, setProxy: async () => {}, setPermissionCheckHandler() {}, setPermissionRequestHandler() {}, setDevicePermissionHandler() {}, webRequest: { onBeforeRequest() {} } }) } };
const main = new Window(); main.show(); main.focus(); windowManager.registerMainWindow(main);
const settings = await windowManager.openSettingsWindow(electron, { isDev: false, isMac: false, preload: "synthetic-preload.cjs", sourceWindow: main });
assert.equal(windowManager.getSettingsWindow(), settings); assert.equal(settings.isFocused(), true); assert.equal(main.isFocused(), false);
// The fallback permits running this regression on the original 79 tree;
// production uses the managed registry's new combined getter.
const getWindows = () => windowManager.getManagedAppWindows?.() ?? [...windowManager.getAppContentWindows(), windowManager.getSettingsWindow()].filter(Boolean);
service = createPluginHostService({ app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" }, electron, env: {},
getInteractionWindow: () => main, getPermissionWindows: getWindows, isAppLocked: () => false });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
registerPluginBridge(ipcMain, { ...service, isTrustedSender: createTrustedPluginBridgeSender() });
const event = (sender) => ({ sender, senderFrame: sender.mainFrame });
const call = (sender, name, payload) => handlers.get(`netcatty:plugins:${name}`)(event(sender), payload);
const ready = (window, value = true) => call(window.webContents, "permission-ready", { ready: value });
const promptCount = (window) => window.webContents.messages.filter((item) => item.channel === "netcatty:plugins:permission-prompt" && !item.payload.closed).length;
const nextPrompt = (window) => new Promise((resolve) => {
const listener = (prompt) => { if (!prompt.closed) { window.webContents.removeListener("netcatty:plugins:permission-prompt", listener); resolve(prompt); } };
window.webContents.on("netcatty:plugins:permission-prompt", listener);
});
const response = (prompt, decision = "allow") => ({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
const id = "com.netcatty.permission-window-test";
t.after(async () => {
await service.manager.shutdown(); windowManager.unregisterMainWindow(main); windowManager.closeSettingsWindow();
await fs.rm(root, { recursive: true, force: true });
});
await service.manager.initialize(); await ready(main);
// Original implementation rejects this registration and subsequently fails
// the activation with provider-failed while Settings owns focus.
await ready(settings).catch(() => {});
const manifest = { manifestVersion: 1, id, name: "permission-window-test", version: "1.0.0", publisher: "netcatty",
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "browser.js" },
activationEvents: [`onView:${id}.panel`], permissions: { required: ["settings.read", "commands", "views"] },
contributes: { settings: [{ id: `${id}.greeting`, label: "Greeting", control: "text", scope: "application", default: "synthetic greeting" }],
commands: [{ id: `${id}.read`, title: "Read own setting" }], views: [{ id: `${id}.panel`, title: "Settings-owned panel", location: "panel", entry: "panel.html" }] } };
const staging = path.join(root, "package"); await fs.mkdir(staging);
await fs.writeFile(path.join(staging, "netcatty.plugin.json"), JSON.stringify(manifest));
await fs.writeFile(path.join(staging, "panel.html"), "<!doctype html><p>Synthetic view</p>");
await fs.writeFile(path.join(staging, "browser.js"), `export default { async activate(context) {
await context.settings.get(${JSON.stringify(`${id}.greeting`)});
context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(`${id}.read`)}, () => context.settings.get(${JSON.stringify(`${id}.greeting`)})));
} };`);
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(root, "fixture.ncpkg"); await buildPluginPackage(staging, archive); await service.manager.install(archive, { enable: true });
const plugin = service.database.getActivePlugin(id); await service.permissionEngine.setMode(plugin, "settings.read", "ask");
const openedPrompt = nextPrompt(settings);
const opening = call(settings.webContents, "open-view", { viewId: `${id}.panel`, scopeId: "window:settings", bounds: { x: 0, y: 0, width: 300, height: 200 } });
const prompt = await Promise.race([openedPrompt, opening.then(() => { throw new Error("Activation skipped its settings Ask"); })]);
assert.equal(prompt.permission, "settings.read"); assert.equal(promptCount(main), 0);
assert.equal(main.isFocused(), false, "Ask must not steal focus or close the originating view");
await call(settings.webContents, "permission-response", response(prompt));
const opened = await opening;
const instance = service.viewHost.instances.get(opened.instanceId);
assert.equal(instance.owner, settings); assert.equal(instance.state, "open");
const read = () => handlers.get("netcatty-plugin-view:execute-command")({ sender: instance.contents }, { command: `${id}.read` });
const asked = nextPrompt(settings); const reading = read(); const commandPrompt = await asked;
await ready(main, false); // Another renderer's teardown cannot cancel Settings.
assert.equal(service.permissionDecisionHost.pending.size, 1);
const foreign = new Window(); foreign.show(); // Same app URL, but unmanaged.
await assert.rejects(ready(foreign), /sender is invalid/);
await assert.rejects(call(instance.contents, "permission-response", response(commandPrompt)), /Untrusted/);
const childFrame = { url: settings.webContents.getURL(), parent: settings.webContents.mainFrame };
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: childFrame }, { ready: true }), /frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: null }, { ready: true }), /frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-response")({ sender: settings.webContents, senderFrame: childFrame }, response(commandPrompt)), /frame/);
await call(settings.webContents, "permission-response", response(commandPrompt)); assert.equal(await reading, "synthetic greeting");
await ready(main); main.show(); main.focus();
const mainPrompt = nextPrompt(main); const mainRead = call(main.webContents, "execute-command", { command: `${id}.read` });
await call(main.webContents, "permission-response", response(await mainPrompt)); assert.equal(await mainRead, "synthetic greeting");
await ready(main, false);
await assert.rejects(read(), /permission|decision/i); // Never fall back to a ready background Settings window.
await ready(main);
main.hide(); foreign.focus();
const before = promptCount(main) + promptCount(settings);
await assert.rejects(read(), /permission|decision/i);
assert.equal(promptCount(main) + promptCount(settings), before, "foreign/background focus cannot receive or redirect a prompt");
settings.show(); settings.focus();
const hidingPrompt = nextPrompt(settings); const hiddenRead = read(); const hiddenRejected = assert.rejects(hiddenRead, /denied|cancel/i);
const hiddenToken = response(await hidingPrompt); settings.hide(); await hiddenRejected;
settings.show(); settings.focus();
assert.equal(await call(settings.webContents, "permission-response", hiddenToken), false, "reopening Settings cannot revive an old decision");
const closingPrompt = nextPrompt(settings); const cancelled = read(); const rejected = assert.rejects(cancelled, /denied|cancel/i);
const stale = response(await closingPrompt); settings.destroy(); await rejected;
assert.equal(service.permissionDecisionHost.pending.size, 0); assert.equal(service.viewHost.instances.size, 0);
await assert.rejects(call(settings.webContents, "permission-response", stale), /focused unlocked|Untrusted/);
});
+6 -2
View File
@@ -713,8 +713,12 @@ function registerPluginBridge(ipcMain, options) {
await options.logService.ready;
return options.logService[action](payload);
});
handle(CHANNELS.permissionReady, async (_manager, payload, event) => options.permissionDecisionHost?.setReady(event.sender, payload?.ready));
handle(CHANNELS.permissionResponse, async (_manager, payload, event) => options.permissionDecisionHost?.respond(event.sender, payload));
const permissionSender = (event) => {
if ("senderFrame" in event && (!event.senderFrame || event.senderFrame !== event.sender?.mainFrame)) throw new Error("Untrusted permission UI frame");
return event.sender;
};
handle(CHANNELS.permissionReady, async (_manager, payload, event) => options.permissionDecisionHost?.setReady(permissionSender(event), payload?.ready));
handle(CHANNELS.permissionResponse, async (_manager, payload, event) => options.permissionDecisionHost?.respond(permissionSender(event), payload));
handle(CHANNELS.setPermissionMode, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
const plugin = options.database?.getActivePlugin(pluginId);
@@ -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;
+1
View File
@@ -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;
+14
View File
@@ -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;