Compare commits

...
Author SHA1 Message Date
陈奇 3cdc97782e wip(plugins): save selected-terminal sharing authority and contract 2026-10-26 13:04:31 +00:00
17 changed files with 404 additions and 20 deletions

No files matched your search

@@ -0,0 +1,51 @@
# 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.
+1
View File
@@ -13,6 +13,7 @@ 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,6 +390,7 @@ function createTerminalWorkerManager(options = {}) {
onRendererEvent = null,
} = options;
let child = null;
let pluginSessionAuthorityEpoch = 0;
const pending = new Map();
const pendingOutput = new Map();
const pendingOutputBytes = new Map();
@@ -1857,6 +1858,7 @@ function createTerminalWorkerManager(options = {}) {
},
});
child = worker;
pluginSessionAuthorityEpoch++;
worker.on?.("message", (message) => {
if (child === worker) handleMessage(message);
});
@@ -2246,6 +2248,14 @@ 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,6 +166,7 @@ function createBridgeRegistrar(context) {
electron: electronModule,
safeStorage,
getInteractionWindow: () => win,
getTerminalWorkerManager: () => terminalWorkerManager,
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
requestPermissionDecision: createNativePermissionDecisionProvider({
@@ -390,6 +390,9 @@
},
{
"$ref": "#/$defs/HostClipboardWriteRequest"
},
{
"$ref": "#/$defs/HostTerminalSelectionReadRequest"
}
]
},
@@ -426,6 +429,9 @@
},
{
"const": true
},
{
"$ref": "#/$defs/SharedTerminalSelection"
}
]
}
@@ -536,6 +542,11 @@
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"terminalSessionId": {
"type": "string",
"maxLength": 128,
"pattern": "^[A-Za-z0-9_-]+$"
}
},
"additionalProperties": false
@@ -731,7 +742,8 @@
"provider.connection",
"provider.authentication",
"provider.sync",
"provider.importer"
"provider.importer",
"terminal.selection.read"
]
},
"NonResourceScopedPermission": {
@@ -765,7 +777,8 @@
"provider.connection",
"provider.authentication",
"provider.sync",
"provider.importer"
"provider.importer",
"terminal.selection.read"
]
},
"ResourceScopedPermission": {
@@ -872,7 +885,10 @@
{
"$ref": "#/$defs/RequiredPermissionResourceDeclaration"
}
]
],
"not": {
"const": "terminal.selection.read"
}
},
"PermissionSet": {
"type": "object",
@@ -5163,6 +5179,77 @@
"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
}
}
}
+41 -6
View File
@@ -2,6 +2,7 @@
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;
@@ -31,7 +32,12 @@ function assertInteractionRequest(params) {
}
return Object.freeze(structuredClone(params));
}
function interactionAuthorization(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"] };
}
const permission = params.kind === "clipboard.read" ? "clipboard.read"
: params.kind === "clipboard.write" ? "clipboard.write" : "ui.interact";
return {
@@ -55,6 +61,12 @@ 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();
@@ -75,8 +87,9 @@ 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); }
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
return interactionAuthorization(params);
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);
},
});
}
@@ -132,7 +145,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.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt, entry.terminalTarget?.resource ?? null,
]);
return `${nonce}.${createHmac("sha256", this.key).update(binding).digest("base64url")}`;
}
@@ -143,6 +156,7 @@ 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 }),
});
}
@@ -222,10 +236,13 @@ 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,
id: randomUUID(), context, params, sender, resolve, reject, terminalTarget,
expiresAt: now + this.timeoutMs, phase: "request", actionToken: null,
};
entry.onAbort = () => this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
@@ -254,10 +271,19 @@ 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");
@@ -281,6 +307,9 @@ 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");
@@ -295,8 +324,11 @@ 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;
@@ -305,12 +337,14 @@ 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);
}
@@ -323,7 +357,7 @@ class PluginHostInteractionService {
}
}
cancelAll(invalidateAdmission = true) {
if (invalidateAdmission) this.admissionRevision++;
if (invalidateAdmission) { this.admissionRevision++; this.terminalSelection.clearHandles(); }
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
}
shutdown() {
@@ -332,6 +366,7 @@ 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);
}
}
+3 -2
View File
@@ -99,7 +99,7 @@ function createPluginHostService(options) {
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
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 ?? [])])],
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 ?? [])])],
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -190,6 +190,7 @@ function createPluginHostService(options) {
getWindow: options.getInteractionWindow,
isLocked: options.isAppLocked,
subscribeLock: options.subscribeAppLock,
getTerminalWorkerManager: options.getTerminalWorkerManager,
});
interactionService.registerRpcCapabilities(rpcRegistry);
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
@@ -239,7 +240,7 @@ function createPluginHostService(options) {
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
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 ?? [])])],
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 ?? [])])],
runtimeDirectory,
appRoot,
rpcRegistry,
+5
View File
@@ -20,6 +20,7 @@ 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",
@@ -726,6 +727,10 @@ 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,6 +518,10 @@ 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 = {
@@ -0,0 +1,67 @@
"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,6 +122,7 @@ 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,6 +454,7 @@ 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;
+2
View File
@@ -369,6 +369,8 @@ 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,6 +333,9 @@
},
{
"$ref": "#/$defs/HostClipboardWriteRequest"
},
{
"$ref": "#/$defs/HostTerminalSelectionReadRequest"
}
]
},
@@ -369,6 +372,9 @@
},
{
"const": true
},
{
"$ref": "#/$defs/SharedTerminalSelection"
}
]
}
@@ -479,6 +485,11 @@
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"terminalSessionId": {
"type": "string",
"maxLength": 128,
"pattern": "^[A-Za-z0-9_-]+$"
}
},
"additionalProperties": false
@@ -664,7 +675,8 @@
"provider.connection",
"provider.authentication",
"provider.sync",
"provider.importer"
"provider.importer",
"terminal.selection.read"
]
},
"NonResourceScopedPermission": {
@@ -698,7 +710,8 @@
"provider.connection",
"provider.authentication",
"provider.sync",
"provider.importer"
"provider.importer",
"terminal.selection.read"
]
},
"ResourceScopedPermission": {
@@ -770,9 +783,16 @@
"RequiredPermissionDeclaration": {
"description": "Required resource-scoped permissions must name their complete activation-time approval bounds.",
"oneOf": [
{ "$ref": "#/$defs/NonResourceScopedPermission" },
{ "$ref": "#/$defs/RequiredPermissionResourceDeclaration" }
]
{
"$ref": "#/$defs/NonResourceScopedPermission"
},
{
"$ref": "#/$defs/RequiredPermissionResourceDeclaration"
}
],
"not": {
"const": "terminal.selection.read"
}
},
"PermissionSet": {
"type": "object",
@@ -3058,6 +3078,77 @@
"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,9 +205,10 @@ export type HostInteractionPrompt = {
phase: "request" | "preview";
request: HostInteractionRequest;
previewText?: string;
terminalSessionId?: string;
};
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest) | (HostTerminalSelectionReadRequest);
export type HostInteractionResponse = {
requestId: string;
@@ -220,7 +221,7 @@ export type HostInteractionResult = ({
cancelled: true;
}) | ({
cancelled: false;
value: (string) | (true);
value: (string) | (true) | (SharedTerminalSelection);
});
export type HostQuickPickItem = {
@@ -237,6 +238,15 @@ 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 = {
@@ -431,7 +441,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";
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 NullableRpcId = (RpcId) | (null);
@@ -587,7 +597,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";
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 PluginViewFocusEvent = {
instanceId: string;
@@ -811,6 +821,12 @@ 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 = ({
@@ -1029,6 +1045,10 @@ 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,3 +60,5 @@ 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,6 +45,7 @@ import type {
SyncWriteObjectPayload,
SyncWriteObjectResult,
TerminalSessionSnapshot,
SharedTerminalSelection,
} from "@netcatty/plugin-contract";
export type * from "@netcatty/plugin-contract";
@@ -626,6 +627,8 @@ 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;
}
@@ -935,3 +938,5 @@ 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";