Files
HHXXYY 12a3ae07ef feat: Enable SFTP sidebar and button for local shell sessions (#3379)
* Add local shell side panel auto-open settings with i18n support

- Add settings to control side panel behavior for local shell sessions
- Support auto-opening side panel when local shell is launched
- Allow users to choose which pane (Files, SFTP, Scripts, etc.) opens by default
- Add complete i18n translations for en, zh-CN, zh-TW, es, ru locales

* fix: wire local shell side panel settings

* fix: restore undefined check for local shell protocol detection

Local shell sessions may have protocol=undefined, not just 'local'.
Restore the original condition to auto-open sidebar for both cases.

* fix: enable SFTP sidebar for local shell sessions

Local shell now supports SFTP (for local file browsing).
Update sftpAvailable to include local shell so the auto-open
sidebar setting works correctly for PowerShell/bash sessions.

* chore: bump version to 1.1.83-fork.1

Distinguish fork from upstream (1.1.83) and previous 0.0.0 dev version.

* feat: show SFTP button in local shell toolbar

Local shell sessions now display the 'Open SFTP' button in the toolbar,
allowing users to browse local files via SFTP sidebar, consistent with
the auto-open behavior added earlier.

* feat: 本地Shell侧边栏支持追随终端目录功能

- 追随终端目录现在支持本地 POSIX shell(bash/zsh/fish)
- 排除 PowerShell 和 cmd(因为它们的 cd 命令和路径格式不同)
- 串口连接仍然不支持(它们根本没有 SFTP)
- 定位路径到终端功能已支持本地 Shell(无需修改)

* feat: support PowerShell and CMD in session restore

- Accept Windows absolute paths (C:\...) in isRestoreCwdPathEligible
- Add shell-specific cd commands:
  * PowerShell: Set-Location -LiteralPath with '' quote escaping
  * CMD: cd /d with "" quote escaping
  * bash/POSIX: cd -- with '\'' quote escaping
- Remove PowerShell/CMD restrictions in cwd injection eligibility
- Fix line terminator in SFTP locate path (use \r\n for Windows PTY)

* fix: bound local shell panel preferences and synchronize settings

---------

Co-authored-by: HHXXYY123 <HHXXYY123@outlook.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:10:33 +08:00

2107 lines
68 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import type { SyncPayload } from "../domain/sync.ts";
import type { KnownHost } from "../domain/models.ts";
import type { SyncableVaultData } from "./syncPayload.ts";
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
import commandBlocklistTable from "../lib/commandBlocklist.json";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const {
applyLocalVaultPayload,
prepareLocalVaultPayloadApply,
applySyncPayload,
buildLocalVaultPayload,
buildCloudSyncPayload,
withPluginSyncSidecars,
buildSyncPayload,
sanitizeHostsForSync,
retainLocalHostLastConnectedAt,
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
hasMeaningfulSyncData,
shouldPromptCloudVaultRecovery,
SYNCABLE_SETTING_STORAGE_KEYS,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
const { readCommandBlocklistSetting } = await import("./state/commandBlocklistSettings.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
hostname: `${id}.example.com`,
port: 22,
keyType: "ssh-ed25519",
publicKey: `SHA256:${id}`,
discoveredAt: 1,
});
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
notes: [],
noteGroups: [],
knownHosts,
groupConfigs: [],
});
test.beforeEach(() => {
localStorage.clear();
Object.defineProperty(globalThis, "window", {
value: {
dispatchEvent: () => true,
},
configurable: true,
});
});
test("buildSyncPayload treats known hosts as local-only data", () => {
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
assert.equal("knownHosts" in payload, false);
});
test("buildSyncPayload strips lastConnectedAt so connecting a host does not dirty cloud sync", () => {
const host = {
id: "host-1",
label: "prod",
hostname: "prod.example.com",
username: "root",
tags: [],
os: "linux" as const,
protocol: "ssh" as const,
lastConnectedAt: 1_700_000_000_000,
pinned: true,
};
const payload = buildSyncPayload({ ...vault([]), hosts: [host] });
assert.equal(payload.hosts[0]?.lastConnectedAt, undefined);
assert.equal(payload.hosts[0]?.pinned, true);
assert.equal(payload.hosts[0]?.hostname, "prod.example.com");
});
test("buildCloudSyncPayload strips lastConnectedAt like port-forward lastUsedAt", async () => {
const host = {
id: "host-2",
label: "db",
hostname: "db.example.com",
username: "ubuntu",
tags: [],
os: "linux" as const,
protocol: "ssh" as const,
lastConnectedAt: 42,
};
const payload = await buildCloudSyncPayload({ ...vault([]), hosts: [host] });
const serializedHosts = JSON.parse(JSON.stringify(payload.hosts ?? []));
assert.equal(payload.hosts[0]?.lastConnectedAt, undefined);
assert.equal("lastConnectedAt" in (serializedHosts[0] ?? {}), false);
});
test("sanitizeHostsForSync hashes equal when hosts differ only by lastConnectedAt", () => {
const base = {
id: "host-hash",
label: "edge",
hostname: "edge.example.com",
username: "ops",
tags: [],
os: "linux" as const,
protocol: "ssh" as const,
pinned: true,
};
const sanitizedA = sanitizeHostsForSync([{ ...base, lastConnectedAt: 11 }]);
const sanitizedB = sanitizeHostsForSync([{ ...base, lastConnectedAt: 99 }]);
const payloadA = buildSyncPayload({ ...vault([]), hosts: [{ ...base, lastConnectedAt: 11 }] });
const payloadB = buildSyncPayload({ ...vault([]), hosts: [{ ...base, lastConnectedAt: 99 }] });
assert.equal(JSON.stringify(sanitizedA), JSON.stringify(sanitizedB));
assert.equal(JSON.stringify(payloadA.hosts), JSON.stringify(payloadB.hosts));
});
test("buildLocalVaultPayload keeps lastConnectedAt for local backups", () => {
const host = {
id: "host-3",
label: "lab",
hostname: "lab.example.com",
username: "lab",
tags: [],
os: "linux" as const,
protocol: "ssh" as const,
lastConnectedAt: 99,
};
const payload = buildLocalVaultPayload({ ...vault([]), hosts: [host] });
assert.equal(payload.hosts[0]?.lastConnectedAt, 99);
});
test("applySyncPayload keeps local lastConnectedAt when the cloud host omits it", async () => {
const localHost = {
id: "host-apply",
label: "prod",
hostname: "prod.example.com",
username: "root",
tags: [],
os: "linux" as const,
protocol: "ssh" as const,
lastConnectedAt: 77,
};
const remoteHost = {
...localHost,
label: "prod-renamed",
lastConnectedAt: undefined,
};
const payload = buildSyncPayload({ ...vault([]), hosts: [remoteHost] });
let imported: { hosts?: Array<{ lastConnectedAt?: number; label?: string }> } | null = null;
await applySyncPayload(
payload,
{ importVaultData: (json) => { imported = JSON.parse(json); } },
{ currentHosts: [localHost] },
);
assert.equal(imported?.hosts?.[0]?.lastConnectedAt, 77);
assert.equal(imported?.hosts?.[0]?.label, "prod-renamed");
});
test("retainLocalHostLastConnectedAt does not invent timestamps for unknown hosts", () => {
const incoming = [{
id: "new-host",
label: "new",
hostname: "new.example.com",
username: "root",
tags: [],
os: "linux" as const,
protocol: "ssh" as const,
}];
const retained = retainLocalHostLastConnectedAt(incoming, [{
id: "other",
label: "other",
hostname: "other.example.com",
username: "root",
tags: [],
os: "linux" as const,
protocol: "ssh" as const,
lastConnectedAt: 5,
}]);
assert.equal(retained?.[0]?.lastConnectedAt, undefined);
});
test("buildSyncPayload includes reusable proxy profiles", () => {
const proxyProfiles = [
{
id: "proxy-1",
label: "Office Proxy",
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
createdAt: 1,
updatedAt: 1,
},
];
const payload = buildSyncPayload({
...vault(),
proxyProfiles,
} as SyncableVaultData & { proxyProfiles: typeof proxyProfiles });
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
});
test("sync payloads preserve opaque plugin hosts without requiring the plugin to be installed", async () => {
const providerId = "com.example.transport.connection";
const pluginHost = {
id: "plugin-host-1",
label: "Example transport",
hostname: providerId,
username: "",
tags: [],
os: "linux" as const,
protocol: `plugin:${providerId}` as const,
pluginConnection: {
providerId,
authenticationProviderId: "com.example.transport.authentication",
credentialId: "credential-reference-1234",
configuration: { endpoint: "opaque.example", options: { compression: true } },
},
};
const payload = buildSyncPayload({ ...vault([]), hosts: [pluginHost] });
assert.deepEqual(JSON.parse(JSON.stringify(payload.hosts)), [pluginHost]);
let imported: Record<string, unknown> | null = null;
await applySyncPayload(payload, {
importVaultData: (json) => { imported = JSON.parse(json); },
});
assert.deepEqual(imported?.hosts, [pluginHost]);
});
test("applySyncPayload preserves host startup command and appearance overrides (#2757)", async () => {
const host = {
id: "host-1",
label: "Prod",
hostname: "prod.example",
username: "root",
port: 22,
protocol: "ssh" as const,
tags: [],
os: "linux" as const,
startupCommand: "tmux attach || tmux",
startupCommandRunMode: "lineDelay" as const,
theme: "solarized-dark",
themeOverride: true,
};
const groupConfigs = [
{
path: "prod",
startupCommand: "cd /srv && exec bash -l",
theme: "solarized-dark",
themeOverride: true,
},
];
const payload = buildSyncPayload({
...vault(),
hosts: [host],
groupConfigs,
});
assert.equal(payload.hosts[0]?.startupCommand, "tmux attach || tmux");
assert.equal(payload.hosts[0]?.theme, "solarized-dark");
assert.equal(payload.hosts[0]?.themeOverride, true);
assert.equal(payload.groupConfigs?.[0]?.startupCommand, "cd /srv && exec bash -l");
let imported: Record<string, unknown> | null = null;
await applySyncPayload(payload, {
importVaultData: (json) => { imported = JSON.parse(json); },
});
const importedHosts = imported?.hosts as typeof payload.hosts;
assert.equal(importedHosts?.[0]?.startupCommand, "tmux attach || tmux");
assert.equal(importedHosts?.[0]?.startupCommandRunMode, "lineDelay");
assert.equal(importedHosts?.[0]?.theme, "solarized-dark");
assert.equal(importedHosts?.[0]?.themeOverride, true);
assert.equal(
(imported?.groupConfigs as typeof groupConfigs)?.[0]?.startupCommand,
"cd /srv && exec bash -l",
);
});
test("buildCloudSyncPayload includes notes and note groups", async () => {
const payload = await buildCloudSyncPayload({
...vault([]),
notes: [{
id: "note-1",
title: "Runbook",
content: "# Runbook",
createdAt: 1,
updatedAt: 1,
}],
noteGroups: ["Ops"],
});
assert.equal(payload.notes?.length, 1);
assert.equal(payload.notes?.[0]?.title, "Runbook");
assert.deepEqual(payload.noteGroups, ["Ops"]);
});
test("buildSyncPayload includes AI configuration settings", () => {
const providers = [{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
defaultModel: "gpt-test",
enabled: true,
}];
const webSearch = {
providerId: "tavily",
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
enabled: true,
maxResults: 7,
};
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(providers));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER, "openai-main");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL, "gpt-test");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, "auto");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, "skills");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, "600");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_THINKING_MAP, JSON.stringify({ catty: "high" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
const payload = buildSyncPayload(vault([]));
// Device-bound enc:v1 apiKeys are stripped from portable sync settings.
const { apiKey: _providerKey, ...providerWithoutKey } = providers[0]!;
const { apiKey: _webKey, ...webSearchWithoutKey } = webSearch;
assert.deepEqual(payload.settings?.ai, {
providers: [providerWithoutKey],
activeProviderId: "openai-main",
activeModelId: "gpt-test",
globalPermissionMode: "auto",
toolIntegrationMode: "skills",
defaultAgentId: "codex",
commandBlocklist: ["rm -rf"],
commandTimeout: 120,
responseIdleTimeout: 600,
maxIterations: 10,
agentModelMap: { codex: "gpt-test" },
agentProviderMap: { catty: "openai-main" },
agentThinkingMap: { catty: "high" },
webSearchConfig: webSearchWithoutKey,
showTerminalSelectionAction: false,
});
});
test("terminal selection AI preference is syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
),
);
});
test("AI response wait time is syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
),
);
});
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
),
);
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
),
);
});
test("note appearance settings survive sync and trigger auto-sync", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, "Menlo, monospace");
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE, "16");
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, "14");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.noteFontFamily, "Menlo, monospace");
assert.equal(payload.settings?.noteFontSize, 16);
assert.equal(payload.settings?.noteCodeFontSize, 14);
for (const key of [
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
]) {
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
}
localStorage.clear();
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY), "Menlo, monospace");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE), "16");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.showHostTreeSidebar, false);
assert.equal(payload.settings?.terminalSidePanelAutoOpen, true);
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
});
test("buildSyncPayload reads the versioned terminal font size record", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
"18|9|settings-window",
);
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.terminalFontSize, 18);
});
test("applySyncPayload writes a newer terminal font size that old versions can read", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
"16|7|main-window",
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: { terminalFontSize: 19 },
};
await applySyncPayload(payload, { importVaultData: () => {} });
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE);
assert.ok(raw);
assert.equal(parseInt(raw, 10), 19);
const record = parseTerminalFontSizeRecord(raw);
assert.equal(record.fontSize, 19);
assert.ok(record.version > 7);
assert.equal(record.origin, "sync-payload");
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
]));
const payload = buildSyncPayload(vault([]));
assert.equal("ai" in (payload.settings ?? {}), false);
});
test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
enabled: true,
}));
const payload = buildSyncPayload(vault([]));
assert.equal("apiKey" in (payload.settings?.ai?.providers?.[0] ?? {}), false);
assert.equal("apiKey" in (payload.settings?.ai?.webSearchConfig ?? {}), false);
});
test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sync", async () => {
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
credentialsDecrypt: async (value: string) => {
if (value === "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==") return "sk-provider";
if (value === "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==") return "sk-web";
return value;
},
},
},
configurable: true,
});
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: true,
}));
const payload = await buildCloudSyncPayload(vault([]));
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "sk-provider");
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "sk-web");
});
test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fails", async () => {
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
credentialsDecrypt: async (value: string) => value,
},
},
configurable: true,
});
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
enabled: true,
}]));
await assert.rejects(
() => buildCloudSyncPayload(vault([])),
/Unable to decrypt AI API key/,
);
});
test("applySyncPayload restores AI configuration settings", async () => {
const providers = [{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
enabled: true,
}];
const webSearch = {
providerId: "exa",
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
enabled: true,
};
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers,
activeProviderId: "anthropic-main",
activeModelId: "claude-test",
globalPermissionMode: "observer",
toolIntegrationMode: "mcp",
defaultAgentId: "claude",
commandBlocklist: ["shutdown"],
commandTimeout: 30,
responseIdleTimeout: 900,
maxIterations: 5,
agentModelMap: { claude: "claude-test" },
agentProviderMap: { catty: "anthropic-main" },
webSearchConfig: webSearch,
showTerminalSelectionAction: false,
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!), providers);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER), "anthropic-main");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL), "claude-test");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE), "observer");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE), "mcp");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT), "900");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
});
test("applySyncPayload migrates an untouched legacy command blocklist", async () => {
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
commandBlocklist: legacyDefaults,
},
},
syncedAt: 1,
} as SyncPayload, { importVaultData: () => {} });
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
assert.deepEqual(
readCommandBlocklistSetting(),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
});
test("command blocklist read migrates an untouched local legacy list", () => {
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
const currentDefaults = [...legacyDefaults, ...commandBlocklistTable.powershell];
assert.deepEqual(readCommandBlocklistSetting(), currentDefaults);
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
currentDefaults,
);
localStorage.setItem(
storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST,
JSON.stringify([...legacyDefaults, "old-client-user-rule"]),
);
assert.deepEqual(readCommandBlocklistSetting(), [
...legacyDefaults,
"old-client-user-rule",
...commandBlocklistTable.powershell,
]);
});
test("applySyncPayload preserves a customized PowerShell blocklist", async () => {
const currentCustomized = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
...commandBlocklistTable.powershell.slice(1),
];
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
commandBlocklist: currentCustomized,
},
},
syncedAt: 1,
} as SyncPayload, { importVaultData: () => {} });
assert.deepEqual(readCommandBlocklistSetting(), currentCustomized);
});
test("applySyncPayload encrypts synced plaintext AI API keys before saving locally", async () => {
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
credentialsEncrypt: async (value: string) => {
const body = Buffer.alloc(19, 0);
Buffer.from("v10", "utf8").copy(body, 0);
Buffer.from(`LOCAL_${value}`.slice(0, 16), "utf8").copy(body, 3);
return `enc:v1:${body.toString("base64")}`;
},
},
dispatchEvent: () => true,
},
configurable: true,
});
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: [
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "sk-provider", enabled: true },
],
webSearchConfig: { providerId: "tavily", apiKey: "sk-web", enabled: true },
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
const expectedProviderKey = (() => {
const body = Buffer.alloc(19, 0);
Buffer.from("v10", "utf8").copy(body, 0);
Buffer.from("LOCAL_sk-provider".slice(0, 16), "utf8").copy(body, 3);
return `enc:v1:${body.toString("base64")}`;
})();
const expectedWebKey = (() => {
const body = Buffer.alloc(19, 0);
Buffer.from("v10", "utf8").copy(body, 0);
Buffer.from("LOCAL_sk-web".slice(0, 16), "utf8").copy(body, 3);
return `enc:v1:${body.toString("base64")}`;
})();
assert.equal(provider.apiKey, expectedProviderKey);
assert.equal(webSearch.apiKey, expectedWebKey);
});
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
showHostTreeSidebar: false,
terminalSidePanelAutoOpen: true,
terminalSidePanelAutoOpenTab: "scripts",
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), "true");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
});
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
// Without this nudge, the apply path writes to localStorage but
// `useAIState` (listening for `storage` events) never sees the changes
// in the calling window — mounted UI keeps showing pre-sync data.
const dispatched: Array<{ type: string; detail: unknown }> = [];
const fakeWindow = {
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent(event: Event) {
dispatched.push({
type: event.type,
detail: (event as CustomEvent).detail,
});
return true;
},
};
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
try {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "deepseek-local" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: "deepseek-v4-flash" }));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: [{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true }],
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const events = dispatched.filter((e) => e.type === "netcatty:ai-state-changed");
const keys = events.map((e) => (e.detail as { key?: string })?.key);
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_PROVIDERS), "providers nudge");
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP), "agentProviderMap nudge");
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP), "agentModelMap nudge");
} finally {
delete (globalThis as { window?: unknown }).window;
}
});
test("applySyncPayload prunes per-agent bindings that reference providers absent from the synced set", async () => {
// Local state has Catty bound to a provider the incoming sync no longer
// ships — both the per-agent provider override and the saved model should
// be cleared so we don't dispatch a ghost provider id (or its now-orphan
// model name) to the wrong endpoint.
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({
catty: "deepseek-local",
codex: "openai-main",
}));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({
catty: "deepseek-v4-flash",
codex: "gpt-test",
}));
const syncedProviders = [
{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true },
];
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: syncedProviders,
// Intentionally omit agentProviderMap — exercises the reconcile path.
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!),
{ codex: "openai-main" },
);
// Catty's saved model belonged to the now-missing deepseek-local — drop it.
// Codex's binding stays, so its saved model stays.
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!),
{ codex: "gpt-test" },
);
});
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
const localAgents = [
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
];
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify(localAgents));
const payload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
// Legacy snapshot still carries externalAgents; current code must ignore it.
externalAgents: [
{ id: "claude", name: "Claude", command: "C:\\Tools\\claude.exe", enabled: true },
],
},
},
syncedAt: 1,
} as unknown as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS)!),
localAgents,
);
});
test("applySyncPayload preserves local AI provider apiKeys when synced payload omits them", async () => {
const localProviders = [
{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
enabled: true,
},
{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
enabled: true,
},
];
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(localProviders));
// Synced payload mirrors what `collectSyncableSettings` produces on another device:
// metadata is preserved but encrypted device-bound apiKeys are stripped.
const syncedProviders = [
{ id: "openai-main", providerId: "openai", name: "OpenAI (renamed)", enabled: true },
{ id: "anthropic-main", providerId: "anthropic", name: "Anthropic", enabled: false },
];
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: { ai: { providers: syncedProviders } },
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
assert.deepEqual(stored, [
{
id: "openai-main",
providerId: "openai",
name: "OpenAI (renamed)",
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
enabled: true,
},
{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
enabled: false,
},
]);
});
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==", enabled: true },
]));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: [
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "plaintext-from-other-device", enabled: true },
],
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
assert.equal(stored[0].apiKey, "plaintext-from-other-device");
});
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: true,
maxResults: 7,
}));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
webSearchConfig: { providerId: "tavily", enabled: false, maxResults: 12 },
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.deepEqual(stored, {
providerId: "tavily",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: false,
maxResults: 12,
});
});
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: true,
}));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
webSearchConfig: { providerId: "exa", enabled: true },
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.equal("apiKey" in stored, false);
assert.equal(stored.providerId, "exa");
});
test("buildSyncPayload includes syncable terminal options from settings", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FOLLOW_APP_THEME, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
terminalEmulationType: "vt100",
altAsMeta: true,
kittyKeyboardProtocolEnabled: true,
shiftEnterNewlineEnabled: false,
shiftEnterNewlineText: " \\\\\\n",
middleClickBehavior: "context-menu",
fontSmoothing: false,
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
localShell: "/bin/zsh",
}));
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.followAppTerminalTheme, true);
assert.deepEqual(payload.settings?.terminalSettings, {
terminalEmulationType: "vt100",
altAsMeta: true,
kittyKeyboardProtocolEnabled: true,
shiftEnterNewlineEnabled: false,
shiftEnterNewlineText: " \\\\\\n",
middleClickBehavior: "context-menu",
fontSmoothing: false,
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
});
});
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-only")],
syncedAt: 1,
}),
false,
);
});
test("buildLocalVaultPayload includes last-known plugin sidecars for protective backups", () => {
localStorage.setItem(
"netcatty_plugin_sidecars_last_known_v1",
JSON.stringify({
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "com.example.p.theme\0application\0application",
value: "dark",
updatedAt: 1,
}],
}),
);
try {
const payload = buildLocalVaultPayload(vault([]));
assert.equal(payload.pluginSidecars?.entries?.length, 1);
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
assert.equal(hasMeaningfulSyncData(payload), true);
} finally {
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
}
});
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
collectPluginSyncSidecars: async () => ({ version: 1, entries: [] }),
pluginHostReady: () => true,
},
dispatchEvent: () => true,
},
configurable: true,
});
localStorage.setItem(
"netcatty_plugin_sidecars_last_known_v1",
JSON.stringify({
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "com.example.p.theme\0application\0application",
value: "dark",
updatedAt: 1,
}],
}),
);
try {
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
const payload = await buildLocalVaultPayloadAsync(vault([]));
assert.equal(payload.pluginSidecars?.entries?.length, 0);
} finally {
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
Object.defineProperty(globalThis, "window", {
value: previousWindow,
configurable: true,
});
}
});
test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful", () => {
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: {
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "com.example.p.theme\0application\0application",
value: "dark",
updatedAt: 1,
}],
},
}),
true,
);
// Empty bundle alone must not bypass the empty-vault upload guard.
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [] },
}),
false,
);
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
}),
false,
);
});
test("hasMeaningfulCloudSyncData does not treat last-known-only empty sidecars as meaningful", () => {
const previous = localStorageAdapter.read(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
try {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: "com.example.p",
kind: "settings",
key: "k",
value: 1,
updatedAt: 1,
}],
});
// Empty vault + empty sidecar entries must stay blocked even when last-known
// still remembers prior plugin settings (empty-vault upload guard).
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [] },
}),
false,
);
} finally {
if (previous == null) localStorageAdapter.remove(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
else localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, previous);
}
});
test("plugin sidecar storage keys stay aligned between sync domain and storageKeys registry", () => {
assert.equal(
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN,
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE,
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE,
);
assert.equal(
storageKeys.STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS,
SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS,
);
});
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
assert.equal(
hasCloudSyncEntityData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: { theme: "system", terminalTheme: "default" },
syncedAt: 1,
}),
false,
);
});
test("shouldPromptCloudVaultRecovery ignores settings-only remote payloads", () => {
const settingsOnlyPayload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: { theme: "system", terminalTheme: "default" },
syncedAt: 1,
};
assert.equal(
shouldPromptCloudVaultRecovery(settingsOnlyPayload, settingsOnlyPayload),
false,
);
});
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
});
test("buildLocalVaultPayload preserves local AI API keys for protective backups", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: true,
}));
const payload = buildLocalVaultPayload(vault([]));
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==");
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==");
});
test("applySyncPayload ignores legacy cloud known hosts", async () => {
let imported: Record<string, unknown> | null = null;
const proxyProfiles = [
{
id: "proxy-1",
label: "Office Proxy",
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
createdAt: 1,
updatedAt: 1,
},
];
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-legacy")],
proxyProfiles,
syncedAt: 1,
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal("knownHosts" in imported, false);
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
});
test("applySyncPayload keeps missing proxy references visible to connection guards", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "missing-proxy",
}],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
groupConfigs: [{ path: "prod", proxyProfileId: "missing-proxy" }],
syncedAt: 1,
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
assert.equal((imported.groupConfigs as SyncPayload["groupConfigs"])?.[0]?.proxyProfileId, "missing-proxy");
});
test("applySyncPayload preserves host proxy references when group configs are absent", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "missing-proxy",
}],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
assert.equal("groupConfigs" in imported, false);
});
test("applySyncPayload migrates legacy global line timestamps onto hosts", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [
{
id: "host-1",
label: "Inherited",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
},
{
id: "host-2",
label: "Explicit",
hostname: "example.net",
username: "root",
tags: [],
os: "linux",
showLineTimestamps: false,
},
],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { showLineTimestamps: true } },
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
const hosts = imported.hosts as SyncPayload["hosts"];
assert.equal(hosts[0]?.showLineTimestamps, true);
assert.equal(hosts[1]?.showLineTimestamps, false);
});
test("applySyncPayload waits for async vault imports", async () => {
let finished = false;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
const promise = applySyncPayload(payload, {
importVaultData: async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
finished = true;
},
});
assert.equal(finished, false);
await promise;
assert.equal(finished, true);
});
test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fallbackFont: "PingFang SC", fontLigatures: true }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.fallbackFont, "PingFang SC");
});
test("buildSyncPayload includes the terminal host information bar preference", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ showHostInfoBar: false }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.showHostInfoBar, false);
});
test("terminal auto-close preference survives sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.autoCloseOnExit, false);
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
await applySyncPayload(payload, { importVaultData: () => {} });
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restored.autoCloseOnExit, false);
});
test("terminal disconnected notice preference survives sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ disconnectedNoticeMode: "dialog" }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.disconnectedNoticeMode, "dialog");
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ disconnectedNoticeMode: "terminal" }),
);
await applySyncPayload(payload, { importVaultData: () => {} });
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restored.disconnectedNoticeMode, "dialog");
});
test("applySyncPayload restores the terminal host information bar preference", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ showHostInfoBar: true, scrollback: 5000 }),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { showHostInfoBar: false } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.showHostInfoBar, false);
assert.equal(parsed.scrollback, 5000);
});
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fontLigatures: true }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal("fallbackFont" in termSettings, false);
});
test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", async () => {
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { fallbackFont: "Sarasa Mono SC" } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
});
test("applySyncPayload writes incoming Shift+Enter terminal settings", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000 }),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: {
terminalSettings: {
shiftEnterNewlineEnabled: false,
shiftEnterNewlineText: " \\\\\\n",
},
},
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.scrollback, 5000);
assert.equal(parsed.shiftEnterNewlineEnabled, false);
assert.equal(parsed.shiftEnterNewlineText, " \\\\\\n");
});
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({
scrollback: 2000,
middleClickBehavior: "paste",
middleClickPaste: true,
}),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: {
terminalSettings: {
middleClickPaste: false,
},
},
} as SyncPayload;
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.scrollback, 2000);
assert.equal(parsed.middleClickBehavior, "disabled");
assert.equal(parsed.middleClickPaste, false);
});
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fallbackFont: "Microsoft YaHei UI" }),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { scrollback: 9999 } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
const parsed = JSON.parse(raw!);
assert.equal(parsed.fallbackFont, "Microsoft YaHei UI", "legacy payload must not wipe local fallbackFont");
assert.equal(parsed.scrollback, 9999);
});
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-backup")],
syncedAt: 1,
};
await applyLocalVaultPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
});
test("applyLocalVaultPayload prepares before import and commits after it succeeds", async () => {
const calls: string[] = [];
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
await applyLocalVaultPayload(payload, {
importVaultData: () => {
calls.push("import");
},
}, {
prepareConvergentRestore: async () => {
calls.push("prepare");
return async () => {
calls.push("commit");
};
},
});
assert.deepEqual(calls, ["prepare", "import", "commit"]);
});
test("prepareLocalVaultPayloadApply does not import until the prepared callback runs", async () => {
const calls: string[] = [];
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
const applyPreparedPayload = await prepareLocalVaultPayloadApply(payload, {
importVaultData: () => {
calls.push("import");
},
}, {
prepareConvergentRestore: async () => {
calls.push("prepare");
return async () => {
calls.push("commit");
};
},
});
assert.deepEqual(calls, ["prepare"]);
await applyPreparedPayload();
assert.deepEqual(calls, ["prepare", "import", "commit"]);
});
test("prepareLocalVaultPayloadApply sanitizes enc:v1 before convergent prepare and apply", async () => {
const completeBlob = Buffer.alloc(31, 0);
Buffer.from("v10", "utf8").copy(completeBlob, 0);
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
const preparedPayloads: SyncPayload[] = [];
const imported: Array<Record<string, unknown>> = [];
const applyPreparedPayload = await prepareLocalVaultPayloadApply({
hosts: [{
id: "h1",
label: "prod",
hostname: "prod.example",
username: "root",
password: ENC,
port: 22,
os: "linux",
group: "",
tags: [],
protocol: "ssh",
}],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
}, {
importVaultData: (json) => {
imported.push(JSON.parse(json) as Record<string, unknown>);
},
}, {
prepareConvergentRestore: async (payload) => {
preparedPayloads.push(payload);
return async () => undefined;
},
});
await applyPreparedPayload();
assert.equal(preparedPayloads.length, 1);
assert.equal(preparedPayloads[0]?.hosts[0]?.password, undefined);
assert.equal(imported.length, 1);
const hosts = imported[0]?.hosts as Array<{ password?: string }> | undefined;
assert.equal(hosts?.[0]?.password, undefined);
});
test("applyLocalVaultPayload leaves local data untouched when convergent preparation fails", async () => {
let imported = false;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
await assert.rejects(
() => applyLocalVaultPayload(payload, {
importVaultData: () => {
imported = true;
},
}, {
prepareConvergentRestore: async () => {
throw new Error("replica unavailable");
},
}),
/replica unavailable/,
);
assert.equal(imported, false);
});
test("applyLocalVaultPayload does not commit convergent writes when local import fails", async () => {
let committed = false;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
await assert.rejects(
() => applyLocalVaultPayload(payload, {
importVaultData: async () => {
throw new Error("local import failed");
},
}, {
prepareConvergentRestore: async () => async () => {
committed = true;
},
}),
/local import failed/,
);
assert.equal(committed, false);
});
test("withPluginSyncSidecars attaches non-empty plugin sidecar bundles", () => {
const base = buildSyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
});
const withEmpty = withPluginSyncSidecars(base, { version: 1, entries: [] });
assert.deepEqual(withEmpty.pluginSidecars, { version: 1, entries: [] });
const withData = withPluginSyncSidecars(base, {
version: 1,
entries: [{
pluginId: "com.example.sync",
kind: "settings",
key: "com.example.sync.theme\0application\0application",
value: "dark",
updatedAt: 1,
}],
});
assert.equal(withData.pluginSidecars?.entries.length, 1);
assert.equal(withData.pluginSidecars?.entries[0].value, "dark");
});
test("buildCloudSyncPayload includes plugin sidecars from the production collector hook", async () => {
const payload = await buildCloudSyncPayload(vault([]), [], {
collectPluginSidecars: async () => ({
version: 1,
entries: [{
pluginId: "com.example.sync",
kind: "account_baseline",
key: "account",
value: { id: "acct-1" },
updatedAt: 9,
}],
}),
});
assert.equal(payload.pluginSidecars?.entries.length, 1);
assert.equal(payload.pluginSidecars?.entries[0].kind, "account_baseline");
assert.deepEqual(payload.pluginSidecars?.entries[0].value, { id: "acct-1" });
});
test("applySyncPayload applies pluginSidecars through the production applier hook", async () => {
let applied: unknown = null;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: {
version: 1,
entries: [{
pluginId: "com.missing.plugin",
kind: "crdt_baseline",
key: "replica",
value: { clock: 3 },
updatedAt: 2,
}],
},
};
await applySyncPayload(payload, {
importVaultData: () => {},
}, {
applyPluginSidecars: async (sidecars) => {
applied = sidecars;
},
});
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
});
test("local shell side panel settings round-trip independently and trigger auto-sync", async () => {
for (const key of [storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB]) {
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
}
for (const enabled of [true, false]) {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, String(enabled));
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "notes");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, String(!enabled));
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
const payload = await buildSyncPayload(vault());
assert.equal(payload.settings?.localShellSidePanelAutoOpen, enabled);
assert.equal(payload.settings?.localShellSidePanelAutoOpenTab, "notes");
localStorage.clear();
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN), String(enabled));
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), String(!enabled));
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
}
});
test("missing local shell settings and invalid tabs preserve existing settings", async () => {
const payload = await buildSyncPayload(vault());
assert.equal(payload.settings?.localShellSidePanelAutoOpen, undefined);
assert.equal(payload.settings?.localShellSidePanelAutoOpenTab, undefined);
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "notes");
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN), "true");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
await applySyncPayload({ ...payload, settings: { localShellSidePanelAutoOpenTab: "invalid" } } as unknown as SyncPayload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "invalid");
assert.equal((await buildSyncPayload(vault())).settings?.localShellSidePanelAutoOpenTab, undefined);
});
test("tab bar position survives settings export and import, with a safe fallback", async () => {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.tabBarPosition, "bottom");
localStorage.removeItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION);
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
const invalid = { ...payload, settings: { tabBarPosition: "left" } } as unknown as SyncPayload;
await applySyncPayload(invalid, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "top");
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
});
for (const enabled of [true, false]) {
test(`right-click long press preference survives sync (${enabled})`, async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ rightClickLongPressMenu: enabled }));
const payload = buildSyncPayload(vault());
assert.equal(payload.settings?.terminalSettings?.rightClickLongPressMenu, enabled);
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ rightClickLongPressMenu: !enabled }));
await applySyncPayload(payload, { importVaultData: () => {} });
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restored.rightClickLongPressMenu, enabled);
});
}
test('custom provider headers use portable cloud secrets and local encryption with legacy preservation', async () => {
localStorage.clear();
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 2)]).toString('base64')}`;
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
credentialsEncrypt: async () => sealed,
credentialsDecrypt: async (value: string) => value === sealed ? 'tenant-secret' : value,
},
dispatchEvent: () => true,
} });
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: 'custom-headers', providerId: 'custom', enabled: true, customHeaders: { 'X-Tenant': sealed },
}]));
const plainSettings = buildSyncPayload(vault([]));
assert.equal(plainSettings.settings?.ai?.providers?.[0]?.customHeaders, undefined);
const cloud = await buildCloudSyncPayload(vault([]));
assert.deepEqual(cloud.settings?.ai?.providers?.[0]?.customHeaders, { 'X-Tenant': 'tenant-secret' });
await applySyncPayload(cloud, { importVaultData: () => {} });
const read = () => JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
await applySyncPayload(plainSettings, { importVaultData: () => {} });
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
cloud.settings!.ai!.providers![0].customHeaders = {};
await applySyncPayload(cloud, { importVaultData: () => {} });
assert.deepEqual(read().customHeaders, {});
});
for (const localRestore of [false, true]) {
test(`header encryption failure leaves vault and settings untouched (${localRestore ? 'restore' : 'sync'})`, async () => {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_THEME, 'light');
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, '[]');
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => { throw new Error('keychain locked'); } },
dispatchEvent: () => true,
} });
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { Authorization: 'secret' } }] },
} };
const original = JSON.stringify(payload);
let imports = 0;
let completed = 0;
let commits = 0;
const importers = { importVaultData: () => { imports++; }, onSettingsApplied: () => { completed++; } };
await assert.rejects(localRestore
? applyLocalVaultPayload(payload, importers, { prepareConvergentRestore: async () => async () => { commits++; } })
: applySyncPayload(payload, importers));
assert.equal(imports, 0);
assert.equal(completed, 0);
assert.equal(commits, 0);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_THEME), 'light');
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS), '[]');
assert.equal(JSON.stringify(payload), original);
});
}
test('sync prepares header encryption once before importing and leaves input unchanged', async () => {
localStorage.clear();
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
const events: string[] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => { events.push('encrypt'); return sealed; } },
dispatchEvent: () => true,
} });
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
} };
const original = JSON.stringify(payload);
await applySyncPayload(payload, { importVaultData: () => { events.push('import'); } });
assert.deepEqual(events, ['encrypt', 'import']);
assert.equal(JSON.stringify(payload), original);
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders, { 'X-Tenant': sealed });
});