fix: plugins losing the saved ssh login when copying a host (#1391)

This commit is contained in:
LukeGus committed 2026-10-03 15:31:01 -05:00
1 parent d433718c35
commit 6f442c5863
2 files changed
+119 -9

No files matched your search

+63 -9
View File
@@ -96,6 +96,34 @@ function describeHost(host: number | PluginSshHost): string {
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
}
const OVERRIDE_FIELDS = [
"password",
"key",
"privateKey",
"keyPassword",
"passphrase",
] as const;
/**
* A secret the plugin set on its copy (a password the user just typed) wins
* over the stored one; everything else comes from what core resolved.
*/
function withOverrides(
resolved: SshConnectHost,
given: PluginSshHost,
): SshConnectHost {
if (given === (resolved as unknown)) return resolved;
const source = given as unknown as Record<string, unknown>;
const set = OVERRIDE_FIELDS.filter(
(field) => typeof source[field] === "string" && source[field] !== "",
);
if (set.length === 0) return resolved;
const merged = { ...resolved } as unknown as Record<string, unknown>;
for (const field of set) merged[field] = source[field];
if (typeof source.authType === "string") merged.authType = source.authType;
return merged as unknown as SshConnectHost;
}
const LOGIN_FAILED =
/all configured authentication methods failed|permission denied|authentication failed/i;
@@ -134,8 +162,37 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
{ host: SshConnectHost; userId: string }
>();
const remember = (key: object, host: SshConnectHost, userId: string) =>
resolvedHosts.set(key, { host, userId });
// Plugins often copy a host before connecting ({ ...host, port }). Spread
// copies enumerable symbol keys, so the copy keeps this tag. Its value is an
// opaque token, not the host, so the secrets stay out of the plugin's reach.
const tag = Symbol("resolvedHost");
const tokens = new WeakMap<
object,
{ host: SshConnectHost; userId: string }
>();
const remember = (key: object, host: SshConnectHost, userId: string) => {
const entry = { host, userId };
resolvedHosts.set(key, entry);
const token = Object.freeze({});
tokens.set(token, entry);
if (Object.isExtensible(key)) {
Object.defineProperty(key, tag, {
value: token,
enumerable: true,
configurable: true,
});
}
};
const lookup = (host: object) => {
const known = resolvedHosts.get(host);
if (known) return known;
const token = (host as Record<symbol, unknown>)[tag];
return token && typeof token === "object"
? tokens.get(token as object)
: undefined;
};
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
const redacted = redactHostSecrets(
@@ -148,9 +205,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
/** The acting user for a call about this host. */
const userFor = (host: number | PluginSshHost | undefined): string =>
actingUser(
host && typeof host === "object"
? resolvedHosts.get(host)?.userId
: undefined,
host && typeof host === "object" ? lookup(host)?.userId : undefined,
);
/**
@@ -163,8 +218,8 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
userId: string,
): number | SshConnectHost => {
if (typeof host === "number") return host;
const known = resolvedHosts.get(host);
if (known) return known.host;
const known = lookup(host);
if (known) return withOverrides(known.host, host);
return { ...(host as unknown as SshConnectHost), userId };
};
@@ -173,8 +228,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
* auth or host key failure counts: a timeout says nothing about the login.
*/
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
const hostId =
typeof host === "number" ? host : resolvedHosts.get(host)?.host.id;
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
if (!Number.isInteger(hostId) || hostId <= 0) return;
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
if (error !== undefined) {
@@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
},
}));
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number, userId: string) => ({
id: hostId,
userId,
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
password: "stored-secret",
}),
resolveHostBySyncId: async () => null,
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
reportLogin: (hostId: number, outcome: unknown) =>
@@ -253,6 +265,50 @@ describe("ctx.ssh", () => {
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
});
it("connects a copied redacted host with its stored login", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
const host = (await ssh.resolveHost(5))!;
expect((host as Record<string, unknown>).password).toBeUndefined();
expect(JSON.stringify(host)).not.toContain("stored-secret");
for (const symbol of Object.getOwnPropertySymbols(host)) {
expect(JSON.stringify((host as never)[symbol])).not.toContain(
"stored-secret",
);
}
await ssh.connect({ ...host, port: host.port || 22 });
expect(h.connects[0].target).toMatchObject({ password: "stored-secret" });
await ssh.connect({ ...host, password: "typed", authType: "password" });
expect(h.connects[1].target).toMatchObject({ password: "typed" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]);
});
it("does not honor another plugin's resolved host", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const deps = {
manifest: manifest(["ssh:connect", "credentials:use"]),
audit: vi.fn(async () => {}),
};
const first = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const second = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const host = (await first.resolveHost(5))!;
await second.connect({ ...host });
expect(h.connects[0].target).not.toHaveProperty("password");
});
it("passes a given stream through to the pipeline, gated like any connect", async () => {
const stream = { throughSource: true };
const ssh = createPluginSsh({