mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 14:03:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fdaa357b4 | ||
|
|
587bc9f3f9 | ||
|
|
9f6777a778 | ||
|
|
e4a674bc6b |
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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user