mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdc97782e |
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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user