Compare commits

..
Author SHA1 Message Date
陈大猫 bb81b72ff0 fix(ai): avoid empty CodeBuddy tools whitelist that hides MCP tools (#4214)
Omit tools in MCP mode and explicitly disallow CodeBuddy built-ins, preserving injected MCP visibility and the skills-mode Bash whitelist.

Fixes #4214
2026-11-12 14:13:14 +08:00
38 changed files with 104 additions and 1718 deletions

No files matched your search

@@ -6,7 +6,6 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import type { SftpPane } from "./types";
import { buildCacheKey } from "./sharedRemoteHostCache";
for (const direction of ["download", "upload"] as const) {
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
@@ -1000,86 +999,3 @@ test("no-conflict local copy awaits discovery so a late stat cannot overwrite th
restore();
}
});
for (const scenario of ["folder-cp", "folder-fallback", "file-cp", "different-key", "missing-key", "non-utf8"] as const) {
test(`fresh remote copy selects a consistent strategy: ${scenario} (#4207)`, async () => {
const isDirectory = scenario.startsWith("folder");
const source = makePane("left");
const target = makePane("left");
target.connection = { ...target.connection!, id: "target-conn", currentPath: "/usr/tmp" };
if (scenario === "non-utf8") target.filenameEncoding = "gb18030";
const key = buildCacheKey("host-1", "debian", 22, "ssh", false, "user", "sftp");
const keys = new Map([["remote-conn", key], ["target-conn", key]]);
if (scenario === "different-key") keys.set("target-conn", buildCacheKey("host-2", "debian", 22, "ssh", false, "user", "sftp"));
if (scenario === "missing-key") keys.delete("target-conn");
const ownerId = `same-host-${scenario}-4207`;
const streams: Array<{ resumable: boolean; sameHost?: boolean; parentTaskId?: string }> = [];
const copies: unknown[][] = [];
let listings = 0;
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const restore = installGlobals({
statSftp: async (id: string) => id === "sftp-source"
? { type: "file", size: 3626, lastModified: 1758000001000 }
: null,
mkdirSftp: async () => undefined,
sameHostCopyDirectory: async (...args: unknown[]) => {
copies.push(args);
assert.equal(sftpTransferCenterStore.getTask(args[4] as string)?.resumable, false);
return { success: scenario !== "folder-fallback" };
},
startStreamTransfer: async (options: { transferId: string; resumable: boolean; sameHost?: boolean; parentTaskId?: string }) => {
streams.push(options);
assert.equal(sftpTransferCenterStore.getTask(options.transferId)?.resumable, options.resumable);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: options.transferId,
transferred: 3626, totalBytes: 3626, lifecycleEpoch: 0,
});
return {};
},
});
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => side === "left" ? source : target,
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-source"], ["target-conn", "sftp-target"]]) },
connectionCacheKeyMapRef: { current: keys },
listLocalFiles: async () => [],
listRemoteFiles: async () => { listings++; return source.files; },
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
const results = await ops!.startTransfer(
[{ name: isDirectory ? "bt_mine" : "aops_dns_view_demo.sh", isDirectory }], "left", "right",
);
assert.equal(results[0]?.status, "completed");
});
const eligible = ["folder-cp", "folder-fallback", "file-cp"].includes(scenario);
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId);
assert.ok(rows.length > 0);
assert.ok(rows.every(row => row.resumable === !eligible));
assert.equal(copies.length, isDirectory ? 1 : 0);
if (isDirectory) assert.deepEqual(copies[0]?.slice(0, 4), ["sftp-source", "/opt/bt_mine", "/usr/tmp/bt_mine", "auto"]);
assert.equal(listings, scenario === "folder-fallback" ? 1 : 0);
assert.equal(streams.length, scenario === "folder-cp" ? 0 : 1);
if (streams.length) {
assert.equal(streams[0].resumable, !eligible);
assert.equal(streams[0].sameHost, !["different-key", "missing-key"].includes(scenario) || undefined);
if (isDirectory) assert.ok(streams[0].parentTaskId);
}
} finally {
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
restore();
}
});
}
+2 -15
View File
@@ -1348,17 +1348,6 @@ export const useSftpTransfers = ({
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const batchId = crypto.randomUUID();
const usesLegacyScp = sourcePane.connection.fileProtocol === "scp" || targetPane.connection.fileProtocol === "scp";
const sourceCacheKey = connectionCacheKeyMapRef.current.get(sourcePane.connection.id);
const targetCacheKey = connectionCacheKeyMapRef.current.get(targetPane.connection.id);
const encodingSafeForExec = [sourcePane.filenameEncoding, targetPane.filenameEncoding]
.every((encoding) => !encoding || encoding === "utf-8" || encoding === "auto");
// Select the existing non-resumable cp route only for newly created copies.
// Resume/recovery keeps its saved strategy and checkpoints. If cp fails,
// recursive/download+upload fallback also remains non-resumable.
const useSameHostCopy = !usesLegacyScp
&& !sourcePane.connection.isLocal && !targetPane.connection.isLocal
&& !!sourceCacheKey && sourceCacheKey === targetCacheKey
&& encodingSafeForExec;
const newTasks: TransferTask[] = [];
const skippedResults: TransferResult[] = [];
@@ -1593,10 +1582,8 @@ export const useSftpTransfers = ({
sourceLastModified,
preflightStatSkipped: preflightSkippedNames.has(file.name),
origin: "manual",
resumable: !usesLegacyScp && !useSameHostCopy,
pauseUnavailableReason: usesLegacyScp
? "This server uses legacy SCP; cancel and retry from the beginning instead"
: useSameHostCopy ? "Same-host copies cannot be paused; cancel and retry from the beginning instead" : undefined,
resumable: !usesLegacyScp,
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
phase: file.isDirectory ? "scanning" : "transferring",
});
}
@@ -66,15 +66,9 @@ test('local logging controls default off, disclose verbose risk, and call host-o
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
await act(async () => button('Close').click());
assert.equal(document.querySelector('[role=dialog]'), null);
// Radix restores focus in a setTimeout(0) after the dialog unmounts.
// act() can finish before that timer, so drain it before checking focus.
await new Promise((resolve) => setTimeout(resolve, 0));
// Comparing DOM objects directly makes Node inspect the entire JSDOM graph
// on failure, which can stall the test process while formatting the diff.
assert.ok(document.activeElement === configure, `expected focus on Configure, got ${document.activeElement?.tagName}`);
assert.equal(document.activeElement, configure);
} finally {
await act(async () => root.unmount());
await new Promise((resolve) => setTimeout(resolve, 0));
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
@@ -9,13 +9,6 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
assert.match(source, /Toggle,\s*\n\s*Select,/);
});
test("folder compression row exposes its settings search anchor", () => {
assert.match(
source,
/<SettingRow\s+anchorId="sftp-compressed-upload"\s+label=\{t\('settings\.sftp\.compressedUpload\.enable'\)\}/,
);
});
function functionBody(functionName: string): string {
const start = source.indexOf(`const ${functionName} = useCallback`);
assert.notEqual(start, -1, `${functionName} should exist`);
@@ -180,7 +180,6 @@ export default function SettingsFileAssociationsTab() {
<SectionHeader title={t('settings.sftp.compressedUpload')} />
<SettingCard>
<SettingRow
anchorId="sftp-compressed-upload"
label={t('settings.sftp.compressedUpload.enable')}
description={t('settings.sftp.compressedUpload.enableDesc')}
>
@@ -232,14 +231,12 @@ export default function SettingsFileAssociationsTab() {
</div>
</SettingRow>
<SettingRow
anchorId="sftp-skip-unchanged"
label={t('settings.sftp.skipUnchanged.enable')}
description={t('settings.sftp.skipUnchanged.enableDesc')}
>
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
</SettingRow>
<SettingRow
anchorId="sftp-transport-idle-ttl"
label={t('settings.ssh.transportIdleTtl')}
description={t('settings.ssh.transportIdleTtl.desc')}
>
@@ -75,9 +75,6 @@ test('list hook preserves local copy/move routing, remote HTML drag, selection a
await act(async () => root.render(React.createElement(Harness, { local: false })));
await act(async () => right.handlePaneDrop(event()));
assert.equal(actions.at(-1)?.kind, 'copy');
actions.length = 0;
await act(async () => left.handleEntryDrop(entries[2], event()));
assert.deepEqual(actions, [{ kind: 'move', args: [['/files/b.txt'], '/files/folder'] }], 'same-pane remote drag stays a move');
} finally {
await act(async () => root.unmount());
clearLocalFileDrag();
@@ -89,15 +89,6 @@ test("a visible pane past the cap releases the least recently seen hidden pane",
assert.equal(budget.getActiveCount(), 3);
});
test("new visible panes are refused when visible holders fill the cap", () => {
const { budget } = setup(2);
const members = ["a", "b", "c", "d"].map((name) => new FakeMember(name));
assert.deepEqual(members.map((member) => budget.tryAcquire(member)), [true, true, false, false]);
assert.equal(budget.tryAcquire(members[0]), true, "an existing holder keeps its slot");
assert.equal(budget.getActiveCount(), 2);
assert.deepEqual(members.map((member) => member.releases), [0, 0, 0, 0]);
});
test("recently hidden panes are released only once they stay hidden long enough", () => {
const { clock, budget } = setup(2);
const [a, b] = ["a", "b"].map((name) => new FakeMember(name));
@@ -110,8 +101,6 @@ test("recently hidden panes are released only once they stay hidden long enough"
budget.tryAcquire(new FakeMember("shown"));
assert.equal(a.releases, 0, "a quick tab flip must not lose the renderer");
assert.equal(budget.getActiveCount(), 3);
assert.equal(budget.tryAcquire(new FakeMember("overflow")), false);
assert.equal(budget.getActiveCount(), 3, "one hidden holder allows only one extra context");
clock.advance(2_999);
assert.equal(a.releases, 0);
@@ -121,33 +110,26 @@ test("recently hidden panes are released only once they stay hidden long enough"
assert.equal(clock.pendingTimers, 0);
});
test("a pane hidden after a grace overshoot is released once eligible", () => {
test("a pane hidden after the budget went over is released once eligible", () => {
const { clock, budget } = setup(2);
const [a, b, c] = ["a", "b", "c"].map((name) => new FakeMember(name));
budget.tryAcquire(a);
budget.tryAcquire(b);
assert.equal(budget.tryAcquire(c), false);
assert.equal(budget.getActiveCount(), 2);
budget.tryAcquire(c);
assert.equal(budget.getActiveCount(), 3, "three visible panes all keep WebGL");
assert.equal(clock.pendingTimers, 0);
a.hiddenSince = clock.now();
budget.noteHidden(a);
assert.equal(budget.tryAcquire(c), true);
assert.equal(budget.getActiveCount(), 3, "recently hidden holders allow a grace overshoot");
a.hiddenSince = null;
b.hiddenSince = clock.now();
budget.noteHidden(b);
clock.advance(3_000);
assert.equal(b.releases, 1);
assert.equal(a.releases, 0, "a holder shown again keeps its renderer");
assert.equal(budget.getActiveCount(), 2);
});
test("a hidden pane cannot take a context once the budget is full", () => {
const { clock, budget } = setup(2);
const a = new FakeMember("a");
budget.tryAcquire(a);
const { budget } = setup(2);
budget.tryAcquire(new FakeMember("a"));
budget.tryAcquire(new FakeMember("b"));
const hidden = new FakeMember("hidden");
@@ -156,13 +138,7 @@ test("a hidden pane cannot take a context once the budget is full", () => {
assert.equal(budget.getActiveCount(), 2);
hidden.hiddenSince = null;
assert.equal(budget.tryAcquire(hidden), false, "visible holders still fill the cap");
a.hiddenSince = clock.now();
budget.noteHidden(a);
clock.advance(3_000);
assert.equal(budget.tryAcquire(hidden), true, "a shown pane can replace an eligible hidden holder");
assert.equal(a.releases, 1);
assert.equal(budget.getActiveCount(), 2);
assert.equal(budget.tryAcquire(hidden), true, "it gets one as soon as it is shown");
});
test("released and forgotten panes free their slot", () => {
@@ -10,9 +10,8 @@
*
* The budget stays below the browser limit by releasing the renderers of panes
* that have been hidden for a while, least recently seen first. Visible panes
* are never released, but new ones stay on DOM when visible holders fill the
* cap. Recently hidden holders may temporarily exceed the cap during tab flips.
* A released pane can rebuild when shown if the budget permits.
* are never released and always get a context. A released pane builds a new
* renderer when it is shown again. Below the cap nothing changes.
*/
export const MAX_ACTIVE_WEBGL_CONTEXTS = 12;
@@ -32,8 +31,8 @@ export interface WebglContextBudgetMember {
export interface WebglContextBudget {
/**
* Reserve a context if the budget has room. Visible members may replace
* hidden holders, allowing a temporary overshoot until their grace expires.
* Reserve a context for a member about to create its renderer. Visible
* members always get one; hidden members only while the budget has room.
*/
tryAcquire(member: WebglContextBudgetMember): boolean;
/** Give the slot back once the member's renderer is gone. Idempotent. */
@@ -103,11 +102,7 @@ export function createWebglContextBudget(
return {
tryAcquire(member) {
if (active.has(member)) return true;
if (active.size >= maxActive) {
if (member.getHiddenSince() !== null) return false;
const visibleCount = [...active].filter((holder) => holder.getHiddenSince() === null).length;
if (visibleCount >= maxActive) return false;
}
if (member.getHiddenSince() !== null && active.size >= maxActive) return false;
active.add(member);
releaseOverflow();
return true;
@@ -186,93 +186,20 @@ test("panes past the budget give up WebGL after staying hidden, and get it back
assert.equal(budget.getActiveCount(), 2);
});
test("simultaneous visible ensures stay within the context budget", () => {
const { budget, createPane } = setup();
const panes = Array.from({ length: 13 }, () => createPane());
for (const pane of panes) pane.controller.ensure();
assert.deepEqual(panes.map((pane) => pane.loaded), [...Array(12).fill(true), false]);
assert.equal(panes[12].addons.length, 0);
assert.equal(budget.getActiveCount(), 12);
});
test("visible context loss recovers at the cap, including with a refused visible pane", () => {
for (const withOverflow of [false, true]) {
const { clock, budget, createPane } = setup(2);
const panes = [createPane(), createPane()];
for (const pane of panes) pane.controller.ensure();
const overflow = withOverflow ? createPane() : null;
overflow?.controller.ensure();
assert.equal(overflow?.addons.length ?? 0, 0);
assert.equal(budget.getActiveCount(), 2);
panes[0].addons[0].loseContext();
assert.equal(budget.getActiveCount(), 1);
clock.advance(49);
assert.equal(panes[0].loaded, false);
clock.advance(1);
assert.equal(panes[0].loaded, true);
assert.equal(panes[0].addons.length, 2);
assert.equal(panes[1].addons.length, 1);
overflow?.controller.ensure();
assert.equal(overflow?.addons.length ?? 0, 0);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
}
});
test("visible loss stays on DOM if another visible pane takes the freed slot before recovery", () => {
const { clock, budget, createPane } = setup(2);
const panes = [createPane(), createPane(), createPane()];
for (const pane of panes) pane.controller.ensure();
assert.equal(panes[2].loaded, false);
panes[0].addons[0].loseContext();
panes[2].controller.ensure();
assert.equal(panes[2].loaded, true);
clock.advance(50);
assert.equal(panes[0].loaded, false);
assert.deepEqual(panes.map((pane) => pane.addons.length), [1, 1, 1]);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
});
test("a hidden pane mounted past the budget keeps the DOM renderer until shown", () => {
const { clock, budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const { budget, createPane } = setup(1);
createPane().controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
assert.equal(hidden.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
occupant.setVisible(false);
clock.advance(3_000);
hidden.reveal();
assert.equal(hidden.loaded, true);
assert.equal(occupant.loaded, false);
assert.equal(budget.getActiveCount(), 1);
});
test("revealing a hidden pane is refused while visible holders fill the budget", () => {
const { budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
hidden.reveal();
assert.equal(hidden.loaded, false);
assert.equal(hidden.addons.length, 0);
assert.equal(occupant.loaded, true);
assert.equal(budget.getActiveCount(), 1);
});
test("a holder on screen is never released, even if a sync was missed", () => {
test("a pane on screen is never refused or released, even if a sync was missed", () => {
const { clock, budget, createPane } = setup(1);
const visible = createPane();
visible.controller.ensure();
@@ -283,37 +210,12 @@ test("a holder on screen is never released, even if a sync was missed", () => {
createPane().controller.ensure();
clock.advance(10_000);
assert.equal(visible.loaded, true);
assert.equal(budget.getActiveCount(), 1);
assert.equal(budget.getActiveCount(), 2);
const late = createPane(false);
late.setVisibleWithoutSync(true);
late.controller.ensure();
assert.equal(late.loaded, false);
assert.equal(late.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
});
test("grace overflow never releases a holder shown again without a visibility sync", () => {
const { clock, budget, createPane } = setup(1);
const holder = createPane();
holder.controller.ensure();
holder.setVisible(false);
const front = createPane();
front.controller.ensure();
assert.equal(budget.getActiveCount(), 2);
holder.setVisibleWithoutSync(true);
clock.advance(3_000);
assert.equal(holder.loaded, true);
assert.equal(front.loaded, true);
assert.equal(holder.addons[0].disposeCalls, 0);
front.addons[0].loseContext();
clock.advance(50);
assert.equal(front.loaded, false, "recovery cannot exceed a cap already filled by visible holders");
assert.equal(front.addons.length, 1);
assert.equal(budget.getActiveCount(), 1);
assert.equal(clock.pendingTimers, 0);
assert.equal(late.loaded, true);
});
test("suspend, dispose and failed loads return the budget slot", () => {
@@ -65,7 +65,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
// isVisible() is the source of truth; hiddenSince only remembers when the
// pane was last seen. Re-reading it at every decision keeps a pane that is
// on screen from being released even if a sync was missed.
// on screen from being refused or released even if a sync was missed.
const readVisibility = () => {
if (!options.isVisible) return;
if (options.isVisible()) hiddenSince = null;
@@ -94,7 +94,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
const ensure = () => {
if (loaded || circuitBroken || disposed || !options.enabled) return;
// Panes past the budget keep the DOM renderer until a slot is available.
// A hidden pane past the budget keeps the DOM renderer until it is shown.
if (budget && !budget.tryAcquire(budgetMember)) return;
let nextAddon: TAddon | null = null;
try {
-54
View File
@@ -1,11 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { describeHostKey } = require("../electron/bridges/hostKeyVerifier.cjs");
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("../electron/bridges/fixtures/hostCertificates.test.fixture.cjs");
import type { KnownHost } from "./models";
import {
@@ -144,55 +139,6 @@ test("fingerprintFromPublicKey returns empty string on missing input", () => {
// --- Migration --------------------------------------------------------------
for (const options of [
{ type: "ed25519" },
{ type: "rsa", bits: 2048 },
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
{ type: "ed25519", sk: true },
{ type: "ecdsa", bits: 256, sk: true },
]) {
test(`renderer certificate fingerprint and migration agree with verifier: ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t, options);
const expected = describeHostKey(first.blob).fingerprint;
assert.equal(fingerprintFromPublicKey(first.line), expected);
assert.equal(expected, first.publicKeyFingerprint);
assert.equal(fingerprintFromPublicKey(renewed.line), expected);
assert.notEqual(fingerprintFromPublicKey(different.line), expected);
assert.equal(fingerprintFromPublicKey(first.plain.line), expected);
const legacy = knownHost({
keyType: first.keyType,
publicKey: first.line,
fingerprint: crypto.createHash("sha256").update(first.blob).digest("base64").replace(/=+$/g, ""),
});
const migrated = normalizeKnownHost(legacy);
assert.equal(migrated.fingerprint, expected);
assert.equal(migrated.keyType, first.keyType);
assert.equal(migrated.publicKey, first.line);
assert.equal(normalizeKnownHost(migrated), migrated, "migration is idempotent");
assert.equal(normalizeKnownHost({ ...legacy, fingerprint: undefined }).fingerprint, expected);
assert.equal(normalizeKnownHost({ ...legacy, keyType: "" }).keyType, first.keyType);
const unrelated = { ...legacy, fingerprint: "unrelated-explicit-fingerprint" };
assert.equal(normalizeKnownHost(unrelated), unrelated);
const opaque = { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` };
assert.equal(normalizeKnownHost(opaque), opaque);
const fingerprintOnly = { ...legacy, publicKey: undefined };
assert.equal(normalizeKnownHost(fingerprintOnly), fingerprintOnly);
});
}
test("renderer fingerprints v00 certificates and falls back for unparseable certificates", () => {
const cert = legacyCertificate("ssh-rsa", ["exponent", "modulus"]);
assert.equal(fingerprintFromPublicKey(cert.line), describeHostKey(cert.plain).fingerprint);
const malformed = Buffer.concat([
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
]);
const line = `ssh-ed25519-cert-v01@openssh.com ${malformed.toString("base64")}`;
const fullHash = crypto.createHash("sha256").update(malformed).digest("base64").replace(/=+$/g, "");
assert.equal(fingerprintFromPublicKey(line), fullHash);
assert.equal(fingerprintFromPublicKey(line), describeHostKey(malformed).fingerprint);
});
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
+6 -15
View File
@@ -1,5 +1,4 @@
import type { KnownHost } from "./models";
import { extractCertificatePublicKey } from "@/domain/sshPublicKey.shared.cjs";
const normalizeHost = (value: string) => value.trim().toLowerCase();
@@ -133,7 +132,7 @@ export const fingerprintFromPublicKey = (publicKey: string | undefined | null):
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
const bytes = base64Decode(parts[1]);
if (bytes) return stripPadding(base64Encode(sha256Bytes(extractCertificatePublicKey(bytes) || bytes)));
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
}
return stripPadding(trimmed);
@@ -146,9 +145,9 @@ const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): stri
};
/**
* Backfill missing `fingerprint` / `keyType` and migrate full-certificate
* fingerprints to the underlying key when the certificate body is available.
* Returns the same reference when nothing changes so callers can skip
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
* verifier can match it without falling back to the brittle re-derivation
* path. Returns the same reference when nothing changes so callers can skip
* persistence writes and React re-renders.
*/
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
@@ -157,17 +156,9 @@ export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
&& knownHost.keyType.length > 0
&& knownHost.keyType !== "unknown";
const parts = knownHost.publicKey?.trim().split(/\s+/) ?? [];
const certBytes = /-cert-v0[01]@openssh\.com$/.test(parts[0]) && parts[1]
? base64Decode(parts[1]) : null;
const legacyCertFingerprint = certBytes && extractCertificatePublicKey(certBytes)
? stripPadding(base64Encode(sha256Bytes(certBytes))) : "";
const migrateCertificate = legacyCertFingerprint
&& stripPadding((knownHost.fingerprint ?? "").replace(/^SHA256:/i, "")) === legacyCertFingerprint;
if (hasFingerprint && hasKeyType) return knownHost;
if (hasFingerprint && hasKeyType && !migrateCertificate) return knownHost;
const derivedFingerprint = hasFingerprint && !migrateCertificate
const derivedFingerprint = hasFingerprint
? knownHost.fingerprint!
: fingerprintFromPublicKey(knownHost.publicKey);
const derivedKeyType = hasKeyType
-42
View File
@@ -125,48 +125,6 @@ test("settings search finds App Lock from the zh-CN locale", () => {
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
});
test("settings search finds folder compression from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["压缩", "普通传输"]],
[tEnReal, ["compress", "tar"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-compressed-upload"), query);
}
}
});
test("settings search finds skip unchanged files from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["跳过未改文件", "修改时间"]],
[tEnReal, ["skip unchanged", "rsync"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-skip-unchanged"), query);
}
}
});
test("settings search finds SSH connection keep-alive from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["保活时间", "重新认证"]],
[tEnReal, ["keep-alive", "re-authentication"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-transport-idle-ttl"), query);
}
}
});
test("settings search finds temporary network device mode", () => {
const tEnReal = (key: string) => en[key] ?? key;
const tZhTW = (key: string) => zhTW[key] ?? key;
-21
View File
@@ -671,13 +671,6 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.sftp.autoSync.enableDesc",
sectionKey: "settings.sftp.autoSync",
},
{
id: "sftp-compressed-upload",
tab: "file-associations",
labelKey: "settings.sftp.compressedUpload.enable",
descriptionKey: "settings.sftp.compressedUpload.enableDesc",
sectionKey: "settings.sftp.compressedUpload",
},
{
id: "sftp-follow-terminal-cwd",
tab: "file-associations",
@@ -698,20 +691,6 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
labelKey: "settings.sftp.transferConcurrency",
descriptionKey: "settings.sftp.transferConcurrency.desc",
},
{
id: "sftp-skip-unchanged",
tab: "file-associations",
labelKey: "settings.sftp.skipUnchanged.enable",
descriptionKey: "settings.sftp.skipUnchanged.enableDesc",
sectionKey: "settings.sftp.transferConcurrency",
},
{
id: "sftp-transport-idle-ttl",
tab: "file-associations",
labelKey: "settings.ssh.transportIdleTtl",
descriptionKey: "settings.ssh.transportIdleTtl.desc",
sectionKey: "settings.sftp.transferConcurrency",
},
{
id: "sftp-default-opener",
tab: "file-associations",
-84
View File
@@ -1,84 +0,0 @@
"use strict";
// Extract only the public-key wire blob for fingerprinting, never CA trust or
// certificate validation. ssh2's getPublicSSH() preserves the full certificate,
// and its internal base-type helper is not exported. Keep this byte-only helper
// shared by Electron and the renderer so migration uses exactly the same bytes.
// Layouts: OpenSSH PROTOCOL.certkeys (v00/v01) and PROTOCOL.u2f (SK).
function extractCertificatePublicKey(blob) {
let offset = 0;
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
const skip = (length) => {
if (offset + length > blob.length) throw new Error("Truncated SSH key");
offset += length;
};
const readString = () => {
skip(4);
const length = view.getUint32(offset - 4, false);
const start = offset;
skip(length);
return blob.subarray(start, offset);
};
const ascii = (bytes) => String.fromCharCode(...bytes);
try {
const typeBytes = readString();
if (typeBytes.length > 128) return null;
const match = ascii(typeBytes).match(/^(.*)-cert-v0([01])@openssh\.com$/);
if (!match) return null;
let baseType = match[1];
const v01 = match[2] === "1";
if (/^rsa-sha2-(256|512)$/.test(baseType)) baseType = "ssh-rsa";
if (baseType === "sk-ssh-ed25519" || baseType === "sk-ecdsa-sha2-nistp256") {
baseType += "@openssh.com";
}
let fields;
switch (baseType) {
case "ssh-rsa": fields = 2; break;
case "ssh-dss": fields = 4; break;
case "ssh-ed25519": fields = 1; break;
case "sk-ssh-ed25519@openssh.com": fields = 2; break;
case "sk-ecdsa-sha2-nistp256@openssh.com": fields = 3; break;
case "ecdsa-sha2-nistp256":
case "ecdsa-sha2-nistp384":
case "ecdsa-sha2-nistp521": fields = 2; break;
default: return null;
}
// v00 put its nonce after the constraints, not before the key material.
if (v01) readString();
const keyStart = offset;
for (let i = 0; i < fields; i += 1) {
const field = readString();
const isApplication = baseType.startsWith("sk-") && i === fields - 1;
if (!field.length && !isApplication) return null;
if (i === 0 && baseType.includes("ed25519") && field.length !== 32) return null;
if (i === 0 && baseType.includes("ecdsa")) {
const curve = baseType.replace(/^sk-/, "").replace(/@openssh\.com$/, "").replace(/^ecdsa-sha2-/, "");
if (field.length > 8 || ascii(field) !== curve) return null;
}
}
const keyEnd = offset;
// Check the outer envelope too; truncated/unsupported certificates retain
// their full-blob fingerprint rather than gaining an inner-key match.
if (v01) skip(8); // serial
skip(4); // host/user type
readString(); // key ID
readString(); // principals
skip(16); // validity
readString(); // critical options (v00 constraints)
readString(); // extensions (v00 nonce)
readString(); // reserved
if (!readString().length || !readString().length || offset !== blob.length) return null;
const result = new Uint8Array(4 + baseType.length + keyEnd - keyStart);
new DataView(result.buffer).setUint32(0, baseType.length, false);
for (let i = 0; i < baseType.length; i += 1) result[4 + i] = baseType.charCodeAt(i);
result.set(blob.subarray(keyStart, keyEnd), 4 + baseType.length);
return result;
} catch {
return null;
}
}
exports.extractCertificatePublicKey = extractCertificatePublicKey;
-1
View File
@@ -84,7 +84,6 @@ module.exports = {
// is excluded below, so the shared TOTP runtime must be listed.
'domain/totp.shared.cjs',
'domain/totpPrompt.shared.cjs',
'domain/sshPublicKey.shared.cjs',
'lib/**/*.cjs',
'lib/**/*.json',
'skills/**/*',
@@ -281,26 +281,6 @@ test('wall deadline during authorization reports authorization failure rather th
assert.deepEqual(pty.writes, []);
});
test('idle Enter-PSSession remoting prompt allows the command to be sent', async () => {
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = '[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ';
trackSessionIdlePrompt(session, `PowerShell 7\r\n${prompt}`);
assert.equal(readyPrompt(session), prompt);
const result = await startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
}).resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, 'OK');
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
+3 -4
View File
@@ -50,12 +50,11 @@ function formatSyntheticEcho(command) {
}
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
// `PS /home/alice>`), with optional Enter-PSSession / remoting `[host]: `
// and debug `[DBG]: ` prefixes. Anchored so command output that merely
// starts with `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
// `PS /home/alice>`). Anchored so command output that merely starts with
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
// `"PS >"` (which the default prompt never emits) so a script that prints
// such a line can't trick prompt-driven shell-kind selection.
const POWERSHELL_PROMPT_PATTERN = /^(?:\[[^\]\r\n]+\]: )*PS(?:\s+\S.*)?>$/;
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
// Default cmd.exe prompt (e.g. `C:\>`, `C:\Users\alice>`, `D:\data>`).
// Drive letter + optional path + `>`. Rejects `C: >` (space before `>`) and
-40
View File
@@ -112,46 +112,6 @@ test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alik
assert.equal(isDefaultPowerShellPromptLine(null), false);
});
test("PowerShell prompt helpers recognize remoting and debug prefixes", () => {
for (const prompt of [
"[192.168.1.10]: PS C:\\Users\\Administrator\\Documents>",
"[SERVER01]: PS C:\\>",
"[host.example.com]: PS C:\\Windows\\System32>",
"[fe80::1]: PS C:\\>",
"[DBG]: PS C:\\Users\\alice>",
"[SERVER01]: [DBG]: PS C:\\>",
]) {
assert.equal(isDefaultPowerShellPromptLine(prompt), true, prompt);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt, prompt);
}
});
test("PowerShell prompt helpers reject malformed remoting prefixes and look-alikes", () => {
for (const line of [
"[host] PS C:\\>",
"prefix [host]: PS C:\\>",
"[]: PS C:\\>",
"[ho]st]: PS C:\\>",
"[ho\rst]: PS C:\\>",
"[ho\nst]: PS C:\\>",
"[host]:PS C:\\>",
"[host]: PS >",
"PS >",
"PSO>",
"ZIPS>",
"",
null,
]) {
assert.equal(isDefaultPowerShellPromptLine(line), false, String(line));
assert.equal(extractTrailingIdlePrompt(line), "", String(line));
}
});
test("extractTrailingIdlePrompt preserves trailing space on a remoting prompt", () => {
const prompt = "[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ";
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt);
});
test("extracts a trailing cmd.exe idle prompt", () => {
// Windows OpenSSH default shell is cmd.exe; without capturing `C:\...>`
// AI exec cannot select the cmd wrapper when shellKind is still unset.
@@ -24,6 +24,21 @@ const UI_DISALLOWED_TOOLS = ["AskUserQuestion"];
const MCP_MODE_TOOLS = [];
const SKILLS_MODE_TOOLS = ["Bash"];
// Deduped built-in catalog from @tencent-ai/agent-sdk 0.3.258 cli/product.json.
const CODEBUDDY_BUILTIN_TOOLS = [
"Agent", "Bash", "PowerShell", "Glob", "Grep", "Read", "Edit", "Write",
"NotebookEdit", "WebFetch", "ListMcpResources", "ReadMcpResource", "WaitForMcpServers",
"TaskCreate", "TaskGet", "TaskUpdate", "TaskList", "WebSearch", "EnterPlanMode",
"ExitPlanMode", "KillShell", "TaskStop", "TaskOutput", "SlashCommand", "Skill",
"SkillManage", "AskUserQuestion", "AskUserForStructuredInput", "LSP", "StructuredOutput",
"ToolSearch", "DeferExecuteTool", "ImageGen", "ImageEdit", "VideoGen", "Artifact",
"ArtifactControl", "ComputerUse", "TeamCreate", "TeamDelete", "SendMessage",
"SendUserMessage", "EnterWorktree", "LeaveWorktree", "CronCreate", "CronDelete",
"CronList", "DelegateTool", "WeChatReply", "WeComReply", "PushNotification",
"ReportFindings", "Workflow", "Monitor", "REPL", "A2AGetAgentCard", "A2ASendMessage",
"A2AGetTask", "A2ACancelTask",
];
const CODEBUDDY_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
function isCodebuddyImageAttachment(attachment) {
@@ -261,18 +276,19 @@ function buildCodebuddyQueryOptions({
allowDangerouslySkipPermissions: true,
extraArgs: { "dangerously-skip-permissions": null },
mcpServers: toSdkMcpServers(injectedMcpServers),
tools: builtinTools,
// `tools` is the built-in tool whitelist. In mcp mode it is [], so CodeBuddy
// built-ins stay disabled while injected Netcatty MCP tools remain visible.
// Do not mirror that empty list into allowedTools: the SDK treats
// allowedTools as an auto-approval list, and allowedTools: [] prevents MCP
// tool calls from running under bypassPermissions.
disallowedTools: [...UI_DISALLOWED_TOOLS],
// Block built-ins explicitly in mcp mode: tools: [] becomes --tools "",
// which also hides injected MCP tools in CodeBuddy CLI v2.183.0.
// Leave allowedTools unset: an empty auto-approval list prevents MCP tool
// calls from running under bypassPermissions.
disallowedTools: builtinTools.length > 0
? [...UI_DISALLOWED_TOOLS]
: [...new Set([...UI_DISALLOWED_TOOLS, ...CODEBUDDY_BUILTIN_TOOLS])],
// Keep the SDK isolated from user/project settings so local hooks, plugins,
// or extra MCP servers cannot expand Netcatty's controlled tool boundary.
settingSources: [],
env,
};
if (builtinTools.length > 0) options.tools = builtinTools;
const selection = splitCodebuddyModelSelection(model);
if (selection.model) options.model = selection.model;
if (abortController) options.abortController = abortController;
@@ -59,15 +59,51 @@ test("buildCodebuddyQueryOptions wires SDK options in isolated mode", () => {
assert.equal(opts.pathToCodebuddyCode, "/opt/codebuddy/bin/codebuddy");
assert.equal(opts.abortController, ac);
assert.equal(opts.resume, "sess-1");
assert.deepEqual(opts.tools, []);
// allowedTools must stay unset in mcp mode: tools:[] disables built-ins, while
assert.ok(!("tools" in opts));
// allowedTools:[] would prevent injected Netcatty MCP tools from running.
assert.ok(!("allowedTools" in opts));
assert.ok(opts.disallowedTools.includes("Bash"));
assert.ok(opts.disallowedTools.includes("AskUserQuestion"));
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
});
test("mcp and default mode block built-ins while preserving injected MCP servers", () => {
for (const toolIntegrationMode of ["mcp", undefined]) {
const opts = buildCodebuddyQueryOptions({
toolIntegrationMode,
injectedMcpServers: [{ name: "netcatty", command: "/abs/electron" }],
});
assert.ok(!("tools" in opts));
assert.ok(!("allowedTools" in opts));
assert.equal(opts.permissionMode, "bypassPermissions");
for (const name of ["Bash", "PowerShell", "Read", "Edit", "Write", "Agent", "Skill", "AskUserQuestion"]) {
assert.ok(opts.disallowedTools.includes(name), `${name} must stay disabled`);
}
assert.equal(opts.mcpServers.netcatty.command, "/abs/electron");
}
});
test("mcp mode omits --tools under the SDK serialization rule", () => {
for (const toolIntegrationMode of ["mcp", undefined]) {
const opts = buildCodebuddyQueryOptions({ toolIntegrationMode });
const args = [];
// @tencent-ai/agent-sdk lib/transport/process-transport.js
if (opts.tools !== undefined) {
args.push("--tools", opts.tools.join(","));
}
assert.deepEqual(args, []);
}
});
test("skills mode keeps the Bash whitelist and UI exclusions", () => {
const opts = buildCodebuddyQueryOptions({ toolIntegrationMode: "skills" });
assert.deepEqual(opts.tools, ["Bash"]);
assert.deepEqual(opts.disallowedTools, ["AskUserQuestion"]);
assert.ok(!opts.disallowedTools.includes("Bash"));
assert.ok(!("allowedTools" in opts));
});
test("built-in tools are mode-aware", () => {
assert.deepEqual(codebuddyBuiltinTools("mcp"), []);
assert.deepEqual(codebuddyBuiltinTools(undefined), []);
@@ -1,76 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
let sshKeygenSkip = false;
try {
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
} catch (error) {
if (error.code === "ENOENT") sshKeygenSkip = "ssh-keygen is not installed";
}
const sshString = (value) => {
const body = Buffer.from(value);
const length = Buffer.alloc(4);
length.writeUInt32BE(body.length);
return Buffer.concat([length, body]);
};
// Real local certificates, with no server/network or security-key device.
function mintHostCertificates(t, { type = "ed25519", bits, sk = false, user = false } = {}) {
const dir = fs.mkdtempSync(path.join(__dirname, ".hostcert-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const keygen = (...args) => execFileSync("ssh-keygen", ["-q", ...args], { stdio: "pipe" });
const ca = path.join(dir, "ca");
keygen("-t", "ed25519", "-N", "", "-f", ca);
const readKey = (file) => {
const line = fs.readFileSync(file, "utf8").trim();
const [keyType, body] = line.split(/\s+/);
return { line, keyType, blob: Buffer.from(body, "base64") };
};
const mint = (name, validity, serial) => {
const host = path.join(dir, name);
if (!fs.existsSync(host)) {
keygen("-t", type, ...(bits ? ["-b", String(bits)] : []), "-N", "", "-f", host);
if (sk) {
const plain = readKey(`${host}.pub`);
const keyMaterial = plain.blob.subarray(4 + plain.keyType.length);
const skType = `sk-${plain.keyType}@openssh.com`;
const blob = Buffer.concat([sshString(skType), keyMaterial, sshString("ssh:netcatty-test")]);
fs.writeFileSync(`${host}.pub`, `${skType} ${blob.toString("base64")}\n`);
}
}
keygen("-s", ca, "-I", "netcatty-hostcert", ...(user ? [] : ["-h"]),
"-n", "127.0.0.1", "-V", validity, "-z", String(serial), `${host}.pub`);
const cert = readKey(`${host}-cert.pub`);
const listing = execFileSync("ssh-keygen", ["-L", "-f", `${host}-cert.pub`], { encoding: "utf8" });
const publicKeyFingerprint = listing.match(/Public key: .* SHA256:(\S+)/)?.[1];
if (!publicKeyFingerprint) throw new Error("Missing ssh-keygen certificate fingerprint");
return { ...cert, publicKeyFingerprint, plain: readKey(`${host}.pub`) };
};
return {
first: mint("host", "20250101:20260101", 1),
renewed: mint("host", "20260101:20270101", 2),
different: mint("other", "20260101:20270101", 3),
};
}
// Legacy v00 is no longer emitted by ssh-keygen. This follows the original
// PROTOCOL.certkeys layout: key material first, nonce after constraints.
function legacyCertificate(keyType, keyFields) {
const blob = Buffer.concat([
sshString(`${keyType}-cert-v00@openssh.com`),
...keyFields.map(sshString),
Buffer.from([0, 0, 0, 2]),
sshString("legacy"), sshString(""), Buffer.alloc(16),
sshString(""), sshString("nonce"), sshString(""),
sshString("signing key"), sshString("signature"),
]);
return {
blob,
plain: Buffer.concat([sshString(keyType), ...keyFields.map(sshString)]),
line: `${keyType}-cert-v00@openssh.com ${blob.toString("base64")}`,
};
}
module.exports = { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip };
+4 -17
View File
@@ -1,7 +1,6 @@
const crypto = require("node:crypto");
const { randomUUID } = require("node:crypto");
const { utils: sshUtils } = require("ssh2");
const { extractCertificatePublicKey } = require("../../domain/sshPublicKey.shared.cjs");
const REQUEST_TTL_MS = 2 * 60 * 1000;
const hostKeyRequests = new Map();
@@ -66,9 +65,8 @@ const fingerprintFromPublicKey = (publicKey) => {
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
try {
const blob = Buffer.from(parts[1], "base64");
return crypto.createHash("sha256")
.update(extractCertificatePublicKey(blob) || blob)
.update(Buffer.from(parts[1], "base64"))
.digest("base64")
.replace(/=+$/g, "");
} catch {
@@ -79,16 +77,7 @@ const fingerprintFromPublicKey = (publicKey) => {
};
const getKnownHostFingerprint = (knownHost) => {
const stored = normalizeFingerprint(knownHost?.fingerprint);
const parts = knownHost?.publicKey?.trim().split(/\s+/) || [];
if (parts.length >= 2 && /-cert-v0[01]@openssh\.com$/.test(parts[0])) {
const blob = Buffer.from(parts[1], "base64");
// Only replace an explicit fingerprint if it is the legacy full-cert hash.
// Fingerprint-only records cannot be migrated without the certificate body.
const legacy = crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
if (!stored || stored === legacy) return fingerprintFromPublicKey(knownHost.publicKey);
}
return stored
return normalizeFingerprint(knownHost?.fingerprint)
|| fingerprintFromPublicKey(knownHost?.publicKey);
};
@@ -145,7 +134,7 @@ const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, finger
const describeHostKey = (rawKey) => {
const key = Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || "");
const fingerprint = crypto.createHash("sha256")
.update(extractCertificatePublicKey(key) || key)
.update(key)
.digest("base64")
.replace(/=+$/g, "");
let keyType = "unknown";
@@ -160,9 +149,7 @@ const describeHostKey = (rawKey) => {
try {
const parsed = sshUtils.parseKey(key);
const parsedKey = Array.isArray(parsed) ? parsed[0] : parsed;
// Keep the presented certificate identity, even if a parser returns its
// base algorithm or underlying public key as metadata.
if (parsedKey && !(parsedKey instanceof Error) && !/-cert-v0[01]@openssh\.com$/.test(keyType)) {
if (parsedKey && !(parsedKey instanceof Error)) {
keyType = parsedKey.type || keyType;
const publicSsh = parsedKey.getPublicSSH?.();
if (publicSsh) publicKey = publicSsh.toString("utf8");
-80
View File
@@ -1,13 +1,11 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("./fixtures/hostCertificates.test.fixture.cjs");
const {
classifyHostKey,
createHostVerifier,
describeHostKey,
getKnownHostFingerprint,
handleResponse,
normalizeFingerprint,
} = require("./hostKeyVerifier.cjs");
@@ -19,84 +17,6 @@ const makeRawPublicKey = (keyType, body = "trusted imported host key") => {
return Buffer.concat([length, type, Buffer.from(body)]);
};
const hashKey = (blob) => crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
test("host certificate renewals trust the inner key, including legacy saved fingerprints", { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t);
const original = describeHostKey(first.blob);
const live = describeHostKey(renewed.blob);
const changed = describeHostKey(different.blob);
assert.notDeepEqual(first.blob, renewed.blob);
assert.equal(original.fingerprint, first.publicKeyFingerprint);
assert.equal(live.fingerprint, original.fingerprint);
assert.notEqual(changed.fingerprint, live.fingerprint);
assert.equal(original.keyType, "ssh-ed25519-cert-v01@openssh.com");
assert.equal(original.publicKey, `${first.keyType} ${first.blob.toString("base64")}`);
assert.equal(describeHostKey(first.plain.blob).fingerprint, hashKey(first.plain.blob));
const stored = { id: "kh-cert", hostname: "cert.local", port: 2222, ...original };
const classify = (key, record = stored) => classifyHostKey({
knownHosts: [record], hostname: "cert.local", port: 2222, ...key,
});
assert.equal(classify(live).status, "trusted");
assert.equal(classify(changed).status, "changed");
assert.equal(classify(changed, { ...stored, keyType: "ssh-rsa" }).status, "unknown");
const legacy = { ...stored, fingerprint: hashKey(first.blob) };
assert.notEqual(legacy.fingerprint, live.fingerprint);
assert.equal(getKnownHostFingerprint(legacy), live.fingerprint);
assert.equal(classify(live, legacy).status, "trusted");
assert.equal(classify(changed, legacy).status, "changed");
assert.equal(classify(live, { ...legacy, publicKey: undefined }).status, "changed");
assert.equal(classify(live, { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` }).status, "changed");
assert.equal(classify(live, { ...legacy, fingerprint: "unrelated-explicit-fingerprint" }).status, "changed");
assert.equal(classify(live, { ...legacy, fingerprint: undefined }).status, "trusted");
// Renewal is trusted on reconnect, but changing the raw certificate during
// an established connection must still be rejected synchronously on rekey.
const verifier = createHostVerifier({ hostname: stored.hostname, port: stored.port, knownHosts: [legacy] });
const results = [];
verifier(first.blob, (accepted) => results.push(accepted));
verifier(Buffer.from(first.blob), (accepted) => results.push(accepted));
verifier(renewed.blob, (accepted) => results.push(accepted));
assert.deepEqual(results, [true, true, false]);
});
for (const options of [
{ type: "rsa", bits: 2048 },
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
{ type: "ed25519", sk: true },
{ type: "ecdsa", bits: 256, sk: true },
{ type: "ed25519", user: true },
]) {
test(`certificate fingerprint matches ssh-keygen for ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t, options);
assert.equal(describeHostKey(first.blob).fingerprint, first.publicKeyFingerprint);
assert.equal(describeHostKey(first.blob).fingerprint, hashKey(first.plain.blob));
assert.equal(describeHostKey(renewed.blob).fingerprint, first.publicKeyFingerprint);
assert.notEqual(describeHostKey(different.blob).fingerprint, first.publicKeyFingerprint);
});
}
test("v00 certificates extract the key before the nonce", () => {
for (const [type, fields] of [["ssh-rsa", ["exponent", "modulus"]], ["ssh-dss", ["p", "q", "g", "y"]]]) {
const cert = legacyCertificate(type, fields);
assert.equal(describeHostKey(cert.blob).fingerprint, hashKey(cert.plain));
assert.equal(describeHostKey(cert.blob).keyType, `${type}-cert-v00@openssh.com`);
}
});
test("malformed and unsupported certificates keep their full-blob fingerprint", () => {
const truncated = Buffer.concat([
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
]);
const badLength = Buffer.concat([sshString("ssh-ed25519-cert-v01@openssh.com"), Buffer.from([255, 255, 255, 255])]);
const unsupported = Buffer.concat([sshString("unknown-cert-v01@openssh.com"), sshString("nonce")]);
for (const blob of [truncated, badLength, unsupported]) {
assert.equal(describeHostKey(blob).fingerprint, hashKey(blob));
}
});
test("classifyHostKey prompts for unknown hosts", () => {
const result = classifyHostKey({
knownHosts: [],
-10
View File
@@ -1913,16 +1913,6 @@ function createSessionBackedSftpClient(sessionId, sshClient, options = {}) {
const attrs = await statAsync(sftp, remotePath);
return statResultFromAttrs(attrs);
},
async lstat(remotePath) {
const sftp = await requireSftpChannel(client);
// Copy preflights must inspect link nodes, never their followed targets.
// lstatAsync falls back to STAT when LSTAT is absent, so reject first.
if (typeof sftp.lstat !== "function") {
throw Object.assign(new Error("Remote server does not support LSTAT"), { code: "ENOTSUP" });
}
const attrs = await lstatAsync(sftp, remotePath);
return statResultFromAttrs(attrs);
},
async realPath(remotePath) {
const sftp = await requireSftpChannel(client);
return await realpathAsync(sftp, remotePath);
@@ -1,94 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { Readable, PassThrough } = require("node:stream");
const sftpBridge = require("./sftpBridge.cjs");
const transferBridge = require("./transferBridge.cjs");
const missing = () => Object.assign(new Error("ENOENT"), { code: 2 });
async function openSessionClient(t, { sourceIsLink = false, targetExists = false } = {}) {
const commands = [];
const channel = new EventEmitter();
channel.readdir = (_path, callback) => callback(null, []);
channel.mkdir = (_path, callback) => callback(null);
channel.unlink = (_path, callback) => callback(null);
channel.stat = (remotePath, callback) => {
if (String(remotePath) === "/target/file" && !targetExists) return callback(missing());
callback(null, { size: 66, mode: 0o100640, mtime: 1 });
};
channel.lstat = (remotePath, callback) => {
if (String(remotePath) === "/target" && !targetExists) return callback(missing());
callback(null, { size: 66, mode: sourceIsLink ? 0o120777 : 0o100640, mtime: 1 });
};
channel.createReadStream = () => Readable.from([Buffer.alloc(66)]);
const connection = {
sftp(callback) { callback(null, channel); },
exec(command, callback) {
commands.push(command);
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
};
const clients = new Map();
sftpBridge.init({
electronModule: { webContents: { fromId: () => null } },
sessions: new Map([["copy-session", { conn: connection }]]),
sftpClients: clients,
});
transferBridge.init({ sftpClients: clients });
const { sftpId } = await sftpBridge.openSftpForSession(null, {
sessionId: "copy-session", fileProtocol: "sftp",
});
t.after(() => sftpBridge.closeSftp(null, { sftpId }));
return { client: clients.get(sftpId), channel, commands, sftpId };
}
test("same-host file copy executes cp using the actual session-backed client (#4207)", async (t) => {
const { commands, sftpId } = await openSessionClient(t);
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
transferId: "session-backed-file-copy",
sourcePath: "/source/file", targetPath: "/target/file",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: sftpId, targetSftpId: sftpId,
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: 66, sameHost: true, resumable: false,
});
assert.deepEqual(commands, ["cp -a '/source/file' '/target/file'"]);
assert.equal(result.error, undefined);
});
for (const sourceIsLink of [false, true]) {
test(`session-backed LSTAT preserves link classification (${sourceIsLink})`, async (t) => {
const { client } = await openSessionClient(t, { sourceIsLink });
assert.equal((await client.lstat("/source/file")).isSymbolicLink, sourceIsLink);
assert.equal((await client.stat("/source/file")).isSymbolicLink, false);
});
}
for (const unsupported of ["missing-method", "server-error"]) {
test(`session-backed LSTAT never follows STAT on ${unsupported}`, async (t) => {
const { client, channel } = await openSessionClient(t);
let statCalls = 0;
channel.stat = () => { statCalls++; throw new Error("must not follow symlinks"); };
if (unsupported === "missing-method") delete channel.lstat;
else channel.lstat = (_path, callback) => callback(Object.assign(new Error("unsupported"), { code: 8 }));
await assert.rejects(() => client.lstat("/source/file"), error =>
error.code === (unsupported === "missing-method" ? "ENOTSUP" : 8));
assert.equal(statCalls, 0);
});
}
for (const targetExists of [false, true]) {
test(`session-backed directory cp preserves the destination merge guard (${targetExists})`, async (t) => {
const { commands, sftpId } = await openSessionClient(t, { targetExists });
const result = await transferBridge.sameHostCopyDirectory({ sender: { send() {} } }, {
transferId: `session-backed-directory-copy-${targetExists}`,
sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
});
assert.equal(result.success, !targetExists);
assert.deepEqual(commands, targetExists ? [] : ["cp -ra '/source/.' '/target/'"]);
});
}
@@ -41,8 +41,6 @@
* leaves the Mosh stats bar empty (graceful degradation), never weakens
* security.
*/
const { extractCertificatePublicKey } = require("../../../domain/sshPublicKey.shared.cjs");
function createSystemKnownHostsApi(ctx) {
const { fs, path, os, crypto, log } = ctx;
@@ -66,7 +64,7 @@ function createSystemKnownHostsApi(ctx) {
}
if (blob.length === 0) return "";
return stripFingerprintPadding(
crypto.createHash("sha256").update(extractCertificatePublicKey(blob) || blob).digest("base64"),
crypto.createHash("sha256").update(blob).digest("base64"),
);
};
@@ -178,7 +176,7 @@ function createSystemKnownHostsApi(ctx) {
// Parse one known_hosts line into { revoked, certAuthority, hostField,
// keyType, fingerprint } or null when it is a comment / blank / malformed.
// Certificates pin their underlying key, just like the live verifier.
// The fingerprint is the SHA-256 of the line's key blob.
const parseKnownHostsLine = (rawLine) => {
const line = String(rawLine || "").trim();
if (!line || line.startsWith("#")) return null;
@@ -1,8 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { mintHostCertificates, sshKeygenSkip } = require("../fixtures/hostCertificates.test.fixture.cjs");
const { describeHostKey } = require("../hostKeyVerifier.cjs");
const path = require("node:path");
const os = require("node:os");
@@ -61,22 +59,6 @@ function hashedHostField(token, salt = crypto.randomBytes(20)) {
const HOME_KH = path.join(os.homedir(), ".ssh", "known_hosts");
test("system certificate entries pin the inner key across renewals without CA trust", { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t);
const { api } = makeApi({ [HOME_KH]: `example.com ${first.line}\n` });
const liveFingerprint = describeHostKey(renewed.blob).fingerprint;
assert.equal(api.parseKnownHostsLine(`example.com ${first.line}`).fingerprint, liveFingerprint);
assert.equal(api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), true);
assert.equal(api.isHostKeyTrustedBySystem({
hostname: "example.com", fingerprint: describeHostKey(different.blob).fingerprint,
}), false);
const revoked = makeApi({ [HOME_KH]: `example.com ${first.line}\n@revoked example.com ${renewed.line}\n` });
assert.equal(revoked.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
const ca = makeApi({ [HOME_KH]: `@cert-authority example.com ${first.line}\n` });
assert.equal(ca.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
});
test("system known_hosts paths include the OpenSSH defaults for the platform", () => {
const { api } = makeApi();
const paths = api.getSystemKnownHostsPaths();
-3
View File
@@ -959,9 +959,6 @@ async function buildLocalSessionEnv(
) {
const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs");
const env = { ...buildTerminalProcessEnv(baseEnv), ...(payload?.env || {}) };
// AppImage's ARGV0 makes zsh rename external commands, including ssh. Xorg
// then mistakes ssh's X11 connection for a local client and sends unusable fds.
delete env.ARGV0;
if (platform !== "win32") return env;
const { getPathEnvKey, resolveWindowsLivePath } = require("./ai/shellUtils.cjs");
@@ -37,15 +37,6 @@ const STALE_PATH =
const LIVE_PATH =
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64-pc-windows-msvc;C:\\Windows\\System32";
test("buildLocalSessionEnv strips AppImage ARGV0 without changing the base env", async () => {
const baseEnv = { PATH: "/usr/bin:/bin", ARGV0: "/opt/Netcatty.AppImage" };
const env = await bridge.buildLocalSessionEnv({}, { baseEnv, platform: "linux" });
assert.equal(Object.hasOwn(env, "ARGV0"), false);
assert.equal(env.PATH, baseEnv.PATH);
assert.equal(baseEnv.ARGV0, "/opt/Netcatty.AppImage");
});
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", async () => {
const env = await bridge.buildLocalSessionEnv(
{ env: { NETCATTY_MARKER: "1" } },
+2 -44
View File
@@ -7174,13 +7174,6 @@ async function startTransferNow(event, payload, onProgress) {
const singleChannelCopy = srcClient.__netcattySingleChannelSsh || sshClient?.__netcattySingleChannelSsh;
if (sshClient && typeof sshClient.exec === 'function' && !singleChannelCopy) {
try {
// cp -a preserves link nodes; the SFTP fallback follows target bytes.
// A failed lstat also falls through to that existing route.
const sourceAttrs = await runCancelablePreflight(() => srcClient.lstat(
encodePathForSession(sourceSftpId, sourcePath, resolvedSourceEnc),
));
if (sourceAttrs.isSymbolicLink) throw new Error("Source is a symbolic link");
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try {
await runTransferAbortableOperation(transfer, (signal) => ensureRemoteDirForSession(
@@ -7193,18 +7186,6 @@ async function startTransferNow(event, payload, onProgress) {
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
}
const targetClient = sftpClients.get(targetSftpId);
let existingRemoteMode = null;
try {
const attrs = await runCancelablePreflight(() => targetClient.stat(
encodePathForSession(targetSftpId, targetPath, resolvedTargetEnc),
));
if (Number.isFinite(attrs?.mode)) existingRemoteMode = attrs.mode & 0o7777;
} catch {
// Missing destinations or failed stats need no permission restore.
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
}
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
@@ -7213,13 +7194,6 @@ async function startTransferNow(event, payload, onProgress) {
if (result.code === 0) {
sendProgress(fileSize, fileSize);
sameHostDone = true;
await restoreRemoteUploadModeBestEffort(
targetClient,
targetSftpId,
targetPath,
resolvedTargetEnc,
existingRemoteMode,
);
} else if (result.code === 127) {
// Exit 127 = command not found — cache to skip future attempts
cpUnavailableSet.add(srcClient);
@@ -8230,28 +8204,11 @@ async function sameHostCopyDirectory(event, payload) {
}
if (cpUnavailableSet.has(client)) return { success: false };
const resolvedEncoding = resolveEncodingForRequest(sftpId, encoding);
if (resolvedEncoding && resolvedEncoding !== 'utf-8') {
return { success: false };
}
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== 'function') {
return { success: false };
}
// Merges must use recursive SFTP transfers to preserve destination child modes.
// Only a clearly missing target is safe for the remote cp optimization.
try {
await runTransferCancelablePreflight(transfer, () => client.lstat(
encodePathForSession(sftpId, targetPath, resolvedEncoding),
));
return { success: false };
} catch (error) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
if (!isMissingRemoteStageError(error)) return { success: false };
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
const lifecyclePayload = {
...payload,
@@ -8276,7 +8233,8 @@ async function sameHostCopyDirectory(event, payload) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
}
// Use "source/." to copy directory *contents* into the newly created target.
// Use "source/." to copy directory *contents* into target, preserving merge
// semantics consistent with the recursive per-file transfer path.
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
@@ -106,7 +106,6 @@ test("same-host cp capability cache follows the live SFTP client, not a recycled
bridge.init({ sftpClients });
const makeClient = (exitCode, calls) => ({
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
client: {
exec(_command, callback) {
calls.count += 1;
+1 -325
View File
@@ -1294,9 +1294,7 @@ test("server-to-server resume cancellation settles while source identity verific
t.after(async () => fs.promises.rm(tempPath, { force: true }));
let markPrefixStarted;
const prefixStarted = new Promise((resolve) => { markPrefixStarted = resolve; });
let execCalls = 0;
const sourceClient = {
client: { exec() { execCalls += 1; throw new Error("checkpointed copies must not exec cp"); } },
sftp: createFastSftp({
createReadStream() {
markPrefixStarted();
@@ -1319,7 +1317,7 @@ test("server-to-server resume cancellation settles while source identity verific
targetSftpId: "target",
totalBytes: 8,
resumable: true,
sameHost: true,
sameHost: false,
resumeStage: "download",
checkpointBytes: 4,
downloadCheckpointBytes: 4,
@@ -1334,7 +1332,6 @@ test("server-to-server resume cancellation settles while source identity verific
]);
assert.match(result.error || "", /cancel/i);
assert.equal(execCalls, 0, "same-host resume must verify its saved prefix instead of running cp");
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
});
@@ -13246,119 +13243,6 @@ test("same-host directory copy rejected by a committed close leaves no lease or
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
test("same-host directory copy skips cp when auto encoding resolves to non-UTF-8", async (t) => {
const {
sftpTransferSessionLeaseStore,
} = require("./sftpTransferSessionLease.cjs");
sftpTransferSessionLeaseStore.resetForTests();
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
const sftpId = "same-host-directory-auto-gb18030";
// Seed the session's resolved encoding before the copy requests auto.
sftpBridge.resolveEncodingForRequest(sftpId, "gb18030");
let execCalls = 0;
transferBridge.init({
sftpClients: new Map([[sftpId, {
client: {
exec(_command, callback) {
execCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
}]]),
});
const sender = createSender();
const result = await transferBridge.sameHostCopyDirectory(
{ sender },
{
transferId: "same-host-directory-auto-gb18030-copy",
sftpId,
sourcePath: "/source",
targetPath: "/\u76ee\u6807",
encoding: "auto",
},
);
assert.equal(execCalls, 0);
assert.equal(result.success, false);
assert.deepEqual(sender.sent, []);
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
for (const targetState of ["existing", "missing", "stat-error"]) {
test(`same-host directory copy ${targetState} target uses cp only for a fresh destination`, async (t) => {
const { sftpTransferSessionLeaseStore } = require("./sftpTransferSessionLease.cjs");
sftpTransferSessionLeaseStore.resetForTests();
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
const sftpId = `same-host-directory-${targetState}`;
const commands = [];
const mkdirs = [];
let targetExists = targetState === "existing";
const client = {
async lstat(remotePath) {
assert.equal(String(remotePath), "/target");
assert.deepEqual(mkdirs, [], "check the destination before ensuring it");
if (targetState === "stat-error") {
throw Object.assign(new Error("Permission denied"), { code: 3 });
}
if (!targetExists) throw Object.assign(new Error("ENOENT"), { code: 2 });
return { isDirectory: true };
},
sftp: createFastSftp({
stat(_remotePath, callback) {
if (!targetExists) {
callback(Object.assign(new Error("ENOENT"), { code: 2 }));
return;
}
callback(null, { isDirectory: () => true });
},
mkdir(remotePath, callback) {
mkdirs.push(String(remotePath));
targetExists = true;
callback(null);
},
}),
client: {
exec(command, callback) {
commands.push(command);
assert.equal(targetExists, true, "create the fresh destination before cp");
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
};
const sftpClients = new Map([[sftpId, client]]);
transferBridge.init({ sftpClients });
sftpBridge.init({ sftpClients });
const sender = createSender();
const result = await transferBridge.sameHostCopyDirectory({ sender }, {
transferId: `${sftpId}-copy`, sftpId,
sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
});
if (targetState === "missing") {
assert.deepEqual(result, { success: true });
assert.deepEqual(commands, ["cp -ra '/source/.' '/target/'"]);
assert.deepEqual(mkdirs, ["/target"]);
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
} else {
assert.deepEqual(result, { success: false });
assert.deepEqual(commands, []);
assert.deepEqual(mkdirs, []);
assert.deepEqual(sender.sent, []);
}
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
}
test("same-host directory copy cancellation aborts cp and releases its lease", async (t) => {
const {
sftpTransferSessionLeaseStore,
@@ -13375,7 +13259,6 @@ test("same-host directory copy cancellation aborts cp and releases its lease", a
stream.end = () => {};
stream.destroy = () => {};
const client = {
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
client: {
exec(_command, callback) {
callback(null, stream);
@@ -13459,7 +13342,6 @@ test("same-host file cp cancellation releases the shared session lease", async (
stream.end = () => {};
stream.destroy = () => {};
const client = {
lstat: async () => ({ isSymbolicLink: false }),
client: {
exec(_command, callback) {
callback(null, stream);
@@ -15257,209 +15139,3 @@ test("pending channel-open skips the fastPut reopen and uses the shared channel"
assert.ok(sharedWrites >= 1);
assert.deepEqual(remoteFiles.get(targetPath), payload);
});
test("same-host file cp restores existing destination mode", async () => {
const commands = [];
const chmods = [];
let targetMode = 0o100600;
let downloads = 0;
let uploads = 0;
const sourceSftp = createFastSftp({
open() { downloads++; throw new Error("unexpected download"); },
});
const sourceClient = {
sftp: sourceSftp,
lstat: async () => ({ isSymbolicLink: false }),
stat: async () => ({ size: 64, mode: 0o100644 }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
targetMode = 0o100644;
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
};
const targetClient = {
sftp: createFastSftp({
open() { uploads++; throw new Error("unexpected upload"); },
}),
stat: async () => ({ size: 64, mode: targetMode }),
chmod: async (remotePath, mode) => {
chmods.push([String(remotePath), mode]);
targetMode = mode;
},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId: `same-host-cp-mode-${crypto.randomUUID()}`,
sourcePath: "/source/payload.bin", targetPath: "/target/payload.bin",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: 64,
sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
assert.deepEqual(chmods, [["/target/payload.bin", 0o600]]);
assert.equal(targetMode, 0o600);
assert.equal(downloads, 0);
assert.equal(uploads, 0);
});
for (const sourceState of ["symlink", "lstat-error"]) {
test(`same-host file cp skips a source ${sourceState} and downloads/uploads target bytes`, async (t) => {
const transferId = `same-host-cp-fallback-${crypto.randomUUID()}`;
const sourcePath = "/opt/app/config";
const targetPath = "/tmp/app/config";
const payload = Buffer.from("contents of ../shared/config");
const commands = [];
let reads = 0;
let remote = Buffer.alloc(0);
let promoted = false;
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
const originalRead = sourceSftp.read;
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
const sourceClient = {
sftp: sourceSftp,
async lstat(remotePath) {
assert.equal(String(remotePath), sourcePath);
if (sourceState === "lstat-error") throw new Error("lstat unsupported");
return { isSymbolicLink: true, size: "../shared/config".length };
},
stat: async () => ({ size: payload.length }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
callback(new Error("unexpected exec"));
},
},
};
const targetSftp = createFastSftp({
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
write(_handle, buffer, offset, length, position, callback) {
if (remote.length < position + length) {
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
}
buffer.copy(remote, position, offset, offset + length);
callback(null);
},
close(_handle, callback) { callback(null); },
});
const targetClient = {
sftp: targetSftp,
client: { sftp: (callback) => callback(null, targetSftp) },
async stat(remotePath) {
if (String(remotePath) === targetPath && remote.length === 0) {
throw Object.assign(new Error("ENOENT"), { code: 2 });
}
return { size: remote.length };
},
rename: async () => { promoted = true; },
delete: async () => {},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "config");
t.after(async () => fs.promises.rm(localStage, { force: true }));
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId, sourcePath, targetPath,
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: payload.length, sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, [], "source symlinks and lstat failures must bypass cp -a");
assert.ok(reads > 0, "fallback must download the followed source contents");
assert.deepEqual(remote, payload, "fallback must upload target bytes rather than the link node");
assert.equal(promoted, true);
});
}
for (const cpResult of [0, 127, 1, "exec-error"]) {
test(`fresh same-host file cp result ${cpResult} preserves copy/fallback behavior (#4207)`, async (t) => {
const transferId = `same-host-cp-${crypto.randomUUID()}`;
const payload = Buffer.from("same-host ordinary SFTP copy");
const commands = [];
const chmods = [];
let reads = 0;
let remote = Buffer.alloc(0);
let promoted = false;
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
const originalRead = sourceSftp.read;
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
const sourceClient = {
sftp: sourceSftp,
lstat: async () => ({ isSymbolicLink: false }),
stat: async () => ({ size: payload.length }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
if (cpResult === "exec-error") { callback(new Error("exec denied")); return; }
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", cpResult));
},
},
};
const targetSftp = createFastSftp({
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
write(_handle, buffer, offset, length, position, callback) {
if (remote.length < position + length) {
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
}
buffer.copy(remote, position, offset, offset + length);
callback(null);
},
close(_handle, callback) { callback(null); },
});
const targetClient = {
sftp: targetSftp,
client: { sftp: (callback) => callback(null, targetSftp) },
stat: async (remotePath) => {
if (String(remotePath) === "/target/payload.bin" && remote.length === 0) {
throw Object.assign(new Error("ENOENT"), { code: 2 });
}
return { size: remote.length };
},
chmod: async (...args) => { chmods.push(args); },
rename: async () => { promoted = true; },
delete: async () => {},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
t.after(async () => fs.promises.rm(localStage, { force: true }));
const sender = createSender();
const result = await transferBridge.startTransfer({ sender }, {
transferId,
sourcePath: "/source/payload.bin",
targetPath: "/target/payload.bin",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: payload.length,
sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
if (cpResult === 0) {
assert.deepEqual(chmods, [], "fresh cp must not restore destination permissions");
assert.equal(reads, 0, "successful cp must not download the source");
assert.equal(remote.length, 0, "successful cp must not upload through SFTP");
assert.equal(fs.existsSync(localStage), false);
} else {
assert.ok(reads > 0, "cp failure must fall back to SFTP download");
assert.deepEqual(remote, payload, "fallback must upload the downloaded contents");
assert.equal(promoted, true);
}
});
}
-190
View File
@@ -1,190 +0,0 @@
const { Transform } = require("node:stream");
const DEFAULT_HIDDEN_EXTENSIONS = Object.freeze(["DRI3", "MIT-SHM"]);
const EMPTY = Buffer.alloc(0);
function readUInt16(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
}
function readUInt32(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
}
function pad4(length) {
return Math.ceil(length / 4) * 4;
}
// Retain only the header we need; large request/reply bodies stream through.
function collectHeader(state, length, chunk, offset) {
const count = Math.min(Math.max(0, length - state.header.length), chunk.length - offset);
if (count > 0) {
state.header = Buffer.concat([state.header, chunk.subarray(offset, offset + count)]);
}
return offset + count;
}
class RequestTracker extends Transform {
constructor(shared) {
super();
this.shared = shared;
this.setup = true;
this.header = EMPTY;
this.remaining = 0;
this.sequence = 0;
}
_transform(chunk, _encoding, callback) {
const shared = this.shared;
let offset = 0;
while (offset < chunk.length && shared.enabled) {
if (this.remaining > 0) {
const count = Math.min(this.remaining, chunk.length - offset);
offset += count;
this.remaining -= count;
continue;
}
if (this.setup) {
offset = collectHeader(this, 1, chunk, offset);
if (this.header[0] !== 0x6c && this.header[0] !== 0x42) {
shared.enabled = false;
break;
}
shared.littleEndian = this.header[0] === 0x6c;
offset = collectHeader(this, 12, chunk, offset);
if (this.header.length < 12) break;
this.remaining = pad4(readUInt16(this.header, 6, shared.littleEndian))
+ pad4(readUInt16(this.header, 8, shared.littleEndian));
this.header = EMPTY;
this.setup = false;
continue;
}
offset = collectHeader(this, 4, chunk, offset);
if (this.header.length < 4) break;
let words = readUInt16(this.header, 2, shared.littleEndian);
const headerLength = words === 0 ? 8 : 4;
if (headerLength === 8) {
offset = collectHeader(this, 8, chunk, offset);
if (this.header.length < 8) break;
words = readUInt32(this.header, 4, shared.littleEndian);
}
const total = words * 4;
if (total < headerLength) {
shared.enabled = false;
break;
}
let name;
const nameOffset = headerLength + 4;
if (this.header[0] === 98 && total >= nameOffset) { // QueryExtension
offset = collectHeader(this, nameOffset, chunk, offset);
if (this.header.length < nameOffset) break;
const nameLength = readUInt16(this.header, headerLength, shared.littleEndian);
if (nameLength <= shared.maxNameLength && nameOffset + pad4(nameLength) <= total) {
offset = collectHeader(this, nameOffset + nameLength, chunk, offset);
if (this.header.length < nameOffset + nameLength) break;
name = this.header.toString("latin1", nameOffset, nameOffset + nameLength);
}
}
this.sequence = (this.sequence + 1) & 0xffff;
// Do not let an unanswered query affect an unrelated request after wrap.
shared.pending.delete(this.sequence);
if (shared.hidden.has(name)) shared.pending.set(this.sequence, name);
this.remaining = total - this.header.length;
this.header = EMPTY;
}
callback(null, chunk);
}
}
class ReplyFilter extends Transform {
constructor(shared) {
super();
this.shared = shared;
this.setup = true;
this.header = EMPTY;
this.remaining = 0;
}
_transform(chunk, _encoding, callback) {
const shared = this.shared;
let offset = 0;
while (offset < chunk.length) {
if (!shared.enabled) {
this.push(this.header);
this.header = EMPTY;
this.push(chunk.subarray(offset));
break;
}
if (this.remaining > 0) {
const count = Math.min(this.remaining, chunk.length - offset);
this.push(chunk.subarray(offset, offset + count));
offset += count;
this.remaining -= count;
continue;
}
offset = collectHeader(this, this.setup ? 8 : 4, chunk, offset);
if (this.header.length < (this.setup ? 8 : 4)) break;
if (this.setup) {
this.remaining = readUInt16(this.header, 6, shared.littleEndian) * 4;
this.setup = false;
} else {
const type = this.header[0];
let total = 32; // Errors and ordinary events are fixed size.
if (type === 1 || (type & 0x7f) === 35) { // Reply or GenericEvent
offset = collectHeader(this, 8, chunk, offset);
if (this.header.length < 8) break;
total += readUInt32(this.header, 4, shared.littleEndian) * 4;
}
if (type === 0 || type === 1) {
const sequence = readUInt16(this.header, 2, shared.littleEndian);
const name = shared.pending.get(sequence);
if (name !== undefined) {
if (type === 1) {
offset = collectHeader(this, 9, chunk, offset);
if (this.header.length < 9) break;
if (this.header[8] !== 0) {
this.header[8] = 0; // QueryExtension.present = False
if (!shared.warned) {
shared.warned = true;
shared.logger.warn?.(`[X11] hiding ${name}: forwarding cannot carry file descriptors`);
}
}
}
shared.pending.delete(sequence);
}
}
this.remaining = total - this.header.length;
}
this.push(this.header);
this.header = EMPTY;
}
callback();
}
_flush(callback) {
callback(null, this.header);
}
}
// SCM_RIGHTS descriptors cannot travel over an SSH byte stream. Hide extensions
// that require them so clients select a supported rendering path instead.
function createX11FdGuard(options = {}) {
const hidden = new Set(options.hiddenExtensions ?? DEFAULT_HIDDEN_EXTENSIONS);
const shared = {
enabled: hidden.size > 0,
hidden,
maxNameLength: Math.max(0, ...Array.from(hidden, (name) => name.length)),
littleEndian: true,
pending: new Map(),
warned: false,
logger: options.logger || console,
};
return { toServer: new RequestTracker(shared), toClient: new ReplyFilter(shared) };
}
module.exports = { createX11FdGuard, DEFAULT_HIDDEN_EXTENSIONS };
-204
View File
@@ -1,204 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { once } = require("node:events");
const { createX11FdGuard, DEFAULT_HIDDEN_EXTENSIONS } = require("./x11FdGuard.cjs");
function writeUInt16(buffer, value, offset, endian) {
buffer[endian === "l" ? "writeUInt16LE" : "writeUInt16BE"](value, offset);
}
function writeUInt32(buffer, value, offset, endian) {
buffer[endian === "l" ? "writeUInt32LE" : "writeUInt32BE"](value, offset);
}
function setupRequest(endian = "l") {
const packet = Buffer.alloc(24, 0xab);
packet[0] = endian.charCodeAt(0);
writeUInt16(packet, 11, 2, endian);
writeUInt16(packet, 3, 6, endian);
writeUInt16(packet, 5, 8, endian);
return packet;
}
function setupReply(endian = "l", status = 1) {
const packet = Buffer.alloc(20, 0xcd);
packet[0] = status;
writeUInt16(packet, 11, 2, endian);
writeUInt16(packet, 3, 6, endian);
return packet;
}
function request(name, { endian = "l", big = false, opcode = 98 } = {}) {
const nameBytes = Buffer.from(name, "latin1");
const prefix = big ? 12 : 8;
const packet = Buffer.alloc(prefix + Math.ceil(nameBytes.length / 4) * 4);
packet[0] = opcode;
writeUInt16(packet, big ? 0 : packet.length / 4, 2, endian);
if (big) writeUInt32(packet, packet.length / 4, 4, endian);
writeUInt16(packet, nameBytes.length, big ? 8 : 4, endian);
nameBytes.copy(packet, prefix);
return packet;
}
function reply(sequence, { endian = "l", type = 1, present = 1, extra = 0 } = {}) {
const packet = Buffer.alloc(32 + extra, 0xef);
packet[0] = type;
writeUInt16(packet, sequence, 2, endian);
writeUInt32(packet, extra / 4, 4, endian);
packet[8] = present;
return packet;
}
function withoutPresent(packet) {
const copy = Buffer.from(packet);
copy[8] = 0;
return copy;
}
function chunks(buffer, size) {
if (!size) return [buffer];
const result = [];
for (let offset = 0; offset < buffer.length; offset += size) {
result.push(buffer.subarray(offset, offset + size));
}
return result;
}
async function exchange(uplink, downlink, { chunkSize, ...options } = {}) {
const warnings = [];
const guard = createX11FdGuard({ logger: { warn: (message) => warnings.push(message) }, ...options });
const sent = [];
const received = [];
guard.toServer.on("data", (chunk) => sent.push(chunk));
guard.toClient.on("data", (chunk) => received.push(chunk));
const ended = Promise.all([once(guard.toServer, "end"), once(guard.toClient, "end")]);
for (const chunk of chunks(uplink, chunkSize)) guard.toServer.write(chunk);
for (const chunk of chunks(downlink, chunkSize)) guard.toClient.write(chunk);
guard.toServer.end();
guard.toClient.end();
await ended;
assert.deepEqual(Buffer.concat(sent), uplink, "uplink must stay byte-identical");
return { received: Buffer.concat(received), warnings };
}
for (const endian of ["l", "B"]) {
for (const chunkSize of [undefined, 1]) {
test(`default guard hides only DRI3 and MIT-SHM (${endian}, chunks ${chunkSize || "coalesced"})`, async () => {
assert.deepEqual(DEFAULT_HIDDEN_EXTENSIONS, ["DRI3", "MIT-SHM"]);
const names = ["DRI3", "GLX", "MIT-SHM", "RENDER", "dri3", "DRI3-extra", ""];
const replies = names.map((_, index) => reply(index + 1, { endian }));
const uplink = Buffer.concat([setupRequest(endian), ...names.map((name) => request(name, { endian }))]);
const downlink = Buffer.concat([setupReply(endian), ...replies]);
const expected = Buffer.concat([setupReply(endian), ...replies.map((packet, index) => (
index === 0 || index === 2 ? withoutPresent(packet) : packet
))]);
const result = await exchange(uplink, downlink, { chunkSize });
assert.deepEqual(result.received, expected);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /^\[X11\] hiding DRI3/);
assert.deepEqual(downlink, Buffer.concat([setupReply(endian), ...replies]), "input buffers must stay unchanged");
});
}
test(`BIG-REQUESTS use the extended header offsets (${endian})`, async () => {
const queries = [
request("DRI3", { endian, big: true }),
request("MIT-SHM", { endian, big: true }),
request("GLX", { endian, big: true }),
request("DRI3", { endian }),
];
const replies = queries.map((_, index) => reply(index + 1, { endian }));
const result = await exchange(
Buffer.concat([setupRequest(endian), ...queries]),
Buffer.concat([setupReply(endian), ...replies]),
{ chunkSize: 1 },
);
assert.deepEqual(result.received, Buffer.concat([
setupReply(endian), withoutPresent(replies[0]), withoutPresent(replies[1]), replies[2], withoutPresent(replies[3]),
]));
});
}
test("an empty hide list is byte-identical and a custom list replaces the defaults", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("MIT-SHM"), request("GLX")]);
const replies = [reply(1), reply(2), reply(3)];
const downlink = Buffer.concat([setupReply(), ...replies]);
const disabled = await exchange(uplink, downlink, { hiddenExtensions: [], chunkSize: 1 });
assert.deepEqual(disabled.received, downlink);
assert.deepEqual(disabled.warnings, []);
const custom = await exchange(uplink, downlink, { hiddenExtensions: ["GLX"], chunkSize: 1 });
assert.deepEqual(custom.received, Buffer.concat([setupReply(), replies[0], replies[1], withoutPresent(replies[2])]));
assert.equal(custom.warnings.length, 1);
assert.match(custom.warnings[0], /hiding GLX/);
});
test("errors, unrelated replies, ordinary events and variable GenericEvents preserve sequence isolation", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("GLX"), request("MIT-SHM")]);
const error = reply(1, { type: 0 });
const afterError = reply(1);
const unrelated = reply(2, { extra: 64 });
const event = reply(3, { type: 12 });
const generic = reply(3, { type: 35, extra: 48 });
const sentGeneric = reply(3, { type: 0x80 | 35, extra: 12 });
const hidden = reply(3);
const repeated = reply(3);
const prefix = [setupReply(), error, afterError, unrelated, event, generic, sentGeneric];
const result = await exchange(uplink, Buffer.concat([...prefix, hidden, repeated]), { chunkSize: 1 });
assert.deepEqual(result.received, Buffer.concat([...prefix, withoutPresent(hidden), repeated]));
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /hiding MIT-SHM/);
});
test("absent extensions and failed queries do not log", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("MIT-SHM")]);
const downlink = Buffer.concat([setupReply(), reply(1, { present: 0 }), reply(2, { type: 0 })]);
const result = await exchange(uplink, downlink, { chunkSize: 1 });
assert.deepEqual(result.received, downlink);
assert.deepEqual(result.warnings, []);
});
test("request bodies are skipped without mistaking embedded queries for requests", async () => {
const body = Buffer.alloc(128 * 1024);
request("DRI3").copy(body, 0);
const large = Buffer.alloc(8 + body.length);
large[0] = 18; // ChangeProperty, using BIG-REQUESTS framing
large.writeUInt32LE(large.length / 4, 4);
body.copy(large, 8);
const uplink = Buffer.concat([setupRequest(), large, request("MIT-SHM")]);
const visible = reply(1, { extra: 128 * 1024 });
const hidden = reply(2);
const result = await exchange(uplink, Buffer.concat([setupReply(), visible, hidden]), { chunkSize: 17 });
assert.deepEqual(result.received, Buffer.concat([setupReply(), visible, withoutPresent(hidden)]));
});
test("sequence wrap hides new queries and clears stale entries on reused sequences", async () => {
const noOp = Buffer.from([127, 0, 1, 0]);
const queries = [request("DRI3"), Buffer.concat(Array(65534).fill(noOp)), request("MIT-SHM"), noOp, request("DRI3")];
const stale = reply(1);
const wrapped = reply(0);
const hidden = reply(2);
const result = await exchange(
Buffer.concat([setupRequest(), ...queries]),
Buffer.concat([setupReply(), wrapped, stale, hidden]),
{ chunkSize: 13 },
);
assert.deepEqual(result.received, Buffer.concat([setupReply(), withoutPresent(wrapped), stale, withoutPresent(hidden)]));
});
test("setup failures and truncated headers flush unchanged", async () => {
for (const status of [0, 2]) {
const downlink = setupReply("B", status);
const result = await exchange(setupRequest("B"), downlink, { chunkSize: 1 });
assert.deepEqual(result.received, downlink);
}
const shortSetup = await exchange(setupRequest(), setupReply().subarray(0, 5), { chunkSize: 1 });
assert.deepEqual(shortSetup.received, setupReply().subarray(0, 5));
const partial = Buffer.concat([setupReply(), reply(1).subarray(0, 7)]);
const result = await exchange(Buffer.concat([setupRequest(), request("DRI3")]), partial, { chunkSize: 1 });
assert.deepEqual(result.received, partial);
});
test("non-X11 payloads pass through unchanged", async () => {
const result = await exchange(Buffer.from("remote"), Buffer.from("local"), { chunkSize: 1 });
assert.equal(result.received.toString(), "local");
});
+3 -13
View File
@@ -2,7 +2,6 @@ const net = require("node:net");
const fs = require("node:fs");
const { Transform } = require("node:stream");
const { execFileSync } = require("node:child_process");
const { createX11FdGuard } = require("./x11FdGuard.cjs");
const X11_PORT_BASE = 6000;
const MIT_MAGIC_COOKIE_PROTOCOL = "MIT-MAGIC-COOKIE-1";
@@ -254,16 +253,11 @@ function attachX11Forwarding(conn, options = {}) {
const target = resolveX11DisplaySpec(display, { platform });
const localSocket = createSocket();
let acceptedChannel = null;
let guard = null;
let settled = false;
const cleanup = () => {
if (acceptedChannel) destroyStream(acceptedChannel);
destroyStream(localSocket);
if (guard) {
destroyStream(guard.toServer);
destroyStream(guard.toClient);
}
};
localSocket.once("connect", () => {
@@ -281,19 +275,15 @@ function attachX11Forwarding(conn, options = {}) {
localSocket.on("error", () => cleanup());
acceptedChannel.on("close", () => destroyStream(localSocket));
localSocket.on("close", () => destroyStream(acceptedChannel));
guard = createX11FdGuard({ hiddenExtensions: options.hiddenExtensions, logger });
guard.toServer.on("error", cleanup);
guard.toClient.on("error", cleanup);
const realCookie = resolveLocalAuthCookie();
if (realCookie && fakeCookie) {
acceptedChannel
.pipe(createX11AuthTransform({ fakeCookie, realCookie }))
.pipe(guard.toServer)
.pipe(localSocket);
.pipe(localSocket)
.pipe(acceptedChannel);
} else {
acceptedChannel.pipe(guard.toServer).pipe(localSocket);
acceptedChannel.pipe(localSocket).pipe(acceptedChannel);
}
localSocket.pipe(guard.toClient).pipe(acceptedChannel);
});
localSocket.once("error", (err) => {
-74
View File
@@ -181,7 +181,6 @@ test("attachX11Forwarding pipes accepted X11 channels to the local display socke
attachX11Forwarding(conn, {
display: ":0",
createSocket: () => localSocket,
readLocalAuthCookie: () => null,
sendMessage: (message) => messages.push(message),
platform: "linux",
});
@@ -204,79 +203,6 @@ test("attachX11Forwarding pipes accepted X11 channels to the local display socke
assert.deepEqual(messages, []);
});
for (const rewriteCookie of [false, true]) {
test(`attachX11Forwarding hides fd extensions with cookie rewriting ${rewriteCookie ? "enabled" : "disabled"}`, async (t) => {
const conn = new EventEmitter();
const capturedLocal = [];
const capturedRemote = [];
const warnings = [];
const makeDuplex = (captured) => new Duplex({
read() {},
write(chunk, _encoding, callback) {
captured.push(Buffer.from(chunk));
callback();
},
});
const localSocket = makeDuplex(capturedLocal);
const acceptedChannel = makeDuplex(capturedRemote);
t.after(() => {
localSocket.destroy();
acceptedChannel.destroy();
});
localSocket.connect = () => {
queueMicrotask(() => localSocket.emit("connect"));
return localSocket;
};
const fakeCookie = "11111111111111111111111111111111";
const realCookie = "22222222222222222222222222222222";
attachX11Forwarding(conn, {
display: ":0",
platform: "linux",
createSocket: () => localSocket,
fakeCookie: rewriteCookie ? fakeCookie : undefined,
readLocalAuthCookie: () => rewriteCookie ? Buffer.from(realCookie, "hex") : null,
logger: { warn: (message) => warnings.push(message) },
});
conn.emit("x11", {}, () => acceptedChannel, () => assert.fail("unexpected reject"));
await new Promise((resolve) => setImmediate(resolve));
const setup = buildX11SetupPacket({ cookie: fakeCookie });
const queries = ["DRI3", "MIT-SHM", "GLX"].map((name) => {
const packet = Buffer.alloc(8 + Math.ceil(name.length / 4) * 4);
packet[0] = 98;
packet.writeUInt16LE(packet.length / 4, 2);
packet.writeUInt16LE(name.length, 4);
packet.write(name, 8, "ascii");
return packet;
});
// Fragment the auth packet, coalescing its tail with the first requests.
acceptedChannel.push(setup.subarray(0, 7));
acceptedChannel.push(Buffer.concat([setup.subarray(7), ...queries]));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(Buffer.concat(capturedLocal), Buffer.concat([
buildX11SetupPacket({ cookie: rewriteCookie ? realCookie : fakeCookie }), ...queries,
]));
const setupReply = Buffer.from([1, 0, 11, 0, 0, 0, 0, 0]);
const replies = queries.map((_, index) => {
const packet = Buffer.alloc(32);
packet[0] = 1;
packet.writeUInt16LE(index + 1, 2);
packet[8] = 1;
return packet;
});
const response = Buffer.concat([setupReply, ...replies]);
for (const byte of response) localSocket.push(Buffer.from([byte]));
await new Promise((resolve) => setImmediate(resolve));
const expected = Buffer.from(response);
expected[8 + 8] = 0;
expected[8 + 32 + 8] = 0;
assert.deepEqual(Buffer.concat(capturedRemote), expected);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /^\[X11\] hiding DRI3/);
});
}
test("attachX11Forwarding rejects the remote channel and explains local display failures", async () => {
const conn = new EventEmitter();
const localSocket = new EventEmitter();
+1 -2
View File
@@ -111,7 +111,7 @@ export default defineConfig(() => {
build: {
// The shared runtime is also required by Electron; convert it for browser builds.
commonjsOptions: {
include: [/node_modules/, /domain\/sshPublicKey\.shared\.cjs$/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
},
chunkSizeWarningLimit: 3000,
target: 'esnext', // Required for top-level await in WASM modules
@@ -134,7 +134,6 @@ export default defineConfig(() => {
include: [
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
'@/domain/systemManager/containerRuntime.shared.cjs',
'@/domain/sshPublicKey.shared.cjs',
'@/domain/pluginRuntimePolicy.cjs',
'@/domain/pluginGitHubRepository.cjs',
'@/domain/pluginPermissionDescriptions.cjs',