Compare commits

...
Author SHA1 Message Date
bincxzandCursor 21caffef9c fix(vault): only graft credential refs onto bare collapsed hosts
Avoid copying identityId onto a retained host that already has key or
password auth, which would silently change resolveHostAuth preference.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:18:27 +08:00
bincxzandCursor 94535c8c35 fix(vault): merge credential refs when collapsing destination hosts
Carry identityId/telnetIdentityId/identityFileId onto the retained host
so destination-group collapse does not drop referenced auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:11:18 +08:00
bincxzandCursor 428022ebca fix(vault): only prune newly imported credentials on collision
Pass existing identity/key counts into destination handling so remapped
duplicates of vault credentials are never deleted when a host is skipped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:04:03 +08:00
bincxzandCursor f2077e7b23 fix(vault): prune orphan credentials after destination collisions
When a destination rewrite drops colliding imported hosts, also remove
identities/keys that were only referenced by those skipped hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:51 +08:00
bincxzandCursor 61be891293 fix(vault): keep group case in host merge keys
Vault group paths are compared exactly, so Prod and prod must remain
distinct session identities during import/create dedupe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:44:39 +08:00
bincxzandCursor 255d3d0e4e fix(vault): skip plugin imports that collide after destination rewrite
After rewriting imported hosts into the destination group, drop ones that
now share a fingerprint with existing vault hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:33:46 +08:00
bincxzandCursor 0a72824ee5 fix(vault): preserve SecureCRT and plugin hosts under destination group
Skip endpoint collapse for SecureCRT imports, and keep plugin hosts that
differ by configuration when an import destination rewrites their group.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:25:54 +08:00
bincxzandCursor 9c0b9f5ae3 fix(vault): harden destination re-dedupe passphrase and counts
Keep collapsed destination imports aligned with CSV merge policy for key
passphrases, and correct plugin importer added/duplicate counters when
hosts collapse into one group.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:14:49 +08:00
netcatty-bot 6de3d9e0a9 fix: address Codex review on PR #2739 2026-08-05 09:01:00 +00:00
netcatty-bot fffe3c523d fix(#2737): automated Cursor CLI fix 2026-08-05 07:57:58 +00:00
9 changed files with 852 additions and 12 deletions

No files matched your search

+4
View File
@@ -1520,6 +1520,10 @@ export const useVaultState = () => {
latestHosts.length,
destination,
storedGroups,
{
identities: latestIdentities.length,
keys: latestKeys.length,
},
);
const nextHosts = normalizeVaultOrder(committed.hosts.map((host) => sanitizeHost(host)));
const nextKeys = normalizeVaultOrder(committed.keys);
+10 -4
View File
@@ -28,6 +28,8 @@ import { sanitizeHost } from "../../domain/host";
import {
applyVaultImportDestination,
applyVaultHostImport,
buildVaultHostEndpointKey,
buildVaultHostMergeKey,
filterVaultImportKeyPassphrasesAgainstExisting,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
@@ -179,6 +181,9 @@ export function useVaultImportHandlers({
result = applyVaultImportDestination(
result,
options?.destination ?? { mode: "preserve" },
// SecureCRT keeps distinct session files that share an endpoint;
// only rewrite their group when the user picks an import location.
{ collapseDuplicateEndpoints: format !== "securecrt" },
);
}
updateProgress({ stage: "preparing", percent: 70 });
@@ -308,15 +313,16 @@ export function useVaultImportHandlers({
}
}
const makeKey = (h: Host) =>
`${(h.protocol ?? "ssh").toLowerCase()}|${h.hostname.toLowerCase()}|${h.port}|${(h.username ?? "").toLowerCase()}`;
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
// group as part of session identity so direct vs proxy copies can coexist.
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
const existingKeys = new Set(currentHosts.map(makeKey));
// Filter out duplicates for both managed and non-managed imports
let newHosts = format === "securecrt"
? result.hosts
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
// For managed imports, also update existing hosts to be managed
let updatedExistingHosts: Host[] = [];
if (isManaged) {
+315
View File
@@ -450,6 +450,321 @@ test('plugin importer destination moves only newly imported hosts into the selec
assert.equal(targeted.addedCount, 2);
});
test('plugin importer destination re-dedupes same-endpoint hosts collapsed into one group', () => {
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
value: { label: 'Existing', hostname: 'existing.test', group: 'Original' },
} }]);
const importedDrafts = normalizePluginImporterRecords([
{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'direct',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
},
} },
{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'via-socks',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan-proxy',
},
} },
]);
const merged = mergePluginImporterDrafts({
hosts: existingDrafts.hosts,
identities: [],
keys: [],
snippets: [],
customGroups: ['Original'],
}, importedDrafts);
assert.equal(merged.hosts.length, 3);
assert.equal(merged.addedCount, 4); // 2 hosts + 2 new groups
const targeted = applyPluginImporterDestination(
merged,
existingDrafts.hosts.length,
{ mode: 'group', group: 'Chosen' },
['Original'],
);
assert.equal(targeted.hosts.length, 2);
assert.equal(targeted.hosts[1]?.group, 'Chosen');
assert.deepEqual(targeted.customGroups, ['Original', 'Chosen']);
assert.equal(targeted.duplicateCount, merged.duplicateCount + 1);
assert.equal(targeted.addedCount, 2); // 1 retained host + Chosen group
});
test('plugin importer destination keeps distinct plugin hosts that share an endpoint', () => {
const drafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'credential-a', label: 'Credential A', username: 'root', authMethod: 'password', password: 'first',
} } },
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'credential-b', label: 'Credential B', username: 'root', authMethod: 'password', password: 'second',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Plugin host A',
protocol: 'plugin:com.example.transport.connection',
group: 'lan',
pluginConnection: {
providerId: 'com.example.transport.connection',
configuration: { endpoint: 'shared', path: 'a' },
credentialId: 'credential-a',
},
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Plugin host B',
protocol: 'plugin:com.example.transport.connection',
group: 'lan-proxy',
pluginConnection: {
providerId: 'com.example.transport.connection',
configuration: { endpoint: 'shared', path: 'b' },
credentialId: 'credential-b',
},
} } },
]);
const merged = mergePluginImporterDrafts({
hosts: [], identities: [], keys: [], snippets: [], customGroups: [],
}, drafts);
assert.equal(merged.hosts.length, 2);
const targeted = applyPluginImporterDestination(
merged,
0,
{ mode: 'group', group: 'Chosen' },
);
assert.equal(targeted.hosts.length, 2);
assert.deepEqual(targeted.hosts.map((host) => host.group), ['Chosen', 'Chosen']);
assert.notEqual(
targeted.hosts[0].pluginConnection?.configuration,
targeted.hosts[1].pluginConnection?.configuration,
);
assert.equal(targeted.duplicateCount, merged.duplicateCount);
assert.equal(targeted.addedCount, merged.addedCount - 2 + 1); // replace 2 source groups with Chosen
});
test('plugin importer destination skips imports that collide with existing hosts after rewrite', () => {
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'Existing',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'Chosen',
},
} }]);
const importedDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'Imported copy',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
},
} }]);
const merged = mergePluginImporterDrafts({
hosts: existingDrafts.hosts,
identities: [],
keys: [],
snippets: [],
customGroups: ['Chosen'],
}, importedDrafts);
assert.equal(merged.hosts.length, 2);
const targeted = applyPluginImporterDestination(
merged,
existingDrafts.hosts.length,
{ mode: 'group', group: 'Chosen' },
['Chosen'],
);
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.hosts[0]?.label, 'Existing');
assert.equal(targeted.duplicateCount, merged.duplicateCount + 1);
assert.equal(targeted.addedCount, 0);
});
test('plugin importer destination prunes orphan credentials for skipped colliding hosts', () => {
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'Existing',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'Chosen',
},
} }]);
const importedDrafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'imported-identity',
label: 'Imported identity',
username: 'root',
authMethod: 'password',
password: 'secret',
} } },
{ type: 'draft', draft: { kind: 'key', value: {
id: 'imported-key',
label: 'Imported key',
type: 'ED25519',
privateKey: 'private-key-material',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Imported copy',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
identityId: 'imported-identity',
identityFileId: 'imported-key',
} } },
]);
const merged = mergePluginImporterDrafts({
hosts: existingDrafts.hosts,
identities: [],
keys: [],
snippets: [],
customGroups: ['Chosen'],
}, importedDrafts);
assert.equal(merged.hosts.length, 2);
assert.equal(merged.identities.length, 1);
assert.equal(merged.keys.length, 1);
const targeted = applyPluginImporterDestination(
merged,
existingDrafts.hosts.length,
{ mode: 'group', group: 'Chosen' },
['Chosen'],
{ identities: 0, keys: 0 },
);
assert.equal(targeted.hosts.length, 1);
assert.deepEqual(targeted.identities, []);
assert.deepEqual(targeted.keys, []);
assert.equal(targeted.addedCount, 0);
});
test('plugin importer destination keeps existing credentials remapped onto skipped hosts', () => {
const existingDrafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'existing-identity',
label: 'Existing identity',
username: 'root',
authMethod: 'password',
password: 'vault-secret',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Existing',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'Chosen',
identityId: 'existing-identity',
} } },
]);
const existingIdentityId = existingDrafts.identities[0]?.id;
assert.ok(existingIdentityId);
const importedDrafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'imported-identity',
label: 'Duplicate identity',
username: 'root',
authMethod: 'password',
password: 'vault-secret',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Imported copy',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
identityId: 'imported-identity',
} } },
]);
const merged = mergePluginImporterDrafts({
hosts: existingDrafts.hosts,
identities: existingDrafts.identities,
keys: [],
snippets: [],
customGroups: ['Chosen'],
}, importedDrafts);
assert.equal(merged.identities.length, 1);
assert.equal(merged.identities[0]?.id, existingIdentityId);
assert.equal(merged.duplicateCount >= 1, true);
const targeted = applyPluginImporterDestination(
merged,
existingDrafts.hosts.length,
{ mode: 'group', group: 'Chosen' },
['Chosen'],
{ identities: existingDrafts.identities.length, keys: 0 },
);
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.identities.length, 1);
assert.equal(targeted.identities[0]?.id, existingIdentityId);
});
test('plugin importer destination merges referenced credentials when collapsing imports', () => {
const drafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'imported-identity',
label: 'Imported identity',
username: 'root',
authMethod: 'password',
password: 'secret',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'bare',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'with-identity',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan-proxy',
identityId: 'imported-identity',
} } },
]);
const merged = mergePluginImporterDrafts({
hosts: [], identities: [], keys: [], snippets: [], customGroups: [],
}, drafts);
assert.equal(merged.hosts.length, 2);
assert.equal(merged.identities.length, 1);
const targeted = applyPluginImporterDestination(
merged,
0,
{ mode: 'group', group: 'Chosen' },
[],
{ identities: 0, keys: 0 },
);
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.hosts[0]?.identityId, merged.identities[0]?.id);
assert.equal(targeted.identities.length, 1);
});
test('plugin importer does not count a host-owned destination group as newly added', () => {
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
+88 -3
View File
@@ -474,11 +474,29 @@ export function applyPluginImporterDestination(
existingHostCount: number,
destination?: VaultImportDestination,
existingCustomGroups: ReadonlyArray<string> = [],
existingCounts: {
identities?: number;
keys?: number;
} = {},
): PluginImporterMergeResult {
if (!destination || destination.mode === 'preserve') return merged;
const splitAt = Math.max(0, Math.min(existingHostCount, merged.hosts.length));
const existingHosts = merged.hosts.slice(0, splitAt);
const importedHosts = merged.hosts.slice(splitAt);
const existingIdentityCount = Math.max(
0,
Math.min(existingCounts.identities ?? 0, merged.identities.length),
);
const existingKeyCount = Math.max(
0,
Math.min(existingCounts.keys ?? 0, merged.keys.length),
);
const existingIdentityIds = new Set(
merged.identities.slice(0, existingIdentityCount).map((identity) => identity.id),
);
const existingKeyIds = new Set(
merged.keys.slice(0, existingKeyCount).map((key) => key.id),
);
const targeted = applyVaultImportDestination({
hosts: importedHosts,
groups: [],
@@ -489,7 +507,63 @@ export function applyPluginImporterDestination(
skipped: 0,
duplicates: 0,
},
}, destination);
}, destination, {
// Plugin hosts that differ by configuration/credentials must survive a
// shared destination group; classic vault hosts still collapse by endpoint.
isCollapsible: (host) => !host.pluginConnection,
});
// Destination rewrite can make an imported vault host collide with an existing
// one (same endpoint, now same group). Re-check like the CSV import path.
const existingFingerprints = new Set(existingHosts.map(hostFingerprint));
const retainedImported: Host[] = [];
for (const host of targeted.hosts) {
const fingerprint = hostFingerprint(host);
if (existingFingerprints.has(fingerprint)) continue;
existingFingerprints.add(fingerprint);
retainedImported.push(host);
}
const retainedImportedIds = new Set(retainedImported.map((host) => host.id));
const droppedImported = importedHosts.filter((host) => !retainedImportedIds.has(host.id));
const collectHostCredentialIds = (hosts: ReadonlyArray<Host>): Set<string> => {
const ids = new Set<string>();
for (const host of hosts) {
if (host.identityId) ids.add(host.identityId);
if (host.telnetIdentityId) ids.add(host.telnetIdentityId);
if (host.identityFileId) ids.add(host.identityFileId);
const pluginCredentialId = host.pluginConnection?.credentialId;
if (pluginCredentialId) ids.add(pluginCredentialId);
}
return ids;
};
const keptCredentialIds = collectHostCredentialIds([...existingHosts, ...retainedImported]);
const droppedOnlyCredentialIds = new Set(
[...collectHostCredentialIds(droppedImported)].filter((id) => !keptCredentialIds.has(id)),
);
// Only prune credentials introduced by this import. Remapped duplicates that
// point at pre-existing vault identities/keys must stay in the vault.
const nextIdentities = merged.identities.filter((identity) => (
existingIdentityIds.has(identity.id)
|| !droppedOnlyCredentialIds.has(identity.id)
));
const keptIdentityKeyIds = new Set(
nextIdentities.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
);
const prunedIdentityKeyIds = new Set(
merged.identities
.filter((identity) => (
!existingIdentityIds.has(identity.id)
&& droppedOnlyCredentialIds.has(identity.id)
))
.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
);
const nextKeys = merged.keys.filter((key) => {
if (existingKeyIds.has(key.id)) return true;
if (keptCredentialIds.has(key.id) || keptIdentityKeyIds.has(key.id)) return true;
if (droppedOnlyCredentialIds.has(key.id) || prunedIdentityKeyIds.has(key.id)) return false;
return true;
});
const prunedIdentityCount = merged.identities.length - nextIdentities.length;
const prunedKeyCount = merged.keys.length - nextKeys.length;
const existingGroupSet = new Set([
...existingCustomGroups,
...existingHosts.flatMap((host) => host.group ? [host.group] : []),
@@ -501,11 +575,22 @@ export function applyPluginImporterDestination(
const nextAddedGroupCount = customGroups.filter(
(group) => !existingGroupSet.has(group),
).length;
// Destination rewriting can collapse same-endpoint hosts into one group;
// keep added/duplicate counts aligned with the hosts actually retained.
const collapsedHostCount = Math.max(0, importedHosts.length - retainedImported.length);
return {
...merged,
hosts: [...existingHosts, ...targeted.hosts],
hosts: [...existingHosts, ...retainedImported],
identities: nextIdentities,
keys: nextKeys,
customGroups,
addedCount: merged.addedCount - previousAddedGroupCount + nextAddedGroupCount,
duplicateCount: merged.duplicateCount + collapsedHostCount,
addedCount: merged.addedCount
- previousAddedGroupCount
+ nextAddedGroupCount
- collapsedHostCount
- prunedIdentityCount
- prunedKeyCount,
};
}
+27
View File
@@ -7,13 +7,40 @@ import {
applyVaultHostDelete,
applyVaultHostCreates,
applyVaultHostUpdate,
buildVaultHostEndpointKey,
buildVaultHostFromDraft,
buildVaultHostMergeKey,
buildVaultHostsFromDrafts,
parseVaultHostDraftsInput,
type VaultHostDraft,
type VaultHostUpdatePatch,
} from './vaultHostCreate.ts';
test('buildVaultHostMergeKey treats group as part of session identity', () => {
const base = {
hostname: '10.10.10.10',
port: 22,
username: 'root',
protocol: 'ssh' as const,
};
assert.notEqual(
buildVaultHostMergeKey({ ...base, group: 'lan' }),
buildVaultHostMergeKey({ ...base, group: 'lan-proxy' }),
);
assert.equal(
buildVaultHostMergeKey({ ...base, group: 'lan' }),
buildVaultHostMergeKey({ ...base, group: 'lan' }),
);
assert.notEqual(
buildVaultHostMergeKey({ ...base, group: 'Prod' }),
buildVaultHostMergeKey({ ...base, group: 'prod' }),
);
assert.equal(
buildVaultHostEndpointKey({ ...base, group: 'lan' }),
buildVaultHostEndpointKey({ ...base, group: 'lan-proxy' }),
);
});
test('buildVaultHostFromDraft maps minimal unstructured fields to a vault host', () => {
const built = buildVaultHostFromDraft({
hostname: '192.168.1.10',
+15 -1
View File
@@ -169,11 +169,25 @@ const parseKeyPath = (draft: VaultHostDraft): string | undefined => {
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
};
export const buildVaultHostMergeKey = (
/** Endpoint-only identity (protocol/host/port/user). Used for managed ssh_config matching. */
export const buildVaultHostEndpointKey = (
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol'>,
): string =>
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.port}|${(host.username ?? '').toLowerCase()}`;
/**
* Session identity for vault import/create dedupe.
* Same endpoint in a different group is a distinct connection (e.g. direct vs proxy copy).
*/
export const buildVaultHostMergeKey = (
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>,
): string => {
// Keep normalized group spelling (do not case-fold). Vault group paths are
// compared exactly elsewhere, so Prod vs prod are distinct sessions.
const group = normalizeGroupPath(host.group) ?? '';
return `${buildVaultHostEndpointKey(host)}|${group}`;
};
export function buildVaultHostFromDraft(
draft: VaultHostDraft,
): { ok: true; host: Host } | { ok: false; error: string } {
+242
View File
@@ -185,6 +185,185 @@ test("vault import can place every imported host into one selected group", () =>
assert.deepEqual(imported.groups, ["Production/Web", "Production/DB"]);
});
test("applyVaultImportDestination re-dedupes same-endpoint hosts collapsed into one group", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan,direct,10.10.10.10,22,root",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"));
assert.equal(imported.hosts.length, 2);
const targeted = applyVaultImportDestination(imported, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.hosts[0]?.group, "Imported/July");
assert.equal(targeted.stats.duplicates, 1);
assert.equal(targeted.stats.imported, 1);
assert.deepEqual(targeted.groups, ["Imported/July"]);
const merged = applyVaultHostImport([], [], targeted);
assert.equal(merged.addedCount, 1);
assert.equal(merged.hosts.length, 1);
});
test("applyVaultImportDestination remaps a shared key passphrase onto the retained host", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username,KeyPath,Passphrase",
"lan,direct,10.10.10.10,22,root,~/.ssh/id_ed25519,",
"lan-proxy,via-socks,10.10.10.10,22,root,~/.ssh/id_ed25519,secret",
].join("\n"));
assert.equal(imported.hosts.length, 2);
const targeted = applyVaultImportDestination(imported, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
assert.deepEqual(targeted.keyPassphrases, [{
hostId: targeted.hosts[0]?.id,
keyPath: "~/.ssh/id_ed25519",
passphrase: "secret",
}]);
});
test("applyVaultImportDestination does not attach a passphrase for a non-retained key", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username,KeyPath,Passphrase",
"lan,direct,10.10.10.10,22,root,~/.ssh/id_first,first",
"lan-proxy,via-socks,10.10.10.10,22,root,~/.ssh/id_second,second",
].join("\n"));
const targeted = applyVaultImportDestination(imported, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_first"]);
assert.deepEqual(targeted.keyPassphrases, [{
hostId: targeted.hosts[0]?.id,
keyPath: "~/.ssh/id_first",
passphrase: "first",
}]);
});
test("applyVaultImportDestination can keep same-endpoint hosts when collapse is disabled", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"Prod,web-a,shared.example.com,22,root",
"Staging,web-b,shared.example.com,22,root",
].join("\n"));
assert.equal(imported.hosts.length, 2);
const targeted = applyVaultImportDestination(
imported,
{ mode: "group", group: "Imported/SecureCRT" },
{ collapseDuplicateEndpoints: false },
);
assert.equal(targeted.hosts.length, 2);
assert.deepEqual(targeted.hosts.map((host) => host.group), [
"Imported/SecureCRT",
"Imported/SecureCRT",
]);
assert.equal(targeted.stats.duplicates, 0);
assert.equal(targeted.stats.imported, 2);
});
test("applyVaultImportDestination can skip collapse for selected hosts", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan,direct,10.10.10.10,22,root",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"));
const withPluginFlag = {
...imported,
hosts: imported.hosts.map((host, index) => (
index === 0
? host
: {
...host,
pluginConnection: {
providerId: "com.example.transport.connection",
configuration: { endpoint: "via-socks" },
},
}
)),
};
const targeted = applyVaultImportDestination(
withPluginFlag,
{ mode: "group", group: "Imported/Plugin" },
{ isCollapsible: (host) => !host.pluginConnection },
);
assert.equal(targeted.hosts.length, 2);
assert.deepEqual(targeted.hosts.map((host) => host.group), [
"Imported/Plugin",
"Imported/Plugin",
]);
});
test("applyVaultImportDestination merges referenced credentials onto the retained host", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan,direct,10.10.10.10,22,root",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"));
const [first, second] = imported.hosts;
assert.ok(first && second);
const withRefs = {
...imported,
hosts: [
{ ...first, identityId: undefined, identityFileId: undefined },
{ ...second, identityId: "identity-1", identityFileId: "key-1" },
],
};
const targeted = applyVaultImportDestination(withRefs, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.hosts[0]?.identityId, "identity-1");
assert.equal(targeted.hosts[0]?.identityFileId, "key-1");
});
test("applyVaultImportDestination does not override retained key auth with identity refs", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username,KeyPath",
"lan,direct,10.10.10.10,22,root,~/.ssh/id_ed25519",
"lan-proxy,via-socks,10.10.10.10,22,root,",
].join("\n"));
const [first, second] = imported.hosts;
assert.ok(first && second);
const withRefs = {
...imported,
hosts: [
first,
{ ...second, identityId: "identity-1" },
],
};
const targeted = applyVaultImportDestination(withRefs, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
assert.equal(targeted.hosts[0]?.identityId, undefined);
});
test("CSV import keeps working when KeyPath and Passphrase columns are absent", () => {
const result = importVaultHostsFromText(
"csv",
@@ -614,6 +793,69 @@ test("applyVaultHostImport skips duplicates by default", () => {
assert.equal(merged.hosts.length, 2);
});
test("CSV import keeps same-endpoint rows that use different groups", () => {
const result = importVaultHostsFromText(
"csv",
[
"Groups,Label,Hostname,Port,Username",
"lan,direct,10.10.10.10,22,root",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"),
);
assert.equal(result.hosts.length, 2);
assert.equal(result.stats.duplicates, 0);
assert.deepEqual(
result.hosts.map((host) => host.group).sort(),
["lan", "lan-proxy"],
);
});
test("applyVaultHostImport keeps same-endpoint hosts when the group differs", () => {
const existing: Host = {
id: "existing-1",
label: "direct",
hostname: "10.10.10.10",
username: "root",
port: 22,
group: "lan",
};
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"));
const targeted = applyVaultImportDestination(imported, {
mode: "group",
group: "lan-proxy",
});
const merged = applyVaultHostImport([existing], ["lan"], targeted);
assert.equal(merged.addedCount, 1);
assert.equal(merged.skippedExistingCount, 0);
assert.equal(merged.hosts.length, 2);
assert.equal(merged.addedHosts[0]?.group, "lan-proxy");
});
test("applyVaultHostImport still skips same-endpoint hosts in the same group", () => {
const existing: Host = {
id: "existing-1",
label: "direct",
hostname: "10.10.10.10",
username: "root",
port: 22,
group: "lan",
};
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan,copy,10.10.10.10,22,root",
].join("\n"));
const merged = applyVaultHostImport([existing], ["lan"], imported);
assert.equal(merged.addedCount, 0);
assert.equal(merged.skippedExistingCount, 1);
assert.equal(merged.hosts.length, 1);
});
test("applyVaultHostImport can preserve distinct sessions with the same endpoint", () => {
const existing: Host = {
id: "existing-1",
+119 -4
View File
@@ -9,7 +9,10 @@ import {
type VaultHostDraftProtocol,
} from "./vaultHostCreate";
export { buildVaultHostMergeKey } from "./vaultHostCreate";
export {
buildVaultHostEndpointKey,
buildVaultHostMergeKey,
} from "./vaultHostCreate";
import { parseQuickConnectInput } from "./quickConnect";
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
@@ -136,6 +139,20 @@ export type VaultImportDestination =
| { mode: "preserve" }
| { mode: "group"; group: string };
export type ApplyVaultImportDestinationOptions = {
/**
* When false, only rewrite groups. SecureCRT keeps distinct session files that
* share an endpoint even after an import-location override.
* @default true
*/
collapseDuplicateEndpoints?: boolean;
/**
* Return false to keep a host even when another shares its merge key after the
* destination rewrite (plugin hosts that differ by configuration/credentials).
*/
isCollapsible?: (host: Host) => boolean;
};
export function mergeVaultImportIssues(
...groups: ReadonlyArray<ReadonlyArray<VaultImportIssue>>
): VaultImportIssue[] {
@@ -162,14 +179,89 @@ const normalizeGroupPath = (raw: string | undefined): string | undefined => {
export function applyVaultImportDestination(
result: VaultImportResult,
destination: VaultImportDestination,
options?: ApplyVaultImportDestinationOptions,
): VaultImportResult {
if (destination.mode === "preserve") return result;
const group = normalizeGroupPath(destination.group);
if (!group) return result;
const rewrittenHosts = result.hosts.map((host) => ({ ...host, group }));
// SecureCRT (and similar) keep same-endpoint sessions as distinct imports.
if (options?.collapseDuplicateEndpoints === false) {
return {
...result,
hosts: rewrittenHosts,
groups: [group],
};
}
// Destination rewriting can collapse previously distinct group-scoped sessions
// onto one merge key; re-dedupe so identical endpoints are not imported twice.
// Plugin hosts can opt out via isCollapsible so configuration/credential
// differences survive a shared destination group.
const { hosts, duplicates } = dedupeHosts(rewrittenHosts, {
isCollapsible: options?.isCollapsible,
});
const retainedIds = new Set(hosts.map((host) => host.id));
const retainedByMergeKey = new Map(
hosts
.filter((host) => options?.isCollapsible?.(host) !== false)
.map((host) => [hostKey(host), host]),
);
const originalById = new Map(result.hosts.map((host) => [host.id, host]));
const remapHostId = (hostId: string): string | undefined => {
if (retainedIds.has(hostId)) return hostId;
const original = originalById.get(hostId);
if (!original) return undefined;
if (options?.isCollapsible?.({ ...original, group }) === false) return undefined;
return retainedByMergeKey.get(hostKey({ ...original, group }))?.id;
};
const selectedKeyByHostId = new Map(
hosts.map((host) => {
const selected = host.identityFilePaths?.find((path) => path.trim())?.trim();
return [host.id, selected ? normalizeKeyPathKey(selected) : undefined] as const;
}),
);
const remapKeyPassphrases = (
entries: VaultHostKeyPassphrase[] | undefined,
remapOptions?: { matchSelectedKey?: boolean },
): VaultHostKeyPassphrase[] | undefined => {
if (!entries) return entries;
const remapped: VaultHostKeyPassphrase[] = [];
const seen = new Set<string>();
for (const entry of entries) {
const nextHostId = remapHostId(entry.hostId);
if (!nextHostId) continue;
// Match CSV same-group dedupe: never attach a passphrase for a key the
// retained host did not keep as its selected identity file.
if (remapOptions?.matchSelectedKey) {
const selectedKey = selectedKeyByHostId.get(nextHostId);
if (!selectedKey || normalizeKeyPathKey(entry.keyPath) !== selectedKey) continue;
}
const dedupeKey = `${nextHostId}\u0000${normalizeKeyPathKey(entry.keyPath)}\u0000${entry.passphrase}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
remapped.push(nextHostId === entry.hostId ? entry : { ...entry, hostId: nextHostId });
}
return remapped;
};
return {
...result,
hosts: result.hosts.map((host) => ({ ...host, group })),
hosts,
groups: [group],
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
// Keep remapped candidates for alias conflict checks (same as CSV same-group
// dedupe); the import handler still gates saves on the retained key path.
keyPassphraseCandidates: remapKeyPassphrases(result.keyPassphraseCandidates),
stats: {
...result.stats,
imported: hosts.length,
duplicates: result.stats.duplicates + duplicates,
},
};
}
@@ -227,15 +319,24 @@ const createHost = (input: {
return built.host;
};
const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
const dedupeHosts = (
hosts: Host[],
options?: { isCollapsible?: (host: Host) => boolean },
): { hosts: Host[]; duplicates: number } => {
const seen = new Map<string, Host>();
const retained: Host[] = [];
let duplicates = 0;
for (const host of hosts) {
if (options?.isCollapsible?.(host) === false) {
retained.push(host);
continue;
}
const key = hostKey(host);
const existing = seen.get(key);
if (!existing) {
seen.set(key, host);
retained.push(host);
continue;
}
duplicates++;
@@ -248,13 +349,27 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
existing.authPolicyVersion = host.authPolicyVersion;
existing.useSshAgent = host.useSshAgent;
}
// Only graft referenced credentials onto a retained host that has no auth
// source yet. Otherwise identity auth can override an existing key path.
const retainedHasCredentialSource = Boolean(
existing.identityId
|| existing.telnetIdentityId
|| existing.identityFileId
|| existing.identityFilePaths?.some((path) => path.trim())
|| (typeof existing.password === "string" && existing.password.length > 0)
);
if (!retainedHasCredentialSource) {
if (host.identityId) existing.identityId = host.identityId;
if (host.telnetIdentityId) existing.telnetIdentityId = host.telnetIdentityId;
if (host.identityFileId) existing.identityFileId = host.identityFileId;
}
if (existing.group == null && host.group != null) existing.group = host.group;
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
existing.label = host.label;
}
}
return { hosts: Array.from(seen.values()), duplicates };
return { hosts: retained, duplicates };
};
const uniq = (items: string[]) => Array.from(new Set(items.filter(Boolean)));
@@ -87,6 +87,38 @@ test("SecureCRT keeps separate session files that point to the same endpoint", a
assert.deepEqual(result.hosts.map((host) => host.group), ["Prod", "Staging"]);
});
test("SecureCRT destination group keeps same-endpoint session files", async () => {
const { applyVaultImportDestination } = await import("../../domain/vaultImport");
const session = [
'S:"Hostname"=shared.example.com',
'S:"Username"=root',
'S:"Protocol Name"=SSH2',
].join("\n");
const imported = await importVaultHostFiles({
format: "securecrt",
files: [
new File([session], "web.ini"),
new File([session], "web.ini"),
],
relativePaths: [
"Sessions/Prod/web.ini",
"Sessions/Staging/web.ini",
],
});
const targeted = applyVaultImportDestination(
imported,
{ mode: "group", group: "Imported/SecureCRT" },
{ collapseDuplicateEndpoints: false },
);
assert.equal(targeted.hosts.length, 2);
assert.deepEqual(targeted.hosts.map((host) => host.group), [
"Imported/SecureCRT",
"Imported/SecureCRT",
]);
});
test("SecureCRT batch import does not count an unsupported session twice", async () => {
const result = await importVaultHostFiles({
format: "securecrt",