Compare commits

...
10 changed files with 452 additions and 23 deletions

No files matched your search

@@ -3,6 +3,7 @@ import test from 'node:test';
import { createPluginTerminalSelectionSource } from './usePluginTerminalSelection';
import { readPluginTerminalSelection, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
import type { Terminal } from '@xterm/xterm';
import { foldTerminalPromptSecurityOutput, isConfirmedTerminalShellPrompt, isUntrustedTerminalInputPrompt } from '../../domain/terminalPromptSecurity';
function fixture() {
let reads = 0;
@@ -16,6 +17,39 @@ function fixture() {
return { term, state, source, get reads() { return reads; } };
}
test('reported native fixture prompts deliberately block sharing until an ordinary shell prompt is observed', () => {
const options = { allowHostStyleGreaterThan: false, alternateScreen: false };
for (const name of ['A', 'B']) {
const prompt = `fixture-${name}> `;
const output = `${name}_UNIQUE_SELECTION_ONLY\r\n${prompt}`;
for (const chunks of [[output], [...output]]) {
const f = fixture();
let state = { origin: '', current: '', promptText: '', pendingCr: false };
const observe = (chunk: string) => {
state = foldTerminalPromptSecurityOutput(state, chunk);
// Same latch ordering/options as Terminal.observeTerminalInputPrompt; no metadata override.
if (isUntrustedTerminalInputPrompt(state.promptText, options)) f.state.sensitive = true;
else if (isConfirmedTerminalShellPrompt(state.promptText, options)) f.state.sensitive = false;
};
for (const chunk of chunks) observe(chunk);
assert.equal(state.promptText, prompt);
assert.equal(isConfirmedTerminalShellPrompt(state.promptText, options), false);
assert.equal(isUntrustedTerminalInputPrompt(state.promptText, options), true);
assert.equal(f.state.sensitive, true);
assert.equal(f.source.target(), null, 'ambiguous fixture prompt remains fail-closed');
// Synthetic server output, never terminal input or a plugin exception to the guard.
observe(`\r\nfixture@owned-${name}:~$ `);
assert.equal(isConfirmedTerminalShellPrompt(state.promptText, options), true);
assert.equal(isUntrustedTerminalInputPrompt(state.promptText, options), false);
assert.equal(f.state.sensitive, false);
assert.ok(f.source.target());
observe('\r\nPassword> ');
assert.equal(f.source.target(), null, 'a subsequent sensitive boundary still revokes eligibility');
assert.equal(f.reads, 0, 'classification and publication never read terminal selection');
}
}
});
test('registered reader honors explicit false normalization and reads only on Preview', () => {
const f = fixture();
const cleanup = registerTerminalSelectionSource('a', f.source);
@@ -19,6 +19,12 @@ type SelectionState = {
export function createPluginTerminalSelectionSource(readState: () => SelectionState, instanceId: string) {
const target = () => {
const state = readState();
if (import.meta.env?.VITE_NETCATTY_TERMINAL_TARGET_DIAGNOSTICS === '1') {
// Fixed flags only: never include labels, identities, selection or terminal output.
console.info('[Plugins:TerminalTarget] ' + JSON.stringify({ stage: 'renderer-source', visible: state.visible === true,
paneOwned: state.isFocusedPane !== false, connected: state.connected === true, sensitiveClear: state.sensitive === false,
hasSession: Boolean(state.sessionId), hasTerminal: Boolean(state.term), attached: state.term?.element?.isConnected === true }));
}
// Undefined means solo/Focus mode; only a visible selected split pane may use true.
if (state.visible !== true || state.isFocusedPane === false || state.connected !== true || state.sensitive !== false
|| !state.sessionId || !state.term || state.term.element?.isConnected !== true) return null;
+16 -8
View File
@@ -22,17 +22,19 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostInteractionHost } = await import('./plugins/PluginHostInteractionHost');
const { PluginHostInteractionService, assertInteractionRequest } = await import('../electron/plugins/hostInteractionService.cjs');
const { createPreloadApi } = await import('../electron/preload/api.cjs');
const { registerPluginBridge, createTrustedPluginBridgeSender } = await import('../electron/plugins/pluginBridge.cjs');
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('./terminalPaneVisibility');
const { createPluginTerminalSelectionSource } = await import('../application/state/usePluginTerminalSelection');
const { registerTerminalSelectionSource } = await import('../infrastructure/plugins/terminalSelectionRegistry');
const { privateOwner, LOCAL_SOURCE } = await import('../electron/plugins/sourceIdentity.cjs');
let selectionReads = 0;
let selectionText = 'synthetic terminal selection\nsecond line';
let listener: ((event: unknown) => void) | null = null;
const rendererEvents = new EventEmitter();
let reads = 0;
const writes: string[] = [];
const audits: unknown[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(_channel: string, payload: unknown) { listener?.(payload); } });
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(channel: string, payload: unknown) { rendererEvents.emit(channel, {}, payload); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const terminalManager = { getPluginSessionBinding: (id: string, owner: number) => id === 'terminal-a' && owner === 7 ? { epoch: 'worker-a', generation: 1 } : null };
const service = new PluginHostInteractionService({
@@ -43,12 +45,17 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
quotaManager: { chargeBytes() {} },
clipboard: { readText() { reads++; return 'synthetic private text'; }, writeText(value: string) { writes.push(value); } },
});
Object.assign(dom.window, { netcatty: {
async setPluginTerminalTarget(target: unknown) { return service.setTerminalTarget(sender, target); },
onPluginInteraction(callback: (event: unknown) => void) { listener = callback; return () => { listener = null; }; },
async setPluginInteractionReady(ready: boolean) { return service.setReady(sender, ready); },
async respondPluginInteraction(response: unknown) { return service.respond(sender, response); },
} });
const handlers = new Map();
registerPluginBridge({ handle(channel: string, handler: unknown) { handlers.set(channel, handler); }, on() {} }, {
manager: { async initialize() {} }, interactionService: service,
isTrustedSender: createTrustedPluginBridgeSender(),
});
Object.assign(dom.window, { netcatty: createPreloadApi({ ipcRenderer: {
async invoke(channel: string, payload: unknown) {
return handlers.get(channel)({ sender, senderFrame: { url: 'app://netcatty/index.html' } }, payload);
},
on: rendererEvents.on.bind(rendererEvents), removeListener: rendererEvents.removeListener.bind(rendererEvents),
} }) });
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', manifest: { features: { required: ['terminal.selection-sharing', 'terminal.input-drafts'] } }, signal: new AbortController().signal, async assertActive() {} };
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
@@ -99,6 +106,7 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
assert.equal(inputProps.isFocused, false);
const selectionState = { term: terminal, sessionId: 'terminal-a', bootEpoch: 1, visible: inputProps.isVisible, isFocusedPane: inputProps.isFocusedPane, connected: true, sensitive: false, normalize: false, label: 'Synthetic selected terminal' };
const cleanupSelection = registerTerminalSelectionSource('terminal-a', createPluginTerminalSelectionSource(() => selectionState, 'renderer-a'));
await settle(); // Production preload publication crosses an asynchronous IPC boundary.
try {
const noShare = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /passwords, tokens/);
@@ -0,0 +1,207 @@
# Diagnose selected terminal target publication
## Current result
Native acceptance at `677aac33b1eec9dbd4a367417a49c964cdea63e7` failed before
selection permission in cold/warm solo, after A -> Vault -> A, selected split,
and workspace Focus mode. The native worker reported three owned synthetic SSH
channels and zero input events. No Preview, Share or draft opened. The failing
capture guard combines a missing target with a different target sender; the
recorded error alone does not distinguish them.
This isolated continuation adds diagnostics and exercises the complete target
transport. The subsequent evidence below identifies a fixture-induced sensitive
input stop. No production behavior correction is warranted by that evidence.
The frozen candidate and prior feature branches remain unchanged.
## Native discriminator and fixture verification
At diagnostic runtime head `587bc9f3f9979dfc89c0389e21b0951425d2f875`, the parent
reported a visible, pane-owned, connected and attached solo source with both
session and terminal refs present. `sensitiveClear` was false. The registry had
one source, zero eligible targets and an available API; main accepted its clear,
and the renderer received a successful acknowledgement. This establishes source
ineligibility and rules out a rejected publication for that observation.
The parent supplied the fixture's final prompt `fixture-A> ` and confirmed the
baseline used the same output. The complete native output bytes are not present
in this worktree. Direct invocation of the production renderer and shared main
classifiers verifies the exact supplied prompt (ordinary SSH options,
`allowHostStyleGreaterThan: false`, `alternateScreen: false`):
| Synthetic prompt | Confirmed shell | Untrusted input | Shared main untrusted |
| --- | --- | --- | --- |
| `fixture-A> ` | false | true | true |
| `fixture-B> ` | false | true | true |
| `fixture@owned-A:~$ ` | true | false | false |
| `fixture@owned-B:~$ ` | true | false | false |
These prompts contain no recognized sensitive vocabulary. The existing policy
also treats an ambiguous prompt-shaped `>` boundary as untrusted, protecting
custom authentication challenges. Host-name-style `>` prompts are confirmed only
for actual network-device mode. The fixture is exercising that deliberate guard.
[`Terminal.observeTerminalInputPrompt`](../../components/Terminal.tsx#L2054)
folds the output and sets `passwordPromptActiveRef.current = true` for an untrusted
prompt; the selected-terminal source receives that same flag at line 939.
[`isConfirmedTerminalShellPrompt`](../../domain/terminalPromptSecurity.ts#L150)
and [`isUntrustedTerminalInputPrompt`](../../domain/terminalPromptSecurity.ts#L292)
produce the classifications above. The source's `sensitive !== false` guard then
correctly yields no target. This matches the native diagnostic sequence.
The regression in `application/state/pluginTerminalSelection.test.ts` uses the
real output folder and classifiers with constructed synthetic marker lines and
the exact reported prompt. Both whole-stream and character-by-character delivery
(including split CRLF) latch sensitivity and block the real selection source.
A subsequent ordinary shell prompt clears the latch; a later password boundary
blocks it again. Classification never reads the selection.
### Minimal fixture correction and recheck
Change only the owned SSH fixture server's emitted prompt literals from
`fixture-A> ` / `fixture-B> ` to `fixture@owned-A:~$ ` /
`fixture@owned-B:~$ `. Keep the unique selected content, transport ownership and
all product settings unchanged. Start fresh fixture sessions and repeat the
parent-coordinated native check. Do not send commands to make a prompt, label the
fixture as a network device, override sensitivity metadata, or relax any guard.
The fixture source and reserved GUI slot belong to the native worker and are
not available in this worktree; the edit and native recheck remain pending there.
Runtime head `587bc9f3f` can be reused with that separately recorded fixture
change: this follow-up changes tests and this record only. The earlier matrix
failures with the unchanged prompt are retained as **fixture-induced safety
stops**. They do not establish failure of the corrected ordinary-shell fixture,
whose native preview, consent, draft and zero-input checks remain pending.
## Reproduce without the native GUI
Run these tests from this worktree after building plugin runtime dependencies:
```sh
node --test electron/plugins/terminalSelectionEntrypoint.test.cjs electron/plugins/terminalSelectionAuthority.test.cjs
node --test --import tsx components/PluginHostInteractionHost.test.tsx application/state/pluginTerminalSelection.test.ts components/terminal/terminalCursorRuntimeLifecycle.test.tsx
```
The installed sample now uses the production selection source and registry,
`createPreloadApi`, `registerPluginBridge` with its actual trusted-origin check,
main authority, manager, package store, permission middleware, SDK and browser RPC
entrypoint. Its terminal worker runtime/manager own synthetic sessions only. The
preview response reads the production registry; it no longer injects a target or
selection directly into the service. Publication acknowledgement is awaited in
the fixture because IPC is asynchronous.
The host-dialog test also uses the real preload and trusted main bridge. Tests
cover rejected foreign senders, unavailable sources before selection permission,
blur, owner release and fresh publication, cancellation, renderer/connection
replacement, hidden state, exact snapshots, separate handles, and inert drafts.
They do not establish Electron or native GUI acceptance.
## Collect the missing native evidence
The parent coordinates the reserved native slot. Do not modify the running
candidate, use a debugger, change helper permissions, or send terminal input.
Prepare a separate diagnostic build with:
```sh
VITE_NETCATTY_TERMINAL_TARGET_DIAGNOSTICS=1 npm run build
```
Use the parent's existing authorized launch recipe with
`NETCATTY_TERMINAL_TARGET_DIAGNOSTICS=1 ELECTRON_ENABLE_LOGGING=1` in its environment.
The Vite flag is needed at build time; the two launch flags enable the main
diagnostics and Electron's normal Chromium logging. Use the same owned
synthetic content and unchanged sample package. Invoke Share once after selecting
its bounded range. Stop after the first decisive failure and preserve only the
`[Plugins:TerminalTarget]` records.
### Collect the renderer records
Each diagnostic call now emits **one string**: the fixed prefix, a space, and
`JSON.stringify` of the existing fixed fields. It no longer passes an object as
a second console argument, which can lose those fields in Chromium's console
message formatting. No general console forwarding or new IPC channel is added.
For the project's normal POSIX launcher, the logging environment is sufficient:
```sh
NETCATTY_TERMINAL_TARGET_DIAGNOSTICS=1 ELECTRON_ENABLE_LOGGING=1 node electron/launch.cjs 2>&1 | rg --line-buffered -F '[Plugins:TerminalTarget]'
```
Preserve the parent's authorized profile/dev-server environment. Leave
`ELECTRON_LOG_FILE` unset for stderr collection. Filter the combined stream before
persisting diagnostic records; enabling Chromium logging can also emit unrelated
messages. Chromium may wrap the single diagnostic string with its ordinary
timestamp/process/source metadata. Main-process records use inherited stdout.
If the parent launches the Electron binary directly, the equivalent ordinary
switches are `--enable-logging --log-level=0`, alongside
`NETCATTY_TERMINAL_TARGET_DIAGNOSTICS=1`. The Node launcher does not forward extra
CLI arguments, so use its environment form above. On Windows, use an owned
diagnostic file via `--enable-logging=file --log-file=<owned-path> --log-level=0`
and retain main stdout as well; Electron documents child-process stderr limits
there. No sandbox or debugger switch is needed.
Evidence: [`electron/launch.cjs`](../../electron/launch.cjs) spawns Electron with
inherited stdio; [`electron/launchEnv.cjs`](../../electron/launchEnv.cjs) preserves
these logging variables. [`electron/main.cjs`](../../electron/main.cjs) only
filters DevTools Autofill noise. Electron's
[supported logging switches](https://www.electronjs.org/docs/latest/api/command-line-switches#--enable-loggingfile)
and [environment variables](https://www.electronjs.org/docs/latest/api/environment-variables#electron_enable_logging)
document this collection route. The pinned runtime's
[logging implementation](https://github.com/electron/electron/blob/v42.11.12/shell/common/logging.cc#L75)
maps `ELECTRON_ENABLE_LOGGING` to the normal stderr destination unless a log file
is selected. This is source/documentation verification; native collection and
acceptance remain for the parent-coordinated replay.
| Record | Meaning |
| --- | --- |
| `renderer-source` | Visibility, pane ownership, connected state, sensitive-clear state, session/terminal-ref presence, DOM attachment |
| `renderer-publication` | Registered/eligible counts, API presence, target-versus-clear intent |
| `renderer-ack` | IPC accepted or rejected; no raw exception |
| `main-ipc` | Fixed `untrusted-sender` or `manager-unavailable` rejection before authority |
| `main-publication` | Accepted target/clear, or fixed `invalid-target` / `other-window` reason |
| `main-release` | Target cleared by owner-window release |
| `main-capture` | `targetPresent`, `targetSenderMatchesCaller`, `callerIsMain` at the failing guard |
These records contain fixed stage/reason strings, booleans and counts only.
They omit terminal text, selection data, labels, session/renderer/plugin/source
identities, owner encodings and tokens. Normal builds/launches emit none. There
is no plugin diagnostic API, retry, terminal read, or PTY write.
If `targetPresent` is false, inspect the preceding source/publication/ack records
for ineligibility, a clear, rejection, or owner release. If true while the sender
comparison is false, retain the three capture booleans and accepted publication
sequence before investigating window ownership. A trusted foreign window is
already rejected by `setTarget`; do not relax that guard. Do not infer either
cause from the shared error message or assume palette focus clears selection.
## State and next action
Observed cause: **fixture prompt deliberately classified as untrusted input**.
Corrected fixture edit and native recheck: **pending parent/native-worker
coordination**. No action was denied or bypassed. No behavioral production patch
is warranted for this evidence. The source/runtime/connection guards, explicit
preview, consent and input-only host draft behavior are unchanged.
## Validation and sample
Local checks: 678 plugin-runtime tests passed, one native test skipped, zero
failures; seven focused renderer/dialog tests passed; contract, capability-tool
and Codex schema generation checks left no generated changes; lint passed with
seven existing warnings; the diagnostic production build passed.
The JSON collection delta passed 61 focused bridge/authority/installed-entrypoint/
launcher tests, changed-file lint and a fresh diagnostic production build. A
bundled synthetic renderer probe round-tripped source, publication and ack JSON
as single-line strings and checked that no identities or text entered the records.
Native log collection was performed by the parent/native worker at `587bc9f3f`;
this continuation consumes its fixed diagnostic fields and runs no native GUI.
The fixture-prompt follow-up passed 16 classifier/selection-source tests and
changed-file lint; it changes no runtime file.
The unchanged two-file sample built to archive SHA-256
`4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e`
and content SHA-256
`99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c`.
Use the integrated candidate's hash, rather than the older feature-only SDK
bundle hash. The branch adds no SDK, schema or sample changes.
+11 -2
View File
@@ -290,8 +290,17 @@ function registerPluginBridge(ipcMain, options) {
};
const handle = (channel, callback) => {
ipcMain.handle(channel, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
const activeManager = await resolveManager();
const diagnoseTarget = channel === CHANNELS.terminalTarget && process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS === "1";
if (!isTrustedSender(event)) {
if (diagnoseTarget) console.info('[Plugins:TerminalTarget] ' + JSON.stringify({ stage: "main-ipc", accepted: false, reason: "untrusted-sender" }));
throw new Error("Untrusted plugin management sender");
}
let activeManager;
try { activeManager = await resolveManager(); }
catch (error) {
if (diagnoseTarget) console.info('[Plugins:TerminalTarget] ' + JSON.stringify({ stage: "main-ipc", accepted: false, reason: "manager-unavailable" }));
throw error;
}
return callback(activeManager, payload, event);
});
};
+31
View File
@@ -28,6 +28,37 @@ function createIpcMain() {
};
}
test("target diagnostics categorize rejected IPC without logging sender, payload or exception", async () => {
const ipc = createIpcMain(), events = [];
const previousFlag = process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS, previousInfo = console.info;
const privatePayload = { sessionId: "synthetic-private-session", rendererId: "synthetic-private-renderer", label: "synthetic private label" };
registerPluginBridge(ipc, { manager: { async initialize() { throw new Error("synthetic private initialization detail"); } },
isTrustedSender: createTrustedPluginBridgeSender(),
});
try {
process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS = "1";
console.info = (line, ...extra) => {
assert.equal(extra.length, 0, 'one string survives Chromium console formatting');
assert.ok(line.startsWith('[Plugins:TerminalTarget] '));
assert.equal(line.includes('\n'), false);
events.push(['[Plugins:TerminalTarget]', JSON.parse(line.slice('[Plugins:TerminalTarget] '.length))]);
};
await assert.rejects(ipc.handlers.get(CHANNELS.terminalTarget)({ senderFrame: { url: "https://untrusted.invalid/" } }, privatePayload), /Untrusted/);
await assert.rejects(ipc.handlers.get(CHANNELS.terminalTarget)({ senderFrame: { url: "app://netcatty/index.html" } }, privatePayload), /disabled or unavailable/);
assert.deepEqual(events, [
["[Plugins:TerminalTarget]", { stage: "main-ipc", accepted: false, reason: "untrusted-sender" }],
["[Plugins:TerminalTarget]", { stage: "main-ipc", accepted: false, reason: "manager-unavailable" }],
]);
delete process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS;
await assert.rejects(ipc.handlers.get(CHANNELS.terminalTarget)({}, privatePayload), /Untrusted/);
assert.equal(events.length, 2);
} finally {
console.info = previousInfo;
if (previousFlag === undefined) delete process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS;
else process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS = previousFlag;
}
});
test("plugin management bridge is unavailable unless the local development gate is explicit", async () => {
const ipcMain = createIpcMain();
registerPluginBridge(ipcMain, {
@@ -9,6 +9,9 @@ const FEATURE = schema.$defs.TerminalSelectionSharingFeature.const;
const DRAFT_FEATURE = schema.$defs.TerminalInputDraftFeature.const;
const validateTarget = createDefinitionValidator("HostTerminalTarget");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
function diagnose(flags) {
if (process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS === "1") console.info("[Plugins:TerminalTarget] " + JSON.stringify(flags));
}
/** Host-only references. Selected text is retained only by the pending dialog. */
class TerminalSelectionAuthority {
@@ -55,10 +58,15 @@ class TerminalSelectionAuthority {
if (!features.includes(feature)) throw fail("unsupported", "Terminal sharing feature was not declared");
}
setTarget(sender, payload) {
if (!validateTarget(payload)) throw fail("invalidArgument", "Invalid selected terminal target");
if (!validateTarget(payload)) {
diagnose({ stage: "main-publication", accepted: false, reason: "invalid-target" });
throw fail("invalidArgument", "Invalid selected terminal target");
}
if (!sender || this.getWindow()?.webContents !== sender || sender.isDestroyed?.()) {
diagnose({ stage: "main-publication", accepted: false, reason: "other-window" });
throw fail("permissionDenied", "Selected terminal belongs to another window");
}
diagnose({ stage: "main-publication", accepted: true, hasTarget: payload.sessionId !== null });
if (this.target?.sender === sender && this.target.sessionId === payload.sessionId
&& this.target.rendererId === payload.rendererId && this.target.label === payload.label) return true;
this.target = payload.sessionId === null ? null : Object.freeze({ sender, ...payload });
@@ -69,7 +77,11 @@ class TerminalSelectionAuthority {
this.assertFeature(context, FEATURE);
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");
if (!this.target || this.target.sender !== sender) {
diagnose({ stage: "main-capture", targetPresent: this.target !== null,
targetSenderMatchesCaller: this.target?.sender === sender, callerIsMain: this.getWindow()?.webContents === sender });
throw fail("failedPrecondition", "Select a live terminal in the main window first");
}
const { sessionId, rendererId, label } = this.target;
const binding = manager?.getPluginSessionBinding?.(sessionId, sender.id);
if (!binding) throw fail("unavailable", "Selected terminal has no active owned worker connection");
@@ -122,7 +134,10 @@ class TerminalSelectionAuthority {
}
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.target.pluginId === pluginId) this.handles.delete(key); }
releaseWindow(sender) { if (this.target?.sender === sender) this.target = null; this.invalidate(); }
releaseWindow(sender) {
if (this.target?.sender === sender) { this.target = null; diagnose({ stage: "main-release", reason: "owner-window" }); }
this.invalidate();
}
clearHandles() { this.handles.clear(); }
shutdown() {
clearInterval(this.timer);
@@ -69,3 +69,43 @@ test('quotas, undeclared features and malformed targets fail before issuing a re
for (let i = 0; i < LIMITS.handlesPerRuntime; i++) f.authority.issue(target, f.context);
assert.throws(() => f.authority.issue(target, f.context), /limit/);
});
test('opt-in capture diagnostics distinguish a missing target from a sender mismatch without identities or text', (t) => {
const f = fixture(t), events = [];
const previousFlag = process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS, previousInfo = console.info;
try {
process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS = '1';
console.info = (line, ...extra) => {
assert.equal(extra.length, 0, 'one string survives Chromium console formatting');
assert.ok(line.startsWith('[Plugins:TerminalTarget] '));
assert.equal(line.includes('\n'), false);
events.push(['[Plugins:TerminalTarget]', JSON.parse(line.slice('[Plugins:TerminalTarget] '.length))]);
};
f.authority.setTarget(f.sender, { sessionId: null });
assert.throws(() => f.capture(), /Select a live terminal/);
assert.deepEqual(events.at(-1), ['[Plugins:TerminalTarget]', {
stage: 'main-capture', targetPresent: false, targetSenderMatchesCaller: false, callerIsMain: true,
}]);
f.select();
assert.throws(() => f.authority.capture({ id: 99 }, f.context), /Select a live terminal/);
assert.deepEqual(events.at(-1), ['[Plugins:TerminalTarget]', {
stage: 'main-capture', targetPresent: true, targetSenderMatchesCaller: false, callerIsMain: false,
}]);
assert.equal(JSON.stringify(events).includes('Synthetic'), false);
assert.equal(JSON.stringify(events).includes('renderer-a'), false);
assert.equal(JSON.stringify(events).includes(f.context.pluginId), false);
assert.throws(() => f.authority.setTarget(f.sender, { sessionId: 'synthetic-private-session' }), /Invalid/);
assert.deepEqual(events.at(-1)[1], { stage: 'main-publication', accepted: false, reason: 'invalid-target' });
assert.throws(() => f.authority.setTarget({ id: 99 }, { sessionId: null }), /another window/);
assert.deepEqual(events.at(-1)[1], { stage: 'main-publication', accepted: false, reason: 'other-window' });
delete process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS;
const count = events.length;
f.authority.setTarget(f.sender, { sessionId: null });
assert.throws(() => f.capture(), /Select a live terminal/);
assert.equal(events.length, count, 'normal launches emit no diagnostic events');
} finally {
console.info = previousInfo;
if (previousFlag === undefined) delete process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS;
else process.env.NETCATTY_TERMINAL_TARGET_DIAGNOSTICS = previousFlag;
}
});
@@ -9,8 +9,13 @@ const { createPluginHostService } = require("./hostService.cjs");
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
const { createTerminalWorkerManager } = require("../bridges/terminalWorkerManager.cjs");
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
const { createPreloadApi } = require("../preload/api.cjs");
const { registerPluginBridge, createTrustedPluginBridgeSender } = require("./pluginBridge.cjs");
async function fixture(t) {
const { tsImport } = await import('tsx/esm/api');
const { createPluginTerminalSelectionSource } = await tsImport('../../application/state/usePluginTerminalSelection.ts', __filename);
const { registerTerminalSelectionSource, publishTerminalSelectionTarget, readPluginTerminalSelection } = await tsImport('../../infrastructure/plugins/terminalSelectionRegistry.ts', __filename);
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'netcatty-terminal-entry-'));
const writes = [], prompts = [], decisions = [], responses = [];
let service, mode = 'share', selection = 'synthetic owned selection\nsecond line', allow = true, nextSession = 'terminal-a';
@@ -35,8 +40,14 @@ async function fixture(t) {
if (mode === 'pending') return;
queueMicrotask(() => {
const action = mode === 'cancel' ? 'cancel' : prompt.request.kind === 'terminal.selection.read' && prompt.phase === 'request' ? 'preview' : 'submit';
let value;
try { if (action === 'preview') value = readPluginTerminalSelection(prompt.terminalSessionId, prompt.terminalRendererId); }
catch {
responses.push(service.interactionService.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'cancel' }));
return;
}
responses.push(service.interactionService.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action,
...(action === 'preview' ? { value: selection } : {}),
...(action === 'preview' ? { value } : {}),
}).catch(async () => {
await service.interactionService.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'cancel' }).catch(() => false);
return false;
@@ -57,11 +68,39 @@ async function fixture(t) {
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
service.interactionService.setReady(sender, true);
const ipcHandlers = new Map(), rendererEvents = new EventEmitter(), publications = [];
registerPluginBridge({ handle(channel, handler) { ipcHandlers.set(channel, handler); }, on() {} }, {
manager: service.manager, interactionService: service.interactionService,
isTrustedSender: createTrustedPluginBridgeSender(),
});
const api = createPreloadApi({ ipcRenderer: {
invoke(channel, payload) {
const result = ipcHandlers.get(channel)({ sender, senderFrame: { url: 'app://netcatty/index.html' } }, payload);
if (channel === 'netcatty:plugins:terminal-target') publications.push(result);
return result;
}, on: rendererEvents.on.bind(rendererEvents), removeListener: rendererEvents.removeListener.bind(rendererEvents),
} });
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: api } });
await api.setPluginInteractionReady(true);
await manager.request('netcatty:local:start', {}, { webContentsId: 7 });
const select = (sessionId = 'terminal-a', rendererId = 'renderer-a') => service.interactionService.setTerminalTarget(sender, { sessionId, rendererId, label: `Synthetic ${sessionId}` });
select();
let disposeSource;
let state;
const select = async (sessionId = 'terminal-a', rendererId = 'renderer-a') => {
disposeSource?.();
state = { sessionId, bootEpoch: 1, visible: true, isFocusedPane: undefined, connected: true, sensitive: false, normalize: false, label: 'Synthetic selected terminal',
term: { cols: 80, element: { isConnected: true },
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: Math.min(selection.length, 4097), y: 0 } }),
getSelection: () => selection },
};
disposeSource = registerTerminalSelectionSource('synthetic-entrypoint', createPluginTerminalSelectionSource(() => state, rendererId));
await Promise.all(publications);
};
await select();
t.after(async () => {
disposeSource?.(); await Promise.all(publications);
await api.setPluginInteractionReady(false);
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); else Reflect.deleteProperty(globalThis, 'window');
await service.manager.shutdown();
manager.stop();
runtime.closeUrgentInputPortsForTest();
@@ -79,7 +118,9 @@ async function fixture(t) {
};
return { root, service, sender, window, manager, runtime, select, install, writes, prompts, decisions, responses,
mode(value) { mode = value; }, selection(value) { selection = value; }, allow(value) { allow = value; },
async startB() { nextSession = 'terminal-b'; await manager.request('netcatty:local:start', {}, { webContentsId: 7 }); select('terminal-b', 'renderer-b'); },
async startB() { nextSession = 'terminal-b'; await manager.request('netcatty:local:start', {}, { webContentsId: 7 }); await select('terminal-b', 'renderer-b'); },
async availability(changes) { Object.assign(state, changes); publishTerminalSelectionTarget(); await Promise.all(publications); },
api, ipcHandlers,
};
}
@@ -127,7 +168,7 @@ test('installed entrypoint keeps consent, exact snapshots, separate references a
assert.equal(proposal.terminalSessionId, 'terminal-a');
assert.deepEqual(f.writes, []);
f.selection('');
await assert.rejects(run('share'), /Select terminal text/);
assert.deepEqual(await run('share'), { cancelled: true });
f.selection('x'.repeat(4097));
assert.deepEqual(await run('share'), { cancelled: true });
f.selection('synthetic B'); await f.startB();
@@ -159,7 +200,7 @@ test('installed references revoke across worker reconnect, attached owner, hidde
await f.manager.request('netcatty:local:reconnect', { sessionId: 'terminal-a' }, { webContentsId: 7 });
assert.notEqual(f.manager.getPluginSessionBinding('terminal-a', 7).generation, first.generation);
await assert.rejects(run('draftFirst'), /unavailable/);
await run('share'); f.select('terminal-a', 'replacement-renderer');
await run('share'); await f.select('terminal-a', 'replacement-renderer');
await assert.rejects(run('draftLast'), /unavailable/);
await run('share');
assert.equal((await f.manager.rebindOutputSession('terminal-a', 9)).success, true);
@@ -167,7 +208,7 @@ test('installed references revoke across worker reconnect, attached owner, hidde
await assert.rejects(run('draftLast'), /unavailable/);
assert.equal((await f.manager.rebindOutputSession('terminal-a', 7)).success, true);
await run('share');
f.service.interactionService.setTerminalTarget(f.sender, { sessionId: null });
await f.availability({ visible: false });
await assert.rejects(run('share'), /Select a live terminal/);
await assert.rejects(run('draftLast'), /unavailable/);
assert.deepEqual(f.writes, []);
@@ -218,9 +259,39 @@ test('changing the selected renderer cancels an installed preview and rejects la
assert.equal(await f.service.interactionService.respond(f.sender, { requestId: first.requestId, actionToken: first.actionToken, action: 'preview', value: 'synthetic snapshot' }), true);
const preview = f.prompts.filter((p) => !p.closed).at(-1);
assert.equal(preview.phase, 'preview');
f.select('terminal-a', 'new-renderer');
await f.select('terminal-a', 'new-renderer');
await assert.rejects(pending, /cancel|changed/);
assert.equal(await f.service.interactionService.respond(f.sender, { requestId: preview.requestId, actionToken: preview.actionToken, action: 'submit' }), false);
assert.equal(f.service.interactionService.terminalSelection.handles.size, 0);
assert.deepEqual(f.writes, []);
});
test('real publisher, preload and trusted main IPC preserve a live target across palette blur and reject ineligible sources', { timeout: 15_000 }, async (t) => {
const f = await fixture(t); await f.install(source, manifest);
const run = () => f.service.contributionService.executeCommand(`${id}.share`);
f.window.emit('blur'); // Palette/native-prompt focus loss must not erase the host target.
assert.equal((await run()).text, 'synthetic owned selection\nsecond line');
await assert.rejects(f.ipcHandlers.get('netcatty:plugins:terminal-target')({
sender: f.sender, senderFrame: { url: 'https://untrusted.invalid/' },
}, { sessionId: null }), /Untrusted/);
await assert.rejects(f.ipcHandlers.get('netcatty:plugins:terminal-target')({
sender: { id: 9, isDestroyed: () => false }, senderFrame: { url: 'app://netcatty/index.html' },
}, { sessionId: null }), /another window/);
assert.equal((await run()).text, 'synthetic owned selection\nsecond line');
for (const changes of [{ visible: false }, { isFocusedPane: false }, { connected: false }, { sensitive: true }, { term: null }]) {
await f.select();
await f.availability(changes);
const before = f.decisions.length;
await assert.rejects(run(), /Select a live terminal/);
assert.equal(f.decisions.length, before, 'ineligible source fails before selection permission');
}
await f.select();
await f.availability({ isFocusedPane: true }); // Selected split pane, alongside the solo/Focus undefined case above.
assert.equal((await run()).text, 'synthetic owned selection\nsecond line');
await f.api.setPluginInteractionReady(false);
await f.api.setPluginInteractionReady(true);
await assert.rejects(run(), /Select a live terminal/, 'owner release requires a fresh publication');
await f.select();
assert.equal((await run()).text, 'synthetic owned selection\nsecond line');
assert.deepEqual(f.writes, []);
});
@@ -13,7 +13,15 @@ const MAX_BYTES = 4096;
export function publishTerminalSelectionTarget() {
const targets = [...sources.values()].map((source) => source.target()).filter((target) => target !== null);
const target = targets.length === 1 ? targets[0] : { sessionId: null };
void netcattyBridge.get()?.setPluginTerminalTarget?.(target).catch(() => {});
const bridge = netcattyBridge.get();
const diagnostics = import.meta.env?.VITE_NETCATTY_TERMINAL_TARGET_DIAGNOSTICS === '1';
if (diagnostics) console.info('[Plugins:TerminalTarget] ' + JSON.stringify({ stage: 'renderer-publication', sourceCount: sources.size,
eligibleCount: targets.length, apiAvailable: typeof bridge?.setPluginTerminalTarget === 'function', hasTarget: target.sessionId !== null }));
void bridge?.setPluginTerminalTarget?.(target).then((accepted) => {
if (diagnostics) console.info('[Plugins:TerminalTarget] ' + JSON.stringify({ stage: 'renderer-ack', accepted: accepted === true }));
}).catch(() => {
if (diagnostics) console.info('[Plugins:TerminalTarget] ' + JSON.stringify({ stage: 'renderer-ack', accepted: false }));
});
}
export function registerTerminalSelectionSource(key: string, source: TerminalSelectionSource) {