Compare commits

..
Author SHA1 Message Date
陈奇 91b8927c56 fix(plugin-themes): pin utility color aliases in security surfaces 2026-10-26 13:07:03 +00:00
20 changed files with 122 additions and 404 deletions

No files matched your search

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { compile } from '@tailwindcss/node';
import postcss from 'postcss';
import HostDetailsPanel from './HostDetailsPanel';
import { Card } from './ui/card';
import { TooltipProvider } from './ui/tooltip';
import { PluginPermissionPanel } from './settings/tabs/PluginPermissionPanel';
import { I18nProvider } from '../application/i18n/I18nProvider';
test('real Host form cards, inputs and Save consume scoped security aliases without freezing an ordinary card', async () => {
const markup = renderToStaticMarkup(<I18nProvider locale="en"><TooltipProvider>
<HostDetailsPanel availableKeys={[]} identities={[]} groups={[]} terminalThemeId="default" terminalFontSize={14} onSave={() => assert.fail('test never saves a host')} onCancel={() => {}} />
<PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => {}} />
<Card id="ordinary-card">Outside the security surface</Card>
</TooltipProvider></I18nProvider>);
const dom = new JSDOM(`<!doctype html><html data-plugin-theme-active><body>${markup}</body></html>`);
try {
const panel = dom.window.document.querySelector('[data-section="host-details-panel"]')!;
assert.ok(panel, 'exercise the actual HostDetailsPanel and AsidePanel');
assert.ok(panel.querySelector('.bg-card'), 'General/Address/Credentials use real Card components');
assert.ok(panel.querySelector('input.bg-background'), 'exercise actual Input utilities');
assert.ok(panel.querySelector('button.bg-primary'), 'exercise the actual Save button');
const permissions = dom.window.document.querySelector('[data-host-security-surface]')!;
assert.ok(permissions.querySelector('[role="status"]'), 'exercise the real grant-panel security root without loading or changing any grants');
const source = await readFile(new URL('../index.css', import.meta.url), 'utf8');
const compiler = await compile(source, {base: path.resolve('.'), onDependency() {}});
const classes = [...new Set([...dom.window.document.querySelectorAll('[class]')].flatMap(el => [...el.classList]))];
const css = compiler.build(classes);
const parsed = postcss.parse(css);
const rules: postcss.Rule[] = [];
parsed.walkRules(rule => { rules.push(rule); });
const rule = rules.find(node => node.selector.startsWith(':root[data-plugin-theme-active]'));
assert.ok(rule && rule.type === 'rule');
const matches = [...dom.window.document.querySelectorAll(rule.selector)];
assert.ok(matches.includes(panel));
assert.ok(matches.includes(permissions));
assert.ok(!matches.includes(dom.window.document.getElementById('ordinary-card')!), 'security selector stays within the existing marked surfaces');
const declarations = new Map(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node]));
for (const name of ['background','foreground','card','card-foreground','popover','popover-foreground','primary','primary-foreground','secondary','secondary-foreground','muted','muted-foreground','accent','accent-foreground','destructive','destructive-foreground','border','input','ring']) {
const declaration = declarations.get(`--color-${name}`);
assert.equal(declaration?.value, `hsl(var(--host-security-${name}))`, `Tailwind alias ${name} cannot inherit the plugin value`);
assert.equal(declaration.important, true);
}
for (const name of ['card','background','primary']) {
const utility = rules.find(node => node.selector === `.bg-${name}`);
assert.ok(utility && utility.type === 'rule');
assert.ok(utility.nodes.some(node => node.type === 'decl' && node.prop === 'background-color' && node.value === `var(--color-${name})`), 'verify what the actual compiled utility consumes');
}
const mutedText = rules.find(node => node.selector === '.text-muted-foreground');
assert.ok(mutedText?.nodes.some(node => node.type === 'decl' && node.prop === 'color' && node.value === 'var(--color-muted-foreground)'));
} finally { dom.window.close(); }
});
@@ -1,51 +0,0 @@
# Selected terminal sharing — implementation WIP
Base: ThemePack `f868221b5f11810802f929369ac8eee46166079a`.
Branch: `feat/plugin-terminal-sharing`. This is saved unfinished source, not an
accepted API or an installable/native-tested workflow. Do not merge or release.
Implemented source so far:
- Canonical optional action-only `terminal.selection.read`, feature
`terminal.selection-sharing`, bounded result and host-target schemas; SDK
`context.terminals.shareSelection()` and runtime forwarding through the
existing `ui.interact` broker. No independent parallel RPC stack.
- Main-only worker/session generation and window-owner binding accessor; no
endpoint or Host DTO returned to plugins.
- Host target/handle authority, opaque signed short-lived receipts, capacity,
expiry and lifecycle hooks; no selected text stored in the authority.
- Broker admission binding to the selected resource before a once-only grant,
signed Preview/Share phases, fixed audit metadata and teardown integration.
- Trusted app-only selected-target setter, not a plugin API. No database,
installation/source/cache identity or private-storage schema changes.
Still missing, and required before this counts as working:
1. Trusted renderer target publication for the real active terminal/workspace;
actual bounded selection reader registered by the terminal surface. Read
only on Preview, from a visible connected terminal, never through Agent/MCP
history/context readers; refuse sensitive input, empty or oversized selection.
2. Host dialog Preview response and explicit Share rendering, failure states,
exact resource/selection consistency and global lifecycle verification.
3. Canonical/SDK/broker/authority/real-worker/renderer normal and boundary tests,
generated-contract and compatibility review (including optional-only
declaration semantics), quotas, owner/reconnect/close/revoke/late-result cases.
4. Useful ordinary-browser synthetic example, fixture ID/hash/precise permissions,
build/CI, independent review/fix/review and parent-owned native acceptance.
5. Separate input-draft layer: reuse the actual compose control without inheriting
broadcast targets, overwriting an existing draft silently, writing PTY bytes
or sending Enter. Require a separately approved target-bound draft action and
final active identity check. No input-proposal method/permission is delivered
by this WIP.
Known integration constraints:
- The market source-binding task owns installation, cache, authorization/private
storage identity and associated database schema. Coordinate before touching
those locations; runtime/assertActive identity must remain host-owned.
- Keep the ThemePack security-color delta in its own branch/head. Incorporate
the eventually accepted head with a normal integration merge, not by rewriting
frozen evidence.
- The user allows simplification of unreleased plugin historical data/protocols;
do not add legacy migration layers. Do not remove unrelated host/input data
migrations or weaken the sandbox.
+24
View File
@@ -133,3 +133,27 @@ This is source identity and a source-based ordering diagnosis, not a claim that
baseline runtime failure was reproduced locally. The updated test needs the new
exact-head CI result. That same first run passed the added Vite browser-import
smoke, resolving the earlier local temp-directory limitation for that head.
### Native security-color delta
Parent native acceptance at f868 verified zero-permission installation/enabling,
light/dark Preview and Cancel, Apply across main/Settings windows, Restore
default, restart persistence, and Disable fallback with the unavailable notice.
The isolated schema-3 profile was not touched. These core results remain valid.
Native QA also found Forest-colored inner New Host cards/inputs/Save and some
grant-panel text inside an otherwise fixed security container. The cause was
Tailwind's derived `--color-*` aliases: changing only raw semantic tokens at the
boundary does not recompute aliases already resolved on an ancestor. The same
existing security rule now fixes both raw tokens and those color aliases. The
selector and protected regions are unchanged; ordinary application cards remain
theme-controlled. No host, credential or grant operation changed.
The regression renders the actual HostDetailsPanel (including General, Address,
Credentials cards, real Inputs and Save), plus the real grant-panel root. It
compiles the production Tailwind source, verifies which aliases those utilities
consume, checks the fixed declarations, and verifies an ordinary card is outside
the security selector. This is component/compiled-CSS coverage, not a claim of
native computed-color acceptance. The parent's native color delta remains the
final visual check. A local Chromium probe aborted because its SUID sandbox was
not configured; sandboxing was not disabled to retry.
-1
View File
@@ -13,7 +13,6 @@ const PERMISSION_DESCRIPTIONS = {
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
"terminal.selection.read": [true, "Share terminal selection", "Read only text explicitly previewed and shared from the selected live terminal. It may contain secrets. Each action requires approval.", "分享终端选区", "仅读取用户从当前活跃终端明确预览并分享的文本,可能包含秘密;每次操作均需确认。"],
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
"terminal.input": [true, "Send terminal input", "Send input to a terminal; input may execute commands or change a remote host.", "发送终端输入", "向终端发送输入,可能执行命令或更改远程主机。"],
@@ -390,7 +390,6 @@ function createTerminalWorkerManager(options = {}) {
onRendererEvent = null,
} = options;
let child = null;
let pluginSessionAuthorityEpoch = 0;
const pending = new Map();
const pendingOutput = new Map();
const pendingOutputBytes = new Map();
@@ -1858,7 +1857,6 @@ function createTerminalWorkerManager(options = {}) {
},
});
child = worker;
pluginSessionAuthorityEpoch++;
worker.on?.("message", (message) => {
if (child === worker) handleMessage(message);
});
@@ -2248,14 +2246,6 @@ function createTerminalWorkerManager(options = {}) {
if (!sessionId) return null;
return sessionWebContentsIds.get(sessionId) ?? null;
},
// Main-process resource authority only; never expose worker objects or endpoints.
getPluginSessionBinding(sessionId, webContentsId) {
const generation = sessionGenerations.get(sessionId);
if (!child || !Number.isSafeInteger(generation) || closedSessions.has(sessionId)
|| pendingSessionStartSequences.has(sessionId) || outputRoutePending.has(sessionId)
|| sessionWebContentsIds.get(sessionId) !== webContentsId || !isLiveWebContentsId(webContentsId)) return null;
return Object.freeze({ epoch: pluginSessionAuthorityEpoch, generation });
},
getSessionHostId(sessionId) {
if (!sessionId) return null;
return sessionHostIds.get(sessionId) ?? null;
-1
View File
@@ -166,7 +166,6 @@ function createBridgeRegistrar(context) {
electron: electronModule,
safeStorage,
getInteractionWindow: () => win,
getTerminalWorkerManager: () => terminalWorkerManager,
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
requestPermissionDecision: createNativePermissionDecisionProvider({
@@ -390,9 +390,6 @@
},
{
"$ref": "#/$defs/HostClipboardWriteRequest"
},
{
"$ref": "#/$defs/HostTerminalSelectionReadRequest"
}
]
},
@@ -429,9 +426,6 @@
},
{
"const": true
},
{
"$ref": "#/$defs/SharedTerminalSelection"
}
]
}
@@ -542,11 +536,6 @@
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"terminalSessionId": {
"type": "string",
"maxLength": 128,
"pattern": "^[A-Za-z0-9_-]+$"
}
},
"additionalProperties": false
@@ -742,8 +731,7 @@
"provider.connection",
"provider.authentication",
"provider.sync",
"provider.importer",
"terminal.selection.read"
"provider.importer"
]
},
"NonResourceScopedPermission": {
@@ -777,8 +765,7 @@
"provider.connection",
"provider.authentication",
"provider.sync",
"provider.importer",
"terminal.selection.read"
"provider.importer"
]
},
"ResourceScopedPermission": {
@@ -885,10 +872,7 @@
{
"$ref": "#/$defs/RequiredPermissionResourceDeclaration"
}
],
"not": {
"const": "terminal.selection.read"
}
]
},
"PermissionSet": {
"type": "object",
@@ -5179,77 +5163,6 @@
"entries",
"diagnostics"
]
},
"TerminalSelectionSharingFeature": {
"const": "terminal.selection-sharing"
},
"TerminalSelectionSharingLimits": {
"const": {
"textBytes": 4096,
"handleTtlMs": 120000,
"handlesPerRuntime": 4,
"handlesTotal": 64
}
},
"HostTerminalTarget": {
"type": "object",
"properties": {
"sessionId": {
"type": [
"string",
"null"
],
"maxLength": 128,
"pattern": "^[A-Za-z0-9_-]+$"
}
},
"required": [
"sessionId"
],
"additionalProperties": false
},
"HostTerminalSelectionReadRequest": {
"type": "object",
"properties": {
"kind": {
"const": "terminal.selection.read"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
},
"required": [
"kind",
"title"
],
"additionalProperties": false
},
"SharedTerminalSelection": {
"type": "object",
"properties": {
"text": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"handle": {
"type": "string",
"minLength": 64,
"maxLength": 256
},
"expiresAt": {
"$ref": "#/$defs/SafePositiveInteger"
}
},
"required": [
"text",
"handle",
"expiresAt"
],
"additionalProperties": false
}
}
}
+6 -41
View File
@@ -2,7 +2,6 @@
const { createHmac, randomBytes, randomUUID, timingSafeEqual } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const { TerminalSelectionAuthority } = require("./terminalSelectionAuthority.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const schema = require("./generated/plugin-contract.schema.json");
const LIMITS = schema.$defs.HostInteractionLimits.const;
@@ -32,12 +31,7 @@ function assertInteractionRequest(params) {
}
return Object.freeze(structuredClone(params));
}
function interactionAuthorization(params, terminalTarget) {
if (params.kind === "terminal.selection.read") {
if (!terminalTarget) throw fail("failedPrecondition", "A host-selected terminal is required");
return { permission: "terminal.selection.read", resources: [terminalTarget.resource],
reason: "Preview and explicitly share the selected terminal text", operationId: "terminal:share-selection", allowedScopes: ["once"] };
}
function interactionAuthorization(params) {
const permission = params.kind === "clipboard.read" ? "clipboard.read"
: params.kind === "clipboard.write" ? "clipboard.write" : "ui.interact";
return {
@@ -61,12 +55,6 @@ class PluginHostInteractionService {
this.timeoutMs = Math.min(options.timeoutMs ?? LIMITS.timeoutMs, LIMITS.timeoutMs);
this.key = randomBytes(32);
this.pending = new Map();
this.terminalSelection = new TerminalSelectionAuthority({getWindow: this.getWindow, getManager: options.getTerminalWorkerManager, clock: this.clock});
this.terminalInvalidation = this.terminalSelection.onDidInvalidate(() => {
for (const entry of [...this.pending.values()]) if (entry.terminalTarget && !this.terminalSelection.isCurrent(entry.terminalTarget)) {
this.#finish(entry, null, fail("cancelled", "Selected terminal connection changed"));
}
});
this.rates = new Map();
this.admissions = new WeakMap();
this.admissionGuards = new Map();
@@ -87,9 +75,8 @@ class PluginHostInteractionService {
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
const terminalTarget = params.kind === "terminal.selection.read" ? this.terminalSelection.capture(sender, context) : undefined;
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard, terminalTarget });
return interactionAuthorization(params, terminalTarget);
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
return interactionAuthorization(params);
},
});
}
@@ -145,7 +132,7 @@ class PluginHostInteractionService {
const nonce = randomBytes(24).toString("base64url");
const binding = JSON.stringify([
nonce, entry.id, entry.context.pluginId, entry.context.pluginVersion,
entry.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt, entry.terminalTarget?.resource ?? null,
entry.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt,
]);
return `${nonce}.${createHmac("sha256", this.key).update(binding).digest("base64url")}`;
}
@@ -156,7 +143,6 @@ class PluginHostInteractionService {
requestId: entry.id, actionToken: entry.actionToken,
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
...(entry.terminalTarget ? { terminalSessionId: entry.terminalTarget.sessionId } : {}),
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
});
}
@@ -236,13 +222,10 @@ class PluginHostInteractionService {
if (this.pending.size >= LIMITS.pendingTotal || [...this.pending.values()].some((entry) => entry.context.runtimeId === context.runtimeId || entry.sender === sender)) {
throw fail("resourceExhausted", "Host interaction limit reached");
}
const terminalTarget = params.kind === "terminal.selection.read"
? admission?.terminalTarget ?? this.terminalSelection.capture(sender, context) : undefined;
if (terminalTarget) this.terminalSelection.assertCurrent(terminalTarget);
const now = this.clock();
return new Promise((resolve, reject) => {
const entry = {
id: randomUUID(), context, params, sender, resolve, reject, terminalTarget,
id: randomUUID(), context, params, sender, resolve, reject,
expiresAt: now + this.timeoutMs, phase: "request", actionToken: null,
};
entry.onAbort = () => this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
@@ -271,19 +254,10 @@ class PluginHostInteractionService {
await entry.context.assertActive();
this.#assertWindow(sender);
if (this.pending.get(entry.id) !== entry) return false;
if (entry.terminalTarget) this.terminalSelection.assertCurrent(entry.terminalTarget);
if (response.action === "cancel") {
this.#audit(entry, "cancelled");
return this.#finish(entry, { cancelled: true });
}
if (entry.params.kind === "terminal.selection.read" && entry.phase === "request") {
if (response.action !== "preview") throw fail("invalidArgument", "Terminal sharing requires a preview action");
const text = assertTextBytes(response.value);
if (!text.length) throw fail("failedPrecondition", "Select terminal text before requesting a share");
this.#audit(entry, "preview-confirmed");
this.quotaManager.chargeBytes(entry.context.runtimeId, "terminal", Buffer.byteLength(text));
entry.previewText = text; entry.phase = "preview"; this.#present(entry); return true;
}
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
this.#audit(entry, "preview-confirmed");
@@ -307,9 +281,6 @@ class PluginHostInteractionService {
else if (entry.params.kind === "quickPick") {
if (!entry.params.items.some((item) => item.id === response.value)) throw fail("invalidArgument", "Host interaction choice is invalid");
value = response.value;
} else if (entry.params.kind === "terminal.selection.read") {
if (entry.phase !== "preview" || typeof entry.previewText !== "string") throw fail("failedPrecondition", "Terminal selection was not previewed");
value = {text:entry.previewText, ...this.terminalSelection.issue(entry.terminalTarget, entry.context)};
} else if (entry.params.kind === "clipboard.read") value = entry.previewText;
else value = true;
this.#audit(entry, entry.params.kind === "clipboard.write" ? "copy-confirmed" : "share-confirmed");
@@ -324,11 +295,8 @@ class PluginHostInteractionService {
}
}
setTerminalTarget(sender, target) { return this.terminalSelection.setTarget(sender, target); }
releaseWindow(sender) {
if (this.owner !== sender) return;
this.terminalSelection.releaseWindow(sender);
this.admissionRevision++;
this.owner = null;
this.ownerCleanup?.(); this.ownerCleanup = null;
@@ -337,14 +305,12 @@ class PluginHostInteractionService {
}
}
releasePlugin(pluginId) {
this.terminalSelection.releasePlugin(pluginId);
this.admissionRevision++;
for (const entry of [...this.pending.values()]) {
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
}
}
releaseRuntime(runtimeId) {
this.terminalSelection.releaseRuntime(runtimeId);
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
this.rates.delete(runtimeId);
}
@@ -357,7 +323,7 @@ class PluginHostInteractionService {
}
}
cancelAll(invalidateAdmission = true) {
if (invalidateAdmission) { this.admissionRevision++; this.terminalSelection.clearHandles(); }
if (invalidateAdmission) this.admissionRevision++;
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
}
shutdown() {
@@ -366,7 +332,6 @@ class PluginHostInteractionService {
if (this.owner) this.releaseWindow(this.owner);
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
this.revocation.dispose(); this.unsubscribeLock?.();
this.terminalInvalidation.dispose(); this.terminalSelection.shutdown();
this.key.fill(0);
}
}
+2 -3
View File
@@ -99,7 +99,7 @@ function createPluginHostService(options) {
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: [...new Set(["terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -190,7 +190,6 @@ function createPluginHostService(options) {
getWindow: options.getInteractionWindow,
isLocked: options.isAppLocked,
subscribeLock: options.subscribeAppLock,
getTerminalWorkerManager: options.getTerminalWorkerManager,
});
interactionService.registerRpcCapabilities(rpcRegistry);
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
@@ -240,7 +239,7 @@ function createPluginHostService(options) {
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: [...new Set(["terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
runtimeDirectory,
appRoot,
rpcRegistry,
-5
View File
@@ -20,7 +20,6 @@ const CHANNELS = Object.freeze({
themeApply: "netcatty:plugins:theme-apply",
themesChanged: "netcatty:plugins:themes-changed",
status: "netcatty:plugins:status",
terminalTarget: "netcatty:plugins:terminal-target",
interactionReady: "netcatty:plugins:interaction-ready",
interactionResponse: "netcatty:plugins:interaction-response",
list: "netcatty:plugins:list",
@@ -727,10 +726,6 @@ function registerPluginBridge(ipcMain, options) {
));
handle(CHANNELS.restart, async (activeManager, payload) => activeManager.restart(payload?.pluginId));
handle(CHANNELS.uninstall, async (activeManager, payload) => activeManager.uninstall(payload?.pluginId));
handle(CHANNELS.terminalTarget, async (_manager, target, event) => {
if (!options.interactionService) throw new Error("Host interactions unavailable");
return options.interactionService.setTerminalTarget(event.sender, target);
});
handle(CHANNELS.themeCatalog, async () => {
if (!options.themeService) throw new Error("Plugin themes are unavailable");
return options.themeService.catalog();
-4
View File
@@ -518,10 +518,6 @@ function createPluginContext(config, client, runtimeApi) {
},
};
const terminals = {
shareSelection: (title) => {
if (!config.enabledFeatures?.includes("terminal.selection-sharing")) return Promise.reject(new Error("Terminal selection sharing is unsupported"));
return interact({ kind: "terminal.selection.read", title });
},
onDidChange: runtimeApi.terminalEvents.event,
};
const environment = {
@@ -1,67 +0,0 @@
"use strict";
const {randomBytes,createHmac}=require('node:crypto');
const {createDefinitionValidator}=require('./contractValidator.cjs');
const {PluginRpcError,RPC_ERRORS}=require('./rpcRouter.cjs');
const schema=require('./generated/plugin-contract.schema.json');
const LIMITS=schema.$defs.TerminalSelectionSharingLimits.const;
const FEATURE=schema.$defs.TerminalSelectionSharingFeature.const;
const validateTarget=createDefinitionValidator('HostTerminalTarget');
const fail=(code,message)=>new PluginRpcError(RPC_ERRORS[code],message);
/** Host-only resource authority. It never stores selected text or an endpoint DTO. */
class TerminalSelectionAuthority {
constructor(options){
this.getWindow=options.getWindow??(()=>null);this.getManager=options.getManager??(()=>null);this.clock=options.clock??Date.now;
this.key=randomBytes(32);this.target=null;this.handles=new Map();this.listeners=new Set();this.observed=null;this.disposables=[];
this.timer=setInterval(()=>this.prune(),1000);this.timer.unref?.();
}
onDidInvalidate(listener){this.listeners.add(listener);return{dispose:()=>this.listeners.delete(listener)};}
invalidate(){this.prune();for(const listener of this.listeners){try{listener();}catch{}}}
observe(manager){
if(this.observed===manager)return;
for(const item of this.disposables)item?.dispose?.();this.disposables=[];this.observed=manager;
for(const method of ['onSessionOwned','onSessionClosed','onWorkerExit'])if(typeof manager?.[method]==='function')this.disposables.push(manager[method](()=>this.invalidate()));
}
setTarget(sender,payload){
if(!validateTarget(payload))throw fail('invalidArgument','Invalid selected terminal target');
if(!sender || this.getWindow()?.webContents!==sender || sender.isDestroyed?.())throw fail('permissionDenied','Selected terminal belongs to another window');
if(this.target?.sender===sender && this.target.sessionId===payload.sessionId)return true;
this.target=payload.sessionId?{sender,sessionId:payload.sessionId}:null;this.invalidate();return true;
}
capture(sender,context){
const features=[...(context.manifest?.features?.required??[]),...(context.manifest?.features?.optional??[])];
if(!features.includes(FEATURE))throw fail('unsupported','Terminal selection sharing feature was not declared');
const manager=this.getManager();this.observe(manager);
if(!this.target || this.target.sender!==sender)throw fail('failedPrecondition','Select a live terminal in the main window first');
const {sessionId}=this.target;const binding=manager?.getPluginSessionBinding?.(sessionId,sender.id);
if(!binding)throw fail('unavailable','Selected terminal has no active owned worker connection');
return Object.freeze({sender,manager,sessionId,epoch:binding.epoch,generation:binding.generation,
resource:`terminal-session:${sessionId}:window:${sender.id}:worker:${binding.epoch}:generation:${binding.generation}`});
}
isCurrent(target){
if(!target || this.target?.sender!==target.sender || this.target?.sessionId!==target.sessionId || target.sender.isDestroyed?.() || this.getManager()!==target.manager)return false;
const current=target.manager.getPluginSessionBinding(target.sessionId,target.sender.id);
return !!current && current.epoch===target.epoch && current.generation===target.generation;
}
assertCurrent(target){if(!this.isCurrent(target))throw fail('cancelled','Selected terminal connection or owner changed');}
prune(){for(const [token,entry]of this.handles)if(entry.expiresAt<=this.clock()||!this.isCurrent(entry.target))this.handles.delete(token);}
issue(target,context){
this.assertCurrent(target);this.prune();
if(this.handles.size>=LIMITS.handlesTotal || [...this.handles.values()].filter(e=>e.runtimeId===context.runtimeId).length>=LIMITS.handlesPerRuntime)throw fail('resourceExhausted','Terminal selection handle limit reached');
const expiresAt=this.clock()+LIMITS.handleTtlMs,nonce=randomBytes(24).toString('base64url');
const signature=createHmac('sha256',this.key).update(JSON.stringify([nonce,target.resource,context.pluginId,context.pluginVersion,context.runtimeId,context.securityPrincipal??null,expiresAt])).digest('base64url');
const handle=`${nonce}.${signature}`;
this.handles.set(handle,{target,expiresAt,pluginId:context.pluginId,pluginVersion:context.pluginVersion,runtimeId:context.runtimeId,securityPrincipal:context.securityPrincipal});
return {handle,expiresAt};
}
resolve(handle,context){
this.prune();const entry=this.handles.get(handle);
if(!entry || entry.pluginId!==context.pluginId || entry.pluginVersion!==context.pluginVersion || entry.runtimeId!==context.runtimeId || entry.securityPrincipal!==context.securityPrincipal)throw fail('permissionDenied','Terminal selection handle is unavailable');
this.assertCurrent(entry.target);return entry.target;
}
releaseRuntime(runtimeId){for(const [key,entry]of this.handles)if(entry.runtimeId===runtimeId)this.handles.delete(key);}
releasePlugin(pluginId){for(const [key,entry]of this.handles)if(entry.pluginId===pluginId)this.handles.delete(key);}
releaseWindow(sender){if(this.target?.sender===sender)this.target=null;this.invalidate();}
clearHandles(){this.handles.clear();}
shutdown(){clearInterval(this.timer);for(const d of this.disposables)d?.dispose?.();this.handles.clear();this.listeners.clear();this.target=null;this.key.fill(0);}
}
module.exports={TerminalSelectionAuthority,FEATURE,LIMITS};
-1
View File
@@ -122,7 +122,6 @@ function createPreloadApi(ctx) {
}),
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
setPluginTerminalTarget: (target) => ipcRenderer.invoke("netcatty:plugins:terminal-target", target),
getPluginThemes: () => ipcRenderer.invoke("netcatty:plugins:theme-catalog"),
applyPluginTheme: (request) => ipcRenderer.invoke("netcatty:plugins:theme-apply", request),
onPluginThemesChanged: (callback) => {
Vendored
-1
View File
@@ -454,7 +454,6 @@ declare global {
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
restartPlugin?(pluginId: string): Promise<NetcattyInstalledPlugin>;
uninstallPlugin?(pluginId: string): Promise<boolean>;
setPluginTerminalTarget?(target: import("@netcatty/plugin-contract").HostTerminalTarget): Promise<boolean>;
getPluginThemes?(): Promise<import("@netcatty/plugin-contract").ThemePackCatalog>;
applyPluginTheme?(request: import("@netcatty/plugin-contract").ThemePackApplyRequest): Promise<import("@netcatty/plugin-contract").ThemePackCatalog>;
onPluginThemesChanged?(callback: () => void): () => void;
+20
View File
@@ -2221,6 +2221,26 @@ html[data-theme-transition] *::after {
--border: var(--host-security-border) !important;
--input: var(--host-security-input) !important;
--ring: var(--host-security-ring) !important;
/* Tailwind theme aliases resolve where declared; pin them in this scope too. */
--color-background: hsl(var(--host-security-background)) !important;
--color-foreground: hsl(var(--host-security-foreground)) !important;
--color-card: hsl(var(--host-security-card)) !important;
--color-card-foreground: hsl(var(--host-security-card-foreground)) !important;
--color-popover: hsl(var(--host-security-popover)) !important;
--color-popover-foreground: hsl(var(--host-security-popover-foreground)) !important;
--color-primary: hsl(var(--host-security-primary)) !important;
--color-primary-foreground: hsl(var(--host-security-primary-foreground)) !important;
--color-secondary: hsl(var(--host-security-secondary)) !important;
--color-secondary-foreground: hsl(var(--host-security-secondary-foreground)) !important;
--color-muted: hsl(var(--host-security-muted)) !important;
--color-muted-foreground: hsl(var(--host-security-muted-foreground)) !important;
--color-accent: hsl(var(--host-security-accent)) !important;
--color-accent-foreground: hsl(var(--host-security-accent-foreground)) !important;
--color-destructive: hsl(var(--host-security-destructive)) !important;
--color-destructive-foreground: hsl(var(--host-security-destructive-foreground)) !important;
--color-border: hsl(var(--host-security-border)) !important;
--color-input: hsl(var(--host-security-input)) !important;
--color-ring: hsl(var(--host-security-ring)) !important;
color: hsl(var(--foreground)) !important;
background-color: hsl(var(--background)) !important;
border-color: hsl(var(--border)) !important;
-2
View File
@@ -369,8 +369,6 @@ function validateSemantics(manifest: PluginManifest): string[] {
}
const requiredFeatures = new Set(manifest.features?.required ?? []);
const allFeatures = new Set([...requiredFeatures, ...(manifest.features?.optional ?? [])]);
if ((manifest.permissions?.optional ?? []).some((p) => p === 'terminal.selection.read') && !allFeatures.has('terminal.selection-sharing')) errors.push('Terminal selection sharing requires terminal.selection-sharing');
if ((manifest.main.data || manifest.contributes?.themes?.length) && !requiredFeatures.has('appearance.theme-packs')) errors.push('Theme packs require appearance.theme-packs');
for (const theme of manifest.contributes?.themes ?? []) if (!theme.entry.endsWith('.json')) errors.push('Theme palette entry must be JSON');
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
@@ -333,9 +333,6 @@
},
{
"$ref": "#/$defs/HostClipboardWriteRequest"
},
{
"$ref": "#/$defs/HostTerminalSelectionReadRequest"
}
]
},
@@ -372,9 +369,6 @@
},
{
"const": true
},
{
"$ref": "#/$defs/SharedTerminalSelection"
}
]
}
@@ -485,11 +479,6 @@
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"terminalSessionId": {
"type": "string",
"maxLength": 128,
"pattern": "^[A-Za-z0-9_-]+$"
}
},
"additionalProperties": false
@@ -675,8 +664,7 @@
"provider.connection",
"provider.authentication",
"provider.sync",
"provider.importer",
"terminal.selection.read"
"provider.importer"
]
},
"NonResourceScopedPermission": {
@@ -710,8 +698,7 @@
"provider.connection",
"provider.authentication",
"provider.sync",
"provider.importer",
"terminal.selection.read"
"provider.importer"
]
},
"ResourceScopedPermission": {
@@ -783,16 +770,9 @@
"RequiredPermissionDeclaration": {
"description": "Required resource-scoped permissions must name their complete activation-time approval bounds.",
"oneOf": [
{
"$ref": "#/$defs/NonResourceScopedPermission"
},
{
"$ref": "#/$defs/RequiredPermissionResourceDeclaration"
}
],
"not": {
"const": "terminal.selection.read"
}
{ "$ref": "#/$defs/NonResourceScopedPermission" },
{ "$ref": "#/$defs/RequiredPermissionResourceDeclaration" }
]
},
"PermissionSet": {
"type": "object",
@@ -3078,77 +3058,6 @@
"entries",
"diagnostics"
]
},
"TerminalSelectionSharingFeature": {
"const": "terminal.selection-sharing"
},
"TerminalSelectionSharingLimits": {
"const": {
"textBytes": 4096,
"handleTtlMs": 120000,
"handlesPerRuntime": 4,
"handlesTotal": 64
}
},
"HostTerminalTarget": {
"type": "object",
"properties": {
"sessionId": {
"type": [
"string",
"null"
],
"maxLength": 128,
"pattern": "^[A-Za-z0-9_-]+$"
}
},
"required": [
"sessionId"
],
"additionalProperties": false
},
"HostTerminalSelectionReadRequest": {
"type": "object",
"properties": {
"kind": {
"const": "terminal.selection.read"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
},
"required": [
"kind",
"title"
],
"additionalProperties": false
},
"SharedTerminalSelection": {
"type": "object",
"properties": {
"text": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"handle": {
"type": "string",
"minLength": 64,
"maxLength": 256
},
"expiresAt": {
"$ref": "#/$defs/SafePositiveInteger"
}
},
"required": [
"text",
"handle",
"expiresAt"
],
"additionalProperties": false
}
}
}
@@ -205,10 +205,9 @@ export type HostInteractionPrompt = {
phase: "request" | "preview";
request: HostInteractionRequest;
previewText?: string;
terminalSessionId?: string;
};
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest) | (HostTerminalSelectionReadRequest);
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
export type HostInteractionResponse = {
requestId: string;
@@ -221,7 +220,7 @@ export type HostInteractionResult = ({
cancelled: true;
}) | ({
cancelled: false;
value: (string) | (true) | (SharedTerminalSelection);
value: (string) | (true);
});
export type HostQuickPickItem = {
@@ -238,15 +237,6 @@ export type HostQuickPickRequest = {
export type HostSingleLineInputText = string;
export type HostTerminalSelectionReadRequest = {
kind: "terminal.selection.read";
title: string;
};
export type HostTerminalTarget = {
sessionId: string | null;
};
export type IconReference = (ThemeIcon) | (PackageIcon);
export type ImporterDetectPayload = {
@@ -441,7 +431,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
export type NetworkCredentialLeaseFeature = "network.credential-lease";
export type NonResourceScopedPermission = "storage" | "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";
export type NonResourceScopedPermission = "storage" | "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";
export type NullableRpcId = (RpcId) | (null);
@@ -597,7 +587,7 @@ export type PluginNetworkResponse = {
};
};
export type PluginPermission = "storage" | "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" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read";
export type PluginPermission = "storage" | "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" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type PluginViewFocusEvent = {
instanceId: string;
@@ -821,12 +811,6 @@ export type SettingOption = {
export type SettingScope = "application" | "workspace" | "host" | "session" | "device";
export type SharedTerminalSelection = {
text: string;
handle: string;
expiresAt: SafePositiveInteger;
};
export type StreamChunkByteLength = number;
export type StreamChunkData = ({
@@ -1045,10 +1029,6 @@ export type TerminalInterceptorReadyFrame = {
export type TerminalInterceptorWindowBytes = number;
export type TerminalSelectionSharingFeature = "terminal.selection-sharing";
export type TerminalSelectionSharingLimits = {"textBytes":4096,"handleTtlMs":120000,"handlesPerRuntime":4,"handlesTotal":64};
export type TerminalSessionSnapshot = {
sessionId: string;
hostId?: string;
-2
View File
@@ -60,5 +60,3 @@ export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const
export const PLUGIN_INTERACTIVE_COMMAND_FEATURE = "commands.interactive" as const satisfies import("./generated/plugin-contract.js").InteractiveCommandFeature;
export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satisfies import("./generated/plugin-contract.js").ThemePackFeature;
export const PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE = "terminal.selection-sharing" as const satisfies import("./generated/plugin-contract.js").TerminalSelectionSharingFeature;
-5
View File
@@ -45,7 +45,6 @@ import type {
SyncWriteObjectPayload,
SyncWriteObjectResult,
TerminalSessionSnapshot,
SharedTerminalSelection,
} from "@netcatty/plugin-contract";
export type * from "@netcatty/plugin-contract";
@@ -627,8 +626,6 @@ export type SyncProviderHandler = Readonly<{
}>;
export interface PluginTerminalSessions {
/** Explicit host Preview then Share; no screen/history query or automatic read. */
shareSelection(title: string): Promise<SharedTerminalSelection | undefined>;
onDidChange(listener: (event: TerminalSessionEvent) => void): Disposable;
}
@@ -938,5 +935,3 @@ export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE } from "@netcatty/plugin-contract";